diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index 1b0f6ea..216524e 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -347,6 +347,12 @@ manifest = ModuleManifest( version_max_exclusive="0.2.0", optional=True, ), + ModuleInterfaceRequirement( + name="addresses.contact_writer", + version_min="0.1.0", + version_max_exclusive="0.2.0", + optional=True, + ), ModuleInterfaceRequirement( name=CAPABILITY_POSTBOX_DELIVERY, version_min="0.1.0", @@ -583,6 +589,52 @@ manifest = ModuleManifest( metadata={"kind": "reference", "help_contexts": ["mail.quick_access.messages"]}, order=37, ), + DocumentationTopic( + id="mail.address-book-integration", + title="Use address-book contacts in Mail", + summary="Autocomplete recipients and add message participants to an explicitly writable address book when Addresses is installed.", + body=( + "Mail resolves the optional addresses.lookup and addresses.contact_writer capabilities through the platform registry. " + "Quick Access compose accepts manual recipients in every installation and adds visible contact suggestions when lookup is available, " + "then opens the user's configured mail application. A selected mailbox message offers add-contact actions only through writer decisions " + "returned for the current principal. Read-only connector books and policy-blocked targets remain disabled with the owning Addresses reason. " + "Mail never imports Addresses models, bypasses its scope checks, or changes its read-only mailbox and external-compose custody model." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("mail_user", "mail_admin", "administrator"), + related_modules=("addresses",), + conditions=( + DocumentationCondition( + required_modules=("mail",), + required_scopes=("mail:profile:use",), + ), + ), + links=( + DocumentationLink(label="Mail", href="/mail", kind="runtime"), + DocumentationLink(label="Addresses", href="/addresses", kind="runtime"), + ), + translations={ + "de": { + "title": "Adressbuchkontakte in Mail verwenden", + "summary": "Empfänger vervollständigen und Nachrichtenbeteiligte in einem ausdrücklich beschreibbaren Adressbuch speichern, wenn Addresses installiert ist.", + "body": ( + "Mail löst die optionalen Capabilities addresses.lookup und addresses.contact_writer über die Plattformregistrierung auf. " + "Beim Verfassen im Schnellzugriff können Empfänger immer manuell eingegeben werden; bei verfügbarer Suche kommen sichtbare Kontaktvorschläge hinzu, " + "anschließend öffnet sich die konfigurierte Mail-Anwendung. Für eine ausgewählte Postfachnachricht werden Kontaktaktionen ausschließlich anhand der " + "Writer-Entscheidungen für den aktuellen Principal angeboten. Schreibgeschützte Connector-Adressbücher und durch Richtlinien gesperrte Ziele bleiben " + "mit dem von Addresses gelieferten Grund deaktiviert. Mail importiert keine Addresses-Modelle, umgeht keine Bereichsprüfung und ändert weder das " + "nur lesende Postfach noch das Verwahrungsmodell des externen Verfassens." + ), + } + }, + metadata={ + "kind": "workflow", + "route": "/mail", + "help_contexts": ["mail.quick_access.messages", "mail.mailbox"], + }, + order=38, + ), DocumentationTopic( id="mail.search.mailbox-messages", title="Search authorized mailbox messages", @@ -598,7 +650,7 @@ manifest = ModuleManifest( documentation_types=("admin", "user"), audience=("mail_user", "mail_admin", "administrator"), related_modules=("search",), - order=38, + order=39, ), DocumentationTopic( id="mail.profiles-and-policy", diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index 2150395..0d48385 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -11,6 +11,10 @@ from sqlalchemy.orm import Session from govoplan_mail.backend.schemas import ( MailAddressLookupCandidate, MailAddressLookupResponse, + MailAddressWriteTarget, + MailAddressWriteTargetResponse, + MailContactCreateRequest, + MailContactCreateResponse, MailConnectionTestResponse, MailBounceObservationListResponse, MailBounceObservationResponse, @@ -149,6 +153,7 @@ MAIL_CREDENTIAL_RESOURCE = "mail_credential" MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1" DEFAULT_MAILBOX_MESSAGE_LIMIT = 50 CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" +CAPABILITY_ADDRESSES_CONTACT_WRITER = "addresses.contact_writer" bounce_provider = SqlMailBounceProcessingProvider() @@ -416,6 +421,15 @@ def _capability_payload(value: object) -> dict[str, Any]: "source_ref", "source_revision", "provenance", + "address_book_label", + "operation", + "allowed", + "reason", + "message", + "scope_type", + "scope_id", + "read_only", + "required_scopes", ): if hasattr(value, key): payload[key] = getattr(value, key) @@ -1361,6 +1375,78 @@ def lookup_mail_addresses( ) +@router.get("/address-write-targets", response_model=MailAddressWriteTargetResponse) +def list_mail_address_write_targets( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> MailAddressWriteTargetResponse: + _require_scope(principal, "mail:profile:use") + capability = _registry_capability(CAPABILITY_ADDRESSES_CONTACT_WRITER) + if capability is None or not hasattr(capability, "list_write_targets"): + return MailAddressWriteTargetResponse(available=False, targets=[]) + targets = getattr(capability, "list_write_targets")(session, principal, operation="create_contact") + return MailAddressWriteTargetResponse( + available=True, + targets=[MailAddressWriteTarget.model_validate(_capability_payload(target)) for target in targets], + ) + + +@router.post( + "/address-contacts", + response_model=MailContactCreateResponse, + status_code=status.HTTP_201_CREATED, +) +def create_mail_address_contact( + payload: MailContactCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> MailContactCreateResponse: + _require_scope(principal, "mail:profile:use") + capability = _registry_capability(CAPABILITY_ADDRESSES_CONTACT_WRITER) + if capability is None or not all( + hasattr(capability, method) + for method in ("can_write_to_address_book", "create_contact") + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Address-book contact writing is not available.", + ) + decision = getattr(capability, "can_write_to_address_book")( + session, + principal, + address_book_id=payload.address_book_id, + operation="create_contact", + ) + decision_payload = _capability_payload(decision) + if decision_payload.get("allowed") is not True: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(decision_payload.get("message") or "The selected address book is not writable."), + ) + try: + result = getattr(capability, "create_contact")( + session, + principal, + address_book_id=payload.address_book_id, + payload={ + "display_name": payload.display_name or payload.email, + "emails": [{"label": "Mail", "email": payload.email, "is_primary": True}], + }, + provenance={ + "consumer_module": MAIL_MODULE_ID, + "consumer_workflow": "mailbox_add_contact", + }, + ) + session.commit() + except ValueError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return MailContactCreateResponse.model_validate(_capability_payload(result)) + + @router.get("/settings/delta", response_model=MailSettingsDeltaResponse) def mail_settings_delta( scope_type: str = Query(default="tenant"), diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py index 8aeed4e..68bfb53 100644 --- a/src/govoplan_mail/backend/schemas.py +++ b/src/govoplan_mail/backend/schemas.py @@ -344,6 +344,43 @@ class MailAddressLookupResponse(BaseModel): candidates: list[MailAddressLookupCandidate] = Field(default_factory=list) +class MailAddressWriteTarget(BaseModel): + address_book_id: str + address_book_label: str | None = None + operation: str = "create_contact" + allowed: bool = False + reason: str + message: str + scope_type: str | None = None + scope_id: str | None = None + source_kind: str | None = None + read_only: bool = False + required_scopes: list[str] = Field(default_factory=list) + provenance: dict[str, Any] = Field(default_factory=dict) + + +class MailAddressWriteTargetResponse(BaseModel): + available: bool = False + targets: list[MailAddressWriteTarget] = Field(default_factory=list) + + +class MailContactCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + address_book_id: str = Field(min_length=1, max_length=36) + display_name: str | None = Field(default=None, max_length=255) + email: str = Field(min_length=3, max_length=320, pattern=r"^[^\s@]+@[^\s@]+\.[^\s@]+$") + + +class MailContactCreateResponse(BaseModel): + contact_id: str + address_book_id: str + display_name: str + email: str | None = None + source_kind: str = "local" + provenance: dict[str, Any] = Field(default_factory=dict) + + class MailConnectionTestResponse(BaseModel): ok: bool protocol: Literal["smtp", "imap"] diff --git a/tests/test_address_integration.py b/tests/test_address_integration.py new file mode 100644 index 0000000..9d09d99 --- /dev/null +++ b/tests/test_address_integration.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from fastapi import HTTPException +from pydantic import ValidationError + +from govoplan_mail.backend.router import ( + create_mail_address_contact, + list_mail_address_write_targets, + lookup_mail_addresses, +) +from govoplan_mail.backend.schemas import MailContactCreateRequest + + +class _Session: + def __init__(self) -> None: + self.commits = 0 + self.rollbacks = 0 + + def commit(self) -> None: + self.commits += 1 + + def rollback(self) -> None: + self.rollbacks += 1 + + +class _Writer: + def __init__(self, *, allowed: bool = True, read_only: bool = False) -> None: + self.allowed = allowed + self.read_only = read_only + self.created_payload = None + self.created_provenance = None + + def list_write_targets(self, _session, _principal, *, operation): + return ( + SimpleNamespace( + address_book_id="book-1", + address_book_label="Personal contacts", + operation=operation, + allowed=self.allowed, + reason="allowed" if self.allowed else "read_only_source", + message="Contact can be added." if self.allowed else "This source is read-only.", + scope_type="user", + scope_id="user-1", + source_kind="local" if self.allowed else "ldap", + read_only=self.read_only, + required_scopes=("addresses:contacts:write",), + provenance={"policy": "addresses"}, + ), + ) + + def can_write_to_address_book(self, _session, _principal, *, address_book_id, operation): + return self.list_write_targets(_session, _principal, operation=operation)[0] + + def create_contact(self, _session, _principal, *, address_book_id, payload, provenance): + self.created_payload = payload + self.created_provenance = provenance + return SimpleNamespace( + contact_id="contact-1", + address_book_id=address_book_id, + display_name=payload["display_name"], + email=payload["emails"][0]["email"], + source_kind="local", + provenance=provenance, + ) + + +def _principal(): + return SimpleNamespace(has=lambda scope: scope == "mail:profile:use") + + +class MailAddressIntegrationTests(unittest.TestCase): + def test_optional_capabilities_fail_open_for_mail(self) -> None: + with patch("govoplan_mail.backend.router._registry_capability", return_value=None): + lookup = lookup_mail_addresses(query="ada", limit=25, session=_Session(), principal=_principal()) + targets = list_mail_address_write_targets(session=_Session(), principal=_principal()) + + self.assertFalse(lookup.available) + self.assertEqual(lookup.candidates, []) + self.assertFalse(targets.available) + self.assertEqual(targets.targets, []) + + def test_write_target_preserves_read_only_decision(self) -> None: + writer = _Writer(allowed=False, read_only=True) + with patch("govoplan_mail.backend.router._registry_capability", return_value=writer): + response = list_mail_address_write_targets(session=_Session(), principal=_principal()) + + self.assertTrue(response.available) + self.assertFalse(response.targets[0].allowed) + self.assertTrue(response.targets[0].read_only) + self.assertEqual(response.targets[0].reason, "read_only_source") + + def test_blocked_target_cannot_be_bypassed_by_create(self) -> None: + session = _Session() + writer = _Writer(allowed=False, read_only=True) + with ( + patch("govoplan_mail.backend.router._registry_capability", return_value=writer), + self.assertRaises(HTTPException) as raised, + ): + create_mail_address_contact( + MailContactCreateRequest( + address_book_id="book-1", + display_name="Ada Lovelace", + email="ada@example.test", + ), + session=session, + principal=_principal(), + ) + + self.assertEqual(raised.exception.status_code, 422) + self.assertEqual(session.commits, 0) + + def test_allowed_create_uses_writer_and_records_consumer_provenance(self) -> None: + session = _Session() + writer = _Writer() + with patch("govoplan_mail.backend.router._registry_capability", return_value=writer): + result = create_mail_address_contact( + MailContactCreateRequest( + address_book_id="book-1", + display_name="Ada Lovelace", + email="ada@example.test", + ), + session=session, + principal=_principal(), + ) + + self.assertEqual(result.contact_id, "contact-1") + self.assertEqual(writer.created_payload["emails"][0]["email"], "ada@example.test") + self.assertEqual(writer.created_provenance["consumer_module"], "mail") + self.assertEqual(session.commits, 1) + + def test_proxy_rejects_invalid_email_before_calling_writer(self) -> None: + with self.assertRaises(ValidationError): + MailContactCreateRequest(address_book_id="book-1", email="not-an-email") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 02877a4..1fff79c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -16,6 +16,7 @@ class MailManifestTests(unittest.TestCase): self.assertEqual(manifest.id, "mail") self.assertIn("addresses", manifest.optional_dependencies) self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces}) + self.assertIn("addresses.contact_writer", {interface.name for interface in manifest.requires_interfaces}) self.assertIn( { "name": "campaigns.access", @@ -62,6 +63,7 @@ class MailManifestTests(unittest.TestCase): "mail.workflow.read-mailbox", "mail.reference.credentials-egress-retirement", "mail.reference.campaign-delivery-contract", + "mail.address-book-integration", }.issubset(topics) ) ownership = topics["mail.profile-ownership-and-consumers"] diff --git a/webui/package.json b/webui/package.json index 140bf0d..30f4e94 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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" diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index c86f37a..22c778c 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -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; +}; + +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; +}; + export type MailMailboxAttachment = { filename?: string | null; content_type: string; @@ -209,6 +244,20 @@ export async function lookupMailAddresses(settings: ApiSettings, query: string, return apiFetch(settings, apiPath("/api/v1/mail/address-lookup", { query, limit })); } +export async function listMailAddressWriteTargets(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/mail/address-write-targets"); +} + +export async function createMailAddressContact( + settings: ApiSettings, + payload: MailContactCreatePayload +): Promise { + return apiFetch(settings, "/api/v1/mail/address-contacts", { + method: "POST", + body: JSON.stringify(payload) + }); +} + export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise { return apiGetList(settings, "/api/v1/mail/profiles", "profiles", { include_inactive: includeInactive ? true : undefined, diff --git a/webui/src/features/mail/MailQuickAccess.tsx b/webui/src/features/mail/MailQuickAccess.tsx index df8f973..c9de076 100644 --- a/webui/src/features/mail/MailQuickAccess.tsx +++ b/webui/src/features/mail/MailQuickAccess.tsx @@ -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([]); + const [suggestions, setSuggestions] = useState([]); + const [lookupAvailable, setLookupAvailable] = useState(null); + const [lookupError, setLookupError] = useState(""); + const lookupRequestRef = useRef(0); const load = useCallback(async (): Promise => { 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 ( {error ? {error} : null} @@ -67,10 +98,37 @@ export default function MailQuickAccess({ settings, launchContext, close }: Prop onClick: close }))} /> + {composing ? ( +
+ + void lookupRecipients(query)} + compact + interfaceId="mail.quick-access.compose.recipients" + helpModuleId="mail" + helpTopicId="mail.address-book-integration" + /> + {lookupAvailable === false ? ( +

i18n:govoplan-mail.address_suggestions_unavailable

+ ) : null} + {lookupError ? {lookupError} : null} +
+ + + +
+
+ ) : null} @@ -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([]); + const [selectedTargetId, setSelectedTargetId] = useState(""); + const [creatingEmail, setCreatingEmail] = useState(""); + const [addedEmails, setAddedEmails] = useState>(() => 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 ( +
+

i18n:govoplan-mail.address_book_actions

+ {error ? {error} : null} + {success ? {success} : null} + {writableTargets.length > 0 ? ( + + ) : ( +

i18n:govoplan-mail.no_writable_address_book

+ )} +
+ {addresses.map((address) => { + const added = addedEmails.has(address.email); + return ( + + ); + })} +
+ {blockedTargets.length > 0 ? ( +
+ i18n:govoplan-mail.unavailable_address_books +
    + {blockedTargets.map((target) => ( +
  • + {target.address_book_label || target.address_book_id}: {target.message} +
  • + ))} +
+
+ ) : null} +
+ ); +} + +function uniqueMailboxAddresses(addresses: T[]): T[] { + const seen = new Set(); + 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}`; diff --git a/webui/src/features/mail/mailAddressIntegration.ts b/webui/src/features/mail/mailAddressIntegration.ts new file mode 100644 index 0000000..28135c9 --- /dev/null +++ b/webui/src/features/mail/mailAddressIntegration.ts @@ -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(); + 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(); + 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(",")}`; +} diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 5377243..e2792b6 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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.", diff --git a/webui/src/styles/mail-profiles.css b/webui/src/styles/mail-profiles.css index aafd04c..059f0cd 100644 --- a/webui/src/styles/mail-profiles.css +++ b/webui/src/styles/mail-profiles.css @@ -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); diff --git a/webui/tests/mail-address-integration.test.ts b/webui/tests/mail-address-integration.test.ts new file mode 100644 index 0000000..2b71951 --- /dev/null +++ b/webui/tests/mail-address-integration.test.ts @@ -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 , "Grace Hopper" '), + [ + { 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"); diff --git a/webui/tsconfig.mail-tests.json b/webui/tsconfig.mail-tests.json index 00e6c7a..ca184de 100644 --- a/webui/tsconfig.mail-tests.json +++ b/webui/tsconfig.mail-tests.json @@ -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" ] }