feat: integrate governed address contacts
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(",")}`;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user