Support Gmail labels and inbox label UI

Backend: load Gmail label names, map message labelIds to human-readable names (excluding UNREAD), and add labels to GmailMessage record.

Frontend: add CSS for label chips and layout fixes; extend MailMessage with labels; track available/selected labels; add label MultiSelect filter and render label chips in message list and details. Also add custom attachment-type input and minor message-list sizing/auto-load adjustments.

This ties Gmail label metadata into the UI and enables filtering/display of message labels.
This commit is contained in:
Ludy87
2026-08-24 19:00:37 +02:00
parent c57e859aa9
commit adcee1be72
4 changed files with 195 additions and 26 deletions
@@ -115,11 +115,13 @@ public class GmailOAuthController {
public ResponseEntity<GmailOAuthService.GmailMessagePage> messages(
@RequestParam(defaultValue = "inbox") String folder,
@RequestParam(required = false) String types,
@RequestParam(required = false) String query,
@RequestParam(required = false) String pageToken,
HttpServletRequest request)
throws IOException, InterruptedException {
GmailOAuthService.GmailToken token = currentToken();
return ResponseEntity.ok(gmailOAuthService.listMessages(token, folder, types, pageToken));
return ResponseEntity.ok(
gmailOAuthService.listMessages(token, folder, types, query, pageToken));
}
@GetMapping("/api/v1/email/gmail/messages/{messageId}/attachments/{attachmentId}")
@@ -232,7 +232,7 @@ public class GmailOAuthService {
}
public GmailMessagePage listMessages(
GmailToken token, String folder, String types, String pageToken)
GmailToken token, String folder, String types, String query, String pageToken)
throws IOException, InterruptedException {
String label =
switch (folder) {
@@ -244,17 +244,18 @@ public class GmailOAuthService {
pageToken == null || pageToken.isBlank()
? ""
: "&pageToken=" + URLEncoder.encode(pageToken, StandardCharsets.UTF_8);
String typeQuery = buildAttachmentTypeQuery(types);
String gmailQuery = buildGmailQuery(types, query);
JsonNode list =
sendJson(
token,
GMAIL_API_URI
+ "/messages?labelIds="
+ label
+ "&maxResults=25&q=has%3Aattachment"
+ typeQuery
+ "&maxResults=25&q="
+ URLEncoder.encode(gmailQuery, StandardCharsets.UTF_8)
+ pageQuery);
List<GmailMessage> messages = new ArrayList<>();
Map<String, String> labelNames = loadLabelNames(token);
for (JsonNode item : list.path("messages")) {
JsonNode message =
sendJson(
@@ -263,27 +264,30 @@ public class GmailOAuthService {
+ "/messages/"
+ item.path("id").asText()
+ "?format=full");
messages.add(toMessage(message));
messages.add(toMessage(message, labelNames));
}
return new GmailMessagePage(messages, list.path("nextPageToken").asText(null));
}
private String buildAttachmentTypeQuery(String types) {
if (types == null || types.isBlank()) {
return "";
private String buildGmailQuery(String types, String query) {
StringBuilder gmailQuery = new StringBuilder("has:attachment");
if (types != null && !types.isBlank()) {
String filenameQuery =
Arrays.stream(types.split(","))
.map(String::trim)
.map(String::toLowerCase)
.filter(type -> type.matches("[a-z0-9]{1,10}"))
.distinct()
.map(type -> "filename:" + type)
.collect(Collectors.joining(" "));
if (!filenameQuery.isBlank()) {
gmailQuery.append(" {").append(filenameQuery).append("}");
}
}
String filenameQuery =
Arrays.stream(types.split(","))
.map(String::trim)
.map(String::toLowerCase)
.filter(type -> type.matches("[a-z0-9]{1,10}"))
.distinct()
.map(type -> "filename:" + type)
.collect(Collectors.joining(" "));
if (filenameQuery.isBlank()) {
return "";
if (query != null && !query.isBlank()) {
gmailQuery.append(' ').append(query.trim());
}
return "%20" + URLEncoder.encode("{" + filenameQuery + "}", StandardCharsets.UTF_8);
return gmailQuery.toString();
}
public GmailAttachmentData downloadAttachment(
@@ -297,11 +301,33 @@ public class GmailOAuthService {
return new GmailAttachmentData(data);
}
private GmailMessage toMessage(JsonNode message) {
private Map<String, String> loadLabelNames(GmailToken token)
throws IOException, InterruptedException {
Map<String, String> labelNames = new LinkedHashMap<>();
JsonNode response = sendJson(token, GMAIL_API_URI + "/labels");
for (JsonNode label : response.path("labels")) {
String id = label.path("id").asText("");
String name = label.path("name").asText("");
if (!id.isBlank() && !name.isBlank()) {
labelNames.put(id, name);
}
}
return labelNames;
}
private GmailMessage toMessage(JsonNode message, Map<String, String> labelNames) {
JsonNode payload = message.path("payload");
String from = header(payload, "From");
String subject = header(payload, "Subject");
String date = header(payload, "Date");
List<String> labels = new ArrayList<>();
for (JsonNode labelId : message.path("labelIds")) {
String id = labelId.asText("");
String name = labelNames.get(id);
if (!"UNREAD".equals(id) && name != null && !name.isBlank()) {
labels.add(name);
}
}
List<GmailAttachment> attachments = new ArrayList<>();
collectAttachments(payload, attachments);
return new GmailMessage(
@@ -311,6 +337,7 @@ public class GmailOAuthService {
message.path("snippet").asText(""),
date,
message.path("labelIds").toString().contains("UNREAD"),
labels,
attachments);
}
@@ -390,6 +417,7 @@ public class GmailOAuthService {
String preview,
String date,
boolean unread,
List<String> labels,
List<GmailAttachment> attachments) {}
public record GmailMessagePage(List<GmailMessage> messages, String nextPageToken) {}
@@ -219,6 +219,8 @@
}
.email-message-column {
display: flex;
flex-direction: column;
border-right: 1px solid var(--c-border-subtle);
background: var(--c-bg);
}
@@ -255,7 +257,9 @@
width: 100%;
}
.email-message-list {
height: calc(100% - 7.25rem);
flex: 1;
min-height: 0;
height: auto;
}
.email-message-row {
display: flex;
@@ -323,6 +327,32 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.email-message-labels,
.email-detail-labels {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.email-message-labels {
margin-top: 0.2rem;
}
.email-detail-labels {
margin-top: 0.7rem;
}
.email-label-tag {
display: inline-flex;
max-width: 100%;
align-items: center;
padding: 0.12rem 0.4rem;
border: 1px solid var(--c-border-subtle);
border-radius: 999px;
color: var(--c-text-subtle);
font-size: 0.65rem;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-attachment-indicator {
display: inline-flex;
align-items: center;
@@ -50,6 +50,7 @@ interface MailMessage {
preview: string;
date: string;
unread?: boolean;
labels: string[];
hasAttachment?: boolean;
attachments: MailAttachment[];
}
@@ -81,6 +82,7 @@ const DEMO_MESSAGES: MailMessage[] = [
"Anbei finden Sie die Rechnung für den aktuellen Abrechnungszeitraum.",
date: "Heute, 09:42",
unread: true,
labels: [],
hasAttachment: true,
attachments: [
{
@@ -101,6 +103,7 @@ const DEMO_MESSAGES: MailMessage[] = [
"Die aktualisierten Unterlagen liegen im Anhang. Bitte um kurze Rückmeldung.",
date: "Gestern",
hasAttachment: true,
labels: [],
attachments: [
{
id: "contract-pdf",
@@ -126,6 +129,7 @@ const DEMO_MESSAGES: MailMessage[] = [
preview:
"Danke für das Gespräch. Die nächsten Schritte sind im Überblick zusammengefasst.",
date: "12. Aug.",
labels: [],
attachments: [],
},
];
@@ -166,6 +170,11 @@ export default function EmailInboxPage() {
const [selectedAttachmentTypes, setSelectedAttachmentTypes] = useState<
string[]
>([]);
const [customAttachmentTypes, setCustomAttachmentTypes] = useState<string[]>(
[],
);
const [attachmentTypeDraft, setAttachmentTypeDraft] = useState("");
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
const [downloadedAttachment, setDownloadedAttachment] = useState<
string | null
>(null);
@@ -233,6 +242,7 @@ export default function EmailInboxPage() {
preview: string;
date: string;
unread: boolean;
labels?: string[];
attachments: Array<{
id: string;
name: string;
@@ -242,7 +252,7 @@ export default function EmailInboxPage() {
}>;
nextPageToken?: string | null;
}>(
`/api/v1/email/gmail/messages?folder=${selectedFolder}${selectedAttachmentTypes.length > 0 ? `&types=${encodeURIComponent(selectedAttachmentTypes.join(","))}` : ""}${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`,
`/api/v1/email/gmail/messages?folder=${selectedFolder}${selectedAttachmentTypes.length > 0 ? `&types=${encodeURIComponent(selectedAttachmentTypes.join(","))}` : ""}${query.trim() ? `&query=${encodeURIComponent(query.trim())}` : ""}${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`,
);
const mappedMessages = data.messages
.filter((message) => message.attachments.length > 0)
@@ -256,6 +266,7 @@ export default function EmailInboxPage() {
preview: message.preview,
date: message.date,
unread: message.unread,
labels: message.labels ?? [],
hasAttachment: true,
attachments: message.attachments.map((attachment) => ({
id: attachment.id,
@@ -287,6 +298,7 @@ export default function EmailInboxPage() {
selectedFolder,
refreshVersion,
selectedAttachmentTypes,
query,
]);
const refreshInbox = () => {
@@ -308,6 +320,19 @@ export default function EmailInboxPage() {
}
};
useEffect(() => {
const element = messageListViewportRef.current;
if (
!element ||
!nextPageToken ||
loadingMore ||
element.scrollHeight > element.clientHeight + 120
) {
return;
}
void loadMessages(nextPageToken);
}, [messages, nextPageToken, loadingMore, selectedLabels]);
const attachmentTypes = useMemo(
() =>
Array.from(
@@ -319,10 +344,26 @@ export default function EmailInboxPage() {
).sort(),
[messages],
);
const availableLabels = useMemo(
() =>
Array.from(
new Set(messages.flatMap((message) => message.labels ?? [])),
).sort((left, right) => left.localeCompare(right)),
[messages],
);
const attachmentTypeOptions = useMemo(
() =>
Array.from(new Set([...attachmentTypes, ...customAttachmentTypes])).sort(),
[attachmentTypes, customAttachmentTypes],
);
const filteredMessages = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery && selectedAttachmentTypes.length === 0)
if (
!normalizedQuery &&
selectedAttachmentTypes.length === 0 &&
selectedLabels.length === 0
)
return messages;
return messages.filter((message) =>
(normalizedQuery
@@ -335,9 +376,14 @@ export default function EmailInboxPage() {
? message.attachments.some(
(attachment) => selectedAttachmentTypes.includes(attachment.type),
)
: true) &&
(selectedLabels.length > 0
? selectedLabels.some((label) =>
(message.labels ?? []).includes(label),
)
: true),
);
}, [messages, query, selectedAttachmentTypes]);
}, [messages, query, selectedAttachmentTypes, selectedLabels]);
const unreadMessageCount = messages.filter((message) => message.unread).length;
@@ -372,12 +418,25 @@ export default function EmailInboxPage() {
setMessages([]);
setNextPageToken(null);
setSelectedAttachmentTypes([]);
setSelectedLabels([]);
setSettingsOpen(false);
} catch {
// Keep the connected state visible when the server could not complete the request.
}
};
const addAttachmentType = (value: string) => {
const type = value.trim().toUpperCase();
if (!/^[A-Z0-9]{1,10}$/.test(type)) return;
setCustomAttachmentTypes((current) =>
current.includes(type) ? current : [...current, type],
);
setSelectedAttachmentTypes((current) =>
current.includes(type) ? current : [...current, type],
);
setAttachmentTypeDraft("");
};
const importAttachment = async (
messageId: string,
attachment: MailAttachment,
@@ -575,12 +634,44 @@ export default function EmailInboxPage() {
<MultiSelect
className="email-type-filter"
clearable
data={attachmentTypes}
searchable
data={attachmentTypeOptions}
value={selectedAttachmentTypes}
onChange={setSelectedAttachmentTypes}
onSearchChange={setAttachmentTypeDraft}
onKeyDown={(event) => {
if (event.key === "Enter" && attachmentTypeDraft.trim()) {
event.preventDefault();
addAttachmentType(attachmentTypeDraft);
}
}}
placeholder={t("email.fileTypeFilter", "Dateityp")}
aria-label={t("email.fileTypeFilter", "Dateityp filtern")}
/>
<TextInput
className="email-type-custom-input"
value={attachmentTypeDraft}
onChange={(event) => setAttachmentTypeDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
addAttachmentType(attachmentTypeDraft);
}}
placeholder={t(
"email.customFileType",
"Eigenen Dateityp eingeben und Enter drücken",
)}
aria-label={t("email.customFileType", "Eigenen Dateityp")}
/>
<MultiSelect
className="email-label-filter"
clearable
data={availableLabels}
value={selectedLabels}
onChange={setSelectedLabels}
placeholder={t("email.labelFilter", "Label")}
aria-label={t("email.labelFilter", "Nach Labels filtern")}
searchable
/>
</div>
<ScrollArea
className="email-message-list"
@@ -615,6 +706,15 @@ export default function EmailInboxPage() {
<span className="email-message-preview">
{message.preview}
</span>
{(message.labels ?? []).length > 0 && (
<span className="email-message-labels">
{(message.labels ?? []).slice(0, 3).map((label) => (
<span className="email-label-tag" key={label}>
{label}
</span>
))}
</span>
)}
{message.hasAttachment && (
<span className="email-attachment-indicator">
<AttachFileIcon fontSize="inherit" />{" "}
@@ -700,6 +800,15 @@ export default function EmailInboxPage() {
<StarBorderIcon fontSize="small" />
</ActionIcon>
</div>
{(selectedMessage.labels ?? []).length > 0 && (
<div className="email-detail-labels">
{(selectedMessage.labels ?? []).map((label) => (
<span className="email-label-tag" key={label}>
{label}
</span>
))}
</div>
)}
<div className="email-sender-row">
<span className="email-message-avatar is-large">
{selectedMessage.sender.charAt(0)}