feat: integrate governed address contacts

This commit is contained in:
2026-08-20 11:10:18 +02:00
parent 2ad122056e
commit 169b81d9db
14 changed files with 745 additions and 7 deletions
+1 -1
View File
@@ -26,7 +26,7 @@
}
},
"scripts": {
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node .mail-test-build/tests/mail-address-integration.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
},
"devDependencies": {
"typescript": "^5.7.2"
+49
View File
@@ -65,6 +65,41 @@ export type MailAddressLookupResponse = {
candidates: MailAddressLookupCandidate[];
};
export type MailAddressWriteTarget = {
address_book_id: string;
address_book_label?: string | null;
operation: string;
allowed: boolean;
reason: string;
message: string;
scope_type?: string | null;
scope_id?: string | null;
source_kind?: string | null;
read_only: boolean;
required_scopes: string[];
provenance: Record<string, unknown>;
};
export type MailAddressWriteTargetResponse = {
available: boolean;
targets: MailAddressWriteTarget[];
};
export type MailContactCreatePayload = {
address_book_id: string;
display_name?: string | null;
email: string;
};
export type MailContactCreateResponse = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
source_kind: string;
provenance: Record<string, unknown>;
};
export type MailMailboxAttachment = {
filename?: string | null;
content_type: string;
@@ -209,6 +244,20 @@ export async function lookupMailAddresses(settings: ApiSettings, query: string,
return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
}
export async function listMailAddressWriteTargets(settings: ApiSettings): Promise<MailAddressWriteTargetResponse> {
return apiFetch<MailAddressWriteTargetResponse>(settings, "/api/v1/mail/address-write-targets");
}
export async function createMailAddressContact(
settings: ApiSettings,
payload: MailContactCreatePayload
): Promise<MailContactCreateResponse> {
return apiFetch<MailContactCreateResponse>(settings, "/api/v1/mail/address-contacts", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined,
+61 -3
View File
@@ -1,19 +1,23 @@
import { useCallback } from "react";
import { useCallback, useRef, useState } from "react";
import { ExternalLink, FilePenLine, Mail, Pencil } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
EmailAddressInput,
LoadingFrame,
quickAccessLaunchState,
useDashboardWidgetData,
type MailboxAddress,
type QuickAccessToolRenderContext
} from "@govoplan/core-webui";
import {
bootstrapMailbox,
listMailServerProfiles,
lookupMailAddresses,
type MailMailboxMessageSummary
} from "../../api/mail";
import { mailLookupSuggestions, mailtoHref } from "./mailAddressIntegration";
import {
mailboxDraftsLaunchPath,
mailboxMessageLaunchPath
@@ -34,6 +38,12 @@ type Props = Pick<
>;
export default function MailQuickAccess({ settings, launchContext, close }: Props) {
const [composing, setComposing] = useState(false);
const [recipients, setRecipients] = useState<MailboxAddress[]>([]);
const [suggestions, setSuggestions] = useState<MailboxAddress[]>([]);
const [lookupAvailable, setLookupAvailable] = useState<boolean | null>(null);
const [lookupError, setLookupError] = useState("");
const lookupRequestRef = useRef(0);
const load = useCallback(async (): Promise<MailQuickAccessData> => {
const profiles = await listMailServerProfiles(settings);
const profile = profiles.find((item) => item.is_active && item.imap);
@@ -51,6 +61,27 @@ export default function MailQuickAccess({ settings, launchContext, close }: Prop
}, [settings]);
const { data, loading, error } = useDashboardWidgetData(load, 0);
const lookupRecipients = useCallback(async (query: string) => {
const request = ++lookupRequestRef.current;
const normalized = query.trim();
if (!normalized) {
setSuggestions([]);
setLookupError("");
return;
}
try {
const response = await lookupMailAddresses(settings, normalized, 12);
if (request !== lookupRequestRef.current) return;
setLookupAvailable(response.available);
setSuggestions(mailLookupSuggestions(response.candidates));
setLookupError("");
} catch (lookupFailure) {
if (request !== lookupRequestRef.current) return;
setSuggestions([]);
setLookupError(lookupFailure instanceof Error ? lookupFailure.message : String(lookupFailure));
}
}, [settings]);
return (
<LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_messages.4294022c">
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
@@ -67,10 +98,37 @@ export default function MailQuickAccess({ settings, launchContext, close }: Prop
onClick: close
}))}
/>
{composing ? (
<div className="mail-quick-compose" aria-label="i18n:govoplan-mail.compose">
<label>i18n:govoplan-mail.recipients</label>
<EmailAddressInput
value={recipients}
onChange={setRecipients}
suggestions={suggestions}
onSuggestionQueryChange={(query) => void lookupRecipients(query)}
compact
interfaceId="mail.quick-access.compose.recipients"
helpModuleId="mail"
helpTopicId="mail.address-book-integration"
/>
{lookupAvailable === false ? (
<p className="form-help">i18n:govoplan-mail.address_suggestions_unavailable</p>
) : null}
{lookupError ? <DismissibleAlert tone="warning" resetKey={lookupError}>{lookupError}</DismissibleAlert> : null}
<div className="button-row compact-actions">
<button type="button" className="btn btn-secondary" onClick={() => setComposing(false)}>
i18n:govoplan-mail.cancel.77dfd213
</button>
<a className="btn btn-primary" href={mailtoHref(recipients)} onClick={close}>
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.open_mail_application
</a>
</div>
</div>
) : null}
<div className="dashboard-contribution-footer">
<a className="btn btn-secondary" href="mailto:" onClick={close}>
<button type="button" className="btn btn-secondary" onClick={() => setComposing((current) => !current)} aria-expanded={composing}>
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.compose
</a>
</button>
{data?.profileId && data.draftsFolder ? (
<Link
className="btn btn-secondary"
+147 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Activity, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react";
import { useLocation } from "react-router";
import { ToolbarGroup, ActionToolbar,
ActionBlockerHint,
@@ -21,9 +21,12 @@ import { ToolbarGroup, ActionToolbar,
} from "@govoplan/core-webui";
import {
bootstrapMailbox,
createMailAddressContact,
getMailboxMessage,
listMailAddressWriteTargets,
listMailboxMessages,
listMailServerProfiles,
type MailAddressWriteTarget,
type MailImapFolderResponse,
type MailMailboxMessageDetail,
type MailMailboxMessageSummary,
@@ -32,6 +35,7 @@ import {
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch";
import { mailboxHeaderAddresses } from "./mailAddressIntegration";
const MAILBOX_DOCUMENTATION = {
topicId: "mail.workflow.read-mailbox",
@@ -628,6 +632,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}))}
emptyText={previewEmptyText} />
<MailboxContactActions settings={settings} message={selectedMessage} />
</div>
</section>
@@ -641,6 +647,146 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
function MailboxContactActions({
settings,
message
}: {
settings: ApiSettings;
message: MailMailboxMessageDetail | null;
}) {
const [available, setAvailable] = useState(false);
const [loaded, setLoaded] = useState(false);
const [targets, setTargets] = useState<MailAddressWriteTarget[]>([]);
const [selectedTargetId, setSelectedTargetId] = useState("");
const [creatingEmail, setCreatingEmail] = useState("");
const [addedEmails, setAddedEmails] = useState<Set<string>>(() => new Set());
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
let active = true;
setLoaded(false);
void listMailAddressWriteTargets(settings)
.then((response) => {
if (!active) return;
const writable = response.targets.filter((target) => target.allowed);
setAvailable(response.available);
setTargets(response.targets);
setSelectedTargetId((current) => writable.some((target) => target.address_book_id === current)
? current
: writable[0]?.address_book_id || "");
setError("");
})
.catch((loadError) => {
if (!active) return;
setAvailable(false);
setTargets([]);
setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => {
if (active) setLoaded(true);
});
return () => { active = false; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
setAddedEmails(new Set());
setError("");
setSuccess("");
}, [message?.folder, message?.uid]);
if (!message || !loaded || !available) return null;
const writableTargets = targets.filter((target) => target.allowed);
const blockedTargets = targets.filter((target) => !target.allowed);
const addresses = uniqueMailboxAddresses([
...mailboxHeaderAddresses(message.from_header),
...mailboxHeaderAddresses(message.to_header),
...mailboxHeaderAddresses(message.cc_header)
]);
async function addContact(address: { name?: string | null; email: string }) {
if (!selectedTargetId || creatingEmail) return;
setCreatingEmail(address.email);
setError("");
setSuccess("");
try {
const result = await createMailAddressContact(settings, {
address_book_id: selectedTargetId,
display_name: address.name || address.email,
email: address.email
});
setAddedEmails((current) => new Set(current).add(address.email));
setSuccess(i18nMessage("i18n:govoplan-mail.contact_added", { value0: result.display_name }));
} catch (createError) {
setError(createError instanceof Error ? createError.message : String(createError));
} finally {
setCreatingEmail("");
}
}
return (
<section className="mailbox-contact-actions" aria-label="i18n:govoplan-mail.address_book_actions">
<h4>i18n:govoplan-mail.address_book_actions</h4>
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
{writableTargets.length > 0 ? (
<label className="mailbox-contact-target">
<span>i18n:govoplan-mail.save_contacts_to</span>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
{writableTargets.map((target) => (
<option key={target.address_book_id} value={target.address_book_id}>
{target.address_book_label || target.address_book_id}
</option>
))}
</select>
</label>
) : (
<p className="form-help">i18n:govoplan-mail.no_writable_address_book</p>
)}
<div className="mailbox-contact-candidates">
{addresses.map((address) => {
const added = addedEmails.has(address.email);
return (
<Button
key={address.email}
className="compact"
disabled={!selectedTargetId || Boolean(creatingEmail) || added}
disabledReason={!selectedTargetId ? blockedTargets[0]?.message || "i18n:govoplan-mail.no_writable_address_book" : undefined}
onClick={() => void addContact(address)}
>
{added ? <Check size={15} aria-hidden="true" /> : <UserPlus size={15} aria-hidden="true" />}
{added ? "i18n:govoplan-mail.contact_added_short" : i18nMessage("i18n:govoplan-mail.add_value_to_contacts", { value0: address.name || address.email })}
</Button>
);
})}
</div>
{blockedTargets.length > 0 ? (
<details className="mailbox-contact-policy">
<summary>i18n:govoplan-mail.unavailable_address_books</summary>
<ul>
{blockedTargets.map((target) => (
<li key={target.address_book_id}>
<strong>{target.address_book_label || target.address_book_id}</strong>: {target.message}
</li>
))}
</ul>
</details>
) : null}
</section>
);
}
function uniqueMailboxAddresses<T extends { email: string }>(addresses: T[]): T[] {
const seen = new Set<string>();
return addresses.filter((address) => {
const email = address.email.toLocaleLowerCase();
if (seen.has(email)) return false;
seen.add(email);
return true;
});
}
function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`;
@@ -0,0 +1,44 @@
type MailAddressLookupCandidateLike = {
display_name: string;
email?: string | null;
};
export type MailAddressValue = {
name?: string | null;
email: string;
};
const EMAIL_PATTERN = /([^<>;,\s]+@[^<>;,\s]+)/g;
export function mailLookupSuggestions(candidates: readonly MailAddressLookupCandidateLike[]): MailAddressValue[] {
const seen = new Set<string>();
const suggestions: MailAddressValue[] = [];
for (const candidate of candidates) {
const email = String(candidate.email ?? "").trim().toLocaleLowerCase();
if (!email || seen.has(email)) continue;
seen.add(email);
suggestions.push({ name: candidate.display_name || email, email });
}
return suggestions;
}
export function mailboxHeaderAddresses(value?: string | null): MailAddressValue[] {
const input = String(value ?? "").trim();
if (!input) return [];
const results: MailAddressValue[] = [];
const seen = new Set<string>();
for (const match of input.matchAll(EMAIL_PATTERN)) {
const email = match[1]?.replace(/[)>]+$/, "").toLocaleLowerCase();
if (!email || seen.has(email)) continue;
seen.add(email);
const prefix = input.slice(Math.max(0, input.lastIndexOf(",", match.index) + 1), match.index).trim();
const name = prefix.replace(/[<"']/g, "").trim() || undefined;
results.push({ name, email });
}
return results;
}
export function mailtoHref(recipients: readonly MailAddressValue[]): string {
const addresses = recipients.map((recipient) => recipient.email.trim()).filter(Boolean);
return `mailto:${addresses.map(encodeURIComponent).join(",")}`;
}
+20
View File
@@ -96,6 +96,16 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.compose": "Compose",
"i18n:govoplan-mail.recipients": "Recipients",
"i18n:govoplan-mail.address_suggestions_unavailable": "Address-book suggestions are unavailable. You can still enter an email address manually.",
"i18n:govoplan-mail.open_mail_application": "Open mail application",
"i18n:govoplan-mail.address_book_actions": "Address-book actions",
"i18n:govoplan-mail.save_contacts_to": "Save contacts to",
"i18n:govoplan-mail.no_writable_address_book": "No writable address book is available for your account.",
"i18n:govoplan-mail.contact_added": "{value0} was added to contacts.",
"i18n:govoplan-mail.contact_added_short": "Added",
"i18n:govoplan-mail.add_value_to_contacts": "Add {value0} to contacts",
"i18n:govoplan-mail.unavailable_address_books": "Unavailable address books and policy reasons",
"i18n:govoplan-mail.open_mail": "Open Mail",
"i18n:govoplan-mail.quick_access_description": "Recent mailbox messages and mail actions.",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
@@ -296,6 +306,16 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.compose": "Verfassen",
"i18n:govoplan-mail.recipients": "Empfänger",
"i18n:govoplan-mail.address_suggestions_unavailable": "Adressbuchvorschläge sind nicht verfügbar. Eine E-Mail-Adresse kann weiterhin manuell eingegeben werden.",
"i18n:govoplan-mail.open_mail_application": "Mail-Anwendung öffnen",
"i18n:govoplan-mail.address_book_actions": "Adressbuchaktionen",
"i18n:govoplan-mail.save_contacts_to": "Kontakte speichern in",
"i18n:govoplan-mail.no_writable_address_book": "Für dieses Konto ist kein beschreibbares Adressbuch verfügbar.",
"i18n:govoplan-mail.contact_added": "{value0} wurde zu den Kontakten hinzugefügt.",
"i18n:govoplan-mail.contact_added_short": "Hinzugefügt",
"i18n:govoplan-mail.add_value_to_contacts": "{value0} zu Kontakten hinzufügen",
"i18n:govoplan-mail.unavailable_address_books": "Nicht verfügbare Adressbücher und Richtliniengründe",
"i18n:govoplan-mail.open_mail": "Mail öffnen",
"i18n:govoplan-mail.quick_access_description": "Aktuelle Posteingangsnachrichten und Mail-Aktionen.",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
+50
View File
@@ -627,6 +627,56 @@
padding: 18px;
}
.mail-quick-compose {
display: grid;
gap: 10px;
padding: 12px;
margin-top: 10px;
border: var(--border-line);
border-radius: var(--radius-md);
background: var(--panel-soft);
}
.mail-quick-compose > label,
.mailbox-contact-target > span {
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
}
.mailbox-contact-actions {
display: grid;
gap: 10px;
margin-top: 16px;
padding-top: 16px;
border-top: var(--border-line);
}
.mailbox-contact-actions h4 {
margin: 0;
}
.mailbox-contact-target {
display: grid;
gap: 5px;
}
.mailbox-contact-candidates {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mailbox-contact-policy {
color: var(--muted);
font-size: 12px;
}
.mailbox-contact-policy ul {
margin: 8px 0 0;
padding-left: 20px;
}
@media (max-width: 1280px) {
.mailbox-shell.file-manager-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
@@ -0,0 +1,51 @@
import {
mailboxHeaderAddresses,
mailLookupSuggestions,
mailtoHref
} from "../src/features/mail/mailAddressIntegration";
function assertEqual(actual: unknown, expected: unknown): void {
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
}
assertDeepEqual(
mailLookupSuggestions([
{
display_name: "Ada Lovelace",
email: "Ada@Example.Test"
},
{
display_name: "Duplicate",
email: "ada@example.test"
},
{
display_name: "No email",
email: null
}
]),
[{ name: "Ada Lovelace", email: "ada@example.test" }]
);
assertDeepEqual(
mailboxHeaderAddresses('Ada Lovelace <ada@example.test>, "Grace Hopper" <grace@example.test>'),
[
{ name: "Ada Lovelace", email: "ada@example.test" },
{ name: "Grace Hopper", email: "grace@example.test" }
]
);
assertEqual(
mailtoHref([
{ name: "Ada Lovelace", email: "ada@example.test" },
{ email: "grace@example.test" }
]),
"mailto:ada%40example.test,grace%40example.test"
);
console.log("mail address integration tests passed");
+3 -1
View File
@@ -22,10 +22,12 @@
"tests/mailbox-launch.test.ts",
"tests/mail-profile-editor-model.test.ts",
"tests/mail-policy-validation.test.ts",
"tests/mail-address-integration.test.ts",
"src/features/mail/mailboxDisplay.ts",
"src/features/mail/mailboxFolders.ts",
"src/features/mail/mailboxLaunch.ts",
"src/features/mail/mailProfileEditorModel.ts",
"src/features/mail/mailPolicyValidation.ts"
"src/features/mail/mailPolicyValidation.ts",
"src/features/mail/mailAddressIntegration.ts"
]
}