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
+53 -1
View File
@@ -347,6 +347,12 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0", version_max_exclusive="0.2.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name="addresses.contact_writer",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
name=CAPABILITY_POSTBOX_DELIVERY, name=CAPABILITY_POSTBOX_DELIVERY,
version_min="0.1.0", version_min="0.1.0",
@@ -583,6 +589,52 @@ manifest = ModuleManifest(
metadata={"kind": "reference", "help_contexts": ["mail.quick_access.messages"]}, metadata={"kind": "reference", "help_contexts": ["mail.quick_access.messages"]},
order=37, 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( DocumentationTopic(
id="mail.search.mailbox-messages", id="mail.search.mailbox-messages",
title="Search authorized mailbox messages", title="Search authorized mailbox messages",
@@ -598,7 +650,7 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("mail_user", "mail_admin", "administrator"), audience=("mail_user", "mail_admin", "administrator"),
related_modules=("search",), related_modules=("search",),
order=38, order=39,
), ),
DocumentationTopic( DocumentationTopic(
id="mail.profiles-and-policy", id="mail.profiles-and-policy",
+86
View File
@@ -11,6 +11,10 @@ from sqlalchemy.orm import Session
from govoplan_mail.backend.schemas import ( from govoplan_mail.backend.schemas import (
MailAddressLookupCandidate, MailAddressLookupCandidate,
MailAddressLookupResponse, MailAddressLookupResponse,
MailAddressWriteTarget,
MailAddressWriteTargetResponse,
MailContactCreateRequest,
MailContactCreateResponse,
MailConnectionTestResponse, MailConnectionTestResponse,
MailBounceObservationListResponse, MailBounceObservationListResponse,
MailBounceObservationResponse, MailBounceObservationResponse,
@@ -149,6 +153,7 @@ MAIL_CREDENTIAL_RESOURCE = "mail_credential"
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1" MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50 DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
CAPABILITY_ADDRESSES_CONTACT_WRITER = "addresses.contact_writer"
bounce_provider = SqlMailBounceProcessingProvider() bounce_provider = SqlMailBounceProcessingProvider()
@@ -416,6 +421,15 @@ def _capability_payload(value: object) -> dict[str, Any]:
"source_ref", "source_ref",
"source_revision", "source_revision",
"provenance", "provenance",
"address_book_label",
"operation",
"allowed",
"reason",
"message",
"scope_type",
"scope_id",
"read_only",
"required_scopes",
): ):
if hasattr(value, key): if hasattr(value, key):
payload[key] = getattr(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) @router.get("/settings/delta", response_model=MailSettingsDeltaResponse)
def mail_settings_delta( def mail_settings_delta(
scope_type: str = Query(default="tenant"), scope_type: str = Query(default="tenant"),
+37
View File
@@ -344,6 +344,43 @@ class MailAddressLookupResponse(BaseModel):
candidates: list[MailAddressLookupCandidate] = Field(default_factory=list) 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): class MailConnectionTestResponse(BaseModel):
ok: bool ok: bool
protocol: Literal["smtp", "imap"] protocol: Literal["smtp", "imap"]
+141
View File
@@ -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()
+2
View File
@@ -16,6 +16,7 @@ class MailManifestTests(unittest.TestCase):
self.assertEqual(manifest.id, "mail") self.assertEqual(manifest.id, "mail")
self.assertIn("addresses", manifest.optional_dependencies) self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces}) 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( self.assertIn(
{ {
"name": "campaigns.access", "name": "campaigns.access",
@@ -62,6 +63,7 @@ class MailManifestTests(unittest.TestCase):
"mail.workflow.read-mailbox", "mail.workflow.read-mailbox",
"mail.reference.credentials-egress-retirement", "mail.reference.credentials-egress-retirement",
"mail.reference.campaign-delivery-contract", "mail.reference.campaign-delivery-contract",
"mail.address-book-integration",
}.issubset(topics) }.issubset(topics)
) )
ownership = topics["mail.profile-ownership-and-consumers"] ownership = topics["mail.profile-ownership-and-consumers"]
+1 -1
View File
@@ -26,7 +26,7 @@
} }
}, },
"scripts": { "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": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
+49
View File
@@ -65,6 +65,41 @@ export type MailAddressLookupResponse = {
candidates: MailAddressLookupCandidate[]; 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 = { export type MailMailboxAttachment = {
filename?: string | null; filename?: string | null;
content_type: string; 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 })); 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[]> { export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", { return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined, include_inactive: includeInactive ? true : undefined,
+62 -4
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 { ExternalLink, FilePenLine, Mail, Pencil } from "lucide-react";
import { Link } from "react-router"; import { Link } from "react-router";
import { import {
DashboardWidgetList, DashboardWidgetList,
DismissibleAlert, DismissibleAlert,
EmailAddressInput,
LoadingFrame, LoadingFrame,
quickAccessLaunchState, quickAccessLaunchState,
useDashboardWidgetData, useDashboardWidgetData,
type MailboxAddress,
type QuickAccessToolRenderContext type QuickAccessToolRenderContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
bootstrapMailbox, bootstrapMailbox,
listMailServerProfiles, listMailServerProfiles,
lookupMailAddresses,
type MailMailboxMessageSummary type MailMailboxMessageSummary
} from "../../api/mail"; } from "../../api/mail";
import { mailLookupSuggestions, mailtoHref } from "./mailAddressIntegration";
import { import {
mailboxDraftsLaunchPath, mailboxDraftsLaunchPath,
mailboxMessageLaunchPath mailboxMessageLaunchPath
@@ -34,6 +38,12 @@ type Props = Pick<
>; >;
export default function MailQuickAccess({ settings, launchContext, close }: Props) { 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 load = useCallback(async (): Promise<MailQuickAccessData> => {
const profiles = await listMailServerProfiles(settings); const profiles = await listMailServerProfiles(settings);
const profile = profiles.find((item) => item.is_active && item.imap); const profile = profiles.find((item) => item.is_active && item.imap);
@@ -51,6 +61,27 @@ export default function MailQuickAccess({ settings, launchContext, close }: Prop
}, [settings]); }, [settings]);
const { data, loading, error } = useDashboardWidgetData(load, 0); 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 ( return (
<LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_messages.4294022c"> <LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_messages.4294022c">
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null} {error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
@@ -67,10 +98,37 @@ export default function MailQuickAccess({ settings, launchContext, close }: Prop
onClick: close onClick: close
}))} }))}
/> />
<div className="dashboard-contribution-footer"> {composing ? (
<a className="btn btn-secondary" href="mailto:" onClick={close}> <div className="mail-quick-compose" aria-label="i18n:govoplan-mail.compose">
<Pencil size={15} aria-hidden="true" /> 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> </a>
</div>
</div>
) : null}
<div className="dashboard-contribution-footer">
<button type="button" className="btn btn-secondary" onClick={() => setComposing((current) => !current)} aria-expanded={composing}>
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.compose
</button>
{data?.profileId && data.draftsFolder ? ( {data?.profileId && data.draftsFolder ? (
<Link <Link
className="btn btn-secondary" className="btn btn-secondary"
+147 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; 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 { useLocation } from "react-router";
import { ToolbarGroup, ActionToolbar, import { ToolbarGroup, ActionToolbar,
ActionBlockerHint, ActionBlockerHint,
@@ -21,9 +21,12 @@ import { ToolbarGroup, ActionToolbar,
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
bootstrapMailbox, bootstrapMailbox,
createMailAddressContact,
getMailboxMessage, getMailboxMessage,
listMailAddressWriteTargets,
listMailboxMessages, listMailboxMessages,
listMailServerProfiles, listMailServerProfiles,
type MailAddressWriteTarget,
type MailImapFolderResponse, type MailImapFolderResponse,
type MailMailboxMessageDetail, type MailMailboxMessageDetail,
type MailMailboxMessageSummary, type MailMailboxMessageSummary,
@@ -32,6 +35,7 @@ import {
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders"; import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay"; import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch"; import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch";
import { mailboxHeaderAddresses } from "./mailAddressIntegration";
const MAILBOX_DOCUMENTATION = { const MAILBOX_DOCUMENTATION = {
topicId: "mail.workflow.read-mailbox", topicId: "mail.workflow.read-mailbox",
@@ -628,6 +632,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}))} }))}
emptyText={previewEmptyText} /> emptyText={previewEmptyText} />
<MailboxContactActions settings={settings} message={selectedMessage} />
</div> </div>
</section> </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 { function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`; 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_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail", "i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.compose": "Compose", "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.open_mail": "Open Mail",
"i18n:govoplan-mail.quick_access_description": "Recent mailbox messages and mail actions.", "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.", "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_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail", "i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.compose": "Verfassen", "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.open_mail": "Mail öffnen",
"i18n:govoplan-mail.quick_access_description": "Aktuelle Posteingangsnachrichten und Mail-Aktionen.", "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.", "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; 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) { @media (max-width: 1280px) {
.mailbox-shell.file-manager-shell { .mailbox-shell.file-manager-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); 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/mailbox-launch.test.ts",
"tests/mail-profile-editor-model.test.ts", "tests/mail-profile-editor-model.test.ts",
"tests/mail-policy-validation.test.ts", "tests/mail-policy-validation.test.ts",
"tests/mail-address-integration.test.ts",
"src/features/mail/mailboxDisplay.ts", "src/features/mail/mailboxDisplay.ts",
"src/features/mail/mailboxFolders.ts", "src/features/mail/mailboxFolders.ts",
"src/features/mail/mailboxLaunch.ts", "src/features/mail/mailboxLaunch.ts",
"src/features/mail/mailProfileEditorModel.ts", "src/features/mail/mailProfileEditorModel.ts",
"src/features/mail/mailPolicyValidation.ts" "src/features/mail/mailPolicyValidation.ts",
"src/features/mail/mailAddressIntegration.ts"
] ]
} }