Add admin mailbox settings & Gmail type filter

Add mailbox config and admin UI plus Gmail attachment-type filtering. Backend: add mailbox.gmail properties to ApplicationProperties, extend GmailOAuthController/Service to accept a `types` query (builds filename query) and expose mailbox in AdminSettingsController. Frontend: add AdminMailboxSection, include nav entry and VALID_NAV_KEYS, update EmailInboxPage (UI, CSS) to support multi-select attachment type filtering and pass types to API, and add OG metadata for /settings/adminMailbox. Note: mailbox changes require server restart.
This commit is contained in:
Ludy87
2026-08-24 17:41:16 +02:00
parent fc3a8e3232
commit 2953358dd1
11 changed files with 313 additions and 39 deletions
@@ -66,6 +66,7 @@ public class ApplicationProperties {
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Mail mail = new Mail();
private Mailbox mailbox = new Mailbox();
private Telegram telegram = new Telegram();
private Premium premium = new Premium();
@@ -82,6 +83,18 @@ public class ApplicationProperties {
private Cluster cluster = new Cluster();
private Policies policies = new Policies();
@Data
public static class Mailbox {
private Gmail gmail = new Gmail();
@Data
public static class Gmail {
private String clientId = "";
@ToString.Exclude private String clientSecret = "";
private String redirectUri = "";
}
}
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
throws IOException {
@@ -103,11 +103,12 @@ public class GmailOAuthController {
@PreAuthorize("isAuthenticated()")
public ResponseEntity<GmailOAuthService.GmailMessagePage> messages(
@RequestParam(defaultValue = "inbox") String folder,
@RequestParam(required = false) String types,
@RequestParam(required = false) String pageToken,
HttpServletRequest request)
throws IOException, InterruptedException {
GmailOAuthService.GmailToken token = sessionToken(request);
return ResponseEntity.ok(gmailOAuthService.listMessages(token, folder, pageToken));
return ResponseEntity.ok(gmailOAuthService.listMessages(token, folder, types, pageToken));
}
@GetMapping("/api/v1/email/gmail/messages/{messageId}/attachments/{attachmentId}")
@@ -9,7 +9,9 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.stream.Collectors;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -118,7 +120,8 @@ public class GmailOAuthService {
return connections.get(username);
}
public GmailMessagePage listMessages(GmailToken token, String folder, String pageToken)
public GmailMessagePage listMessages(
GmailToken token, String folder, String types, String pageToken)
throws IOException, InterruptedException {
String label =
switch (folder) {
@@ -130,6 +133,7 @@ public class GmailOAuthService {
pageToken == null || pageToken.isBlank()
? ""
: "&pageToken=" + URLEncoder.encode(pageToken, StandardCharsets.UTF_8);
String typeQuery = buildAttachmentTypeQuery(types);
JsonNode list =
sendJson(
token,
@@ -137,6 +141,7 @@ public class GmailOAuthService {
+ "/messages?labelIds="
+ label
+ "&maxResults=25&q=has%3Aattachment"
+ typeQuery
+ pageQuery);
List<GmailMessage> messages = new ArrayList<>();
for (JsonNode item : list.path("messages")) {
@@ -152,6 +157,24 @@ public class GmailOAuthService {
return new GmailMessagePage(messages, list.path("nextPageToken").asText(null));
}
private String buildAttachmentTypeQuery(String types) {
if (types == null || types.isBlank()) {
return "";
}
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 "";
}
return "%20" + URLEncoder.encode("{" + filenameQuery + "}", StandardCharsets.UTF_8);
}
public GmailAttachmentData downloadAttachment(
GmailToken token, String messageId, String attachmentId)
throws IOException, InterruptedException {
@@ -665,6 +665,7 @@ public class AdminSettingsController {
case "endpoints" -> applicationProperties.getEndpoints();
case "metrics" -> applicationProperties.getMetrics();
case "mail" -> applicationProperties.getMail();
case "mailbox" -> applicationProperties.getMailbox();
case "storage" -> applicationProperties.getStorage();
case "premium" -> applicationProperties.getPremium();
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
@@ -690,6 +691,7 @@ public class AdminSettingsController {
"endpoints",
"metrics",
"mail",
"mailbox",
"storage",
"premium",
"processExecutor",
+6
View File
@@ -450,6 +450,11 @@
"title": "Admin Features Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminMailbox": {
"image": "/og_images/home.png",
"title": "Admin Mailbox Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminPlan": {
"image": "/og_images/home.png",
"title": "Admin Plan Settings - Stirling PDF",
@@ -679,6 +684,7 @@
"/settings/adminLegal": "/settings/adminLegal",
"/settings/adminPremium": "/settings/adminPremium",
"/settings/adminFeatures": "/settings/adminFeatures",
"/settings/adminMailbox": "/settings/adminMailbox",
"/settings/adminPlan": "/settings/adminPlan",
"/settings/adminAudit": "/settings/adminAudit",
"/settings/adminUsage": "/settings/adminUsage",
@@ -452,6 +452,11 @@
"title": "Admin Features Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminMailbox": {
"image": "/og_images/home.png",
"title": "Admin Mailbox Settings - Stirling PDF",
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
},
"/settings/adminPlan": {
"image": "/og_images/home.png",
"title": "Admin Plan Settings - Stirling PDF",
@@ -692,6 +697,7 @@
"/settings/adminLegal": "/settings/adminLegal",
"/settings/adminPremium": "/settings/adminPremium",
"/settings/adminFeatures": "/settings/adminFeatures",
"/settings/adminMailbox": "/settings/adminMailbox",
"/settings/adminPlan": "/settings/adminPlan",
"/settings/adminAudit": "/settings/adminAudit",
"/settings/adminUsage": "/settings/adminUsage",
@@ -26,6 +26,7 @@ export const VALID_NAV_KEYS = [
"adminLegal",
"adminPremium",
"adminFeatures",
"adminMailbox",
"adminPlan",
"adminAudit",
"adminUsage",
@@ -223,12 +223,21 @@
background: var(--c-bg);
}
.email-column-toolbar {
flex-direction: column;
align-items: stretch;
justify-content: space-between;
gap: 0.75rem;
min-height: 5.15rem;
min-height: 7.25rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--c-border-subtle);
}
.email-toolbar-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-width: 0;
}
.email-column-toolbar h2 {
font-size: 0.98rem;
}
@@ -240,13 +249,13 @@
font-size: 0.72rem;
}
.email-search {
width: min(15rem, 52%);
width: min(15rem, 60%);
}
.email-type-filter {
width: 8.5rem;
width: 100%;
}
.email-message-list {
height: calc(100% - 5.15rem);
height: calc(100% - 7.25rem);
}
.email-message-row {
display: flex;
@@ -577,12 +586,15 @@
border-right: 0;
}
.email-column-toolbar {
min-height: 4.75rem;
min-height: 7rem;
}
.email-toolbar-top {
gap: 0.5rem;
}
.email-search {
width: 48%;
}
.email-message-list {
height: calc(100% - 4.75rem);
height: calc(100% - 7rem);
}
}
@@ -6,7 +6,7 @@ import {
Menu,
Modal,
ScrollArea,
Select,
MultiSelect,
TextInput,
Tooltip,
} from "@mantine/core";
@@ -163,7 +163,9 @@ export default function EmailInboxPage() {
DEMO_MESSAGES[0].id,
);
const [query, setQuery] = useState("");
const [attachmentType, setAttachmentType] = useState<string | null>(null);
const [selectedAttachmentTypes, setSelectedAttachmentTypes] = useState<
string[]
>([]);
const [downloadedAttachment, setDownloadedAttachment] = useState<
string | null
>(null);
@@ -240,7 +242,7 @@ export default function EmailInboxPage() {
}>;
nextPageToken?: string | null;
}>(
`/api/v1/email/gmail/messages?folder=${selectedFolder}${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`,
`/api/v1/email/gmail/messages?folder=${selectedFolder}${selectedAttachmentTypes.length > 0 ? `&types=${encodeURIComponent(selectedAttachmentTypes.join(","))}` : ""}${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`,
);
const mappedMessages = data.messages
.filter((message) => message.attachments.length > 0)
@@ -280,7 +282,12 @@ export default function EmailInboxPage() {
setMessages([]);
setNextPageToken(null);
if (mailboxConfirmed) void loadMessages();
}, [mailboxConfirmed, selectedFolder, refreshVersion]);
}, [
mailboxConfirmed,
selectedFolder,
refreshVersion,
selectedAttachmentTypes,
]);
const refreshInbox = () => {
setMessages([]);
@@ -315,7 +322,8 @@ export default function EmailInboxPage() {
const filteredMessages = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery && !attachmentType) return messages;
if (!normalizedQuery && selectedAttachmentTypes.length === 0)
return messages;
return messages.filter((message) =>
(normalizedQuery
? [message.sender, message.address, message.subject, message.preview]
@@ -323,13 +331,13 @@ export default function EmailInboxPage() {
.toLocaleLowerCase()
.includes(normalizedQuery)
: true) &&
(attachmentType
(selectedAttachmentTypes.length > 0
? message.attachments.some(
(attachment) => attachment.type === attachmentType,
(attachment) => selectedAttachmentTypes.includes(attachment.type),
)
: true),
);
}, [attachmentType, messages, query]);
}, [messages, query, selectedAttachmentTypes]);
const unreadMessageCount = messages.filter((message) => message.unread).length;
@@ -337,7 +345,9 @@ export default function EmailInboxPage() {
filteredMessages.find((message) => message.id === selectedMessageId) ??
filteredMessages[0];
const selectedAttachments = selectedMessage?.attachments.filter(
(attachment) => !attachmentType || attachment.type === attachmentType,
(attachment) =>
selectedAttachmentTypes.length === 0 ||
selectedAttachmentTypes.includes(attachment.type),
);
const connectAccount = async () => {
@@ -519,32 +529,34 @@ export default function EmailInboxPage() {
aria-label={t("email.messageList", "E-Mail-Liste")}
>
<div className="email-column-toolbar">
<div>
<h2>{t("email.inbox", "Posteingang")}</h2>
<span>
{filteredMessages.length} {t("email.messages", "Nachrichten")}
</span>
<div className="email-toolbar-top">
<div>
<h2>{t("email.inbox", "Posteingang")}</h2>
<span>
{filteredMessages.length} {t("email.messages", "Nachrichten")}
</span>
</div>
<TextInput
className="email-search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder={t(
"email.searchPlaceholder",
"Nachrichten durchsuchen",
)}
leftSection={<SearchIcon fontSize="small" />}
aria-label={t(
"email.searchPlaceholder",
"Nachrichten durchsuchen",
)}
/>
</div>
<TextInput
className="email-search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder={t(
"email.searchPlaceholder",
"Nachrichten durchsuchen",
)}
leftSection={<SearchIcon fontSize="small" />}
aria-label={t(
"email.searchPlaceholder",
"Nachrichten durchsuchen",
)}
/>
<Select
<MultiSelect
className="email-type-filter"
clearable
data={attachmentTypes}
value={attachmentType}
onChange={setAttachmentType}
value={selectedAttachmentTypes}
onChange={setSelectedAttachmentTypes}
placeholder={t("email.fileTypeFilter", "Dateityp")}
aria-label={t("email.fileTypeFilter", "Dateityp filtern")}
/>
@@ -17,6 +17,7 @@ import AdminPlanSection from "@app/components/shared/config/configSections/Admin
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
import AdminMailboxSection from "@app/components/shared/config/configSections/AdminMailboxSection";
import AdminAiGeneralSection from "@app/components/shared/config/configSections/AdminAiGeneralSection";
import AdminAiModelsSection from "@app/components/shared/config/configSections/AdminAiModelsSection";
import AdminAiDocumentsSection from "@app/components/shared/config/configSections/AdminAiDocumentsSection";
@@ -123,6 +124,14 @@ export const useConfigNavSections = (
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminMailbox",
label: t("settings.configuration.mailbox", "Mailbox"),
icon: "mail-rounded",
component: <AdminMailboxSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
},
{
key: "adminStorageSharing",
label: t(
@@ -0,0 +1,189 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Stack,
Paper,
Text,
TextInput,
Loader,
Group,
} from "@mantine/core";
import { alert } from "@app/components/toast";
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
import { useAdminSettings } from "@app/hooks/useAdminSettings";
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
import EditableSecretField from "@app/components/shared/EditableSecretField";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
interface GmailMailboxSettings {
clientId?: string;
clientSecret?: string;
redirectUri?: string;
}
interface MailboxSettingsData {
gmail?: GmailMailboxSettings;
}
export default function AdminMailboxSection() {
const { t } = useTranslation();
const { loginEnabled } = useLoginRequired();
const {
restartModalOpened,
showRestartModal,
closeRestartModal,
restartServer,
} = useRestartServer();
const {
settings,
setSettings,
loading,
saving,
fetchSettings,
saveSettings,
isFieldPending,
} = useAdminSettings<MailboxSettingsData>({
sectionName: "mailbox",
saveTransformer: (current) => ({
sectionData: {},
deltaSettings: {
"mailbox.gmail.clientId": current.gmail?.clientId ?? "",
"mailbox.gmail.clientSecret": current.gmail?.clientSecret ?? "",
"mailbox.gmail.redirectUri": current.gmail?.redirectUri ?? "",
},
}),
});
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
settings,
loading,
);
const handleSave = async () => {
try {
await saveSettings();
markSaved();
showRestartModal();
} catch (_error) {
alert({
alertType: "error",
title: t("admin.error", "Error"),
body: t("admin.settings.saveError", "Failed to save settings"),
});
}
};
const handleDiscard = useCallback(() => {
setSettings(resetToSnapshot());
}, [resetToSnapshot, setSettings]);
const gmail = settings.gmail ?? {};
const updateGmail = (patch: Partial<GmailMailboxSettings>) =>
setSettings({ ...settings, gmail: { ...gmail, ...patch } });
if (loading) {
return (
<Stack align="center" justify="center" h={200}>
<Loader size="lg" />
</Stack>
);
}
return (
<div className="settings-section-container">
<Stack gap="lg" className="settings-section-content">
<div>
<Text fw={600} size="lg">
{t("admin.settings.mailbox.title", "Mailbox")}
</Text>
<Text size="sm" c="dimmed">
{t(
"admin.settings.mailbox.description",
"Configure the OAuth connection used to read mailbox attachments.",
)}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Text fw={500} size="sm">
Gmail OAuth
</Text>
<Text size="xs" c="dimmed">
{t(
"admin.settings.mailbox.gmail.note",
"These values are read from settings.yml under mailbox.gmail. Changes require a server restart.",
)}
</Text>
<TextInput
label={
<Group gap="xs">
<span>Client ID</span>
<PendingBadge show={isFieldPending("gmail.clientId")} />
</Group>
}
value={gmail.clientId || ""}
onChange={(event) =>
updateGmail({ clientId: event.currentTarget.value })
}
disabled={!loginEnabled}
/>
<div>
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>
Client Secret
</span>
<PendingBadge show={isFieldPending("gmail.clientSecret")} />
</Group>
<EditableSecretField
value={gmail.clientSecret || ""}
onChange={(value) => updateGmail({ clientSecret: value })}
placeholder="Google OAuth client secret"
disabled={!loginEnabled}
/>
</div>
<TextInput
label={
<Group gap="xs">
<span>Redirect URI</span>
<PendingBadge show={isFieldPending("gmail.redirectUri")} />
</Group>
}
description="Optional fixed public callback URI"
placeholder="https://example.com/api/v1/email/gmail/callback"
value={gmail.redirectUri || ""}
onChange={(event) =>
updateGmail({ redirectUri: event.currentTarget.value })
}
disabled={!loginEnabled}
/>
</Stack>
</Paper>
</Stack>
<SettingsStickyFooter
isDirty={isDirty}
saving={saving}
loginEnabled={loginEnabled}
onSave={handleSave}
onDiscard={handleDiscard}
/>
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
}