Compare commits

..
2 Commits
Author SHA1 Message Date
zemion a044fee379 feat: add reusable SMTP batch sessions 2026-08-20 17:39:00 +02:00
zemion 169b81d9db feat: integrate governed address contacts 2026-08-20 11:10:18 +02:00
18 changed files with 1361 additions and 19 deletions
+12
View File
@@ -41,6 +41,18 @@ and requires explicit evidence-backed reconciliation before any deliberate
resend. Business readers receive only counts and sanitized state; recipient
refusal details require `mail:delivery:diagnostic`.
Synchronous Campaign batches now preflight DNS, egress, connectivity, TLS, and
authentication before the first message, then reuse the authorized SMTP
connection for the bounded batch. A health check precedes reuse; a stale
connection is reopened before the next message, while a connection loss after
DATA starts remains outcome-unknown and is never replayed automatically.
Systemic authentication, sender, and connectivity failures pause remaining
Campaign jobs instead of producing one failure per recipient. Deployment
operators can disable reuse or bound connection lifetime and reconnects with
`GOVOPLAN_SMTP_BATCH_REUSE`, `GOVOPLAN_SMTP_BATCH_MAX_MESSAGES`,
`GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS`, and
`GOVOPLAN_SMTP_BATCH_HEALTH_CHECK`.
SMTP effects decrypt only SMTP credentials; Sent-folder effects decrypt only
IMAP credentials. A connection loss after an effect starts is surfaced as an
unknown outcome. Campaign does not automatically retry an unknown IMAP append,
+143 -2
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from typing import Any
from typing import Any, Iterator
from sqlalchemy.orm import Session
@@ -40,13 +42,27 @@ from govoplan_mail.backend.sending.imap import (
append_message_to_sent,
)
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
from govoplan_mail.backend.sending.smtp import (
SmtpBatchSession,
SmtpConfigurationError,
SmtpSendError,
send_email_bytes,
)
_ACTIVE_SMTP_BATCH: ContextVar[SmtpBatchSession | None] = ContextVar(
"govoplan_mail_active_smtp_batch",
default=None,
)
@dataclass(frozen=True, slots=True)
class CampaignSmtpDeliveryResult:
envelope_recipients: list[str]
refused_recipients: dict[str, dict[str, int | str]]
connection_sequence: int = 1
session_reused: bool = False
reconnect_count: int = 0
@property
def accepted_count(self) -> int:
@@ -58,6 +74,23 @@ class CampaignImapAppendResult:
folder: str
@dataclass(frozen=True, slots=True)
class CampaignSmtpBatchState:
session: SmtpBatchSession
@property
def status(self) -> str:
return "ready"
@property
def connection_count(self) -> int:
return self.session.connection_count
@property
def reconnect_count(self) -> int:
return self.session.reconnect_count
def _sanitized_refusals(
refused_recipients: dict[str, tuple[int, bytes | str]],
) -> dict[str, dict[str, int | str]]:
@@ -95,6 +128,9 @@ def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
message,
temporary=exc.temporary,
outcome_unknown=exc.outcome_unknown,
systemic=exc.systemic,
reason_code=exc.reason_code,
phase=exc.phase,
)
@@ -279,6 +315,106 @@ def campaign_profile_delivery_summary(
}
@contextmanager
def campaign_smtp_batch(
session: Session,
*,
tenant_id: str,
campaign_id: str,
profile_id: str,
envelope_from: str,
envelope_recipients: list[str],
from_header: str | None,
expected_smtp_transport_revision: str,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
) -> Iterator[CampaignSmtpBatchState]:
"""Preflight and retain one authorized SMTP connection for a batch."""
selection = _selection_payload(
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
)
try:
profile = _authorized_campaign_profile(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
profile_id=profile_id,
selection=selection,
)
except MailProfileError:
raise
except Exception:
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
if _supports_hierarchy(session):
context = _campaign_hierarchy_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
try:
selected_smtp = select_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
)
except MailServerHierarchyError as exc:
raise MailProfileError(str(exc)) from exc
current_revision = selected_smtp.transport_revision
else:
context = None
current_revision = campaign_profile_transport_revisions(profile)["smtp"]
if current_revision != expected_smtp_transport_revision:
raise MailProfileError(
"The selected Mail profile's SMTP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before delivery."
)
try:
smtp = (
resolve_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
).config
if context is not None
else smtp_config_from_profile(profile)
)
except MailProfileError:
raise
except Exception:
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
try:
assert_mail_policy_allows_send(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
smtp=smtp,
imap=None,
envelope_sender=envelope_from,
from_header=from_header,
recipients=envelope_recipients,
)
except MailProfileError:
raise MailProfileError("Mail delivery is blocked by the effective Mail policy.") from None
smtp_session = SmtpBatchSession(smtp)
smtp_session.preflight()
token = _ACTIVE_SMTP_BATCH.set(smtp_session)
try:
yield CampaignSmtpBatchState(session=smtp_session)
finally:
_ACTIVE_SMTP_BATCH.reset(token)
smtp_session.close()
def send_campaign_email_bytes(
session: Session,
*,
@@ -394,6 +530,7 @@ def send_campaign_email_bytes(
smtp_config=smtp,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
batch_session=_ACTIVE_SMTP_BATCH.get(),
)
except SmtpSendError as exc:
sanitized = _sanitized_smtp_error(exc)
@@ -420,6 +557,9 @@ def send_campaign_email_bytes(
sanitized_result = CampaignSmtpDeliveryResult(
envelope_recipients=list(result.envelope_recipients),
refused_recipients=_sanitized_refusals(result.refused_recipients),
connection_sequence=getattr(result, "connection_sequence", 0),
session_reused=getattr(result, "session_reused", False),
reconnect_count=getattr(result, "reconnect_count", 0),
)
if recovery is not None:
try:
@@ -604,6 +744,7 @@ class MailCampaignCapability:
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
campaign_smtp_batch = staticmethod(campaign_smtp_batch)
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
+55 -3
View File
@@ -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",
@@ -877,7 +929,7 @@ manifest = ModuleManifest(
id="mail.reference.campaign-delivery-contract",
title="Integrate Campaign through the Mail delivery contract",
summary="Campaign freezes a Mail profile reference and opaque revision; Mail re-authorizes, revision-checks, resolves credentials, and performs the effect in one call.",
body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Every current SMTP and Sent-folder attempt passes a stable effect identifier into a Mail-owned Core recovery operation before provider contact. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority. Mail outbox dispatch and retention scans are partitioned by tenant entitlement, so disabling Mail leaves accepted commands and evidence untouched for operator resolution.",
body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Synchronous batches authorize the complete recipient set and preflight DNS, egress, connectivity, TLS, and authentication before the first effect. Mail reuses the bounded connection when deployment policy permits, health-checks it before reuse, and reconnects before the next message when a stale connection is detected. A connection loss after DATA begins remains outcome-unknown and is never replayed. Systemic authentication, sender, or connectivity failures carry stable reason codes so Campaign pauses remaining queued work and shows connection, reconnect, failure, and pause progress. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Every current SMTP and Sent-folder attempt passes a stable effect identifier into a Mail-owned Core recovery operation before provider contact. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority. Mail outbox dispatch and retention scans are partitioned by tenant entitlement, so disabling Mail leaves accepted commands and evidence untouched for operator resolution.",
layer="available",
documentation_types=("admin", "user"),
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
@@ -900,7 +952,7 @@ manifest = ModuleManifest(
"route": "/campaigns/{campaign_id}/mail-settings",
"screen": "Campaign Mail settings",
"section": "Mail-owned profile and transport boundary",
"verification": "Prove stale revisions fail before credential decryption, SMTP never decrypts IMAP credentials, IMAP never decrypts SMTP credentials, provider details are sanitized, and the interface/version gate passes.",
"verification": "Prove stale revisions fail before credential decryption, batch preflight fails before DATA, two messages reuse one healthy connection, a stale connection reconnects before the next message, post-DATA disconnect is never replayed, systemic failures pause remaining jobs, provider details are sanitized, and the interface/version gate passes.",
"related_topic_ids": [
"mail.profile-ownership-and-consumers",
"campaigns.mail-profile-user-journey",
+86
View File
@@ -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"),
+37
View File
@@ -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"]
+348 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import logging
import os
import smtplib
import ssl
from dataclasses import dataclass
@@ -64,10 +65,22 @@ class SmtpSendError(RuntimeError):
started, so automatic retry is intentionally forbidden.
"""
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False):
def __init__(
self,
message: str,
*,
temporary: bool = False,
outcome_unknown: bool = False,
systemic: bool = False,
reason_code: str | None = None,
phase: str = "send",
):
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
self.systemic = systemic
self.reason_code = reason_code
self.phase = phase
@dataclass(frozen=True, slots=True)
@@ -86,12 +99,315 @@ class SmtpSendResult:
envelope_from: str
envelope_recipients: list[str]
refused_recipients: dict[str, tuple[int, bytes | str]]
connection_sequence: int = 1
session_reused: bool = False
reconnect_count: int = 0
@property
def accepted_count(self) -> int:
return len(self.envelope_recipients) - len(self.refused_recipients)
@dataclass(frozen=True, slots=True)
class SmtpBatchPolicy:
reuse_connections: bool = True
max_messages_per_connection: int = 100
reconnect_attempts: int = 1
health_check_before_reuse: bool = True
@classmethod
def from_environment(cls) -> "SmtpBatchPolicy":
return cls(
reuse_connections=_environment_bool("GOVOPLAN_SMTP_BATCH_REUSE", True),
max_messages_per_connection=_environment_int(
"GOVOPLAN_SMTP_BATCH_MAX_MESSAGES",
default=100,
minimum=1,
maximum=10_000,
),
reconnect_attempts=_environment_int(
"GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS",
default=1,
minimum=0,
maximum=5,
),
health_check_before_reuse=_environment_bool(
"GOVOPLAN_SMTP_BATCH_HEALTH_CHECK",
True,
),
)
@dataclass(frozen=True, slots=True)
class SmtpBatchPreflightResult:
ready: bool
authenticated: bool
connection_sequence: int
reconnect_count: int
class SmtpBatchSession:
"""Bounded reusable SMTP connection for one already-authorized batch."""
def __init__(
self,
smtp_config: SmtpConfig,
*,
policy: SmtpBatchPolicy | None = None,
) -> None:
self.smtp_config = smtp_config
self.policy = policy or SmtpBatchPolicy.from_environment()
self._smtp: smtplib.SMTP | None = None
self._connection_sequence = 0
self._reconnect_count = 0
self._messages_on_connection = 0
self._closed = False
@property
def connection_count(self) -> int:
return self._connection_sequence
@property
def reconnect_count(self) -> int:
return self._reconnect_count
def preflight(self) -> SmtpBatchPreflightResult:
"""Validate DNS/egress/connectivity/TLS/auth before a provider effect."""
if self._closed:
raise SmtpSendError(
"SMTP batch session is closed.",
systemic=True,
reason_code="batch_session_closed",
phase="preflight",
)
_require_smtp_config(self.smtp_config)
if is_mock_smtp_host(self.smtp_config.host):
if self._connection_sequence == 0:
self._connection_sequence = 1
return self._preflight_result()
if self._smtp is None:
self._connect_with_retries()
return self._preflight_result()
def send(
self,
message: EmailMessage | bytes,
*,
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
host, port, recipients = _prepare_smtp_send(
smtp_config=self.smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
if is_mock_smtp_host(self.smtp_config.host):
preflight = self.preflight()
_accepted, refused = _send_mock_smtp_payload(
message,
smtp_config=self.smtp_config,
envelope_from=envelope_from,
envelope_recipients=recipients,
)
self._messages_on_connection += 1
return _smtp_send_result(
smtp_config=self.smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
connection_sequence=preflight.connection_sequence,
session_reused=self._messages_on_connection > 1,
reconnect_count=preflight.reconnect_count,
)
reused = self._prepare_connection_for_send()
smtp = self._smtp
if smtp is None: # Defensive: preflight either opens or raises.
raise SmtpSendError(
"SMTP preflight did not establish a connection.",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
)
try:
if isinstance(message, bytes):
refused = smtp.sendmail(envelope_from, recipients, message)
else:
refused = smtp.send_message(
message,
from_addr=envelope_from,
to_addrs=recipients,
)
except smtplib.SMTPRecipientsRefused as exc:
raise SmtpSendError(
f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}",
temporary=False,
reason_code="smtp_recipients_refused",
) from exc
except smtplib.SMTPSenderRefused as exc:
self._discard_connection()
raise SmtpSendError(
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_sender_refused",
) from exc
except smtplib.SMTPResponseException as exc:
disconnected = int(exc.smtp_code) == 421
if disconnected:
self._discard_connection()
raise SmtpSendError(
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=disconnected,
reason_code="smtp_connection_closed" if disconnected else "smtp_message_rejected",
) from exc
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
self._discard_connection()
raise SmtpSendError(
f"SMTP outcome is unknown after transmission started: {exc}",
outcome_unknown=True,
systemic=True,
reason_code="smtp_connection_lost_after_transmission",
) from exc
self._messages_on_connection += 1
result = _smtp_send_result(
smtp_config=self.smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
connection_sequence=self._connection_sequence,
session_reused=reused,
reconnect_count=self._reconnect_count,
)
if not self.policy.reuse_connections:
self._discard_connection()
return result
def close(self) -> None:
self._closed = True
self._discard_connection()
def __enter__(self) -> "SmtpBatchSession":
self.preflight()
return self
def __exit__(self, _exc_type, _exc, _traceback) -> None:
self.close()
def _preflight_result(self) -> SmtpBatchPreflightResult:
return SmtpBatchPreflightResult(
ready=True,
authenticated=bool(self.smtp_config.username and self.smtp_config.password),
connection_sequence=self._connection_sequence,
reconnect_count=self._reconnect_count,
)
def _prepare_connection_for_send(self) -> bool:
reused = self._smtp is not None and self._messages_on_connection > 0
if self._smtp is not None and self._messages_on_connection >= self.policy.max_messages_per_connection:
self._discard_connection()
reused = False
elif reused and self.policy.health_check_before_reuse:
try:
code, _message = self._smtp.noop()
if int(code) >= 400:
raise smtplib.SMTPServerDisconnected(f"SMTP NOOP returned {code}")
except (OSError, smtplib.SMTPException):
self._discard_connection()
reused = False
self.preflight()
return reused and self._smtp is not None
def _connect_with_retries(self) -> None:
last_error: BaseException | None = None
for attempt in range(self.policy.reconnect_attempts + 1):
try:
smtp = _open_smtp(self.smtp_config)
except smtplib.SMTPAuthenticationError as exc:
raise SmtpSendError(
"SMTP authentication failed during batch preflight.",
systemic=True,
reason_code="smtp_authentication_failed",
phase="preflight",
) from exc
except SmtpConfigurationError:
raise
except smtplib.SMTPResponseException as exc:
temporary = 400 <= int(exc.smtp_code) < 500
last_error = exc
if not temporary or attempt >= self.policy.reconnect_attempts:
raise SmtpSendError(
"SMTP server rejected batch preflight.",
temporary=temporary,
systemic=True,
reason_code="smtp_preflight_rejected",
phase="preflight",
) from exc
continue
except (OSError, smtplib.SMTPException) as exc:
last_error = exc
if attempt >= self.policy.reconnect_attempts:
raise SmtpSendError(
"SMTP connectivity is unavailable during batch preflight.",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
) from exc
continue
self._smtp = smtp
if attempt > 0 or self._connection_sequence > 0:
self._reconnect_count += 1
self._connection_sequence += 1
self._messages_on_connection = 0
return
raise SmtpSendError(
f"SMTP batch preflight failed: {last_error}",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
)
def _discard_connection(self) -> None:
smtp, self._smtp = self._smtp, None
self._messages_on_connection = 0
if smtp is None:
return
try:
smtp.quit()
except Exception as quit_exc:
_log_smtp_cleanup_failure("closing batch connection", quit_exc)
try:
smtp.close()
except Exception as close_exc:
_log_smtp_cleanup_failure("closing batch socket", close_exc)
def _environment_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().casefold() in {"1", "true", "yes", "on"}
def _environment_int(name: str, *, default: int, minimum: int, maximum: int) -> int:
value = os.getenv(name)
try:
parsed = int(value) if value is not None else default
except ValueError:
parsed = default
return max(minimum, min(maximum, parsed))
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
@@ -324,15 +640,27 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
temporary=False,
systemic=True,
reason_code="smtp_authentication_failed",
phase="preflight",
) from exc
except smtplib.SMTPResponseException as exc:
raise SmtpSendError(
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_preflight_rejected",
phase="preflight",
) from exc
except (OSError, smtplib.SMTPException) as exc:
# No message transmission has begun yet; a later explicit retry is safe.
raise SmtpSendError(f"SMTP connection failed: {exc}", temporary=True) from exc
raise SmtpSendError(
f"SMTP connection failed: {exc}",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
) from exc
try:
if isinstance(message, bytes):
@@ -352,6 +680,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_sender_refused",
) from exc
except smtplib.SMTPResponseException as exc:
# An explicit SMTP response means the server rejected the transaction;
@@ -359,6 +689,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=int(exc.smtp_code) == 421,
reason_code="smtp_connection_closed" if int(exc.smtp_code) == 421 else "smtp_message_rejected",
) from exc
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
# A connection loss after DATA began can happen after the server accepted
@@ -366,6 +698,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP outcome is unknown after transmission started: {exc}",
outcome_unknown=True,
systemic=True,
reason_code="smtp_connection_lost_after_transmission",
) from exc
finally:
try:
@@ -387,6 +721,9 @@ def _smtp_send_result(
envelope_from: str,
envelope_recipients: list[str],
refused: dict[str, tuple[int, bytes]],
connection_sequence: int = 1,
session_reused: bool = False,
reconnect_count: int = 0,
) -> SmtpSendResult:
return SmtpSendResult(
host=host,
@@ -395,6 +732,9 @@ def _smtp_send_result(
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
connection_sequence=connection_sequence,
session_reused=session_reused,
reconnect_count=reconnect_count,
)
@@ -404,15 +744,19 @@ def send_email_bytes(
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
batch_session: SmtpBatchSession | None = None,
) -> SmtpSendResult:
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
return _send_smtp_payload(
if batch_session is not None:
if batch_session.smtp_config != smtp_config:
raise SmtpConfigurationError("SMTP batch session does not match the resolved transport.")
return batch_session.send(
message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
return _send_smtp_payload(message_bytes, smtp_config=smtp_config, envelope_from=envelope_from, envelope_recipients=envelope_recipients)
def send_email_message(
+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.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"]
+107
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import smtplib
import unittest
from unittest.mock import patch
@@ -7,6 +8,9 @@ from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import SmtpConfig
from govoplan_mail.backend.sending.smtp import (
SmtpConfigurationError,
SmtpBatchPolicy,
SmtpBatchSession,
SmtpSendError,
_open_smtp,
_prepare_smtp_send,
_smtp_send_result,
@@ -70,6 +74,109 @@ class SmtpSendHelperTests(unittest.TestCase):
self.assertEqual(result.accepted_count, 1)
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
def test_batch_preflight_reuses_one_authenticated_connection(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp()
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp) as opener:
with SmtpBatchSession(config) as batch:
first = batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
second = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
opener.assert_called_once_with(config)
self.assertFalse(first.session_reused)
self.assertTrue(second.session_reused)
self.assertEqual(1, second.connection_sequence)
self.assertEqual([b"first", b"second"], smtp.messages)
self.assertTrue(smtp.quit_called)
def test_batch_reconnects_before_next_message_when_health_check_fails(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
first_smtp = _FakeSmtp(noop_error_on_call=1)
second_smtp = _FakeSmtp()
policy = SmtpBatchPolicy(reconnect_attempts=1)
with patch(
"govoplan_mail.backend.sending.smtp._open_smtp",
side_effect=[first_smtp, second_smtp],
) as opener:
with SmtpBatchSession(config, policy=policy) as batch:
batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
result = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
self.assertEqual(2, opener.call_count)
self.assertEqual(2, result.connection_sequence)
self.assertEqual(1, result.reconnect_count)
self.assertEqual([b"first"], first_smtp.messages)
self.assertEqual([b"second"], second_smtp.messages)
def test_preflight_retries_a_transient_connection_failure(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp()
with patch(
"govoplan_mail.backend.sending.smtp._open_smtp",
side_effect=[OSError("temporary DNS failure"), smtp],
) as opener:
with SmtpBatchSession(config, policy=SmtpBatchPolicy(reconnect_attempts=1)) as batch:
result = batch.send(b"message", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
self.assertEqual(2, opener.call_count)
self.assertEqual(1, result.reconnect_count)
def test_connection_loss_after_send_starts_is_unknown_and_never_replayed(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp(send_error=smtplib.SMTPServerDisconnected("lost"))
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp), self.assertRaises(SmtpSendError) as raised:
with SmtpBatchSession(config) as batch:
batch.send(b"one", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
self.assertTrue(raised.exception.outcome_unknown)
self.assertTrue(raised.exception.systemic)
self.assertEqual("smtp_connection_lost_after_transmission", raised.exception.reason_code)
self.assertEqual(1, smtp.send_calls)
def test_authentication_preflight_is_systemic_and_blocks_batch(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
error = smtplib.SMTPAuthenticationError(535, b"bad credentials")
with patch("govoplan_mail.backend.sending.smtp._open_smtp", side_effect=error), self.assertRaises(SmtpSendError) as raised:
SmtpBatchSession(config).preflight()
self.assertTrue(raised.exception.systemic)
self.assertFalse(raised.exception.temporary)
self.assertEqual("preflight", raised.exception.phase)
self.assertEqual("smtp_authentication_failed", raised.exception.reason_code)
class _FakeSmtp:
def __init__(self, *, noop_error_on_call: int | None = None, send_error: BaseException | None = None):
self.noop_error_on_call = noop_error_on_call
self.send_error = send_error
self.noop_calls = 0
self.send_calls = 0
self.messages: list[bytes] = []
self.quit_called = False
def noop(self):
self.noop_calls += 1
if self.noop_error_on_call == self.noop_calls:
raise smtplib.SMTPServerDisconnected("stale")
return 250, b"ok"
def sendmail(self, _sender, _recipients, message):
self.send_calls += 1
if self.send_error is not None:
raise self.send_error
self.messages.append(message)
return {}
def send_message(self, message, **_kwargs):
return self.sendmail(None, None, message.as_bytes())
def quit(self):
self.quit_called = True
return 221, b"bye"
def close(self):
return None
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -26,7 +26,7 @@
}
},
"scripts": {
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node .mail-test-build/tests/mail-address-integration.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
},
"devDependencies": {
"typescript": "^5.7.2"
+49
View File
@@ -65,6 +65,41 @@ export type MailAddressLookupResponse = {
candidates: MailAddressLookupCandidate[];
};
export type MailAddressWriteTarget = {
address_book_id: string;
address_book_label?: string | null;
operation: string;
allowed: boolean;
reason: string;
message: string;
scope_type?: string | null;
scope_id?: string | null;
source_kind?: string | null;
read_only: boolean;
required_scopes: string[];
provenance: Record<string, unknown>;
};
export type MailAddressWriteTargetResponse = {
available: boolean;
targets: MailAddressWriteTarget[];
};
export type MailContactCreatePayload = {
address_book_id: string;
display_name?: string | null;
email: string;
};
export type MailContactCreateResponse = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
source_kind: string;
provenance: Record<string, unknown>;
};
export type MailMailboxAttachment = {
filename?: string | null;
content_type: string;
@@ -209,6 +244,20 @@ export async function lookupMailAddresses(settings: ApiSettings, query: string,
return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
}
export async function listMailAddressWriteTargets(settings: ApiSettings): Promise<MailAddressWriteTargetResponse> {
return apiFetch<MailAddressWriteTargetResponse>(settings, "/api/v1/mail/address-write-targets");
}
export async function createMailAddressContact(
settings: ApiSettings,
payload: MailContactCreatePayload
): Promise<MailContactCreateResponse> {
return apiFetch<MailContactCreateResponse>(settings, "/api/v1/mail/address-contacts", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined,
+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 { 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
}))}
/>
<div className="dashboard-contribution-footer">
<a className="btn btn-secondary" href="mailto:" onClick={close}>
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.compose
{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">
<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 ? (
<Link
className="btn btn-secondary"
+147 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Activity, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react";
import { useLocation } from "react-router";
import { ToolbarGroup, ActionToolbar,
ActionBlockerHint,
@@ -21,9 +21,12 @@ import { ToolbarGroup, ActionToolbar,
} from "@govoplan/core-webui";
import {
bootstrapMailbox,
createMailAddressContact,
getMailboxMessage,
listMailAddressWriteTargets,
listMailboxMessages,
listMailServerProfiles,
type MailAddressWriteTarget,
type MailImapFolderResponse,
type MailMailboxMessageDetail,
type MailMailboxMessageSummary,
@@ -32,6 +35,7 @@ import {
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch";
import { mailboxHeaderAddresses } from "./mailAddressIntegration";
const MAILBOX_DOCUMENTATION = {
topicId: "mail.workflow.read-mailbox",
@@ -628,6 +632,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}))}
emptyText={previewEmptyText} />
<MailboxContactActions settings={settings} message={selectedMessage} />
</div>
</section>
@@ -641,6 +647,146 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
function MailboxContactActions({
settings,
message
}: {
settings: ApiSettings;
message: MailMailboxMessageDetail | null;
}) {
const [available, setAvailable] = useState(false);
const [loaded, setLoaded] = useState(false);
const [targets, setTargets] = useState<MailAddressWriteTarget[]>([]);
const [selectedTargetId, setSelectedTargetId] = useState("");
const [creatingEmail, setCreatingEmail] = useState("");
const [addedEmails, setAddedEmails] = useState<Set<string>>(() => new Set());
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
let active = true;
setLoaded(false);
void listMailAddressWriteTargets(settings)
.then((response) => {
if (!active) return;
const writable = response.targets.filter((target) => target.allowed);
setAvailable(response.available);
setTargets(response.targets);
setSelectedTargetId((current) => writable.some((target) => target.address_book_id === current)
? current
: writable[0]?.address_book_id || "");
setError("");
})
.catch((loadError) => {
if (!active) return;
setAvailable(false);
setTargets([]);
setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => {
if (active) setLoaded(true);
});
return () => { active = false; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
setAddedEmails(new Set());
setError("");
setSuccess("");
}, [message?.folder, message?.uid]);
if (!message || !loaded || !available) return null;
const writableTargets = targets.filter((target) => target.allowed);
const blockedTargets = targets.filter((target) => !target.allowed);
const addresses = uniqueMailboxAddresses([
...mailboxHeaderAddresses(message.from_header),
...mailboxHeaderAddresses(message.to_header),
...mailboxHeaderAddresses(message.cc_header)
]);
async function addContact(address: { name?: string | null; email: string }) {
if (!selectedTargetId || creatingEmail) return;
setCreatingEmail(address.email);
setError("");
setSuccess("");
try {
const result = await createMailAddressContact(settings, {
address_book_id: selectedTargetId,
display_name: address.name || address.email,
email: address.email
});
setAddedEmails((current) => new Set(current).add(address.email));
setSuccess(i18nMessage("i18n:govoplan-mail.contact_added", { value0: result.display_name }));
} catch (createError) {
setError(createError instanceof Error ? createError.message : String(createError));
} finally {
setCreatingEmail("");
}
}
return (
<section className="mailbox-contact-actions" aria-label="i18n:govoplan-mail.address_book_actions">
<h4>i18n:govoplan-mail.address_book_actions</h4>
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
{writableTargets.length > 0 ? (
<label className="mailbox-contact-target">
<span>i18n:govoplan-mail.save_contacts_to</span>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
{writableTargets.map((target) => (
<option key={target.address_book_id} value={target.address_book_id}>
{target.address_book_label || target.address_book_id}
</option>
))}
</select>
</label>
) : (
<p className="form-help">i18n:govoplan-mail.no_writable_address_book</p>
)}
<div className="mailbox-contact-candidates">
{addresses.map((address) => {
const added = addedEmails.has(address.email);
return (
<Button
key={address.email}
className="compact"
disabled={!selectedTargetId || Boolean(creatingEmail) || added}
disabledReason={!selectedTargetId ? blockedTargets[0]?.message || "i18n:govoplan-mail.no_writable_address_book" : undefined}
onClick={() => void addContact(address)}
>
{added ? <Check size={15} aria-hidden="true" /> : <UserPlus size={15} aria-hidden="true" />}
{added ? "i18n:govoplan-mail.contact_added_short" : i18nMessage("i18n:govoplan-mail.add_value_to_contacts", { value0: address.name || address.email })}
</Button>
);
})}
</div>
{blockedTargets.length > 0 ? (
<details className="mailbox-contact-policy">
<summary>i18n:govoplan-mail.unavailable_address_books</summary>
<ul>
{blockedTargets.map((target) => (
<li key={target.address_book_id}>
<strong>{target.address_book_label || target.address_book_id}</strong>: {target.message}
</li>
))}
</ul>
</details>
) : null}
</section>
);
}
function uniqueMailboxAddresses<T extends { email: string }>(addresses: T[]): T[] {
const seen = new Set<string>();
return addresses.filter((address) => {
const email = address.email.toLocaleLowerCase();
if (seen.has(email)) return false;
seen.add(email);
return true;
});
}
function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`;
@@ -0,0 +1,44 @@
type MailAddressLookupCandidateLike = {
display_name: string;
email?: string | null;
};
export type MailAddressValue = {
name?: string | null;
email: string;
};
const EMAIL_PATTERN = /([^<>;,\s]+@[^<>;,\s]+)/g;
export function mailLookupSuggestions(candidates: readonly MailAddressLookupCandidateLike[]): MailAddressValue[] {
const seen = new Set<string>();
const suggestions: MailAddressValue[] = [];
for (const candidate of candidates) {
const email = String(candidate.email ?? "").trim().toLocaleLowerCase();
if (!email || seen.has(email)) continue;
seen.add(email);
suggestions.push({ name: candidate.display_name || email, email });
}
return suggestions;
}
export function mailboxHeaderAddresses(value?: string | null): MailAddressValue[] {
const input = String(value ?? "").trim();
if (!input) return [];
const results: MailAddressValue[] = [];
const seen = new Set<string>();
for (const match of input.matchAll(EMAIL_PATTERN)) {
const email = match[1]?.replace(/[)>]+$/, "").toLocaleLowerCase();
if (!email || seen.has(email)) continue;
seen.add(email);
const prefix = input.slice(Math.max(0, input.lastIndexOf(",", match.index) + 1), match.index).trim();
const name = prefix.replace(/[<"']/g, "").trim() || undefined;
results.push({ name, email });
}
return results;
}
export function mailtoHref(recipients: readonly MailAddressValue[]): string {
const addresses = recipients.map((recipient) => recipient.email.trim()).filter(Boolean);
return `mailto:${addresses.map(encodeURIComponent).join(",")}`;
}
+20
View File
@@ -96,6 +96,16 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.compose": "Compose",
"i18n:govoplan-mail.recipients": "Recipients",
"i18n:govoplan-mail.address_suggestions_unavailable": "Address-book suggestions are unavailable. You can still enter an email address manually.",
"i18n:govoplan-mail.open_mail_application": "Open mail application",
"i18n:govoplan-mail.address_book_actions": "Address-book actions",
"i18n:govoplan-mail.save_contacts_to": "Save contacts to",
"i18n:govoplan-mail.no_writable_address_book": "No writable address book is available for your account.",
"i18n:govoplan-mail.contact_added": "{value0} was added to contacts.",
"i18n:govoplan-mail.contact_added_short": "Added",
"i18n:govoplan-mail.add_value_to_contacts": "Add {value0} to contacts",
"i18n:govoplan-mail.unavailable_address_books": "Unavailable address books and policy reasons",
"i18n:govoplan-mail.open_mail": "Open Mail",
"i18n:govoplan-mail.quick_access_description": "Recent mailbox messages and mail actions.",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
@@ -296,6 +306,16 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.compose": "Verfassen",
"i18n:govoplan-mail.recipients": "Empfänger",
"i18n:govoplan-mail.address_suggestions_unavailable": "Adressbuchvorschläge sind nicht verfügbar. Eine E-Mail-Adresse kann weiterhin manuell eingegeben werden.",
"i18n:govoplan-mail.open_mail_application": "Mail-Anwendung öffnen",
"i18n:govoplan-mail.address_book_actions": "Adressbuchaktionen",
"i18n:govoplan-mail.save_contacts_to": "Kontakte speichern in",
"i18n:govoplan-mail.no_writable_address_book": "Für dieses Konto ist kein beschreibbares Adressbuch verfügbar.",
"i18n:govoplan-mail.contact_added": "{value0} wurde zu den Kontakten hinzugefügt.",
"i18n:govoplan-mail.contact_added_short": "Hinzugefügt",
"i18n:govoplan-mail.add_value_to_contacts": "{value0} zu Kontakten hinzufügen",
"i18n:govoplan-mail.unavailable_address_books": "Nicht verfügbare Adressbücher und Richtliniengründe",
"i18n:govoplan-mail.open_mail": "Mail öffnen",
"i18n:govoplan-mail.quick_access_description": "Aktuelle Posteingangsnachrichten und Mail-Aktionen.",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
+50
View File
@@ -627,6 +627,56 @@
padding: 18px;
}
.mail-quick-compose {
display: grid;
gap: 10px;
padding: 12px;
margin-top: 10px;
border: var(--border-line);
border-radius: var(--radius-md);
background: var(--panel-soft);
}
.mail-quick-compose > label,
.mailbox-contact-target > span {
color: var(--text-strong);
font-size: 12px;
font-weight: 700;
}
.mailbox-contact-actions {
display: grid;
gap: 10px;
margin-top: 16px;
padding-top: 16px;
border-top: var(--border-line);
}
.mailbox-contact-actions h4 {
margin: 0;
}
.mailbox-contact-target {
display: grid;
gap: 5px;
}
.mailbox-contact-candidates {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mailbox-contact-policy {
color: var(--muted);
font-size: 12px;
}
.mailbox-contact-policy ul {
margin: 8px 0 0;
padding-left: 20px;
}
@media (max-width: 1280px) {
.mailbox-shell.file-manager-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
@@ -0,0 +1,51 @@
import {
mailboxHeaderAddresses,
mailLookupSuggestions,
mailtoHref
} from "../src/features/mail/mailAddressIntegration";
function assertEqual(actual: unknown, expected: unknown): void {
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
}
assertDeepEqual(
mailLookupSuggestions([
{
display_name: "Ada Lovelace",
email: "Ada@Example.Test"
},
{
display_name: "Duplicate",
email: "ada@example.test"
},
{
display_name: "No email",
email: null
}
]),
[{ name: "Ada Lovelace", email: "ada@example.test" }]
);
assertDeepEqual(
mailboxHeaderAddresses('Ada Lovelace <ada@example.test>, "Grace Hopper" <grace@example.test>'),
[
{ name: "Ada Lovelace", email: "ada@example.test" },
{ name: "Grace Hopper", email: "grace@example.test" }
]
);
assertEqual(
mailtoHref([
{ name: "Ada Lovelace", email: "ada@example.test" },
{ email: "grace@example.test" }
]),
"mailto:ada%40example.test,grace%40example.test"
);
console.log("mail address integration tests passed");
+3 -1
View File
@@ -22,10 +22,12 @@
"tests/mailbox-launch.test.ts",
"tests/mail-profile-editor-model.test.ts",
"tests/mail-policy-validation.test.ts",
"tests/mail-address-integration.test.ts",
"src/features/mail/mailboxDisplay.ts",
"src/features/mail/mailboxFolders.ts",
"src/features/mail/mailboxLaunch.ts",
"src/features/mail/mailProfileEditorModel.ts",
"src/features/mail/mailPolicyValidation.ts"
"src/features/mail/mailPolicyValidation.ts",
"src/features/mail/mailAddressIntegration.ts"
]
}