mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
+3
-1
@@ -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}")
|
||||
|
||||
+48
-20
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user