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