Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd3847524 | ||
|
|
2fe56fca00 | ||
|
|
34bd5be8d4 | ||
|
|
a044fee379 | ||
|
|
169b81d9db | ||
|
|
2ad122056e | ||
|
|
9ac8847559 | ||
|
|
e0e00d7000 | ||
|
|
393331574a | ||
|
|
bb8c60abb9 | ||
|
|
c59c67bd18 | ||
|
|
761dd76b9e | ||
|
|
28f357e151 | ||
|
|
cff428b475 | ||
|
|
77f44dfab5 | ||
|
|
2c5585ba99 | ||
|
|
62721d204d | ||
|
|
e41d05914a | ||
|
|
a040de4fe5 |
@@ -19,6 +19,15 @@ This repository owns:
|
|||||||
|
|
||||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
||||||
|
|
||||||
|
Mail publishes `privacy.dsar.mail` for Core's governed data-subject-request
|
||||||
|
workflow. It isolates matching mailbox header parties and returns bounded index,
|
||||||
|
personal-profile, delivery, reconciliation, and bounce metadata. SMTP/IMAP
|
||||||
|
configuration and credentials, encrypted messages and envelopes, folder/UID
|
||||||
|
locators, worker and idempotency state, diagnostics, and opaque evidence are
|
||||||
|
excluded. Delivery and bounce outcomes remain retained evidence; mailbox and
|
||||||
|
profile changes require coordinated Mail and external-provider review, so the
|
||||||
|
provider does not perform direct erasure.
|
||||||
|
|
||||||
## Profile and credential ownership
|
## Profile and credential ownership
|
||||||
|
|
||||||
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
|
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
|
||||||
@@ -41,6 +50,18 @@ and requires explicit evidence-backed reconciliation before any deliberate
|
|||||||
resend. Business readers receive only counts and sanitized state; recipient
|
resend. Business readers receive only counts and sanitized state; recipient
|
||||||
refusal details require `mail:delivery:diagnostic`.
|
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
|
SMTP effects decrypt only SMTP credentials; Sent-folder effects decrypt only
|
||||||
IMAP credentials. A connection loss after an effect starts is surfaced as an
|
IMAP credentials. A connection loss after an effect starts is surfaced as an
|
||||||
unknown outcome. Campaign does not automatically retry an unknown IMAP append,
|
unknown outcome. Campaign does not automatically retry an unknown IMAP append,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ mailbox, policy, and delivery-evidence consequences described here.
|
|||||||
| System/tenant/group/user/campaign profile surfaces | Compare profiles, protocol servers, reusable credentials, status, and scope | Administration/configuration | High because endpoints, credentials, and inheritance control external communication | Shared `ConnectionTree`, stable row actions, textual status, permission/target blockers, and contextual admin help |
|
| System/tenant/group/user/campaign profile surfaces | Compare profiles, protocol servers, reusable credentials, status, and scope | Administration/configuration | High because endpoints, credentials, and inheritance control external communication | Shared `ConnectionTree`, stable row actions, textual status, permission/target blockers, and contextual admin help |
|
||||||
| Profile creation and focused profile/server/credential editors | Create a governed transport identity or edit one hierarchy object | Guided setup plus adaptive create/edit | High because saving may enable provider access or replace encrypted credentials | Shared `Dialog` and `StageRail` for multi-object setup; focused edit modes show only the selected hierarchy object; field help, connection tests, unsaved-draft guard, and disabled-save reasons |
|
| Profile creation and focused profile/server/credential editors | Create a governed transport identity or edit one hierarchy object | Guided setup plus adaptive create/edit | High because saving may enable provider access or replace encrypted credentials | Shared `Dialog` and `StageRail` for multi-object setup; focused edit modes show only the selected hierarchy object; field help, connection tests, unsaved-draft guard, and disabled-save reasons |
|
||||||
| Mail profile policy card | Narrow visible profiles, lower-scope definitions, hosts, senders, and recipients | Effective-policy editor | High because inherited allow/deny rules govern delivery and lower scopes | Shared policy rows, typed selectors, source path, locked/read-only blocker, dirty-save state, and contextual policy help |
|
| Mail profile policy card | Narrow visible profiles, lower-scope definitions, hosts, senders, and recipients | Effective-policy editor | High because inherited allow/deny rules govern delivery and lower scopes | Shared policy rows, typed selectors, source path, locked/read-only blocker, dirty-save state, and contextual policy help |
|
||||||
| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions |
|
| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `PageLayout`, `ContentGrid`, `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions |
|
||||||
| `/mail/bounces` observation table | Inspect correlated or unmatched delivery-status evidence | Evidence/reporting | Medium because recipient and diagnostic data may be sensitive | Bounded sanitized rows, textual status, filters, correlation state, and no raw bounce body |
|
| `/mail/bounces` observation table | Inspect correlated or unmatched delivery-status evidence | Evidence/reporting | Medium because recipient and diagnostic data may be sensitive | Bounded sanitized rows, textual status, filters, correlation state, and no raw bounce body |
|
||||||
| Bounce watcher removal | Stop future scans while retaining evidence | Destructive confirmation | Medium and reversible by recreating the watcher; observations are retained | Shared `ConfirmDialog` states the immediate consequence and retained evidence |
|
| Bounce watcher removal | Stop future scans while retaining evidence | Destructive confirmation | Medium and reversible by recreating the watcher; observations are retained | Shared `ConfirmDialog` states the immediate consequence and retained evidence |
|
||||||
| `mail.profiles` and credential-reference capabilities | Let another module select or validate Mail-owned transport without receiving secrets | Governed capability composition | High because the selected identity can perform external effects | Stable references and Core capability boundaries; no sibling-private WebUI import; authorization and credential resolution remain Mail-owned |
|
| `mail.profiles` and credential-reference capabilities | Let another module select or validate Mail-owned transport without receiving secrets | Governed capability composition | High because the selected identity can perform external effects | Stable references and Core capability boundaries; no sibling-private WebUI import; authorization and credential resolution remain Mail-owned |
|
||||||
@@ -22,6 +22,9 @@ mailbox, policy, and delivery-evidence consequences described here.
|
|||||||
|
|
||||||
- Loading, success, error, empty results, permission blockers, and destructive
|
- Loading, success, error, empty results, permission blockers, and destructive
|
||||||
confirmation use Core components. Mail does not reproduce the shell.
|
confirmation use Core components. Mail does not reproduce the shell.
|
||||||
|
- Bounce processing now delegates its page inset, sticky responsive heading,
|
||||||
|
route actions, alert regions, loading boundary, scrolling, and help audience
|
||||||
|
to Core `PageLayout`; Mail retains only watcher and evidence semantics.
|
||||||
- A target-dependent profile surface cannot load until a concrete user, group,
|
- A target-dependent profile surface cannot load until a concrete user, group,
|
||||||
or campaign is selected. The blocker identifies the responsible actor and
|
or campaign is selected. The blocker identifies the responsible actor and
|
||||||
destination instead of silently hiding the editor.
|
destination instead of silently hiding the editor.
|
||||||
|
|||||||
+50
-3
@@ -43,6 +43,28 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the
|
|||||||
module registry. Optional consumers provide narrow context through capabilities;
|
module registry. Optional consumers provide narrow context through capabilities;
|
||||||
Mail does not import their ORM or service implementations.
|
Mail does not import their ORM or service implementations.
|
||||||
|
|
||||||
|
## Deployment configuration packages
|
||||||
|
|
||||||
|
Mail registers the `mail.configuration` capability for `smtp_profile`
|
||||||
|
fragments. The provider reads the validated `mail.smtp` entry from the
|
||||||
|
installer-generated infrastructure capability receipt. Receipt host and port
|
||||||
|
are authoritative; the generic package workflow asks for missing non-secret
|
||||||
|
transport fields such as security mode. An external relay may operate without
|
||||||
|
authentication, or the operator may select an existing credential-envelope id.
|
||||||
|
Inline usernames, passwords, tokens, and secret values are rejected.
|
||||||
|
|
||||||
|
Tenant scope is the default. A system-scoped profile requires system settings
|
||||||
|
or governance write authority. The fragment's stable slug is its idempotency
|
||||||
|
identity: an absent profile is created, an exact profile is skipped, and a
|
||||||
|
conflicting profile is preserved unless the reviewed fragment explicitly sets
|
||||||
|
`on_conflict` to `update`. Credential bindings are added idempotently and are
|
||||||
|
never removed merely because a package omits a credential reference.
|
||||||
|
|
||||||
|
Preflight does not prove SMTP reachability. After apply, use the normal Mail
|
||||||
|
profile test and Ops health surfaces. If the receipt says SMTP is unavailable,
|
||||||
|
is invalid, or is not mounted, import is blocked with an operator-facing
|
||||||
|
resolution instead of creating a partial profile.
|
||||||
|
|
||||||
## Interface patterns and unavailable actions
|
## Interface patterns and unavailable actions
|
||||||
|
|
||||||
Mail uses the platform's shared explorer, connection tree, adaptive form,
|
Mail uses the platform's shared explorer, connection tree, adaptive form,
|
||||||
@@ -71,6 +93,13 @@ configuration. It has a stable id, lifecycle state, scope, owner context, and
|
|||||||
non-secret connection metadata. Passwords are write-only encrypted values and
|
non-secret connection metadata. Passwords are write-only encrypted values and
|
||||||
are never returned through list/read/capability responses.
|
are never returned through list/read/capability responses.
|
||||||
|
|
||||||
|
An IMAP server may map the standard Inbox, Sent, Drafts, Trash, Archive, and
|
||||||
|
Junk roles to exact provider folder names. These mappings belong to the reusable
|
||||||
|
profile/server. Empty roles retain automatic behavior. The historical
|
||||||
|
`imap.sent_folder` value is read as the Sent mapping and remains synchronized
|
||||||
|
for compatibility; a Campaign-specific Sent override still wins for that
|
||||||
|
Campaign.
|
||||||
|
|
||||||
Profiles may be scoped to system, tenant, user, group, or campaign context.
|
Profiles may be scoped to system, tenant, user, group, or campaign context.
|
||||||
Scope controls where a profile can be discovered; effective policy can narrow
|
Scope controls where a profile can be discovered; effective policy can narrow
|
||||||
that further. A visible profile is not automatically authorized for every
|
that further. A visible profile is not automatically authorized for every
|
||||||
@@ -156,7 +185,17 @@ ordinary consumers to bypass reusable profiles.
|
|||||||
The current mailbox UI and API are read-only. An authorized user can list IMAP
|
The current mailbox UI and API are read-only. An authorized user can list IMAP
|
||||||
folders, page through messages, and inspect a bounded full message. Folder
|
folders, page through messages, and inspect a bounded full message. Folder
|
||||||
names are parsed and quoted defensively; Sent-folder discovery uses provider
|
names are parsed and quoted defensively; Sent-folder discovery uses provider
|
||||||
flags and common names.
|
flags and common names. The list exposes the provider's `Seen` flag as a
|
||||||
|
read/unread indicator without changing it. It also labels whether the current
|
||||||
|
page came directly from the provider, from the bounded mailbox index, or from
|
||||||
|
an index while a refresh is in progress, including the index timestamp when
|
||||||
|
available.
|
||||||
|
|
||||||
|
Message HTML is displayed only in the shared sandboxed message component.
|
||||||
|
Remote URLs and active markup are removed, embedded `data:`/`cid:` image
|
||||||
|
references remain isolated, and plain text is always available when supplied.
|
||||||
|
Attachments and provider/content failures remain explicit rather than being
|
||||||
|
silently interpreted as an empty message.
|
||||||
|
|
||||||
Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must
|
Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must
|
||||||
not mutate read/unread, delete, move, or reply state. Message responses are
|
not mutate read/unread, delete, move, or reply state. Message responses are
|
||||||
@@ -205,8 +244,10 @@ account's user scope. Grant `mail:profile:write_own` for self-service;
|
|||||||
`mail:profile:write` remains broad profile administration authority.
|
`mail:profile:write` remains broad profile administration authority.
|
||||||
|
|
||||||
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
||||||
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
|
2. Configure SMTP, optional IMAP, TLS mode, account identity, standard folder
|
||||||
behavior, and timeouts. Sender/envelope/recipient constraints belong to
|
mappings, and timeouts. Folder discovery proposes provider-visible Inbox,
|
||||||
|
Sent, Drafts, Trash, Archive, and Junk names without mutating the mailbox.
|
||||||
|
Sender/envelope/recipient constraints belong to
|
||||||
effective Mail policy; Campaign rate limits remain delivery configuration.
|
effective Mail policy; Campaign rate limits remain delivery configuration.
|
||||||
3. Enter credentials only in the dedicated credential fields. Returned profile
|
3. Enter credentials only in the dedicated credential fields. Returned profile
|
||||||
data indicates whether credentials are configured without returning them.
|
data indicates whether credentials are configured without returning them.
|
||||||
@@ -456,6 +497,12 @@ Before claiming a Mail composition is production-ready:
|
|||||||
is stable.
|
is stable.
|
||||||
- POP3 except for a future explicit legacy download/import requirement.
|
- POP3 except for a future explicit legacy download/import requirement.
|
||||||
- A full mail client with compose/reply/move/delete/read-state mutation.
|
- A full mail client with compose/reply/move/delete/read-state mutation.
|
||||||
|
- Quick Access may launch the operating environment's configured composer via
|
||||||
|
`mailto:`. That explicit handoff is not a GovOPlaN Mail delivery: it selects
|
||||||
|
no Mail profile or credential, bypasses no Mail policy, and reports no
|
||||||
|
GovOPlaN delivery result. Recent-message and Drafts links remain read-only
|
||||||
|
deep links into the authorized Mail profile and preserve their Quick Access
|
||||||
|
return context.
|
||||||
- Recovery-ledger adoption for future provider-side move, delete, and flag
|
- Recovery-ledger adoption for future provider-side move, delete, and flag
|
||||||
mutations; no such production path exists in the current read-only mailbox.
|
mutations; no such production path exists in the current read-only mailbox.
|
||||||
- Proof that process-local throttling coordinates multiple workers when Redis
|
- Proof that process-local throttling coordinates multiple workers when Redis
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-mail"
|
name = "govoplan-mail"
|
||||||
version = "0.1.15"
|
version = "0.1.19"
|
||||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.15",
|
"govoplan-core>=0.1.18",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"redis>=5,<6",
|
"redis>=5,<6",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import formatdate, make_msgid
|
from email.utils import formatdate, make_msgid
|
||||||
from typing import Any
|
from typing import Any, Iterator
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -40,13 +42,27 @@ from govoplan_mail.backend.sending.imap import (
|
|||||||
append_message_to_sent,
|
append_message_to_sent,
|
||||||
)
|
)
|
||||||
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class CampaignSmtpDeliveryResult:
|
class CampaignSmtpDeliveryResult:
|
||||||
envelope_recipients: list[str]
|
envelope_recipients: list[str]
|
||||||
refused_recipients: dict[str, dict[str, int | str]]
|
refused_recipients: dict[str, dict[str, int | str]]
|
||||||
|
connection_sequence: int = 1
|
||||||
|
session_reused: bool = False
|
||||||
|
reconnect_count: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def accepted_count(self) -> int:
|
def accepted_count(self) -> int:
|
||||||
@@ -58,6 +74,23 @@ class CampaignImapAppendResult:
|
|||||||
folder: str
|
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(
|
def _sanitized_refusals(
|
||||||
refused_recipients: dict[str, tuple[int, bytes | str]],
|
refused_recipients: dict[str, tuple[int, bytes | str]],
|
||||||
) -> dict[str, dict[str, int | str]]:
|
) -> dict[str, dict[str, int | str]]:
|
||||||
@@ -95,6 +128,9 @@ def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
|
|||||||
message,
|
message,
|
||||||
temporary=exc.temporary,
|
temporary=exc.temporary,
|
||||||
outcome_unknown=exc.outcome_unknown,
|
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(
|
def send_campaign_email_bytes(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -394,6 +530,7 @@ def send_campaign_email_bytes(
|
|||||||
smtp_config=smtp,
|
smtp_config=smtp,
|
||||||
envelope_from=envelope_from,
|
envelope_from=envelope_from,
|
||||||
envelope_recipients=envelope_recipients,
|
envelope_recipients=envelope_recipients,
|
||||||
|
batch_session=_ACTIVE_SMTP_BATCH.get(),
|
||||||
)
|
)
|
||||||
except SmtpSendError as exc:
|
except SmtpSendError as exc:
|
||||||
sanitized = _sanitized_smtp_error(exc)
|
sanitized = _sanitized_smtp_error(exc)
|
||||||
@@ -420,6 +557,9 @@ def send_campaign_email_bytes(
|
|||||||
sanitized_result = CampaignSmtpDeliveryResult(
|
sanitized_result = CampaignSmtpDeliveryResult(
|
||||||
envelope_recipients=list(result.envelope_recipients),
|
envelope_recipients=list(result.envelope_recipients),
|
||||||
refused_recipients=_sanitized_refusals(result.refused_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:
|
if recovery is not None:
|
||||||
try:
|
try:
|
||||||
@@ -604,6 +744,7 @@ class MailCampaignCapability:
|
|||||||
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
|
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)
|
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
|
||||||
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
|
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)
|
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
|
||||||
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
||||||
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from govoplan_core.mail.config import (
|
from govoplan_core.mail.config import (
|
||||||
ImapConfig,
|
ImapConfig,
|
||||||
|
ImapFolderMappings,
|
||||||
ImapServerConfig,
|
ImapServerConfig,
|
||||||
SmtpConfig,
|
SmtpConfig,
|
||||||
SmtpServerConfig,
|
SmtpServerConfig,
|
||||||
@@ -13,6 +14,7 @@ from govoplan_core.mail.config import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ImapConfig",
|
"ImapConfig",
|
||||||
|
"ImapFolderMappings",
|
||||||
"ImapServerConfig",
|
"ImapServerConfig",
|
||||||
"SmtpConfig",
|
"SmtpConfig",
|
||||||
"SmtpServerConfig",
|
"SmtpServerConfig",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from email.utils import getaddresses
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.dsar import (
|
||||||
|
DsarErasureActionRef,
|
||||||
|
DsarExecutionResultRef,
|
||||||
|
DsarRecordRef,
|
||||||
|
DsarSubjectRef,
|
||||||
|
dsar_capability_name,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.db.models import (
|
||||||
|
MailBounceObservation,
|
||||||
|
MailDeliveryAttempt,
|
||||||
|
MailDeliveryCommand,
|
||||||
|
MailDeliveryReconciliation,
|
||||||
|
MailMailboxMessageIndex,
|
||||||
|
MailServerProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAIL_DSAR_CAPABILITY = dsar_capability_name("mail")
|
||||||
|
_MAX_RECORDS = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
class MailDsarProvider:
|
||||||
|
provider_id = "mail"
|
||||||
|
module_id = "mail"
|
||||||
|
|
||||||
|
def search_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
) -> Sequence[DsarRecordRef]:
|
||||||
|
db = _session(session)
|
||||||
|
email = _subject_email(subject)
|
||||||
|
membership_ids = _membership_ids(subject)
|
||||||
|
references = _mail_references(subject)
|
||||||
|
if email is None and not membership_ids and not references:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
records: list[DsarRecordRef] = []
|
||||||
|
|
||||||
|
def append(record: DsarRecordRef) -> None:
|
||||||
|
if len(records) >= _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Mail DSAR match limit exceeded; narrow the subject selectors."
|
||||||
|
)
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
profiles = _matching_profiles(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
membership_ids=membership_ids,
|
||||||
|
profile_id=references.get("profile"),
|
||||||
|
)
|
||||||
|
for profile in profiles:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_server_profile",
|
||||||
|
profile.id,
|
||||||
|
"mail_profile",
|
||||||
|
profile.name,
|
||||||
|
{
|
||||||
|
"match_fields": _profile_matching_fields(
|
||||||
|
profile, membership_ids
|
||||||
|
),
|
||||||
|
"name": profile.name,
|
||||||
|
"slug": profile.slug,
|
||||||
|
"description": profile.description,
|
||||||
|
"scope_type": profile.scope_type,
|
||||||
|
"is_active": profile.is_active,
|
||||||
|
"inherit_to_lower_scopes": profile.inherit_to_lower_scopes,
|
||||||
|
},
|
||||||
|
observed_at=profile.updated_at,
|
||||||
|
source_path="/settings?section=mail-profiles",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = _matching_messages(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
email=email,
|
||||||
|
message_id=references.get("message_index"),
|
||||||
|
)
|
||||||
|
for message in messages:
|
||||||
|
matching_headers = _matching_headers(message, email)
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mailbox_message_index",
|
||||||
|
message.id,
|
||||||
|
"mailbox_message",
|
||||||
|
message.subject or "Mailbox message",
|
||||||
|
{
|
||||||
|
"match_fields": list(matching_headers),
|
||||||
|
"subject": _bounded_text(message.subject),
|
||||||
|
"matching_headers": matching_headers,
|
||||||
|
"date": message.date,
|
||||||
|
"flags": tuple(
|
||||||
|
str(flag)[:100] for flag in (message.flags or ())[:32]
|
||||||
|
),
|
||||||
|
"size_bytes": message.size_bytes,
|
||||||
|
"body_preview": _bounded_text(message.body_preview),
|
||||||
|
"attachment_count": message.attachment_count,
|
||||||
|
"indexed_at": _iso(message.indexed_at),
|
||||||
|
},
|
||||||
|
observed_at=message.updated_at,
|
||||||
|
source_path="/mail",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
bounces = _matching_bounces(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
email=email,
|
||||||
|
bounce_id=references.get("bounce"),
|
||||||
|
)
|
||||||
|
bounce_command_ids = {row.command_id for row in bounces if row.command_id}
|
||||||
|
commands = _matching_commands(
|
||||||
|
db,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
membership_ids=membership_ids,
|
||||||
|
command_id=references.get("command"),
|
||||||
|
related_command_ids=bounce_command_ids,
|
||||||
|
)
|
||||||
|
command_ids = {row.id for row in commands}
|
||||||
|
for command in commands:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_delivery_command",
|
||||||
|
command.id,
|
||||||
|
"mail_delivery_evidence",
|
||||||
|
f"Mail {command.command_type} command",
|
||||||
|
{
|
||||||
|
"match_fields": (
|
||||||
|
["created_by_user_id"]
|
||||||
|
if command.created_by_user_id in membership_ids
|
||||||
|
else (
|
||||||
|
["reference"]
|
||||||
|
if command.id == references.get("command")
|
||||||
|
else ["bounce"]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"command_type": command.command_type,
|
||||||
|
"source_module": command.source_module,
|
||||||
|
"source_resource_type": command.source_resource_type,
|
||||||
|
"message_sha256": command.message_sha256,
|
||||||
|
"rfc_message_id": command.rfc_message_id,
|
||||||
|
"message_size_bytes": command.message_size_bytes,
|
||||||
|
"recipient_count": command.recipient_count,
|
||||||
|
"status": command.status,
|
||||||
|
"attempt_count": command.attempt_count,
|
||||||
|
"effect_started_at": _iso(command.effect_started_at),
|
||||||
|
"completed_at": _iso(command.completed_at),
|
||||||
|
"accepted_count": command.accepted_count,
|
||||||
|
"refused_count": command.refused_count,
|
||||||
|
"failure_code": command.failure_code,
|
||||||
|
"payload_purged_at": _iso(command.payload_purged_at),
|
||||||
|
},
|
||||||
|
observed_at=command.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Mail delivery commands are immutable transport, retry, and outcome evidence; encrypted payload retention is governed separately.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in _command_attempts(db, command_ids):
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_delivery_attempt",
|
||||||
|
attempt.id,
|
||||||
|
"mail_delivery_evidence",
|
||||||
|
f"Mail delivery attempt {attempt.attempt_number}",
|
||||||
|
{
|
||||||
|
"command_id": attempt.command_id,
|
||||||
|
"attempt_number": attempt.attempt_number,
|
||||||
|
"status": attempt.status,
|
||||||
|
"started_at": _iso(attempt.started_at),
|
||||||
|
"effect_started_at": _iso(attempt.effect_started_at),
|
||||||
|
"completed_at": _iso(attempt.completed_at),
|
||||||
|
"accepted_count": attempt.accepted_count,
|
||||||
|
"refused_count": attempt.refused_count,
|
||||||
|
"outcome_code": attempt.outcome_code,
|
||||||
|
},
|
||||||
|
observed_at=attempt.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Per-attempt Mail outcome state is immutable delivery and recovery evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for reconciliation in _command_reconciliations(db, command_ids):
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_delivery_reconciliation",
|
||||||
|
reconciliation.id,
|
||||||
|
"mail_delivery_evidence",
|
||||||
|
"Mail delivery reconciliation",
|
||||||
|
{
|
||||||
|
"command_id": reconciliation.command_id,
|
||||||
|
"decision": reconciliation.decision,
|
||||||
|
},
|
||||||
|
observed_at=reconciliation.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Mail reconciliation decisions are immutable authorization and recovery evidence.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for bounce in bounces:
|
||||||
|
append(
|
||||||
|
_record(
|
||||||
|
"mail_bounce_observation",
|
||||||
|
bounce.id,
|
||||||
|
"mail_bounce_evidence",
|
||||||
|
"Mail bounce observation",
|
||||||
|
{
|
||||||
|
"match_fields": (
|
||||||
|
["recipient"]
|
||||||
|
if email and _normalized_email(bounce.recipient) == email
|
||||||
|
else ["reference"]
|
||||||
|
),
|
||||||
|
"command_id": bounce.command_id
|
||||||
|
if bounce.command_id in command_ids
|
||||||
|
else None,
|
||||||
|
"recipient": email
|
||||||
|
if email and _normalized_email(bounce.recipient) == email
|
||||||
|
else None,
|
||||||
|
"action": bounce.action,
|
||||||
|
"status_code": bounce.status_code,
|
||||||
|
"permanent": bounce.permanent,
|
||||||
|
"observed_at": _iso(bounce.observed_at),
|
||||||
|
"matched": bounce.matched,
|
||||||
|
},
|
||||||
|
observed_at=bounce.updated_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Bounce observations are immutable delivery-status and suppression evidence.",
|
||||||
|
source_path="/mail/bounces",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
def plan_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
records: Sequence[DsarRecordRef],
|
||||||
|
) -> Sequence[DsarErasureActionRef]:
|
||||||
|
del session, tenant_id, subject
|
||||||
|
actions = []
|
||||||
|
for record in records:
|
||||||
|
if (
|
||||||
|
record.provider_id != self.provider_id
|
||||||
|
or record.module_id != self.module_id
|
||||||
|
):
|
||||||
|
raise ValueError("Mail DSAR received a foreign provider record.")
|
||||||
|
actions.append(
|
||||||
|
DsarErasureActionRef(
|
||||||
|
action_id=f"mail:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}",
|
||||||
|
provider_id="mail",
|
||||||
|
module_id="mail",
|
||||||
|
kind="retain" if record.immutable_evidence else "manual_review",
|
||||||
|
resource_type=record.resource_type,
|
||||||
|
resource_id=record.resource_id,
|
||||||
|
title=f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}",
|
||||||
|
rationale=record.retention_reason
|
||||||
|
or "Mailbox indexes and personal profiles must be reviewed through Mail and the authoritative external mailbox lifecycle.",
|
||||||
|
executable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(actions)
|
||||||
|
|
||||||
|
def execute_erasure(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
subject: DsarSubjectRef,
|
||||||
|
actions: Sequence[DsarErasureActionRef],
|
||||||
|
request_id: str,
|
||||||
|
) -> Sequence[DsarExecutionResultRef]:
|
||||||
|
del session, tenant_id, subject, request_id
|
||||||
|
return tuple(
|
||||||
|
DsarExecutionResultRef(
|
||||||
|
action_id=action.action_id,
|
||||||
|
status="blocked",
|
||||||
|
summary="Mail erasure requires an authorized Mail/external-mailbox lifecycle action; the DSAR provider does not mutate it directly.",
|
||||||
|
)
|
||||||
|
for action in actions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_profiles(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
membership_ids: set[str],
|
||||||
|
profile_id: str | None,
|
||||||
|
) -> list[MailServerProfile]:
|
||||||
|
conditions = []
|
||||||
|
if profile_id:
|
||||||
|
conditions.append(MailServerProfile.id == profile_id)
|
||||||
|
if membership_ids:
|
||||||
|
conditions.extend(
|
||||||
|
(
|
||||||
|
MailServerProfile.created_by_user_id.in_(membership_ids),
|
||||||
|
MailServerProfile.updated_by_user_id.in_(membership_ids),
|
||||||
|
(MailServerProfile.scope_type == "user")
|
||||||
|
& MailServerProfile.scope_id.in_(membership_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailServerProfile)
|
||||||
|
.filter(MailServerProfile.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(MailServerProfile.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_matching_fields(
|
||||||
|
row: MailServerProfile, membership_ids: set[str]
|
||||||
|
) -> list[str]:
|
||||||
|
fields = []
|
||||||
|
if row.scope_type == "user" and row.scope_id in membership_ids:
|
||||||
|
fields.append("scope_id")
|
||||||
|
for field in ("created_by_user_id", "updated_by_user_id"):
|
||||||
|
if getattr(row, field) in membership_ids:
|
||||||
|
fields.append(field)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_messages(
|
||||||
|
session: Session, *, tenant_id: str, email: str | None, message_id: str | None
|
||||||
|
) -> list[MailMailboxMessageIndex]:
|
||||||
|
conditions = []
|
||||||
|
if message_id:
|
||||||
|
conditions.append(MailMailboxMessageIndex.id == message_id)
|
||||||
|
if email:
|
||||||
|
pattern = f"%{_escape_like(email)}%"
|
||||||
|
conditions.extend(
|
||||||
|
func.lower(field).like(pattern, escape="\\")
|
||||||
|
for field in (
|
||||||
|
MailMailboxMessageIndex.from_header,
|
||||||
|
MailMailboxMessageIndex.to_header,
|
||||||
|
MailMailboxMessageIndex.cc_header,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
candidates = _bounded_rows(
|
||||||
|
session.query(MailMailboxMessageIndex)
|
||||||
|
.filter(MailMailboxMessageIndex.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(MailMailboxMessageIndex.id)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
row
|
||||||
|
for row in candidates
|
||||||
|
if row.id == message_id or _matching_headers(row, email)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_headers(
|
||||||
|
row: MailMailboxMessageIndex, email: str | None
|
||||||
|
) -> dict[str, list[dict[str, str | None]]]:
|
||||||
|
if email is None:
|
||||||
|
return {}
|
||||||
|
result = {}
|
||||||
|
for role, value in (
|
||||||
|
("from", row.from_header),
|
||||||
|
("to", row.to_header),
|
||||||
|
("cc", row.cc_header),
|
||||||
|
):
|
||||||
|
matches = [
|
||||||
|
{"email": address.casefold(), "name": name or None}
|
||||||
|
for name, address in getaddresses([value or ""])
|
||||||
|
if address.casefold() == email
|
||||||
|
]
|
||||||
|
if matches:
|
||||||
|
result[role] = matches[:64]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_bounces(
|
||||||
|
session: Session, *, tenant_id: str, email: str | None, bounce_id: str | None
|
||||||
|
) -> list[MailBounceObservation]:
|
||||||
|
conditions = []
|
||||||
|
if bounce_id:
|
||||||
|
conditions.append(MailBounceObservation.id == bounce_id)
|
||||||
|
if email:
|
||||||
|
conditions.append(func.lower(MailBounceObservation.recipient) == email)
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailBounceObservation)
|
||||||
|
.filter(MailBounceObservation.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(MailBounceObservation.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matching_commands(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
membership_ids: set[str],
|
||||||
|
command_id: str | None,
|
||||||
|
related_command_ids: set[str],
|
||||||
|
) -> list[MailDeliveryCommand]:
|
||||||
|
conditions = []
|
||||||
|
if command_id:
|
||||||
|
conditions.append(MailDeliveryCommand.id == command_id)
|
||||||
|
if related_command_ids:
|
||||||
|
conditions.append(MailDeliveryCommand.id.in_(related_command_ids))
|
||||||
|
if membership_ids:
|
||||||
|
conditions.append(MailDeliveryCommand.created_by_user_id.in_(membership_ids))
|
||||||
|
if not conditions:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailDeliveryCommand)
|
||||||
|
.filter(MailDeliveryCommand.tenant_id == tenant_id, or_(*conditions))
|
||||||
|
.order_by(MailDeliveryCommand.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _command_attempts(
|
||||||
|
session: Session, command_ids: set[str]
|
||||||
|
) -> list[MailDeliveryAttempt]:
|
||||||
|
if not command_ids:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailDeliveryAttempt)
|
||||||
|
.filter(MailDeliveryAttempt.command_id.in_(command_ids))
|
||||||
|
.order_by(MailDeliveryAttempt.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _command_reconciliations(
|
||||||
|
session: Session, command_ids: set[str]
|
||||||
|
) -> list[MailDeliveryReconciliation]:
|
||||||
|
if not command_ids:
|
||||||
|
return []
|
||||||
|
return _bounded_rows(
|
||||||
|
session.query(MailDeliveryReconciliation)
|
||||||
|
.filter(MailDeliveryReconciliation.command_id.in_(command_ids))
|
||||||
|
.order_by(MailDeliveryReconciliation.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_references(subject: DsarSubjectRef) -> dict[str, str]:
|
||||||
|
aliases = {
|
||||||
|
"mail.profile": "profile",
|
||||||
|
"mail.message_index": "message_index",
|
||||||
|
"mail.delivery_command": "command",
|
||||||
|
"mail.bounce_observation": "bounce",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
target: value
|
||||||
|
for key, target in aliases.items()
|
||||||
|
if (value := str(subject.external_references.get(key) or "").strip())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _membership_ids(subject: DsarSubjectRef) -> set[str]:
|
||||||
|
values = [subject.membership_id]
|
||||||
|
values.extend(
|
||||||
|
subject.external_references.get(key)
|
||||||
|
for key in (
|
||||||
|
"mail.user",
|
||||||
|
"mail.membership",
|
||||||
|
"access.membership",
|
||||||
|
"membership_id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {value for item in values if (value := str(item or "").strip())}
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_email(subject: DsarSubjectRef) -> str | None:
|
||||||
|
values = [subject.email, subject.external_references.get("mail.email")]
|
||||||
|
normalized = {email for value in values if (email := _normalized_email(value))}
|
||||||
|
return normalized.pop() if len(normalized) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_email(value: object) -> str | None:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
value = value.strip().casefold()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
category: str,
|
||||||
|
title: str,
|
||||||
|
data: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
observed_at: datetime | None = None,
|
||||||
|
immutable: bool = False,
|
||||||
|
retention_reason: str | None = None,
|
||||||
|
source_path: str | None = None,
|
||||||
|
) -> DsarRecordRef:
|
||||||
|
return DsarRecordRef(
|
||||||
|
provider_id="mail",
|
||||||
|
module_id="mail",
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
category=category,
|
||||||
|
title=title,
|
||||||
|
data=data,
|
||||||
|
observed_at=observed_at,
|
||||||
|
immutable_evidence=immutable,
|
||||||
|
retention_reason=retention_reason,
|
||||||
|
source_path=source_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Mail DSAR provider requires a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_rows(query: object) -> list[object]:
|
||||||
|
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
|
||||||
|
if len(rows) > _MAX_RECORDS:
|
||||||
|
raise ValueError(
|
||||||
|
"Mail DSAR match limit exceeded; narrow the subject selectors."
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_text(value: str | None) -> str | None:
|
||||||
|
return value[:2_000] if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_like(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["MAIL_DSAR_CAPABILITY", "MailDsarProvider"]
|
||||||
@@ -126,7 +126,7 @@ def slugify_profile_name(value: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]:
|
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]:
|
||||||
payload = config.model_dump(mode="json")
|
payload = config.model_dump(mode="json", exclude_none=True)
|
||||||
username_was_supplied = "username" in config.model_fields_set
|
username_was_supplied = "username" in config.model_fields_set
|
||||||
password_was_supplied = "password" in config.model_fields_set
|
password_was_supplied = "password" in config.model_fields_set
|
||||||
username = payload.pop("username", None)
|
username = payload.pop("username", None)
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ from govoplan_core.core.mail import (
|
|||||||
CAPABILITY_MAIL_BOUNCE_PROCESSING,
|
CAPABILITY_MAIL_BOUNCE_PROCESSING,
|
||||||
CAPABILITY_MAIL_DELIVERY_OUTBOX,
|
CAPABILITY_MAIL_DELIVERY_OUTBOX,
|
||||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||||
|
CAPABILITY_MAIL_POSTBOX_BRIDGE,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_DELIVERY
|
||||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
DocumentationConfigurationProviderRegistration,
|
DocumentationConfigurationProviderRegistration,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
@@ -26,6 +29,8 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
|
QuickAccessTool,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
@@ -38,6 +43,7 @@ from govoplan_core.core.provider_governance import (
|
|||||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_mail.backend.configuration_provider import MAIL_CONFIGURATION_CAPABILITY
|
||||||
from govoplan_mail.backend.documentation import (
|
from govoplan_mail.backend.documentation import (
|
||||||
documentation_configuration_states,
|
documentation_configuration_states,
|
||||||
documentation_topics,
|
documentation_topics,
|
||||||
@@ -49,6 +55,7 @@ from govoplan_mail.backend.provider_state import (
|
|||||||
smtp_provider_states,
|
smtp_provider_states,
|
||||||
)
|
)
|
||||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||||
|
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY
|
||||||
from govoplan_mail.backend.search_source import create_mail_search_source
|
from govoplan_mail.backend.search_source import create_mail_search_source
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +100,20 @@ def _mail_retirement_provider(session: object | None, module_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_mail.backend.configuration_provider import SqlMailConfigurationProvider
|
||||||
|
|
||||||
|
return SqlMailConfigurationProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_dsar_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_mail.backend.dsar_provider import MailDsarProvider
|
||||||
|
|
||||||
|
return MailDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
@@ -299,15 +320,17 @@ IMAP_PROVIDER = ExternalProviderDeclaration(
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="mail",
|
id="mail",
|
||||||
name="Mail",
|
name="Mail",
|
||||||
version="0.1.15",
|
version="0.1.19",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("campaigns", "addresses", "calendar", "search"),
|
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
|
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
|
||||||
ModuleInterfaceProvider(name="mail.delivery_commands", version="0.1.0"),
|
ModuleInterfaceProvider(name="mail.delivery_commands", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="mail.delivery_outbox", version="0.1.0"),
|
ModuleInterfaceProvider(name="mail.delivery_outbox", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
|
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
|
||||||
ModuleInterfaceProvider(name="mail.bounce_processing", version="0.1.0"),
|
ModuleInterfaceProvider(name="mail.bounce_processing", version="0.1.0"),
|
||||||
|
ModuleInterfaceProvider(name=CAPABILITY_MAIL_POSTBOX_BRIDGE, version="1.0.0"),
|
||||||
|
ModuleInterfaceProvider(name=MAIL_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -334,6 +357,18 @@ 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(
|
||||||
|
name=CAPABILITY_POSTBOX_DELIVERY,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name="search.source",
|
name="search.source",
|
||||||
version_min="1.0.0",
|
version_min="1.0.0",
|
||||||
@@ -376,6 +411,35 @@ manifest = ModuleManifest(
|
|||||||
ViewSurface(id="mail.admin.group-servers", module_id="mail", kind="section", label="Group mail servers", order=20),
|
ViewSurface(id="mail.admin.group-servers", module_id="mail", kind="section", label="Group mail servers", order=20),
|
||||||
ViewSurface(id="mail.admin.user-servers", module_id="mail", kind="section", label="User mail servers", order=20),
|
ViewSurface(id="mail.admin.user-servers", module_id="mail", kind="section", label="User mail servers", order=20),
|
||||||
ViewSurface(id="mail.settings.profiles", module_id="mail", kind="section", label="Personal mail profiles", order=10),
|
ViewSurface(id="mail.settings.profiles", module_id="mail", kind="section", label="Personal mail profiles", order=10),
|
||||||
|
ViewSurface(id="mail.quick_access.messages", module_id="mail", kind="quick_access", label="Mail Quick Access", order=80),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="communication",
|
||||||
|
module_id="mail",
|
||||||
|
label="i18n:govoplan-core.product_area.communication",
|
||||||
|
icon="mail",
|
||||||
|
description="i18n:govoplan-core.product_area.communication_description",
|
||||||
|
surface_ids=("mail.nav.mail", "mail.route.mail"),
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
quick_access_tools=(
|
||||||
|
QuickAccessTool(
|
||||||
|
id="mail.messages",
|
||||||
|
module_id="mail",
|
||||||
|
category_id="messages",
|
||||||
|
label="i18n:govoplan-mail.mail.92379cbb",
|
||||||
|
description="i18n:govoplan-mail.quick_access_description",
|
||||||
|
surface_id="mail.quick_access.messages",
|
||||||
|
icon="mail",
|
||||||
|
full_page_path="/mail",
|
||||||
|
required_any=("mail:mailbox:read",),
|
||||||
|
order=10,
|
||||||
|
modes=("browse", "compose", "select"),
|
||||||
|
returned_reference_kinds=("mail.message",),
|
||||||
|
help_context_id="mail.quick_access.messages",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
@@ -403,6 +467,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
|
MAIL_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||||
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||||
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
|
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
|
||||||
"govoplan_mail.backend.capabilities",
|
"govoplan_mail.backend.capabilities",
|
||||||
@@ -416,8 +481,229 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_mail.backend.bounce_processing",
|
"govoplan_mail.backend.bounce_processing",
|
||||||
fromlist=["SqlMailBounceProcessingProvider"],
|
fromlist=["SqlMailBounceProcessingProvider"],
|
||||||
).SqlMailBounceProcessingProvider(),
|
).SqlMailBounceProcessingProvider(),
|
||||||
|
CAPABILITY_MAIL_POSTBOX_BRIDGE: lambda context: __import__(
|
||||||
|
"govoplan_mail.backend.postbox_bridge",
|
||||||
|
fromlist=["create_postbox_bridge"],
|
||||||
|
).create_postbox_bridge(context),
|
||||||
|
MAIL_DSAR_CAPABILITY: _mail_dsar_provider,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
MAIL_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Mail data-subject request provider",
|
||||||
|
summary="Finds isolated mailbox-index, personal-profile, delivery, reconciliation, and bounce metadata without exposing transport secrets.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "mail_admin", "records_manager"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="mail.privacy.data-subject-requests",
|
||||||
|
title="Review Mail data in a data-subject request",
|
||||||
|
summary="Collect tenant-scoped Mail metadata while preserving transport evidence and external-mailbox authority.",
|
||||||
|
body=(
|
||||||
|
"Mail's DSAR provider searches the effective tenant by normalized header or bounce-recipient email, direct membership references, and namespaced Mail profile, message-index, delivery-command, or bounce references. It isolates only matching From, To, and Cc parties and returns bounded message-index content, safe personal-profile metadata, and delivery, attempt, reconciliation, and bounce outcomes. It excludes SMTP/IMAP configuration, usernames and credentials, encrypted messages and envelopes, refusal detail, mailbox folders and UIDs, endpoint and credential identifiers, idempotency and worker claims, diagnostics, error text, and opaque evidence. "
|
||||||
|
"Delivery attempts, reconciliation decisions, and bounce observations remain retained as immutable transport and recovery evidence. Mailbox indexes and personal profiles require manual review through Mail and the authoritative external mailbox. The provider performs no direct erasure because deleting a derived index alone would not delete its external source, while changing a profile or delivery command can affect credentials, other users, and preserved evidence."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "mail_admin", "records_manager", "operator"),
|
||||||
|
order=35,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("mail", "access"),
|
||||||
|
any_scopes=("access:privacy:read", "access:privacy:manage", "access:privacy:erase"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Data-subject requests", href="/admin?section=tenant-data-subject-requests", kind="runtime"),
|
||||||
|
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("access", "audit", "campaigns", "postbox"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/admin?section=tenant-data-subject-requests",
|
||||||
|
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||||
|
"steps": [
|
||||||
|
"Run the Mail provider search and review mailbox-index, profile, delivery, reconciliation, and bounce dispositions.",
|
||||||
|
"Retain immutable transport evidence with its reason.",
|
||||||
|
"Coordinate approved mailbox content deletion with the authoritative external mailbox and then refresh the derived index.",
|
||||||
|
"Use Mail profile lifecycle controls for approved personal-profile changes; do not edit encrypted payload or evidence rows directly.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Encrypted outbound payloads cannot be searched by recipient without an independently corroborated Mail command reference.",
|
||||||
|
"External mailbox deletion is outside the DSAR provider and must be coordinated with the configured provider.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="mail.postbox.bridge",
|
||||||
|
title="Bridge selected Mail observations into Postbox",
|
||||||
|
summary="Deliver one immutable IMAP observation to an explicit function Postbox without turning Postbox into a mailbox account.",
|
||||||
|
body=(
|
||||||
|
"Mail exposes an optional, idempotent bridge capability. A configured caller supplies an exact Mail profile, folder, UIDVALIDITY, UID, raw observation, "
|
||||||
|
"and typed Postbox target. Mail parses bounded headers, plaintext, participants, and attachment evidence; Postbox owns target resolution, "
|
||||||
|
"delivery, retention, receipts, and access. The bridge stores no credentials in Postbox and never treats account login as Postbox membership."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("mail_user", "mail_admin", "administrator"),
|
||||||
|
related_modules=("postbox", "idm", "organizations"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Ausgewählte Mail-Beobachtungen an Postbox übergeben",
|
||||||
|
"summary": "Eine unveränderliche IMAP-Beobachtung an ein ausdrücklich bestimmtes Funktionspostfach liefern, ohne Postbox zu einem Mailkonto zu machen.",
|
||||||
|
"body": (
|
||||||
|
"Mail stellt eine optionale, idempotente Bridge-Capability bereit. Ein konfigurierter Aufrufer übergibt ein exaktes Mailprofil, Ordner, UIDVALIDITY, UID, die rohe Beobachtung "
|
||||||
|
"und ein typisiertes Postbox-Ziel. Mail liest begrenzte Kopfzeilen, Klartext, Beteiligte und Anlagennachweise; Postbox besitzt Zielauflösung, Zustellung, "
|
||||||
|
"Aufbewahrung, Belege und Zugriff. Die Bridge speichert keine Zugangsdaten in Postbox und behandelt die Kontoanmeldung niemals als Postfachmitgliedschaft."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={"kind": "guide", "help_contexts": ["mail.postbox-bridge"]},
|
||||||
|
order=36,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="mail.configuration-package.smtp-profile",
|
||||||
|
title="Provision SMTP profiles from deployment receipts",
|
||||||
|
summary="Review and apply an idempotent Mail profile without moving plaintext credentials through a package.",
|
||||||
|
body=(
|
||||||
|
"The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. "
|
||||||
|
"Missing non-secret transport fields are collected by preflight; authentication is represented only by an existing credential-envelope id. "
|
||||||
|
"Tenant scope is the default, system scope requires system authority, conflicting profiles are preserved unless update is explicitly reviewed, "
|
||||||
|
"and an unchanged second apply is a no-op. SMTP reachability remains a separate Mail profile test."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("mail_admin", "administrator", "operator"),
|
||||||
|
related_modules=("core", "ops", "access"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("mail", "access"),
|
||||||
|
any_scopes=("admin:settings:read", "system:settings:read"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Configuration packages",
|
||||||
|
href="/admin?section=configuration-packages",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Mail handbook",
|
||||||
|
href="govoplan-mail/docs/MAIL_HANDBOOK.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "SMTP-Profile aus Bereitstellungsnachweisen einrichten",
|
||||||
|
"summary": "Ein idempotentes Mail-Profil prüfen und anwenden, ohne Klartext-Zugangsdaten durch ein Konfigurationspaket zu übertragen.",
|
||||||
|
"body": (
|
||||||
|
"Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. "
|
||||||
|
"Fehlende nicht geheime Transportangaben werden im Preflight abgefragt; Authentifizierung wird ausschließlich durch die ID eines vorhandenen Zugangsdaten-Umschlags referenziert. "
|
||||||
|
"Mandantenbezug ist der Standard, Systembezug erfordert Systemberechtigung, abweichende vorhandene Profile bleiben ohne ausdrücklich geprüfte Aktualisierung unverändert, "
|
||||||
|
"und eine unveränderte zweite Anwendung bleibt wirkungslos. Die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/admin?section=configuration-packages",
|
||||||
|
"help_contexts": ["admin.configuration-packages", "mail.admin.profiles"],
|
||||||
|
},
|
||||||
|
order=4,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="mail.quick-access-and-product-area",
|
||||||
|
title="Mail in Communication and Messages",
|
||||||
|
summary="Use Mail in the Communication product area and the shared Messages Quick Access drawer.",
|
||||||
|
body=(
|
||||||
|
"Mail contributes its full mailbox to Communication. When Quick Access is enabled, its compact provider surface "
|
||||||
|
"appears inside the shared Messages drawer alongside independent Postbox and future chat contributions. "
|
||||||
|
"Recent entries open the exact authorized profile, folder, and message; Drafts resolves the configured or "
|
||||||
|
"provider-detected Drafts folder, and the full Mail page keeps the versioned Quick Access return context. "
|
||||||
|
"When an active Case requests a selection, choosing a recent message returns only its tenant-bound profile, "
|
||||||
|
"folder, UID version, label, and owner route through the shared result contract. Cases discards the label and "
|
||||||
|
"does not receive headers, participants, preview, body, attachments, credentials, or an access decision; Mail "
|
||||||
|
"reauthorizes the mailbox list now and the exact message again whenever its owner route is opened. "
|
||||||
|
"Compose deliberately launches the user's configured mail application because GovOPlaN Mail's mailbox is "
|
||||||
|
"read-only; it neither selects a GovOPlaN transport profile nor claims a GovOPlaN delivery. The shared drawer "
|
||||||
|
"does not merge channel state, credentials, custody, delivery semantics, or authorization."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("mail_user", "mail_admin", "administrator"),
|
||||||
|
related_modules=("quick_access", "views", "postbox"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Mail in Kommunikation und Nachrichten",
|
||||||
|
"summary": "Mail im Produktbereich Kommunikation und in der gemeinsamen Schnellzugriffseinblendung Nachrichten verwenden.",
|
||||||
|
"body": (
|
||||||
|
"Mail ordnet das vollständige Postfach Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die kompakte "
|
||||||
|
"Mail-Oberfläche gemeinsam mit unabhängigen Beiträgen aus Postbox und künftig Chat unter Nachrichten. "
|
||||||
|
"Aktuelle Einträge öffnen das genaue berechtigte Profil, den Ordner und die Nachricht; Entwürfe verwendet "
|
||||||
|
"den konfigurierten oder vom Anbieter erkannten Entwurfsordner. Die vollständige Mail-Seite erhält den "
|
||||||
|
"versionierten Rücksprungkontext. Verfassen öffnet bewusst die konfigurierte Mail-Anwendung des Benutzers, "
|
||||||
|
"Bei einem aktiven Vorgang gibt die Auswahl einer aktuellen Nachricht nur den mandantengebundenen Profil-, "
|
||||||
|
"Ordner- und UID-Verweis sowie die Eigentümerroute über den gemeinsamen Ergebnisvertrag zurück. Vorgänge "
|
||||||
|
"übernehmen weder Kopfzeilen, Beteiligte, Vorschau, Inhalt, Anlagen, Zugangsdaten noch eine Zugriffsentscheidung; "
|
||||||
|
"Mail prüft den Zugriff beim Auflisten und beim späteren Öffnen erneut. "
|
||||||
|
"da das GovOPlaN-Mail-Postfach nur lesend arbeitet; dabei wird weder ein GovOPlaN-Transportprofil gewählt "
|
||||||
|
"noch eine GovOPlaN-Zustellung behauptet. Die gemeinsame Darstellung führt weder Kanalzustand noch "
|
||||||
|
"Zugangsdaten, Verwahrung, Zustelllogik oder Berechtigungen zusammen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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(
|
DocumentationTopic(
|
||||||
id="mail.search.mailbox-messages",
|
id="mail.search.mailbox-messages",
|
||||||
title="Search authorized mailbox messages",
|
title="Search authorized mailbox messages",
|
||||||
@@ -433,7 +719,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",
|
||||||
@@ -473,6 +759,44 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="mail.profile-standard-folder-mappings",
|
||||||
|
title="Map standard folders for an IMAP profile",
|
||||||
|
summary="Store Inbox, Sent, Drafts, Trash, Archive, and Junk mappings on the reusable Mail profile and populate them from bounded IMAP discovery.",
|
||||||
|
body=(
|
||||||
|
"Folder names are profile/server metadata, not Campaign settings. An authorized profile administrator may enter names directly or use folder discovery; empty mappings retain automatic behavior. Existing imap.sent_folder values are presented and persisted as the Sent mapping, while the compatibility field remains synchronized for consumers that still read it. A Campaign-specific Sent-folder override remains authoritative for that Campaign and is not rewritten by profile discovery. Folder discovery lists provider-visible names without creating, renaming, moving, or deleting remote folders."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("mail_admin", "campaign_admin", "mail_user"),
|
||||||
|
order=40,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("mail",),
|
||||||
|
required_scopes=("mail:profile:read",),
|
||||||
|
any_scopes=("mail:profile:write", "mail:profile:write_own", "mail:profile:test"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
||||||
|
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||||
|
),
|
||||||
|
related_modules=("campaigns",),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/settings?section=mail-profiles",
|
||||||
|
"screen": "Mail profiles",
|
||||||
|
"section": "IMAP standard folder mappings",
|
||||||
|
"help_contexts": ["mail.profiles", "mail.admin.profiles"],
|
||||||
|
"steps": [
|
||||||
|
"Open an editable IMAP server in a reusable Mail profile.",
|
||||||
|
"Choose Detect folders to load the provider-visible folder names, or enter exact names manually.",
|
||||||
|
"Apply the detected mappings, review every role, and leave uncertain roles empty for automatic behavior.",
|
||||||
|
"Save the profile and reload it to verify the mappings were retained.",
|
||||||
|
],
|
||||||
|
"verification": "Confirm legacy Sent-only profiles show the same effective Sent mapping, and confirm a Campaign-local Sent override remains unchanged.",
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="mail.bounce-processing",
|
id="mail.bounce-processing",
|
||||||
title="Delivery-status and bounce processing",
|
title="Delivery-status and bounce processing",
|
||||||
@@ -596,8 +920,8 @@ manifest = ModuleManifest(
|
|||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="mail.workflow.read-mailbox",
|
id="mail.workflow.read-mailbox",
|
||||||
title="Read a permitted mailbox without changing it",
|
title="Read a permitted mailbox without changing it",
|
||||||
summary="Choose an IMAP-enabled profile, browse folders, and inspect bounded message content through the read-only mailbox surface.",
|
summary="Choose an IMAP-enabled profile, browse synchronized folders, and inspect bounded message content through the read-only mailbox surface.",
|
||||||
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
|
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Lists expose provider read/unread flags and live/cache/refresh provenance without mutating them. Message HTML is isolated and sanitized, while attachments, inline references, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("mail_user",),
|
audience=("mail_user",),
|
||||||
@@ -624,8 +948,9 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Open Mail and choose an authorized IMAP-enabled profile.",
|
"Open Mail and choose an authorized IMAP-enabled profile.",
|
||||||
"Select a folder and page through its bounded message index.",
|
"Select a folder, review the live/cached synchronization label, and page or filter its bounded message index.",
|
||||||
"Open only the message needed for the task and close it when finished.",
|
"Use the provider-derived read/unread indicator, then open only the message needed for the task.",
|
||||||
|
"Switch between safe plain-text and isolated HTML views as needed, and review attachment or unavailable-content details before closing the preview.",
|
||||||
],
|
],
|
||||||
"outcome": "The required message was inspected without changing provider mailbox state.",
|
"outcome": "The required message was inspected without changing provider mailbox state.",
|
||||||
"verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.",
|
"verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.",
|
||||||
@@ -673,7 +998,7 @@ manifest = ModuleManifest(
|
|||||||
id="mail.reference.campaign-delivery-contract",
|
id="mail.reference.campaign-delivery-contract",
|
||||||
title="Integrate Campaign through the Mail 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.",
|
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",
|
layer="available",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
|
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
|
||||||
@@ -696,7 +1021,7 @@ manifest = ModuleManifest(
|
|||||||
"route": "/campaigns/{campaign_id}/mail-settings",
|
"route": "/campaigns/{campaign_id}/mail-settings",
|
||||||
"screen": "Campaign Mail settings",
|
"screen": "Campaign Mail settings",
|
||||||
"section": "Mail-owned profile and transport boundary",
|
"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": [
|
"related_topic_ids": [
|
||||||
"mail.profile-ownership-and-consumers",
|
"mail.profile-ownership-and-consumers",
|
||||||
"campaigns.mail-profile-user-journey",
|
"campaigns.mail-profile-user-journey",
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from email import policy
|
||||||
|
from email.message import Message
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.utils import getaddresses
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.mail import (
|
||||||
|
MailPostboxBridgeProvider,
|
||||||
|
MailPostboxBridgeRequest,
|
||||||
|
MailPostboxBridgeResult,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
from govoplan_core.core.postbox import (
|
||||||
|
PostboxAttachmentRef,
|
||||||
|
PostboxDeliveryRequest,
|
||||||
|
PostboxParticipantRef,
|
||||||
|
PostboxTargetRef,
|
||||||
|
postbox_delivery_provider,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.db.models import MailServerProfile
|
||||||
|
from govoplan_mail.backend.runtime import configure_runtime, get_registry
|
||||||
|
|
||||||
|
|
||||||
|
MAX_BRIDGE_MESSAGE_BYTES = 50 * 1024 * 1024
|
||||||
|
MAX_POSTBOX_BODY_CHARS = 500_000
|
||||||
|
|
||||||
|
|
||||||
|
class MailPostboxBridgeError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MailPostboxBridge(MailPostboxBridgeProvider):
|
||||||
|
"""Translate immutable IMAP observations into native Postbox delivery."""
|
||||||
|
|
||||||
|
def bridge_message(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
request: MailPostboxBridgeRequest,
|
||||||
|
) -> MailPostboxBridgeResult:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Mail Postbox bridging requires a SQLAlchemy session.")
|
||||||
|
if not isinstance(request.target, PostboxTargetRef):
|
||||||
|
raise MailPostboxBridgeError("A typed Postbox target is required.")
|
||||||
|
if len(request.raw_message) > MAX_BRIDGE_MESSAGE_BYTES:
|
||||||
|
raise MailPostboxBridgeError("Mail message exceeds the Postbox bridge limit.")
|
||||||
|
profile = session.get(MailServerProfile, request.profile_id)
|
||||||
|
if profile is None or profile.tenant_id != request.tenant_id:
|
||||||
|
raise MailPostboxBridgeError("Mail profile not found.")
|
||||||
|
folder = request.folder.strip()
|
||||||
|
uid = request.uid.strip()
|
||||||
|
uidvalidity = request.uidvalidity.strip()
|
||||||
|
if not folder or not uid or not uidvalidity:
|
||||||
|
raise MailPostboxBridgeError(
|
||||||
|
"Mail folder, UIDVALIDITY, and immutable UID are required."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
message = BytesParser(policy=policy.default).parsebytes(request.raw_message)
|
||||||
|
except Exception as exc:
|
||||||
|
raise MailPostboxBridgeError("Mail message could not be parsed.") from exc
|
||||||
|
|
||||||
|
provider = postbox_delivery_provider(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
raise MailPostboxBridgeError("Postbox delivery is not available.")
|
||||||
|
source_digest = hashlib.sha256(request.raw_message).hexdigest()
|
||||||
|
source_key = hashlib.sha256(
|
||||||
|
f"{request.tenant_id}\0{request.profile_id}\0{folder}\0{uidvalidity}\0{uid}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
result = provider.deliver(
|
||||||
|
session,
|
||||||
|
PostboxDeliveryRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
target=request.target,
|
||||||
|
producer_module="mail",
|
||||||
|
producer_resource_type="imap_message",
|
||||||
|
producer_resource_id=source_key,
|
||||||
|
idempotency_key=f"mail-postbox:{source_key}:{source_digest}",
|
||||||
|
subject=_header(message, "Subject") or "(No subject)",
|
||||||
|
body_text=_plain_text_body(message),
|
||||||
|
sender_label=_header(message, "From"),
|
||||||
|
classification=request.classification,
|
||||||
|
participants=_participants(message),
|
||||||
|
attachments=_attachments(
|
||||||
|
message,
|
||||||
|
profile_id=request.profile_id,
|
||||||
|
folder=folder,
|
||||||
|
uidvalidity=uidvalidity,
|
||||||
|
uid=uid,
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
**dict(request.metadata),
|
||||||
|
"transport": "mail-imap",
|
||||||
|
"mail_profile_id": request.profile_id,
|
||||||
|
"mailbox_folder": folder,
|
||||||
|
"mailbox_uidvalidity": uidvalidity,
|
||||||
|
"mailbox_uid": uid,
|
||||||
|
"rfc_message_id": _header(message, "Message-ID"),
|
||||||
|
"raw_sha256": source_digest,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return MailPostboxBridgeResult(
|
||||||
|
postbox_id=result.postbox_id,
|
||||||
|
message_id=result.message_id,
|
||||||
|
delivery_id=result.delivery_id,
|
||||||
|
duplicate=result.duplicate,
|
||||||
|
source_digest=source_digest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _header(message: Message, name: str) -> str | None:
|
||||||
|
value = " ".join(str(message.get(name) or "").split())
|
||||||
|
return value[:1000] or None
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_text_body(message: Message) -> str | None:
|
||||||
|
candidates = message.walk() if message.is_multipart() else (message,)
|
||||||
|
for part in candidates:
|
||||||
|
if part.get_content_type() != "text/plain":
|
||||||
|
continue
|
||||||
|
if part.get_content_disposition() == "attachment":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = part.get_content()
|
||||||
|
except Exception:
|
||||||
|
payload = part.get_payload(decode=True) or b""
|
||||||
|
value = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
value = value.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||||
|
normalized = str(value).strip()
|
||||||
|
if normalized:
|
||||||
|
return normalized[:MAX_POSTBOX_BODY_CHARS]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _participants(message: Message) -> tuple[PostboxParticipantRef, ...]:
|
||||||
|
result: list[PostboxParticipantRef] = []
|
||||||
|
for kind, headers in (
|
||||||
|
("sender", ("From",)),
|
||||||
|
("to", ("To",)),
|
||||||
|
("cc", ("Cc",)),
|
||||||
|
("bcc", ("Bcc",)),
|
||||||
|
):
|
||||||
|
for name, address in getaddresses(
|
||||||
|
[str(value) for header in headers for value in message.get_all(header, [])]
|
||||||
|
):
|
||||||
|
clean_address = address.strip()[:500]
|
||||||
|
if not clean_address:
|
||||||
|
continue
|
||||||
|
result.append(
|
||||||
|
PostboxParticipantRef(
|
||||||
|
kind=kind,
|
||||||
|
reference_type="external_email",
|
||||||
|
label=name.strip()[:500] or None,
|
||||||
|
address=clean_address,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _attachments(
|
||||||
|
message: Message,
|
||||||
|
*,
|
||||||
|
profile_id: str,
|
||||||
|
folder: str,
|
||||||
|
uidvalidity: str,
|
||||||
|
uid: str,
|
||||||
|
) -> tuple[PostboxAttachmentRef, ...]:
|
||||||
|
result: list[PostboxAttachmentRef] = []
|
||||||
|
for index, part in enumerate(message.walk()):
|
||||||
|
filename = part.get_filename()
|
||||||
|
if part.get_content_disposition() != "attachment" and not filename:
|
||||||
|
continue
|
||||||
|
payload = part.get_payload(decode=True) or b""
|
||||||
|
digest = hashlib.sha256(payload).hexdigest()
|
||||||
|
reference_id = hashlib.sha256(
|
||||||
|
f"{profile_id}\0{folder}\0{uidvalidity}\0{uid}\0{index}\0{digest}".encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
result.append(
|
||||||
|
PostboxAttachmentRef(
|
||||||
|
reference_type="mail_attachment",
|
||||||
|
reference_id=reference_id,
|
||||||
|
name=str(filename or f"attachment-{index + 1}")[:1000],
|
||||||
|
media_type=part.get_content_type(),
|
||||||
|
size_bytes=len(payload),
|
||||||
|
digest=digest,
|
||||||
|
metadata={
|
||||||
|
"mail_profile_id": profile_id,
|
||||||
|
"mailbox_folder": folder,
|
||||||
|
"mailbox_uidvalidity": uidvalidity,
|
||||||
|
"mailbox_uid": uid,
|
||||||
|
"mime_part_index": index,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def create_postbox_bridge(context: ModuleContext) -> MailPostboxBridge:
|
||||||
|
configure_runtime(registry=context.registry, settings=context.settings)
|
||||||
|
return MailPostboxBridge()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MailPostboxBridge",
|
||||||
|
"MailPostboxBridgeError",
|
||||||
|
"create_postbox_bridge",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -1179,6 +1193,7 @@ def _mailbox_folder_response(
|
|||||||
message=f"Found {len(folders)} IMAP folder(s).",
|
message=f"Found {len(folders)} IMAP folder(s).",
|
||||||
folders=folders,
|
folders=folders,
|
||||||
detected_sent_folder=result.detected_sent_folder,
|
detected_sent_folder=result.detected_sent_folder,
|
||||||
|
detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {},
|
||||||
from_cache=from_cache,
|
from_cache=from_cache,
|
||||||
refreshing=refreshing,
|
refreshing=refreshing,
|
||||||
indexed_at=indexed_at,
|
indexed_at=indexed_at,
|
||||||
@@ -1360,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"),
|
||||||
@@ -2276,7 +2363,7 @@ def update_profile(
|
|||||||
session,
|
session,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
protocol="smtp",
|
protocol="smtp",
|
||||||
config=smtp_config.model_dump(mode="json"),
|
config=smtp_config.model_dump(mode="json", exclude_none=True),
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
)
|
)
|
||||||
imap_server = None
|
imap_server = None
|
||||||
@@ -2286,7 +2373,7 @@ def update_profile(
|
|||||||
profile=profile,
|
profile=profile,
|
||||||
protocol="imap",
|
protocol="imap",
|
||||||
config=(
|
config=(
|
||||||
imap_config.model_dump(mode="json")
|
imap_config.model_dump(mode="json", exclude_none=True)
|
||||||
if imap_config is not None and not payload.clear_imap
|
if imap_config is not None and not payload.clear_imap
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
@@ -3063,6 +3150,7 @@ def list_imap_folder_settings(
|
|||||||
message=f"Found {len(folders)} IMAP folder(s).",
|
message=f"Found {len(folders)} IMAP folder(s).",
|
||||||
folders=folders,
|
folders=folders,
|
||||||
detected_sent_folder=result.detected_sent_folder,
|
detected_sent_folder=result.detected_sent_folder,
|
||||||
|
detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return MailImapFolderListResponse(
|
return MailImapFolderListResponse(
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -370,6 +407,7 @@ class MailImapFolderListResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
||||||
detected_sent_folder: str | None = None
|
detected_sent_folder: str | None = None
|
||||||
|
detected_folder_mappings: dict[str, str] = Field(default_factory=dict)
|
||||||
from_cache: bool = False
|
from_cache: bool = False
|
||||||
refreshing: bool = False
|
refreshing: bool = False
|
||||||
indexed_at: datetime | None = None
|
indexed_at: datetime | None = None
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class ImapFolderListResult:
|
|||||||
security: str
|
security: str
|
||||||
folders: list[ImapMailboxInfo]
|
folders: list[ImapMailboxInfo]
|
||||||
detected_sent_folder: str | None = None
|
detected_sent_folder: str | None = None
|
||||||
|
detected_folder_mappings: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -317,25 +318,47 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
_STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = {
|
||||||
for name, flags in parsed:
|
"inbox": ("\\inbox",),
|
||||||
if "\\sent" in flags or "\\sentmail" in flags:
|
"sent": ("\\sent", "\\sentmail"),
|
||||||
return name
|
"drafts": ("\\drafts",),
|
||||||
|
"trash": ("\\trash",),
|
||||||
|
"archive": ("\\archive", "\\all"),
|
||||||
|
"junk": ("\\junk", "\\spam"),
|
||||||
|
}
|
||||||
|
|
||||||
common_names = [
|
_STANDARD_FOLDER_NAMES: dict[str, tuple[str, ...]] = {
|
||||||
"Sent",
|
"inbox": ("INBOX", "Posteingang"),
|
||||||
"Sent Items",
|
"sent": ("Sent", "Sent Items", "Sent Messages", "Gesendet", "Gesendete Elemente", "INBOX.Sent", "INBOX/Sent"),
|
||||||
"Sent Messages",
|
"drafts": ("Drafts", "Entwürfe", "Entwuerfe"),
|
||||||
"Gesendet",
|
"trash": ("Trash", "Deleted Items", "Gelöscht", "Geloescht", "Papierkorb"),
|
||||||
"Gesendete Elemente",
|
"archive": ("Archive", "Archives", "Archiv"),
|
||||||
"INBOX.Sent",
|
"junk": ("Junk", "Spam", "Junk Email", "Unerwünscht", "Unerwuenscht"),
|
||||||
"INBOX/Sent",
|
}
|
||||||
]
|
|
||||||
names = {name.lower(): name for name, _ in parsed}
|
|
||||||
for candidate in common_names:
|
def _detect_standard_folder_mappings(parsed: list[tuple[str, set[str]]]) -> dict[str, str]:
|
||||||
if candidate.lower() in names:
|
detected: dict[str, str] = {}
|
||||||
return names[candidate.lower()]
|
for role, accepted_flags in _STANDARD_FOLDER_FLAGS.items():
|
||||||
return None
|
for name, flags in parsed:
|
||||||
|
if any(flag in flags for flag in accepted_flags):
|
||||||
|
detected[role] = name
|
||||||
|
break
|
||||||
|
|
||||||
|
names = {name.casefold(): name for name, _ in parsed}
|
||||||
|
for role, candidates in _STANDARD_FOLDER_NAMES.items():
|
||||||
|
if role in detected:
|
||||||
|
continue
|
||||||
|
for candidate in candidates:
|
||||||
|
match = names.get(candidate.casefold())
|
||||||
|
if match:
|
||||||
|
detected[role] = match
|
||||||
|
break
|
||||||
|
return detected
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
||||||
|
return _detect_standard_folder_mappings(parsed).get("sent")
|
||||||
|
|
||||||
|
|
||||||
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
|
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
|
||||||
@@ -402,12 +425,18 @@ def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
|||||||
name = str(item["name"])
|
name = str(item["name"])
|
||||||
count = sum(1 for record in records if _mock_folder_matches(record, name))
|
count = sum(1 for record in records if _mock_folder_matches(record, name))
|
||||||
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
|
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
|
||||||
|
parsed = [
|
||||||
|
(str(item["name"]), {str(flag).lower() for flag in item.get("flags") or []})
|
||||||
|
for item in MOCK_IMAP_FOLDERS
|
||||||
|
]
|
||||||
|
detected = _detect_standard_folder_mappings(parsed)
|
||||||
return ImapFolderListResult(
|
return ImapFolderListResult(
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
security=imap_config.security.value,
|
security=imap_config.security.value,
|
||||||
folders=folders,
|
folders=folders,
|
||||||
detected_sent_folder="Sent",
|
detected_sent_folder=detected.get("sent"),
|
||||||
|
detected_folder_mappings=detected,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -438,12 +467,14 @@ def _list_imap_folders_on_client(
|
|||||||
)
|
)
|
||||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
|
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
|
||||||
|
|
||||||
|
detected = _detect_standard_folder_mappings(parsed)
|
||||||
return ImapFolderListResult(
|
return ImapFolderListResult(
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
security=security,
|
security=security,
|
||||||
folders=folders,
|
folders=folders,
|
||||||
detected_sent_folder=_detect_sent_folder(parsed),
|
detected_sent_folder=detected.get("sent"),
|
||||||
|
detected_folder_mappings=detected,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -64,10 +65,22 @@ class SmtpSendError(RuntimeError):
|
|||||||
started, so automatic retry is intentionally forbidden.
|
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)
|
super().__init__(message)
|
||||||
self.temporary = temporary
|
self.temporary = temporary
|
||||||
self.outcome_unknown = outcome_unknown
|
self.outcome_unknown = outcome_unknown
|
||||||
|
self.systemic = systemic
|
||||||
|
self.reason_code = reason_code
|
||||||
|
self.phase = phase
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -86,12 +99,315 @@ class SmtpSendResult:
|
|||||||
envelope_from: str
|
envelope_from: str
|
||||||
envelope_recipients: list[str]
|
envelope_recipients: list[str]
|
||||||
refused_recipients: dict[str, tuple[int, bytes | str]]
|
refused_recipients: dict[str, tuple[int, bytes | str]]
|
||||||
|
connection_sequence: int = 1
|
||||||
|
session_reused: bool = False
|
||||||
|
reconnect_count: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def accepted_count(self) -> int:
|
def accepted_count(self) -> int:
|
||||||
return len(self.envelope_recipients) - len(self.refused_recipients)
|
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:
|
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
|
||||||
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
|
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
|
||||||
|
|
||||||
@@ -324,15 +640,27 @@ def _send_network_smtp_payload(
|
|||||||
raise SmtpSendError(
|
raise SmtpSendError(
|
||||||
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
|
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
|
||||||
temporary=False,
|
temporary=False,
|
||||||
|
systemic=True,
|
||||||
|
reason_code="smtp_authentication_failed",
|
||||||
|
phase="preflight",
|
||||||
) from exc
|
) from exc
|
||||||
except smtplib.SMTPResponseException as exc:
|
except smtplib.SMTPResponseException as exc:
|
||||||
raise SmtpSendError(
|
raise SmtpSendError(
|
||||||
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
|
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||||
temporary=400 <= int(exc.smtp_code) < 500,
|
temporary=400 <= int(exc.smtp_code) < 500,
|
||||||
|
systemic=True,
|
||||||
|
reason_code="smtp_preflight_rejected",
|
||||||
|
phase="preflight",
|
||||||
) from exc
|
) from exc
|
||||||
except (OSError, smtplib.SMTPException) as exc:
|
except (OSError, smtplib.SMTPException) as exc:
|
||||||
# No message transmission has begun yet; a later explicit retry is safe.
|
# 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:
|
try:
|
||||||
if isinstance(message, bytes):
|
if isinstance(message, bytes):
|
||||||
@@ -352,6 +680,8 @@ def _send_network_smtp_payload(
|
|||||||
raise SmtpSendError(
|
raise SmtpSendError(
|
||||||
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
|
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
|
||||||
temporary=400 <= int(exc.smtp_code) < 500,
|
temporary=400 <= int(exc.smtp_code) < 500,
|
||||||
|
systemic=True,
|
||||||
|
reason_code="smtp_sender_refused",
|
||||||
) from exc
|
) from exc
|
||||||
except smtplib.SMTPResponseException as exc:
|
except smtplib.SMTPResponseException as exc:
|
||||||
# An explicit SMTP response means the server rejected the transaction;
|
# An explicit SMTP response means the server rejected the transaction;
|
||||||
@@ -359,6 +689,8 @@ def _send_network_smtp_payload(
|
|||||||
raise SmtpSendError(
|
raise SmtpSendError(
|
||||||
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
|
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||||
temporary=400 <= int(exc.smtp_code) < 500,
|
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
|
) from exc
|
||||||
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
|
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
|
||||||
# A connection loss after DATA began can happen after the server accepted
|
# A connection loss after DATA began can happen after the server accepted
|
||||||
@@ -366,6 +698,8 @@ def _send_network_smtp_payload(
|
|||||||
raise SmtpSendError(
|
raise SmtpSendError(
|
||||||
f"SMTP outcome is unknown after transmission started: {exc}",
|
f"SMTP outcome is unknown after transmission started: {exc}",
|
||||||
outcome_unknown=True,
|
outcome_unknown=True,
|
||||||
|
systemic=True,
|
||||||
|
reason_code="smtp_connection_lost_after_transmission",
|
||||||
) from exc
|
) from exc
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -387,6 +721,9 @@ def _smtp_send_result(
|
|||||||
envelope_from: str,
|
envelope_from: str,
|
||||||
envelope_recipients: list[str],
|
envelope_recipients: list[str],
|
||||||
refused: dict[str, tuple[int, bytes]],
|
refused: dict[str, tuple[int, bytes]],
|
||||||
|
connection_sequence: int = 1,
|
||||||
|
session_reused: bool = False,
|
||||||
|
reconnect_count: int = 0,
|
||||||
) -> SmtpSendResult:
|
) -> SmtpSendResult:
|
||||||
return SmtpSendResult(
|
return SmtpSendResult(
|
||||||
host=host,
|
host=host,
|
||||||
@@ -395,6 +732,9 @@ def _smtp_send_result(
|
|||||||
envelope_from=envelope_from,
|
envelope_from=envelope_from,
|
||||||
envelope_recipients=list(envelope_recipients),
|
envelope_recipients=list(envelope_recipients),
|
||||||
refused_recipients=_decode_refused(refused),
|
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,
|
smtp_config: SmtpConfig,
|
||||||
envelope_from: str,
|
envelope_from: str,
|
||||||
envelope_recipients: list[str],
|
envelope_recipients: list[str],
|
||||||
|
batch_session: SmtpBatchSession | None = None,
|
||||||
) -> SmtpSendResult:
|
) -> SmtpSendResult:
|
||||||
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
|
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
|
||||||
|
|
||||||
return _send_smtp_payload(
|
if batch_session is not None:
|
||||||
message_bytes,
|
if batch_session.smtp_config != smtp_config:
|
||||||
smtp_config=smtp_config,
|
raise SmtpConfigurationError("SMTP batch session does not match the resolved transport.")
|
||||||
envelope_from=envelope_from,
|
return batch_session.send(
|
||||||
envelope_recipients=envelope_recipients,
|
message_bytes,
|
||||||
)
|
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(
|
def send_email_message(
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ def initialize_profile_hierarchy(
|
|||||||
for protocol, value in (("smtp", smtp), ("imap", imap)):
|
for protocol, value in (("smtp", smtp), ("imap", imap)):
|
||||||
if value is None or protocol in existing:
|
if value is None or protocol in existing:
|
||||||
continue
|
continue
|
||||||
raw = value.model_dump(mode="json") if hasattr(value, "model_dump") else dict(value)
|
raw = value.model_dump(mode="json", exclude_none=True) if hasattr(value, "model_dump") else dict(value)
|
||||||
credentials = {
|
credentials = {
|
||||||
"username": raw.pop("username", None),
|
"username": raw.pop("username", None),
|
||||||
"password": raw.pop("password", None),
|
"password": raw.pop("password", None),
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import replace
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||||
|
from govoplan_core.core.configuration_packages import (
|
||||||
|
ConfigurationPackageFragment,
|
||||||
|
ConfigurationPreflightContext,
|
||||||
|
)
|
||||||
|
from govoplan_core.admin.models import SystemSettings
|
||||||
|
from govoplan_core.core.infrastructure_capabilities import (
|
||||||
|
infrastructure_capability_receipt_from_mapping,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_core.security.credential_envelopes import CredentialEnvelope
|
||||||
|
from govoplan_mail.backend.configuration_provider import (
|
||||||
|
MAIL_CONFIGURATION_CAPABILITY,
|
||||||
|
SqlMailConfigurationProvider,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.db.models import (
|
||||||
|
MailProfilePolicy,
|
||||||
|
MailServerCredentialBinding,
|
||||||
|
MailServerEndpoint,
|
||||||
|
MailServerProfile,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _receipt():
|
||||||
|
return infrastructure_capability_receipt_from_mapping(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": "mail-provider-test",
|
||||||
|
"profile": "evaluation",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "mail.smtp",
|
||||||
|
"label": "SMTP delivery",
|
||||||
|
"state": "available_unconfigured",
|
||||||
|
"source": "installer-managed-test",
|
||||||
|
"detail": "GreenMail is available for profile binding.",
|
||||||
|
"endpoint": {
|
||||||
|
"scheme": "smtp",
|
||||||
|
"host": "test-mail",
|
||||||
|
"port": 3025,
|
||||||
|
},
|
||||||
|
"secret_refs": [],
|
||||||
|
"dependent_modules": ["mail"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"post_install_tasks": [
|
||||||
|
{
|
||||||
|
"id": "mail.smtp-profile",
|
||||||
|
"resume_key": "mail-provider-test:mail.smtp-profile:v1",
|
||||||
|
"capability_id": "mail.smtp",
|
||||||
|
"state": "pending",
|
||||||
|
"owner_module": "mail",
|
||||||
|
"summary": "Create a Mail SMTP profile.",
|
||||||
|
"required_inputs": ["credential envelope reference when required"],
|
||||||
|
"secret_boundary": "credential-envelope-reference-only",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _external_receipt(*, credential_required: bool = False):
|
||||||
|
return infrastructure_capability_receipt_from_mapping(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": "mail-provider-external",
|
||||||
|
"profile": "production",
|
||||||
|
"capabilities": [
|
||||||
|
{
|
||||||
|
"id": "mail.smtp",
|
||||||
|
"label": "SMTP delivery",
|
||||||
|
"state": "available_unconfigured",
|
||||||
|
"source": "operator-supplied",
|
||||||
|
"detail": "An external relay needs reviewed Mail configuration.",
|
||||||
|
"endpoint": {},
|
||||||
|
"secret_refs": (
|
||||||
|
["env:SMTP_CREDENTIAL_ENVELOPE_REF"]
|
||||||
|
if credential_required
|
||||||
|
else []
|
||||||
|
),
|
||||||
|
"dependent_modules": ["mail"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"post_install_tasks": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MailConfigurationProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory(prefix="govoplan-mail-config-")
|
||||||
|
self.addCleanup(self.tempdir.cleanup)
|
||||||
|
database_path = Path(self.tempdir.name) / "mail.sqlite3"
|
||||||
|
self.engine = create_engine(f"sqlite:///{database_path}")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=(
|
||||||
|
access_models.User.__table__,
|
||||||
|
SystemSettings.__table__,
|
||||||
|
CredentialEnvelope.__table__,
|
||||||
|
MailServerProfile.__table__,
|
||||||
|
MailServerEndpoint.__table__,
|
||||||
|
MailServerCredentialBinding.__table__,
|
||||||
|
MailProfilePolicy.__table__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
configure_database(
|
||||||
|
f"sqlite:///{database_path}",
|
||||||
|
engine=self.engine,
|
||||||
|
dispose_previous=True,
|
||||||
|
)
|
||||||
|
self.SessionLocal = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
class_=Session,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
session.add(
|
||||||
|
MailProfilePolicy(
|
||||||
|
id="tenant-policy",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
policy={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
CredentialEnvelope(
|
||||||
|
id="credential-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="SMTP credential",
|
||||||
|
credential_kind="username_password",
|
||||||
|
public_data={"username": "mailer"},
|
||||||
|
secret_data_encrypted="encrypted-outside-package",
|
||||||
|
secret_keys=["password"],
|
||||||
|
allowed_modules=["mail"],
|
||||||
|
allowed_server_refs=[],
|
||||||
|
inherit_to_lower_scopes=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.addCleanup(self._cleanup_database)
|
||||||
|
self.provider = SqlMailConfigurationProvider()
|
||||||
|
self.context = ConfigurationPreflightContext(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
operator_user_id=None,
|
||||||
|
infrastructure_receipt=_receipt(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cleanup_database(self) -> None:
|
||||||
|
reset_database()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None:
|
||||||
|
self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories)
|
||||||
|
description = self.provider.describe()
|
||||||
|
self.assertEqual(("smtp_profile",), description.fragment_types)
|
||||||
|
|
||||||
|
def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None:
|
||||||
|
fragment = ConfigurationPackageFragment(
|
||||||
|
module_id="mail",
|
||||||
|
fragment_type="smtp_profile",
|
||||||
|
fragment_id="test-smtp",
|
||||||
|
payload={"credential_envelope_id": "credential-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
first_plan = self.provider.preflight(fragment, self.context)
|
||||||
|
self.assertEqual("create", first_plan.plan[0].action)
|
||||||
|
self.assertFalse(
|
||||||
|
any(item.severity == "blocker" for item in first_plan.diagnostics)
|
||||||
|
)
|
||||||
|
first_apply = self.provider.apply(fragment, {}, self.context)
|
||||||
|
self.assertIn("test-smtp", first_apply.created_refs)
|
||||||
|
|
||||||
|
second_plan = self.provider.preflight(fragment, self.context)
|
||||||
|
second_apply = self.provider.apply(fragment, {}, self.context)
|
||||||
|
|
||||||
|
self.assertEqual("skip", second_plan.plan[0].action)
|
||||||
|
self.assertEqual({}, second_apply.created_refs)
|
||||||
|
self.assertEqual({}, second_apply.updated_refs)
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
profiles = session.scalars(select(MailServerProfile)).all()
|
||||||
|
servers = session.scalars(select(MailServerEndpoint)).all()
|
||||||
|
bindings = session.scalars(select(MailServerCredentialBinding)).all()
|
||||||
|
self.assertEqual(1, len(profiles))
|
||||||
|
self.assertEqual("test-mail", profiles[0].smtp_config["host"])
|
||||||
|
self.assertEqual("plain", profiles[0].smtp_config["security"])
|
||||||
|
self.assertEqual(1, len(servers))
|
||||||
|
self.assertEqual("test-mail", servers[0].config["host"])
|
||||||
|
self.assertEqual(1, len(bindings))
|
||||||
|
self.assertEqual("credential-1", bindings[0].credential_id)
|
||||||
|
|
||||||
|
def test_conflicting_existing_profile_is_preserved_by_default(self) -> None:
|
||||||
|
fragment = ConfigurationPackageFragment(
|
||||||
|
module_id="mail",
|
||||||
|
fragment_type="smtp_profile",
|
||||||
|
fragment_id="test-smtp",
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
self.provider.apply(fragment, {}, self.context)
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
server = session.scalar(select(MailServerEndpoint))
|
||||||
|
assert server is not None
|
||||||
|
server.config = {**server.config, "host": "manually-changed.example.test"}
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
plan = self.provider.preflight(fragment, self.context)
|
||||||
|
result = self.provider.apply(fragment, {}, self.context)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", plan.plan[0].action)
|
||||||
|
self.assertIn(
|
||||||
|
"mail_configuration_conflict",
|
||||||
|
{item.code for item in result.diagnostics},
|
||||||
|
)
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
server = session.scalar(select(MailServerEndpoint))
|
||||||
|
assert server is not None
|
||||||
|
self.assertEqual("manually-changed.example.test", server.config["host"])
|
||||||
|
|
||||||
|
def test_explicit_conflict_update_reconciles_then_becomes_noop(self) -> None:
|
||||||
|
fragment = ConfigurationPackageFragment(
|
||||||
|
module_id="mail",
|
||||||
|
fragment_type="smtp_profile",
|
||||||
|
fragment_id="test-smtp",
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
self.provider.apply(fragment, {}, self.context)
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
server = session.scalar(select(MailServerEndpoint))
|
||||||
|
assert server is not None
|
||||||
|
server.config = {**server.config, "host": "manually-changed.example.test"}
|
||||||
|
session.commit()
|
||||||
|
update_fragment = replace(
|
||||||
|
fragment,
|
||||||
|
payload={"on_conflict": "update"},
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = self.provider.preflight(update_fragment, self.context)
|
||||||
|
result = self.provider.apply(update_fragment, {}, self.context)
|
||||||
|
settled = self.provider.preflight(update_fragment, self.context)
|
||||||
|
|
||||||
|
self.assertEqual("update", plan.plan[0].action)
|
||||||
|
self.assertIn("test-smtp", result.updated_refs)
|
||||||
|
self.assertEqual("skip", settled.plan[0].action)
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
server = session.scalar(select(MailServerEndpoint))
|
||||||
|
assert server is not None
|
||||||
|
self.assertEqual("test-mail", server.config["host"])
|
||||||
|
|
||||||
|
def test_inline_credentials_are_rejected(self) -> None:
|
||||||
|
fragment = ConfigurationPackageFragment(
|
||||||
|
module_id="mail",
|
||||||
|
fragment_type="smtp_profile",
|
||||||
|
payload={
|
||||||
|
"smtp": {
|
||||||
|
"host": "test-mail",
|
||||||
|
"port": 3025,
|
||||||
|
"security": "plain",
|
||||||
|
"password": "must-not-cross-boundary",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = self.provider.preflight(fragment, self.context)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", plan.plan[0].action)
|
||||||
|
self.assertIn(
|
||||||
|
"mail_configuration_secret_forbidden",
|
||||||
|
{item.code for item in plan.diagnostics},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_external_relay_collects_missing_non_secret_inputs(self) -> None:
|
||||||
|
fragment = ConfigurationPackageFragment(
|
||||||
|
module_id="mail",
|
||||||
|
fragment_type="smtp_profile",
|
||||||
|
fragment_id="external-smtp",
|
||||||
|
payload={},
|
||||||
|
)
|
||||||
|
missing = self.provider.preflight(
|
||||||
|
fragment,
|
||||||
|
replace(self.context, infrastructure_receipt=_external_receipt()),
|
||||||
|
)
|
||||||
|
ready = self.provider.preflight(
|
||||||
|
fragment,
|
||||||
|
replace(
|
||||||
|
self.context,
|
||||||
|
infrastructure_receipt=_external_receipt(),
|
||||||
|
supplied_data={
|
||||||
|
"mail.smtp.external-smtp.host": "smtp.example.test",
|
||||||
|
"mail.smtp.external-smtp.port": 587,
|
||||||
|
"mail.smtp.external-smtp.security": "starttls",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", missing.plan[0].action)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"mail.smtp.external-smtp.host",
|
||||||
|
"mail.smtp.external-smtp.port",
|
||||||
|
"mail.smtp.external-smtp.security",
|
||||||
|
},
|
||||||
|
{item.key for item in missing.required_data if item.required},
|
||||||
|
)
|
||||||
|
self.assertEqual("create", ready.plan[0].action)
|
||||||
|
|
||||||
|
def test_receipt_secret_reference_requires_credential_envelope_reference(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
fragment = ConfigurationPackageFragment(
|
||||||
|
module_id="mail",
|
||||||
|
fragment_type="smtp_profile",
|
||||||
|
fragment_id="authenticated-smtp",
|
||||||
|
payload={
|
||||||
|
"smtp": {
|
||||||
|
"host": "smtp.example.test",
|
||||||
|
"port": 587,
|
||||||
|
"security": "starttls",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
context = replace(
|
||||||
|
self.context,
|
||||||
|
infrastructure_receipt=_external_receipt(credential_required=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = self.provider.preflight(fragment, context)
|
||||||
|
ready = self.provider.preflight(
|
||||||
|
ConfigurationPackageFragment(
|
||||||
|
module_id="mail",
|
||||||
|
fragment_type="smtp_profile",
|
||||||
|
fragment_id="authenticated-smtp",
|
||||||
|
payload={
|
||||||
|
**fragment.payload,
|
||||||
|
"credential_envelope_id": "credential-1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", missing.plan[0].action)
|
||||||
|
self.assertIn(
|
||||||
|
"mail.smtp.authenticated-smtp.credential_envelope_id",
|
||||||
|
{item.key for item in missing.required_data if item.required},
|
||||||
|
)
|
||||||
|
self.assertEqual("create", ready.plan[0].action)
|
||||||
|
|
||||||
|
def test_system_profile_requires_system_configuration_authority(self) -> None:
|
||||||
|
fragment = ConfigurationPackageFragment(
|
||||||
|
module_id="mail",
|
||||||
|
fragment_type="smtp_profile",
|
||||||
|
payload={"profile": {"scope_type": "system"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
blocked = self.provider.preflight(fragment, self.context)
|
||||||
|
ready = self.provider.preflight(
|
||||||
|
fragment,
|
||||||
|
replace(
|
||||||
|
self.context,
|
||||||
|
operator_scopes=frozenset({"system:settings:write"}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", blocked.plan[0].action)
|
||||||
|
self.assertIn(
|
||||||
|
"system_configuration_authority_required",
|
||||||
|
{item.code for item in blocked.diagnostics},
|
||||||
|
)
|
||||||
|
self.assertEqual("create", ready.plan[0].action)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
DataSubjectRequest,
|
||||||
|
create_data_subject_request,
|
||||||
|
plan_data_subject_erasure,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.db.models import (
|
||||||
|
MailBounceObservation,
|
||||||
|
MailDeliveryAttempt,
|
||||||
|
MailDeliveryCommand,
|
||||||
|
MailDeliveryReconciliation,
|
||||||
|
MailMailboxMessageIndex,
|
||||||
|
MailServerProfile,
|
||||||
|
)
|
||||||
|
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY, MailDsarProvider
|
||||||
|
from govoplan_mail.backend.manifest import manifest
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider, active=True):
|
||||||
|
self.provider = provider
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (MAIL_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
assert name == MAIL_DSAR_CAPABILITY
|
||||||
|
return "mail"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
active = self.active
|
||||||
|
|
||||||
|
class Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State", (), {"effective_modules": ("mail",) if active else ()}
|
||||||
|
)()
|
||||||
|
|
||||||
|
return Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
assert name == MAIL_DSAR_CAPABILITY
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
|
||||||
|
class MailDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
Account.__table__,
|
||||||
|
User.__table__,
|
||||||
|
Group.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
DataSubjectRequest.__table__,
|
||||||
|
MailServerProfile.__table__,
|
||||||
|
MailMailboxMessageIndex.__table__,
|
||||||
|
MailDeliveryCommand.__table__,
|
||||||
|
MailDeliveryAttempt.__table__,
|
||||||
|
MailDeliveryReconciliation.__table__,
|
||||||
|
MailBounceObservation.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="subject@example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=account.id,
|
||||||
|
email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
profile = MailServerProfile(
|
||||||
|
id="profile-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="user",
|
||||||
|
scope_id=user.id,
|
||||||
|
name="Personal mail",
|
||||||
|
slug="personal",
|
||||||
|
smtp_config={"host": "smtp-secret-do-not-export"},
|
||||||
|
smtp_username="smtp-user-do-not-export",
|
||||||
|
smtp_password_encrypted="smtp-cipher-do-not-export",
|
||||||
|
imap_config={"host": "imap-secret-do-not-export"},
|
||||||
|
imap_username="imap-user-do-not-export",
|
||||||
|
imap_password_encrypted="imap-cipher-do-not-export",
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
message = MailMailboxMessageIndex(
|
||||||
|
id="message-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=profile.id,
|
||||||
|
folder="INBOX-secret-do-not-export",
|
||||||
|
uid="uid-secret-do-not-export",
|
||||||
|
uid_int=1,
|
||||||
|
sort_position=1,
|
||||||
|
subject="Subject notice",
|
||||||
|
from_header="Office <office@example.test>",
|
||||||
|
to_header="Subject Person <Subject@Example.Test>",
|
||||||
|
cc_header="Unrelated Person <other@example.test>",
|
||||||
|
date="2026-08-20",
|
||||||
|
message_id="message-locator-do-not-export",
|
||||||
|
flags=["\\Seen"],
|
||||||
|
size_bytes=42,
|
||||||
|
body_preview="Message preview for the subject",
|
||||||
|
attachment_count=1,
|
||||||
|
indexed_at=now,
|
||||||
|
)
|
||||||
|
unrelated = MailMailboxMessageIndex(
|
||||||
|
id="message-other",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=profile.id,
|
||||||
|
folder="INBOX",
|
||||||
|
uid="2",
|
||||||
|
uid_int=2,
|
||||||
|
sort_position=2,
|
||||||
|
subject="Unrelated message do not export",
|
||||||
|
from_header="other@example.test",
|
||||||
|
to_header="someone@example.test",
|
||||||
|
indexed_at=now,
|
||||||
|
)
|
||||||
|
tenant_two_profile = MailServerProfile(
|
||||||
|
id="profile-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
name="Tenant two",
|
||||||
|
slug="tenant-two",
|
||||||
|
smtp_config={},
|
||||||
|
)
|
||||||
|
tenant_two = MailMailboxMessageIndex(
|
||||||
|
id="message-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
profile_id=tenant_two_profile.id,
|
||||||
|
folder="INBOX",
|
||||||
|
uid="1",
|
||||||
|
uid_int=1,
|
||||||
|
sort_position=1,
|
||||||
|
subject="Tenant two message do not export",
|
||||||
|
to_header="subject@example.test",
|
||||||
|
indexed_at=now,
|
||||||
|
)
|
||||||
|
command = MailDeliveryCommand(
|
||||||
|
id="command-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
command_type="send",
|
||||||
|
source_module="notifications",
|
||||||
|
source_resource_type="notification",
|
||||||
|
idempotency_key="idempotency-do-not-export",
|
||||||
|
canonical_request_hash="a" * 64,
|
||||||
|
profile_id=profile.id,
|
||||||
|
expected_smtp_transport_revision="revision-secret",
|
||||||
|
envelope_recipients_encrypted="recipient-cipher-do-not-export",
|
||||||
|
message_encrypted="message-cipher-do-not-export",
|
||||||
|
message_sha256="b" * 64,
|
||||||
|
rfc_message_id="rfc-message-id",
|
||||||
|
message_size_bytes=100,
|
||||||
|
recipient_count=1,
|
||||||
|
status="succeeded",
|
||||||
|
attempt_count=1,
|
||||||
|
accepted_count=1,
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
completed_at=now,
|
||||||
|
expires_at=now + timedelta(days=30),
|
||||||
|
)
|
||||||
|
attempt = MailDeliveryAttempt(
|
||||||
|
id="attempt-subject",
|
||||||
|
command_id=command.id,
|
||||||
|
attempt_number=1,
|
||||||
|
worker_id="worker-do-not-export",
|
||||||
|
status="succeeded",
|
||||||
|
started_at=now,
|
||||||
|
completed_at=now,
|
||||||
|
accepted_count=1,
|
||||||
|
diagnostic_summary="diagnostic-do-not-export",
|
||||||
|
)
|
||||||
|
reconciliation = MailDeliveryReconciliation(
|
||||||
|
id="reconciliation-subject",
|
||||||
|
command_id=command.id,
|
||||||
|
decision="confirmed_sent",
|
||||||
|
evidence_reference="private-reference-do-not-export",
|
||||||
|
note_encrypted="private-note-do-not-export",
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
bounce = MailBounceObservation(
|
||||||
|
id="bounce-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
profile_id=profile.id,
|
||||||
|
folder="bounce-folder-do-not-export",
|
||||||
|
uid="bounce-uid-do-not-export",
|
||||||
|
fingerprint="c" * 64,
|
||||||
|
raw_sha256="d" * 64,
|
||||||
|
original_message_id="original-id-do-not-export",
|
||||||
|
command_id=command.id,
|
||||||
|
recipient="subject@example.test",
|
||||||
|
action="failed",
|
||||||
|
status_code="5.1.1",
|
||||||
|
diagnostic="bounce-diagnostic-do-not-export",
|
||||||
|
permanent=True,
|
||||||
|
observed_at=now,
|
||||||
|
matched=True,
|
||||||
|
evidence={"secret": "bounce-evidence-do-not-export"},
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
account,
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
message,
|
||||||
|
unrelated,
|
||||||
|
tenant_two_profile,
|
||||||
|
tenant_two,
|
||||||
|
command,
|
||||||
|
attempt,
|
||||||
|
reconciliation,
|
||||||
|
bounce,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = MailDsarProvider()
|
||||||
|
self.subject = DsarSubjectRef(
|
||||||
|
membership_id=user.id, email="subject@example.test"
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_manifest_and_minimized_tenant_scoped_search(self):
|
||||||
|
self.assertIn(
|
||||||
|
MAIL_DSAR_CAPABILITY, {item.name for item in manifest.provides_interfaces}
|
||||||
|
)
|
||||||
|
self.assertIsInstance(
|
||||||
|
manifest.capability_factories[MAIL_DSAR_CAPABILITY](None), DsarProvider
|
||||||
|
)
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self.subject
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"mail_server_profile",
|
||||||
|
"mailbox_message_index",
|
||||||
|
"mail_delivery_command",
|
||||||
|
"mail_delivery_attempt",
|
||||||
|
"mail_delivery_reconciliation",
|
||||||
|
"mail_bounce_observation",
|
||||||
|
}.issubset({r.resource_type for r in records})
|
||||||
|
)
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
for hidden in (
|
||||||
|
"other@example.test",
|
||||||
|
"Unrelated Person",
|
||||||
|
"message-other",
|
||||||
|
"Unrelated message do not export",
|
||||||
|
"message-tenant-2",
|
||||||
|
"Tenant two message do not export",
|
||||||
|
"smtp-secret-do-not-export",
|
||||||
|
"smtp-user-do-not-export",
|
||||||
|
"smtp-cipher-do-not-export",
|
||||||
|
"imap-secret-do-not-export",
|
||||||
|
"imap-user-do-not-export",
|
||||||
|
"imap-cipher-do-not-export",
|
||||||
|
"INBOX-secret-do-not-export",
|
||||||
|
"uid-secret-do-not-export",
|
||||||
|
"message-locator-do-not-export",
|
||||||
|
"idempotency-do-not-export",
|
||||||
|
"recipient-cipher-do-not-export",
|
||||||
|
"message-cipher-do-not-export",
|
||||||
|
"worker-do-not-export",
|
||||||
|
"diagnostic-do-not-export",
|
||||||
|
"private-reference-do-not-export",
|
||||||
|
"private-note-do-not-export",
|
||||||
|
"bounce-folder-do-not-export",
|
||||||
|
"bounce-uid-do-not-export",
|
||||||
|
"original-id-do-not-export",
|
||||||
|
"bounce-diagnostic-do-not-export",
|
||||||
|
"bounce-evidence-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(hidden, serialized)
|
||||||
|
|
||||||
|
def test_conflicting_email_fails_closed(self):
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
email="subject@example.test",
|
||||||
|
external_references={"mail.email": "other@example.test"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual((), records)
|
||||||
|
|
||||||
|
def test_plan_preserves_evidence_and_executes_nothing(self):
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self.subject
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session, tenant_id="tenant-1", subject=self.subject, records=records
|
||||||
|
)
|
||||||
|
self.assertTrue({"retain", "manual_review"}.issubset({a.kind for a in actions}))
|
||||||
|
self.assertFalse(any(action.executable for action in actions))
|
||||||
|
|
||||||
|
def test_core_workflow_discovers_active_and_skips_disabled_provider(self):
|
||||||
|
request = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-MAIL-1",
|
||||||
|
request_kind="access_and_erasure",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Authorized request",
|
||||||
|
legal_basis="GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=request,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(["mail"], request.coverage["covered_modules"])
|
||||||
|
plan_data_subject_erasure(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider),
|
||||||
|
row=request,
|
||||||
|
expected_revision=2,
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any(action["executable"] for action in request.erasure_plan["actions"])
|
||||||
|
)
|
||||||
|
disabled = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-MAIL-OFF",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Coverage",
|
||||||
|
legal_basis=None,
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="officer",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, active=False),
|
||||||
|
row=disabled,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[MAIL_DSAR_CAPABILITY], disabled.coverage["inactive_provider_capabilities"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -8,6 +8,7 @@ from govoplan_mail.backend.config import ImapConfig
|
|||||||
from govoplan_mail.backend.sending.imap import (
|
from govoplan_mail.backend.sending.imap import (
|
||||||
ImapAppendError,
|
ImapAppendError,
|
||||||
ImapConfigurationError,
|
ImapConfigurationError,
|
||||||
|
_detect_standard_folder_mappings,
|
||||||
_detect_sent_folder,
|
_detect_sent_folder,
|
||||||
_extract_mailbox_name,
|
_extract_mailbox_name,
|
||||||
_fetch_message_by_uid,
|
_fetch_message_by_uid,
|
||||||
@@ -18,6 +19,7 @@ from govoplan_mail.backend.sending.imap import (
|
|||||||
_select_readonly,
|
_select_readonly,
|
||||||
_sequence_set,
|
_sequence_set,
|
||||||
append_message_to_sent,
|
append_message_to_sent,
|
||||||
|
list_imap_folders,
|
||||||
list_imap_messages,
|
list_imap_messages,
|
||||||
list_imap_uids_since,
|
list_imap_uids_since,
|
||||||
)
|
)
|
||||||
@@ -79,6 +81,45 @@ class ImapFolderParserTests(unittest.TestCase):
|
|||||||
"Gesendet",
|
"Gesendet",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_detects_all_standard_folder_roles_by_flag_then_name(self):
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"inbox": "INBOX",
|
||||||
|
"sent": "Gesendete Elemente",
|
||||||
|
"drafts": "Entwürfe",
|
||||||
|
"trash": "Deleted",
|
||||||
|
"archive": "All Mail",
|
||||||
|
"junk": "Spam",
|
||||||
|
},
|
||||||
|
_detect_standard_folder_mappings(
|
||||||
|
[
|
||||||
|
("INBOX", set()),
|
||||||
|
("Gesendete Elemente", set()),
|
||||||
|
("Entwürfe", set()),
|
||||||
|
("Deleted", {"\\trash"}),
|
||||||
|
("All Mail", {"\\all"}),
|
||||||
|
("Spam", {"\\junk"}),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mock_folder_listing_exposes_detected_standard_mappings(self):
|
||||||
|
result = list_imap_folders(
|
||||||
|
imap_config=ImapConfig(host="mock.imap.local"), include_status=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"inbox": "INBOX",
|
||||||
|
"sent": "Sent",
|
||||||
|
"drafts": "Drafts",
|
||||||
|
"trash": "Trash",
|
||||||
|
"archive": "Archive",
|
||||||
|
},
|
||||||
|
result.detected_folder_mappings,
|
||||||
|
)
|
||||||
|
self.assertEqual("Sent", result.detected_sent_folder)
|
||||||
|
|
||||||
|
|
||||||
class ImapMessagePaginationTests(unittest.TestCase):
|
class ImapMessagePaginationTests(unittest.TestCase):
|
||||||
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
|
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
|
||||||
|
|||||||
@@ -290,6 +290,51 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
|||||||
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
|
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
|
||||||
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
|
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
|
||||||
|
|
||||||
|
def test_apply_transport_update_persists_standard_folder_mappings(self):
|
||||||
|
profile = SimpleNamespace(
|
||||||
|
id="profile-folders",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
smtp_config={"host": "smtp.example.org"},
|
||||||
|
smtp_username=None,
|
||||||
|
smtp_password_encrypted=None,
|
||||||
|
smtp_transport_revision="smtp-before",
|
||||||
|
imap_config={"host": "imap.example.org", "sent_folder": "Legacy Sent"},
|
||||||
|
imap_username=None,
|
||||||
|
imap_password_encrypted=None,
|
||||||
|
imap_transport_revision="imap-before",
|
||||||
|
)
|
||||||
|
session = SimpleNamespace(flush=lambda: None)
|
||||||
|
|
||||||
|
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index"):
|
||||||
|
_apply_profile_transport_update(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
profile,
|
||||||
|
user_id="user-1",
|
||||||
|
api_key_id=None,
|
||||||
|
smtp=None,
|
||||||
|
imap=ImapConfig(
|
||||||
|
host="imap.example.org",
|
||||||
|
folder_mappings={
|
||||||
|
"inbox": "INBOX",
|
||||||
|
"sent": "Sent Items",
|
||||||
|
"drafts": "Drafts",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
clear_imap=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(profile.imap_config["sent_folder"], "Sent Items")
|
||||||
|
self.assertEqual(
|
||||||
|
profile.imap_config["folder_mappings"],
|
||||||
|
{
|
||||||
|
"inbox": "INBOX",
|
||||||
|
"sent": "Sent Items",
|
||||||
|
"drafts": "Drafts",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
|
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
|
||||||
profile = SimpleNamespace(
|
profile = SimpleNamespace(
|
||||||
id="profile-1",
|
id="profile-1",
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manife
|
|||||||
|
|
||||||
|
|
||||||
class MailManifestTests(unittest.TestCase):
|
class MailManifestTests(unittest.TestCase):
|
||||||
|
def test_mail_quick_access_can_return_exact_message_references(self) -> None:
|
||||||
|
frontend = get_manifest().frontend
|
||||||
|
self.assertIsNotNone(frontend)
|
||||||
|
tool = next(
|
||||||
|
item for item in frontend.quick_access_tools if item.id == "mail.messages" # type: ignore[union-attr]
|
||||||
|
)
|
||||||
|
self.assertIn("select", tool.modes)
|
||||||
|
self.assertEqual(("mail.message",), tool.returned_reference_kinds)
|
||||||
|
|
||||||
def test_manifest_declares_optional_addresses_lookup(self) -> None:
|
def test_manifest_declares_optional_addresses_lookup(self) -> None:
|
||||||
manifest = get_manifest()
|
manifest = get_manifest()
|
||||||
|
|
||||||
@@ -16,6 +25,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 +72,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"]
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.mail import MailPostboxBridgeRequest
|
||||||
|
from govoplan_core.core.postbox import PostboxTargetRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_mail.backend.db.models import MailServerProfile
|
||||||
|
from govoplan_mail.backend.postbox_bridge import MailPostboxBridge
|
||||||
|
|
||||||
|
|
||||||
|
RAW_MESSAGE = b"""From: Ada Example <ada@example.test>
|
||||||
|
To: Clerk <clerk@example.test>
|
||||||
|
Cc: Archive <archive@example.test>
|
||||||
|
Message-ID: <bridge-1@example.test>
|
||||||
|
Subject: Submitted evidence
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/mixed; boundary=bridge
|
||||||
|
|
||||||
|
--bridge
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
Please process the attached evidence.
|
||||||
|
--bridge
|
||||||
|
Content-Type: application/pdf
|
||||||
|
Content-Disposition: attachment; filename=evidence.pdf
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
|
||||||
|
UERG
|
||||||
|
--bridge--
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class _DeliveryProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def deliver(self, session, request):
|
||||||
|
del session
|
||||||
|
self.requests.append(request)
|
||||||
|
return SimpleNamespace(
|
||||||
|
postbox_id="postbox-1",
|
||||||
|
message_id="message-1",
|
||||||
|
delivery_id="delivery-1",
|
||||||
|
duplicate=len(self.requests) > 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MailPostboxBridgeTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
access_users = Base.metadata.tables.get("access_users")
|
||||||
|
if access_users is None:
|
||||||
|
access_users = Table(
|
||||||
|
"access_users",
|
||||||
|
Base.metadata,
|
||||||
|
Column("id", String(36), primary_key=True),
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[access_users, MailServerProfile.__table__],
|
||||||
|
)
|
||||||
|
self.Session = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
class_=Session,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
with self.Session() as session:
|
||||||
|
session.add(
|
||||||
|
MailServerProfile(
|
||||||
|
id="profile-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Inbound",
|
||||||
|
slug="inbound",
|
||||||
|
smtp_config={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.addCleanup(self.engine.dispose)
|
||||||
|
|
||||||
|
def test_bridge_parses_bounded_content_and_delegates_idempotently(self) -> None:
|
||||||
|
delivery = _DeliveryProvider()
|
||||||
|
request = MailPostboxBridgeRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
target=PostboxTargetRef(postbox_id="postbox-1"),
|
||||||
|
profile_id="profile-1",
|
||||||
|
folder="INBOX",
|
||||||
|
uid="42",
|
||||||
|
uidvalidity="20260807",
|
||||||
|
raw_message=RAW_MESSAGE,
|
||||||
|
)
|
||||||
|
with self.Session() as session, patch(
|
||||||
|
"govoplan_mail.backend.postbox_bridge.postbox_delivery_provider",
|
||||||
|
return_value=delivery,
|
||||||
|
):
|
||||||
|
first = MailPostboxBridge().bridge_message(session, request)
|
||||||
|
second = MailPostboxBridge().bridge_message(session, request)
|
||||||
|
MailPostboxBridge().bridge_message(
|
||||||
|
session,
|
||||||
|
replace(request, uidvalidity="20260808"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(first.duplicate)
|
||||||
|
self.assertTrue(second.duplicate)
|
||||||
|
self.assertEqual(first.source_digest, second.source_digest)
|
||||||
|
self.assertEqual(
|
||||||
|
delivery.requests[0].idempotency_key,
|
||||||
|
delivery.requests[1].idempotency_key,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
delivery.requests[0].idempotency_key,
|
||||||
|
delivery.requests[2].idempotency_key,
|
||||||
|
)
|
||||||
|
bridged = delivery.requests[0]
|
||||||
|
self.assertEqual("Submitted evidence", bridged.subject)
|
||||||
|
self.assertIn("Please process", bridged.body_text)
|
||||||
|
self.assertEqual(
|
||||||
|
["sender", "to", "cc"],
|
||||||
|
[participant.kind for participant in bridged.participants],
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(bridged.attachments))
|
||||||
|
self.assertEqual("evidence.pdf", bridged.attachments[0].name)
|
||||||
|
self.assertEqual("mail_attachment", bridged.attachments[0].reference_type)
|
||||||
|
self.assertEqual("<bridge-1@example.test>", bridged.metadata["rfc_message_id"])
|
||||||
|
self.assertEqual("20260807", bridged.metadata["mailbox_uidvalidity"])
|
||||||
|
self.assertNotIn("raw_message", bridged.metadata)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import smtplib
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
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.config import SmtpConfig
|
||||||
from govoplan_mail.backend.sending.smtp import (
|
from govoplan_mail.backend.sending.smtp import (
|
||||||
SmtpConfigurationError,
|
SmtpConfigurationError,
|
||||||
|
SmtpBatchPolicy,
|
||||||
|
SmtpBatchSession,
|
||||||
|
SmtpSendError,
|
||||||
_open_smtp,
|
_open_smtp,
|
||||||
_prepare_smtp_send,
|
_prepare_smtp_send,
|
||||||
_smtp_send_result,
|
_smtp_send_result,
|
||||||
@@ -70,6 +74,109 @@ class SmtpSendHelperTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.accepted_count, 1)
|
self.assertEqual(result.accepted_count, 1)
|
||||||
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
@@ -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-folders.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"
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ assert.match(profiles, /disabledReason: credentialMutationBlocker/);
|
|||||||
assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/);
|
assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/);
|
||||||
assert.match(profiles, /disabledReason=\{policySaveBlocker\}/);
|
assert.match(profiles, /disabledReason=\{policySaveBlocker\}/);
|
||||||
assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/);
|
assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/);
|
||||||
|
assert.match(profiles, /folder_mappings: draft\.imapFolderMappings/);
|
||||||
|
assert.match(profiles, /listMailProfileImapFolders[\s\S]*listImapFolders/);
|
||||||
|
assert.match(profiles, /onLookupImapFolders=\{\(\) => void runImapFolderLookup\(\)\}/);
|
||||||
|
assert.match(profiles, /imapFolderLookupResult=\{imapFolderResult\}/);
|
||||||
|
|
||||||
assert.match(mailbox, /ActionBlockerHint/);
|
assert.match(mailbox, /ActionBlockerHint/);
|
||||||
assert.match(mailbox, /DocumentationHelpLink/);
|
assert.match(mailbox, /DocumentationHelpLink/);
|
||||||
assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
|
assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
|
||||||
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
|
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
|
||||||
|
assert.match(mailbox, /folder_mappings\?\.inbox/);
|
||||||
|
assert.match(mailbox, /detected_folder_mappings\?\.inbox/);
|
||||||
assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
|
assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
|
||||||
|
|
||||||
assert.match(bounces, /DocumentationHelpLink/);
|
assert.match(bounces, /DocumentationHelpLink/);
|
||||||
@@ -38,7 +44,7 @@ assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}`, /window\.(?:alert|con
|
|||||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
|
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
|
||||||
assert.match(moduleSource, /"mail\.profiles"/);
|
assert.match(moduleSource, /"mail\.profiles"/);
|
||||||
assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/);
|
assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/);
|
||||||
assert.match(styles, /@media \(max-width: 1250px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
|
assert.match(styles, /@media \(max-width: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
|
||||||
assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-toolbar\.file-manager-toolbar[\s\S]*grid-template-columns: 1fr/);
|
assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-toolbar\.file-manager-toolbar[\s\S]*grid-template-columns: 1fr/);
|
||||||
|
|
||||||
for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) {
|
for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) {
|
||||||
|
|||||||
@@ -23,3 +23,15 @@ assert(
|
|||||||
!styles.includes(".mailbox-search-field button"),
|
!styles.includes(".mailbox-search-field button"),
|
||||||
"Mail must not redefine the central icon-button appearance"
|
"Mail must not redefine the central icon-button appearance"
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("isMailboxMessageRead(message.flags)"),
|
||||||
|
"mailbox rows must present provider-derived read/unread state"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("mailboxSyncState(messageProvenance)"),
|
||||||
|
"mailbox lists must present synchronization provenance"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
styles.includes(".mailbox-message-row.is-unread") && styles.includes(".mailbox-sync-provenance"),
|
||||||
|
"read state and synchronization provenance must retain focused responsive styling"
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,18 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
|
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
|
||||||
import {
|
import { FormGrid, ContentGrid,
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
FormField,
|
FormField,
|
||||||
LoadingFrame,
|
PageActionBar,
|
||||||
PageScrollViewport,
|
PageLayout,
|
||||||
PageTitle,
|
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TableActionGroup,
|
TableActionGroup,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
@@ -194,33 +192,37 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScrollViewport>
|
<>
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="collection"
|
||||||
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
|
title="Bounce processing"
|
||||||
<div className="button-row compact-actions">
|
description="Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands."
|
||||||
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
|
actions={<PageActionBar
|
||||||
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
|
variant="collection"
|
||||||
<Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
|
refreshable
|
||||||
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
|
reloadAction={{ onReload: () => void load(), disabled: Boolean(pageMutationBlocker), disabledReason: pageMutationBlocker }}
|
||||||
</div>
|
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
|
||||||
</div>
|
helpAction={<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />}
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
createAction={<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>}
|
||||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
/>}
|
||||||
<LoadingFrame loading={loading} label="Loading bounce processing">
|
loading={loading}
|
||||||
<div className="dashboard-grid">
|
loadingLabel="Loading bounce processing"
|
||||||
<Card title="Watched mailboxes">
|
error={error}
|
||||||
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
success={message}
|
||||||
</Card>
|
documentationType="admin"
|
||||||
<Card title="Delivery-status observations">
|
>
|
||||||
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
<ContentGrid columns={2} collapseAt="workspace" className="">
|
||||||
</Card>
|
<Card title="Watched mailboxes">
|
||||||
</div>
|
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
||||||
</LoadingFrame>
|
</Card>
|
||||||
</div>
|
<Card title="Delivery-status observations">
|
||||||
|
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
||||||
|
</Card>
|
||||||
|
</ContentGrid>
|
||||||
|
</PageLayout>
|
||||||
|
|
||||||
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
|
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
{imapProfiles.length === 0 &&
|
{imapProfiles.length === 0 &&
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
reason={{
|
reason={{
|
||||||
@@ -242,7 +244,7 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
|||||||
<input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
|
<input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
|
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
|
||||||
</div>
|
</FormGrid>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -254,6 +256,6 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
|||||||
busy={Boolean(busy)}
|
busy={Boolean(busy)}
|
||||||
onCancel={() => setDeleteSource(null)}
|
onCancel={() => setDeleteSource(null)}
|
||||||
onConfirm={() => void removeSource()} />
|
onConfirm={() => void removeSource()} />
|
||||||
</PageScrollViewport>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||||
import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
|
import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
deactivateMailServerProfile,
|
deactivateMailServerProfile,
|
||||||
fetchMailSettingsDelta,
|
fetchMailSettingsDelta,
|
||||||
getMailProfilePolicy,
|
getMailProfilePolicy,
|
||||||
|
listImapFolders,
|
||||||
|
listMailProfileImapFolders,
|
||||||
mailProfilePatternKeys,
|
mailProfilePatternKeys,
|
||||||
mailProfilePolicyLimitKeys,
|
mailProfilePolicyLimitKeys,
|
||||||
listAvailableMailCredentials,
|
listAvailableMailCredentials,
|
||||||
@@ -102,6 +104,7 @@ type ProfileDraft = {
|
|||||||
imapUsername: string;
|
imapUsername: string;
|
||||||
imapPassword: string;
|
imapPassword: string;
|
||||||
imapSentFolder: string;
|
imapSentFolder: string;
|
||||||
|
imapFolderMappings: MailImapFolderMappings;
|
||||||
imapTimeout: string;
|
imapTimeout: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -740,11 +743,11 @@ export function MailProfileScopeManager({
|
|||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
title={profileDialogTitle(editing, editingTarget)}
|
title={profileDialogTitle(editing, editingTarget)}
|
||||||
onClose={() => !busy && closeProfileEditor()}
|
onClose={() => !busy && closeProfileEditor()}
|
||||||
className="admin-dialog admin-dialog-wide mail-profile-dialog"
|
className="mail-profile-dialog"
|
||||||
closeDisabled={busy}
|
closeDisabled={busy}
|
||||||
footer={editing === "new"
|
footer={editing === "new"
|
||||||
? <>
|
? <>
|
||||||
@@ -1020,13 +1023,13 @@ export function MailProfilePolicyEditor({
|
|||||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||||
|
|
||||||
<section className="mail-policy-section policy-section">
|
<section className="mail-policy-section policy-section">
|
||||||
<div className="subsection-heading split">
|
<ActionToolbar surface="section-header" className="subsection-heading split">
|
||||||
<h3>i18n:govoplan-mail.profile_allow_list.507dfe6c</h3>
|
<h3>i18n:govoplan-mail.profile_allow_list.507dfe6c</h3>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
{lowerLevelLimitToggle("allowed_profile_ids")}
|
{lowerLevelLimitToggle("allowed_profile_ids")}
|
||||||
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || profileAllowListLocked || selectedProfileIds.size === 0}>i18n:govoplan-mail.clear_allow_list.f69c8c67</Button>
|
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || profileAllowListLocked || selectedProfileIds.size === 0}>i18n:govoplan-mail.clear_allow_list.f69c8c67</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<p className="muted small-note">{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}</p>
|
<p className="muted small-note">{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}</p>
|
||||||
<AdminSelectionList
|
<AdminSelectionList
|
||||||
options={candidateProfiles.map((profile) => ({
|
options={candidateProfiles.map((profile) => ({
|
||||||
@@ -1163,7 +1166,8 @@ function ProfileForm({
|
|||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null);
|
const [imapFolderResult, setImapFolderResult] = useState<MailImapFolderListResponse | null>(null);
|
||||||
|
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||||
|
|
||||||
const disabled = busy || !canWrite;
|
const disabled = busy || !canWrite;
|
||||||
const credentialDisabled = disabled || !canManageCredentials;
|
const credentialDisabled = disabled || !canManageCredentials;
|
||||||
@@ -1256,6 +1260,7 @@ function ProfileForm({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSmtpTestResult(null);
|
setSmtpTestResult(null);
|
||||||
setImapTestResult(null);
|
setImapTestResult(null);
|
||||||
|
setImapFolderResult(null);
|
||||||
setMailActionState(null);
|
setMailActionState(null);
|
||||||
}, [editing, editTarget]);
|
}, [editing, editTarget]);
|
||||||
|
|
||||||
@@ -1276,6 +1281,7 @@ function ProfileForm({
|
|||||||
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
|
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
|
||||||
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
|
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
|
||||||
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
|
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
|
||||||
|
imapFolderMappings: patch.folder_mappings !== undefined ? normalizeMailImapFolderMappings(patch.folder_mappings) : draft.imapFolderMappings,
|
||||||
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
|
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1325,6 +1331,21 @@ function ProfileForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runImapFolderLookup() {
|
||||||
|
if (!draftHasImap) return;
|
||||||
|
setMailActionState("folders");
|
||||||
|
setImapFolderResult(null);
|
||||||
|
try {
|
||||||
|
setImapFolderResult(useSavedImapTest && existingProfile ?
|
||||||
|
await listMailProfileImapFolders(settings, existingProfile.id, selectedServerId, selectedCredentialId) :
|
||||||
|
await listImapFolders(settings, rawImapPayload(draft, false)));
|
||||||
|
} catch (err) {
|
||||||
|
setImapFolderResult({ ok: false, protocol: "imap", message: errorMessage(err), folders: [] });
|
||||||
|
} finally {
|
||||||
|
setMailActionState(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mail-profile-form">
|
<div className="mail-profile-form">
|
||||||
{!canManageCredentials && (
|
{!canManageCredentials && (
|
||||||
@@ -1343,7 +1364,7 @@ function ProfileForm({
|
|||||||
documentation={MAIL_PROFILE_DOCUMENTATION} />
|
documentation={MAIL_PROFILE_DOCUMENTATION} />
|
||||||
}
|
}
|
||||||
{showProfileFields &&
|
{showProfileFields &&
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="i18n:govoplan-mail.name.709a2322" documentation={MAIL_PROFILE_DOCUMENTATION}><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-mail.name.709a2322" documentation={MAIL_PROFILE_DOCUMENTATION}><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||||
<FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField>
|
<FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField>
|
||||||
<FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField>
|
||||||
@@ -1355,7 +1376,7 @@ function ProfileForm({
|
|||||||
onChange={(checked) => setDraft({ ...draft, inheritToLowerScopes: checked })}
|
onChange={(checked) => setDraft({ ...draft, inheritToLowerScopes: checked })}
|
||||||
label="Visible to lower scopes" />
|
label="Visible to lower scopes" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormGrid>
|
||||||
}
|
}
|
||||||
|
|
||||||
{editTarget.kind === "profile" && existingProfile &&
|
{editTarget.kind === "profile" && existingProfile &&
|
||||||
@@ -1363,7 +1384,7 @@ function ProfileForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
{editTarget.kind === "server" &&
|
{editTarget.kind === "server" &&
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
<FormField label="Server name" documentation={MAIL_PROFILE_DOCUMENTATION}>
|
<FormField label="Server name" documentation={MAIL_PROFILE_DOCUMENTATION}>
|
||||||
<input value={draft.serverName} disabled={disabled} onChange={(event) => setDraft({ ...draft, serverName: event.target.value })} />
|
<input value={draft.serverName} disabled={disabled} onChange={(event) => setDraft({ ...draft, serverName: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -1387,11 +1408,11 @@ function ProfileForm({
|
|||||||
onChange={(checked) => setDraft({ ...draft, serverInheritToLowerScopes: checked })}
|
onChange={(checked) => setDraft({ ...draft, serverInheritToLowerScopes: checked })}
|
||||||
label="Visible to lower scopes" />
|
label="Visible to lower scopes" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormGrid>
|
||||||
}
|
}
|
||||||
|
|
||||||
{editTarget.kind === "credentials" &&
|
{editTarget.kind === "credentials" &&
|
||||||
<div className="admin-form-grid two-columns">
|
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||||
{creatingCredential &&
|
{creatingCredential &&
|
||||||
<FormField label="Reusable credential" documentation={MAIL_PROFILE_WORKFLOW_DOCUMENTATION}>
|
<FormField label="Reusable credential" documentation={MAIL_PROFILE_WORKFLOW_DOCUMENTATION}>
|
||||||
<select value={reuseCredentialId} disabled={credentialDisabled} onChange={(event) => setReuseCredentialId(event.target.value)}>
|
<select value={reuseCredentialId} disabled={credentialDisabled} onChange={(event) => setReuseCredentialId(event.target.value)}>
|
||||||
@@ -1455,7 +1476,7 @@ function ProfileForm({
|
|||||||
The selected credential remains independently managed and will be linked to this server.
|
The selected credential remains independently managed and will be linked to this server.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</FormGrid>
|
||||||
}
|
}
|
||||||
|
|
||||||
{policyMessages.length > 0 && (
|
{policyMessages.length > 0 && (
|
||||||
@@ -1472,7 +1493,7 @@ function ProfileForm({
|
|||||||
{showSettingsPanel && settingsPanelMode && (!creatingCredential || creatingNewCredential) &&
|
{showSettingsPanel && settingsPanelMode && (!creatingCredential || creatingNewCredential) &&
|
||||||
<MailServerSettingsPanel
|
<MailServerSettingsPanel
|
||||||
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
|
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
|
||||||
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
|
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout }}
|
||||||
smtpCredentials={{ username: draft.smtpUsername, password: draft.smtpPassword }}
|
smtpCredentials={{ username: draft.smtpUsername, password: draft.smtpPassword }}
|
||||||
imapCredentials={{ username: draft.imapUsername, password: draft.imapPassword }}
|
imapCredentials={{ username: draft.imapUsername, password: draft.imapPassword }}
|
||||||
onSmtpChange={patchSmtpSettings}
|
onSmtpChange={patchSmtpSettings}
|
||||||
@@ -1501,8 +1522,10 @@ function ProfileForm({
|
|||||||
busyAction={mailActionState}
|
busyAction={mailActionState}
|
||||||
onTestSmtp={() => void runSmtpTest()}
|
onTestSmtp={() => void runSmtpTest()}
|
||||||
onTestImap={() => void runImapTest()}
|
onTestImap={() => void runImapTest()}
|
||||||
|
onLookupImapFolders={() => void runImapFolderLookup()}
|
||||||
smtpTestResult={smtpTestResult}
|
smtpTestResult={smtpTestResult}
|
||||||
imapTestResult={imapTestResult}
|
imapTestResult={imapTestResult}
|
||||||
|
imapFolderLookupResult={imapFolderResult}
|
||||||
initialSection={initialSection}
|
initialSection={initialSection}
|
||||||
visibleSections={visibleSections}
|
visibleSections={visibleSections}
|
||||||
mode={settingsPanelMode} />
|
mode={settingsPanelMode} />
|
||||||
@@ -1645,6 +1668,7 @@ function emptyProfileDraft(): ProfileDraft {
|
|||||||
imapUsername: "",
|
imapUsername: "",
|
||||||
imapPassword: "",
|
imapPassword: "",
|
||||||
imapSentFolder: "auto",
|
imapSentFolder: "auto",
|
||||||
|
imapFolderMappings: {},
|
||||||
imapTimeout: "30"
|
imapTimeout: "30"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1696,7 +1720,11 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
|
|||||||
? targetUsername
|
? targetUsername
|
||||||
: stringValue(profile.credentials?.imap?.username ?? profile.imap?.username),
|
: stringValue(profile.credentials?.imap?.username ?? profile.imap?.username),
|
||||||
imapPassword: "",
|
imapPassword: "",
|
||||||
imapSentFolder: stringValue("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto"),
|
imapSentFolder: stringValue(imapConfig?.folder_mappings?.sent || ("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto")),
|
||||||
|
imapFolderMappings: normalizeMailImapFolderMappings({
|
||||||
|
...(imapConfig?.folder_mappings ?? {}),
|
||||||
|
...(!imapConfig?.folder_mappings?.sent && imapConfig?.sent_folder && imapConfig.sent_folder !== "auto" ? { sent: imapConfig.sent_folder } : {})
|
||||||
|
}),
|
||||||
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30)
|
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1873,7 +1901,7 @@ function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
|
|||||||
|
|
||||||
function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
|
function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
|
||||||
return mailImapSettingsPayload<MailSecurity>(
|
return mailImapSettingsPayload<MailSecurity>(
|
||||||
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout },
|
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout },
|
||||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { ExternalLink, FilePenLine, Mail, Pencil, X } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import {
|
||||||
|
DashboardWidgetList,
|
||||||
|
Button,
|
||||||
|
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
|
||||||
|
} from "./mailboxLaunch";
|
||||||
|
|
||||||
|
|
||||||
|
type MailQuickAccessData = {
|
||||||
|
profileName?: string;
|
||||||
|
profileId?: string;
|
||||||
|
draftsFolder?: string | null;
|
||||||
|
messages: MailMailboxMessageSummary[];
|
||||||
|
available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
QuickAccessToolRenderContext,
|
||||||
|
"settings" | "launchContext" | "complete" | "cancel" | "close"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function MailQuickAccess({
|
||||||
|
settings,
|
||||||
|
launchContext,
|
||||||
|
complete,
|
||||||
|
cancel,
|
||||||
|
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);
|
||||||
|
if (!profile) return { messages: [], available: false };
|
||||||
|
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false);
|
||||||
|
return {
|
||||||
|
profileName: profile.name,
|
||||||
|
profileId: profile.id,
|
||||||
|
draftsFolder: profile.imap?.folder_mappings?.drafts
|
||||||
|
|| response.folders.detected_folder_mappings?.drafts
|
||||||
|
|| null,
|
||||||
|
messages: response.messages.messages ?? [],
|
||||||
|
available: true
|
||||||
|
};
|
||||||
|
}, [settings]);
|
||||||
|
const { data, loading, error } = useDashboardWidgetData(load, 0);
|
||||||
|
const selectingForCase = launchContext.activeObject?.ownerModule === "cases"
|
||||||
|
&& launchContext.activeObject.kind === "case";
|
||||||
|
|
||||||
|
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}
|
||||||
|
{selectingForCase ? (
|
||||||
|
<p className="muted small-note">
|
||||||
|
Select an authorized exact message for {launchContext.activeObject?.label}.
|
||||||
|
Mail content remains in Mail and access is checked again when opened.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<DashboardWidgetList
|
||||||
|
emptyText={data?.available ? "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d" : "i18n:govoplan-mail.select_an_imap_profile.5445648c"}
|
||||||
|
items={(data?.messages ?? []).map((message) => ({
|
||||||
|
id: `${message.folder}:${message.uid}`,
|
||||||
|
title: message.subject || "i18n:govoplan-mail.no_subject.49b20da0",
|
||||||
|
detail: message.from_header || data?.profileName,
|
||||||
|
meta: formatMessageDate(message.date),
|
||||||
|
leading: <Mail size={17} aria-hidden="true" />,
|
||||||
|
to: selectingForCase
|
||||||
|
? undefined
|
||||||
|
: mailboxMessageLaunchPath(data!.profileId!, message),
|
||||||
|
state: selectingForCase ? undefined : quickAccessLaunchState(launchContext),
|
||||||
|
onClick: selectingForCase ? () => complete({
|
||||||
|
contractVersion: "1",
|
||||||
|
outcome: "completed",
|
||||||
|
action: "selected",
|
||||||
|
reference: {
|
||||||
|
ownerModule: "mail",
|
||||||
|
kind: "message",
|
||||||
|
objectId: `${data!.profileId!}:${message.folder}:${message.uid}`,
|
||||||
|
tenantId: launchContext.tenantId,
|
||||||
|
label: message.subject || "Mail message",
|
||||||
|
version: message.uid,
|
||||||
|
path: mailboxMessageLaunchPath(data!.profileId!, message)
|
||||||
|
}
|
||||||
|
}) : close
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{!selectingForCase && 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}
|
||||||
|
{selectingForCase ? (
|
||||||
|
<div className="dashboard-contribution-footer">
|
||||||
|
<Button onClick={() => cancel("user")}>
|
||||||
|
<X size={15} aria-hidden="true" /> Cancel selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : <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"
|
||||||
|
to={mailboxDraftsLaunchPath(data.profileId, data.draftsFolder)}
|
||||||
|
state={quickAccessLaunchState(launchContext)}
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
<FilePenLine size={15} aria-hidden="true" /> i18n:govoplan-mail.drafts.22a31d86
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
className="btn btn-secondary"
|
||||||
|
to="/mail"
|
||||||
|
state={quickAccessLaunchState(launchContext)}
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-mail.open_mail
|
||||||
|
</Link>
|
||||||
|
</div>}
|
||||||
|
</LoadingFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function formatMessageDate(value?: string | null): string | undefined {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value;
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
}).format(parsed);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react";
|
||||||
import {
|
import { useLocation } from "react-router";
|
||||||
|
import { ToolbarGroup, ActionToolbar,
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
|
CountBadge,
|
||||||
DataGridPaginationBar,
|
DataGridPaginationBar,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
@@ -19,15 +21,21 @@ import {
|
|||||||
} 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,
|
||||||
type MailServerProfile } from
|
type MailServerProfile } from
|
||||||
"../../api/mail";
|
"../../api/mail";
|
||||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
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 = {
|
const MAILBOX_DOCUMENTATION = {
|
||||||
topicId: "mail.workflow.read-mailbox",
|
topicId: "mail.workflow.read-mailbox",
|
||||||
@@ -36,6 +44,7 @@ const MAILBOX_DOCUMENTATION = {
|
|||||||
|
|
||||||
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||||
@@ -44,6 +53,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||||
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||||
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
||||||
|
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
|
||||||
const [messagePage, setMessagePage] = useState(1);
|
const [messagePage, setMessagePage] = useState(1);
|
||||||
const [messagePageSize, setMessagePageSize] = useState(10);
|
const [messagePageSize, setMessagePageSize] = useState(10);
|
||||||
const [messageQuery, setMessageQuery] = useState("");
|
const [messageQuery, setMessageQuery] = useState("");
|
||||||
@@ -64,6 +74,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
const messageDetailRequestRef = useRef(0);
|
const messageDetailRequestRef = useRef(0);
|
||||||
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
|
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
|
||||||
const skipNextMessageLoadRef = useRef(false);
|
const skipNextMessageLoadRef = useRef(false);
|
||||||
|
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
|
||||||
|
|
||||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||||
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||||
@@ -76,6 +87,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
|
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
|
||||||
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
|
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
|
||||||
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
|
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
|
||||||
|
const syncState = mailboxSyncState(messageProvenance);
|
||||||
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
|
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
|
||||||
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
|
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
|
||||||
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
|
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
|
||||||
@@ -95,6 +107,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
useEffect(() => {
|
||||||
|
const request = parseMailboxLaunch(location.search);
|
||||||
|
launchRequestRef.current = request;
|
||||||
|
if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return;
|
||||||
|
const usable = profiles.filter((profile) => profile.is_active && profile.imap);
|
||||||
|
const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId)
|
||||||
|
? request.profileId
|
||||||
|
: selectedProfileId || usable[0]?.id || "";
|
||||||
|
if (!targetProfileId) return;
|
||||||
|
if (targetProfileId !== selectedProfileId) {
|
||||||
|
selectProfile(targetProfileId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadMailboxBootstrap(targetProfileId);
|
||||||
|
}, [location.search]);
|
||||||
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
|
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
|
||||||
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
|
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
|
||||||
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
|
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
|
||||||
@@ -107,6 +134,19 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
|
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
|
||||||
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
|
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const request = launchRequestRef.current;
|
||||||
|
if (!request || !foldersReady || loadingMessages) return;
|
||||||
|
const targetProfileMatches = !request.profileId || request.profileId === selectedProfileId;
|
||||||
|
const targetFolder = mailboxLaunchFolder(request, selectedProfile);
|
||||||
|
if (!targetProfileMatches || (targetFolder && targetFolder !== selectedFolder)) return;
|
||||||
|
const target = request.messageUid
|
||||||
|
? messages.find((message) => message.uid === request.messageUid)
|
||||||
|
: null;
|
||||||
|
launchRequestRef.current = null;
|
||||||
|
if (target) void openMessage(target);
|
||||||
|
}, [foldersReady, loadingMessages, messages, selectedFolder, selectedProfile, selectedProfileId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePreviewShortcut = (event: KeyboardEvent) => {
|
const handlePreviewShortcut = (event: KeyboardEvent) => {
|
||||||
if (isEditableTarget(event.target)) return;
|
if (isEditableTarget(event.target)) return;
|
||||||
@@ -154,13 +194,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
try {
|
try {
|
||||||
const loaded = await listMailServerProfiles(settings);
|
const loaded = await listMailServerProfiles(settings);
|
||||||
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
|
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
|
||||||
|
const requestedProfileId = parseMailboxLaunch(location.search).profileId;
|
||||||
setProfiles(loaded);
|
setProfiles(loaded);
|
||||||
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
setSelectedProfileId((current) =>
|
||||||
|
requestedProfileId && usable.some((profile) => profile.id === requestedProfileId)
|
||||||
|
? requestedProfileId
|
||||||
|
: current && usable.some((profile) => profile.id === current)
|
||||||
|
? current
|
||||||
|
: usable[0]?.id ?? ""
|
||||||
|
);
|
||||||
if (usable.length === 0) {
|
if (usable.length === 0) {
|
||||||
setFolders([]);
|
setFolders([]);
|
||||||
setFoldersLoadedForProfile("");
|
setFoldersLoadedForProfile("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
|
setMessageProvenance(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
setSelectedMessageKeyState("");
|
setSelectedMessageKeyState("");
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
@@ -176,6 +224,14 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
|
|
||||||
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
|
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
|
||||||
if (!profileId) return;
|
if (!profileId) return;
|
||||||
|
const profileInbox = profiles.find((profile) => profile.id === profileId)?.imap?.folder_mappings?.inbox || "";
|
||||||
|
const requestedLaunch = launchRequestRef.current;
|
||||||
|
const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId)
|
||||||
|
? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null)
|
||||||
|
: null;
|
||||||
|
const requestedFolder = requestedLaunchFolder || (foldersLoadedForProfile === profileId && selectedFolder
|
||||||
|
? selectedFolder
|
||||||
|
: profileInbox || "INBOX");
|
||||||
const folderRequestId = ++folderRequestRef.current;
|
const folderRequestId = ++folderRequestRef.current;
|
||||||
const messageRequestId = ++messageListRequestRef.current;
|
const messageRequestId = ++messageListRequestRef.current;
|
||||||
messageDetailRequestRef.current += 1;
|
messageDetailRequestRef.current += 1;
|
||||||
@@ -183,6 +239,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
setLoadingMessages(true);
|
setLoadingMessages(true);
|
||||||
setFoldersLoadedForProfile("");
|
setFoldersLoadedForProfile("");
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
|
setMessageProvenance(null);
|
||||||
setMessagePage(1);
|
setMessagePage(1);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
setSelectedMessageKeyState("");
|
setSelectedMessageKeyState("");
|
||||||
@@ -193,7 +250,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
setDetailError("");
|
setDetailError("");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
|
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh);
|
||||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||||
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
|
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
|
||||||
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
|
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
|
||||||
@@ -201,7 +258,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
const total = response.messages.total_count ?? loadedMessages.length;
|
const total = response.messages.total_count ?? loadedMessages.length;
|
||||||
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
|
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
|
||||||
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
|
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
|
||||||
nextFolder = loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
const detectedInbox = response.folders.detected_folder_mappings?.inbox;
|
||||||
|
nextFolder = detectedInbox && loadedFolders.some((folder) => folder.name === detectedInbox)
|
||||||
|
? detectedInbox
|
||||||
|
: loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
||||||
}
|
}
|
||||||
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
|
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
|
||||||
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);
|
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);
|
||||||
@@ -216,6 +276,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
setFoldersLoadedForProfile(profileId);
|
setFoldersLoadedForProfile(profileId);
|
||||||
setMessages(loadedMessages);
|
setMessages(loadedMessages);
|
||||||
setMessageTotalCount(total);
|
setMessageTotalCount(total);
|
||||||
|
setMessageProvenance(mailboxProvenance(response.messages));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||||
const message = errorText(err);
|
const message = errorText(err);
|
||||||
@@ -227,6 +288,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
setFoldersLoadedForProfile("");
|
setFoldersLoadedForProfile("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
|
setMessageProvenance(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
setSelectedMessageKeyState("");
|
setSelectedMessageKeyState("");
|
||||||
selectedMessageKeyRef.current = "";
|
selectedMessageKeyRef.current = "";
|
||||||
@@ -244,6 +306,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
|
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
|
||||||
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
|
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
|
||||||
setLoadingMessages(true);
|
setLoadingMessages(true);
|
||||||
|
setMessageProvenance(null);
|
||||||
setMessageError("");
|
setMessageError("");
|
||||||
setDetailError("");
|
setDetailError("");
|
||||||
setError("");
|
setError("");
|
||||||
@@ -256,6 +319,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
|
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
|
||||||
setMessages(loaded);
|
setMessages(loaded);
|
||||||
setMessageTotalCount(total);
|
setMessageTotalCount(total);
|
||||||
|
setMessageProvenance(mailboxProvenance(response));
|
||||||
setFolderMessageCount(folder, total);
|
setFolderMessageCount(folder, total);
|
||||||
const rememberedKey = selectedMessageKeyRef.current;
|
const rememberedKey = selectedMessageKeyRef.current;
|
||||||
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
|
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
|
||||||
@@ -270,6 +334,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
setError(message);
|
setError(message);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
|
setMessageProvenance(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
setSelectedMessageKeyState("");
|
setSelectedMessageKeyState("");
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
@@ -316,6 +381,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
setFoldersLoadedForProfile("");
|
setFoldersLoadedForProfile("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
|
setMessageProvenance(null);
|
||||||
setMessagePage(1);
|
setMessagePage(1);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
setSelectedMessageKeyState("");
|
setSelectedMessageKeyState("");
|
||||||
@@ -395,7 +461,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
<span className="mailbox-tree-node-label">
|
<span className="mailbox-tree-node-label">
|
||||||
<span className="mailbox-tree-node-main">
|
<span className="mailbox-tree-node-main">
|
||||||
<span>{node.label}</span>
|
<span>{node.label}</span>
|
||||||
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
|
{showCount && <CountBadge tone="neutral" size="compact" className="mailbox-folder-count">{node.messageCount}</CountBadge>}
|
||||||
</span>
|
</span>
|
||||||
{flagText && <small>{flagText}</small>}
|
{flagText && <small>{flagText}</small>}
|
||||||
</span>);
|
</span>);
|
||||||
@@ -407,7 +473,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
|
|
||||||
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
||||||
<div className="file-list-sticky">
|
<div className="file-list-sticky">
|
||||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
<ActionToolbar justify="between" className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
||||||
<label className="mailbox-profile-field">
|
<label className="mailbox-profile-field">
|
||||||
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
|
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
|
||||||
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||||
@@ -415,8 +481,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
|
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</ToolbarGroup>
|
||||||
<div className="mailbox-toolbar-actions">
|
<ToolbarGroup align="end" className="mailbox-toolbar-actions">
|
||||||
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
|
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
|
||||||
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
|
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
|
||||||
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
|
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
|
||||||
@@ -435,8 +501,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
<RefreshCw size={16} aria-hidden="true" />
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
i18n:govoplan-mail.messages.f1702b46
|
i18n:govoplan-mail.messages.f1702b46
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ToolbarGroup>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
|
|
||||||
{noImapProfiles &&
|
{noImapProfiles &&
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
@@ -468,6 +534,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
<span>{messageCountLabel}</span>
|
<span>{messageCountLabel}</span>
|
||||||
<span>{selectedFolder}</span>
|
<span>{selectedFolder}</span>
|
||||||
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
|
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
|
||||||
|
{syncState &&
|
||||||
|
<span className={`mailbox-sync-provenance is-${syncState}`} aria-live="polite">
|
||||||
|
<Database size={13} aria-hidden="true" />
|
||||||
|
{syncState === "refreshing" ? "i18n:govoplan-mail.cached_index_refreshing.75f18a6c" : syncState === "cached" ? "i18n:govoplan-mail.cached_mailbox_index.16fe75d1" : "i18n:govoplan-mail.live_provider_response.39c46538"}
|
||||||
|
{messageProvenance?.indexedAt && <span> · {formatDateTime(messageProvenance.indexedAt, { fallback: "-" })}</span>}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
|
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
|
||||||
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
|
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -486,10 +559,11 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
||||||
const selected = key === selectedMessageKey;
|
const selected = key === selectedMessageKey;
|
||||||
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
||||||
|
const read = isMailboxMessageRead(message.flags);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={message.uid}
|
key={message.uid}
|
||||||
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
className={`file-list-row file-row mailbox-message-row ${read ? "is-read" : "is-unread"} ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
||||||
role="row"
|
role="row"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => void openMessage(message)}
|
onClick={() => void openMessage(message)}
|
||||||
@@ -502,7 +576,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
|
|
||||||
<div className="file-list-name-cell">
|
<div className="file-list-name-cell">
|
||||||
<div className="file-list-name">
|
<div className="file-list-name">
|
||||||
<Mail className="file-row-icon" size={20} aria-hidden="true" />
|
{read ? <MailOpen className="file-row-icon" size={20} aria-hidden="true" /> : <Mail className="file-row-icon" size={20} aria-hidden="true" />}
|
||||||
<span>
|
<span>
|
||||||
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
|
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
|
||||||
<small>{message.from_header || "-"}</small>
|
<small>{message.from_header || "-"}</small>
|
||||||
@@ -511,6 +585,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
</div>
|
</div>
|
||||||
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
|
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
|
||||||
<span className="file-row-tail mailbox-message-tail">
|
<span className="file-row-tail mailbox-message-tail">
|
||||||
|
<span className="mailbox-read-state" title={read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}>
|
||||||
|
<span className="visually-hidden">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
||||||
|
<span aria-hidden="true">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
||||||
|
</span>
|
||||||
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
|
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
|
||||||
<span>{formatBytes(message.size_bytes)}</span>
|
<span>{formatBytes(message.size_bytes)}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -554,6 +632,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
|||||||
}))}
|
}))}
|
||||||
emptyText={previewEmptyText} />
|
emptyText={previewEmptyText} />
|
||||||
|
|
||||||
|
<MailboxContactActions settings={settings} message={selectedMessage} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -567,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}`;
|
||||||
@@ -576,6 +796,14 @@ function mailboxCursorKey(profileId: string, folder: string, pageSize: number):
|
|||||||
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
|
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance {
|
||||||
|
return {
|
||||||
|
fromCache: response.from_cache === true,
|
||||||
|
refreshing: response.refreshing === true,
|
||||||
|
indexedAt: response.indexed_at ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
|
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
|
||||||
const normalized = query.trim().toLocaleLowerCase();
|
const normalized = query.trim().toLocaleLowerCase();
|
||||||
if (!normalized) return messages;
|
if (!normalized) return messages;
|
||||||
|
|||||||
@@ -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(",")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type MailboxSyncProvenance = {
|
||||||
|
fromCache: boolean;
|
||||||
|
refreshing: boolean;
|
||||||
|
indexedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailboxSyncState = "live" | "cached" | "refreshing";
|
||||||
|
|
||||||
|
export function isMailboxMessageRead(flags: readonly string[] | null | undefined): boolean {
|
||||||
|
return (flags ?? []).some((flag) => flag.trim().replace(/^\\+/, "").toLocaleLowerCase() === "seen");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailboxSyncState(provenance: MailboxSyncProvenance | null): MailboxSyncState | null {
|
||||||
|
if (!provenance) return null;
|
||||||
|
if (provenance.refreshing) return "refreshing";
|
||||||
|
return provenance.fromCache ? "cached" : "live";
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
type MessageReference = { folder: string; uid: string };
|
||||||
|
type ProfileFolderMapping = {
|
||||||
|
imap?: { folder_mappings?: { drafts?: string | null } | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailboxLaunch = {
|
||||||
|
profileId: string | null;
|
||||||
|
folder: string | null;
|
||||||
|
folderRole: "drafts" | null;
|
||||||
|
messageUid: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseMailboxLaunch(search: string): MailboxLaunch {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
return {
|
||||||
|
profileId: boundedValue(params.get("profile")),
|
||||||
|
folder: boundedValue(params.get("folder")),
|
||||||
|
folderRole: params.get("folderRole") === "drafts" ? "drafts" : null,
|
||||||
|
messageUid: boundedValue(params.get("message"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailboxMessageLaunchPath(
|
||||||
|
profileId: string,
|
||||||
|
message: MessageReference
|
||||||
|
): string {
|
||||||
|
return mailPath({
|
||||||
|
profile: profileId,
|
||||||
|
folder: message.folder,
|
||||||
|
message: message.uid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailboxDraftsLaunchPath(profileId: string, folder?: string | null): string {
|
||||||
|
return mailPath(folder
|
||||||
|
? { profile: profileId, folder }
|
||||||
|
: { profile: profileId, folderRole: "drafts" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailboxLaunchFolder(
|
||||||
|
launch: MailboxLaunch,
|
||||||
|
profile: ProfileFolderMapping | null
|
||||||
|
): string | null {
|
||||||
|
if (launch.folder) return launch.folder;
|
||||||
|
if (launch.folderRole === "drafts") {
|
||||||
|
return boundedValue(profile?.imap?.folder_mappings?.drafts ?? null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailPath(values: Record<string, string>): string {
|
||||||
|
const params = new URLSearchParams(values);
|
||||||
|
return `/mail?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedValue(value: string | null): string | null {
|
||||||
|
const clean = value?.trim() ?? "";
|
||||||
|
return clean && clean.length <= 500 ? clean : null;
|
||||||
|
}
|
||||||
@@ -95,6 +95,19 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||||
"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.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.",
|
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||||
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
||||||
@@ -292,6 +305,19 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||||
"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.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.",
|
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||||
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
||||||
|
|||||||
+14
-3
@@ -1,9 +1,10 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule, QuickAccessToolsUiCapability } from "@govoplan/core-webui";
|
||||||
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||||
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||||
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
|
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
|
import MailQuickAccess from "./features/mail/MailQuickAccess";
|
||||||
import "./styles/mail-profiles.css";
|
import "./styles/mail-profiles.css";
|
||||||
|
|
||||||
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||||
@@ -14,6 +15,14 @@ const translations = {
|
|||||||
en: generatedTranslations.en,
|
en: generatedTranslations.en,
|
||||||
de: generatedTranslations.de
|
de: generatedTranslations.de
|
||||||
};
|
};
|
||||||
|
const mailQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "mail.messages",
|
||||||
|
render: (context) => createElement(MailQuickAccess, context)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
export const mailModule: PlatformWebModule = {
|
export const mailModule: PlatformWebModule = {
|
||||||
id: "mail",
|
id: "mail",
|
||||||
@@ -27,7 +36,8 @@ export const mailModule: PlatformWebModule = {
|
|||||||
{ id: "mail.admin.tenant-servers", moduleId: "mail", kind: "section", label: "Tenant mail servers", order: 60 },
|
{ id: "mail.admin.tenant-servers", moduleId: "mail", kind: "section", label: "Tenant mail servers", order: 60 },
|
||||||
{ id: "mail.admin.group-servers", moduleId: "mail", kind: "section", label: "Group mail servers", order: 20 },
|
{ id: "mail.admin.group-servers", moduleId: "mail", kind: "section", label: "Group mail servers", order: 20 },
|
||||||
{ id: "mail.admin.user-servers", moduleId: "mail", kind: "section", label: "User mail servers", order: 20 },
|
{ id: "mail.admin.user-servers", moduleId: "mail", kind: "section", label: "User mail servers", order: 20 },
|
||||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 }
|
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 },
|
||||||
|
{ id: "mail.quick_access.messages", moduleId: "mail", kind: "quick_access", label: "Mail Quick Access", order: 80 }
|
||||||
],
|
],
|
||||||
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||||
routes: [
|
routes: [
|
||||||
@@ -36,7 +46,8 @@ export const mailModule: PlatformWebModule = {
|
|||||||
|
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
|
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
|
||||||
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors
|
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors,
|
||||||
|
"quickAccess.tools": mailQuickAccessTools
|
||||||
},
|
},
|
||||||
runtimeUiCapabilities: {
|
runtimeUiCapabilities: {
|
||||||
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
||||||
|
|||||||
@@ -186,14 +186,6 @@
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mailbox-toolbar,
|
|
||||||
.mailbox-message-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mailbox-toolbar label {
|
.mailbox-toolbar label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@@ -385,7 +377,7 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1280px) {
|
||||||
.mailbox-layout {
|
.mailbox-layout {
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -396,8 +388,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.mailbox-toolbar,
|
.mailbox-toolbar {
|
||||||
.mailbox-message-toolbar {
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -458,13 +449,6 @@
|
|||||||
|
|
||||||
.mailbox-folder-count {
|
.mailbox-folder-count {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 20px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
color: var(--text);
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mailbox-toolbar.file-manager-toolbar {
|
.mailbox-toolbar.file-manager-toolbar {
|
||||||
@@ -576,6 +560,14 @@
|
|||||||
opacity: .72;
|
opacity: .72;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-message-row.is-read .file-list-name strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-row.is-unread .file-list-name strong {
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-message-row .file-list-name strong {
|
.mailbox-message-row .file-list-name strong {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -599,6 +591,27 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-read-state,
|
||||||
|
.mailbox-sync-provenance {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-message-row.is-unread .mailbox-read-state {
|
||||||
|
color: var(--accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-sync-provenance {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-sync-provenance.is-refreshing {
|
||||||
|
color: var(--warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-preview-panel {
|
.mailbox-preview-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -614,7 +627,57 @@
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1250px) {
|
.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 {
|
.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");
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
|
||||||
|
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { isMailboxMessageRead, mailboxSyncState } from "../src/features/mail/mailboxDisplay";
|
||||||
|
|
||||||
|
assert(isMailboxMessageRead(["\\Seen"]), "standard IMAP Seen flag marks a message as read");
|
||||||
|
assert(isMailboxMessageRead(["answered", "SEEN"]), "flag matching is case-insensitive and accepts normalized flags");
|
||||||
|
assert(!isMailboxMessageRead(["\\Answered", "\\Flagged"]), "messages without Seen remain unread");
|
||||||
|
assert(!isMailboxMessageRead(undefined), "missing flags fail safely to unread");
|
||||||
|
|
||||||
|
assertEqual(mailboxSyncState({ fromCache: false, refreshing: false, indexedAt: null }), "live");
|
||||||
|
assertEqual(mailboxSyncState({ fromCache: true, refreshing: false, indexedAt: "2026-08-19T09:00:00Z" }), "cached");
|
||||||
|
assertEqual(mailboxSyncState({ fromCache: true, refreshing: true, indexedAt: "2026-08-19T09:00:00Z" }), "refreshing");
|
||||||
|
assertEqual(mailboxSyncState(null), null);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
import {
|
||||||
|
mailboxDraftsLaunchPath,
|
||||||
|
mailboxLaunchFolder,
|
||||||
|
mailboxMessageLaunchPath,
|
||||||
|
parseMailboxLaunch
|
||||||
|
} from "../src/features/mail/mailboxLaunch";
|
||||||
|
|
||||||
|
assertDeepEqual(
|
||||||
|
parseMailboxLaunch("?profile=profile-1&folder=INBOX%2FTeam&message=42"),
|
||||||
|
{
|
||||||
|
profileId: "profile-1",
|
||||||
|
folder: "INBOX/Team",
|
||||||
|
folderRole: null,
|
||||||
|
messageUid: "42"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEqual(
|
||||||
|
mailboxMessageLaunchPath("profile 1", { folder: "INBOX/Team", uid: "42" }),
|
||||||
|
"/mail?profile=profile+1&folder=INBOX%2FTeam&message=42"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
mailboxDraftsLaunchPath("profile-1"),
|
||||||
|
"/mail?profile=profile-1&folderRole=drafts"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
mailboxDraftsLaunchPath("profile-1", "Entwürfe"),
|
||||||
|
"/mail?profile=profile-1&folder=Entw%C3%BCrfe"
|
||||||
|
);
|
||||||
|
assertEqual(
|
||||||
|
mailboxLaunchFolder(parseMailboxLaunch("?folderRole=drafts"), {
|
||||||
|
imap: { folder_mappings: { drafts: "Entwürfe" } }
|
||||||
|
}),
|
||||||
|
"Entwürfe"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("mailbox launch tests passed");
|
||||||
@@ -17,11 +17,17 @@
|
|||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
"tests/mailbox-display.test.ts",
|
||||||
"tests/mailbox-folders.test.ts",
|
"tests/mailbox-folders.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/mailboxFolders.ts",
|
"src/features/mail/mailboxFolders.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