Compare commits

...
27 Commits
Author SHA1 Message Date
zemion 480c18c67c Release govoplan-mail v0.1.27: stabilize credentials, folder encoding and transport progress
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 01:32:44 +02:00
zemion c62c7783d6 feat: inventory SMTP infrastructure dependencies
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 14:56:27 +02:00
zemion ecc1283de7 feat: contribute Messages product surface
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 12:53:44 +02:00
zemion 48205e6e46 fix(webui): bind reusable mail credentials to help
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 11:36:41 +02:00
zemion c31f9b69cb docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:35 +02:00
zemion 1cbf4acaf4 feat(mail): add governed JMAP mailbox sync and search
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 17:09:05 +02:00
zemion fd808336bc docs(mail): complete German help coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 05:44:34 +02:00
zemion 218fef11f1 feat(mail): add governed POP3 legacy import
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 04:52:25 +02:00
zemion 93ecedf607 fix(i18n): complete cached mailbox translations 2026-08-21 17:36:17 +02:00
zemion cfd3847524 feat(mail): return exact messages from Quick Access
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 16:03:29 +02:00
zemion 2fe56fca00 fix(mail): align package and manifest versions 2026-08-21 00:46:06 +02:00
zemion 34bd5be8d4 feat: add governed Mail DSAR coverage 2026-08-20 23:58:21 +02:00
zemion a044fee379 feat: add reusable SMTP batch sessions 2026-08-20 17:39:00 +02:00
zemion 169b81d9db feat: integrate governed address contacts 2026-08-20 11:10:18 +02:00
zemion 2ad122056e feat(mail): complete Quick Access message launches 2026-08-20 02:39:41 +02:00
zemion 9ac8847559 feat(mail): complete mailbox display states 2026-08-19 23:03:46 +02:00
zemion e0e00d7000 feat(mail): map standard IMAP folders per profile 2026-08-19 21:27:42 +02:00
zemion 393331574a refactor(webui): adopt semantic page actions 2026-08-19 18:47:45 +02:00
zemion bb8c60abb9 Adopt semantic mail action layout 2026-08-19 14:26:26 +02:00
zemion c59c67bd18 style: align mail with shared WebUI foundations 2026-08-18 21:32:50 +02:00
zemion 761dd76b9e Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 28f357e151 Adopt shared WebUI layout primitives 2026-08-18 10:42:52 +02:00
zemion cff428b475 feat: bridge observed messages to postboxes 2026-08-07 14:53:56 +02:00
zemion 77f44dfab5 feat: provision SMTP profiles from deployment receipts 2026-08-07 11:15:50 +02:00
zemion 2c5585ba99 Contribute Mail to Quick Access 2026-08-06 19:02:55 +02:00
zemion 62721d204d Release v0.1.18
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 21:07:45 +02:00
zemion e41d05914a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:04 +02:00
70 changed files with 14468 additions and 1268 deletions
+37 -9
View File
@@ -4,7 +4,7 @@
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile management, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP/JMAP profile management, an explicitly enabled legacy POP3 import path, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
## Ownership
@@ -12,16 +12,26 @@ This repository owns:
- backend module manifest `mail`
- mail permissions such as `mail:profile:read`, `mail:profile:write_own`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
- SMTP/IMAP/JMAP profile models, dedicated legacy POP3 sources, policy checks, encrypted credential storage, and profile resolution
- SMTP send and IMAP append adapters, including mock transports for development
- development mock mailbox endpoints used by test-send flows
- WebUI package `@govoplan/mail-webui` with profile management, policy management, and read-only mailbox components
- WebUI package `@govoplan/mail-webui` with profile management, policy management, read-only mailbox components, and governed POP3 import
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, bounce, and imported-message metadata. SMTP/IMAP/JMAP/POP3
configuration and credentials, encrypted messages and envelopes, source UIDL
and 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
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP/JMAP
endpoints, encrypted credentials, tests, scope, and policy. Consumers such as
Campaign store only a stable profile identifier and resolve the authorized,
active profile through `mail.campaign_delivery`; they never copy or override
@@ -41,6 +51,18 @@ and requires explicit evidence-backed reconciliation before any deliberate
resend. Business readers receive only counts and sanitized state; recipient
refusal details require `mail:delivery:diagnostic`.
Synchronous Campaign batches now preflight DNS, egress, connectivity, TLS, and
authentication before the first message, then reuse the authorized SMTP
connection for the bounded batch. A health check precedes reuse; a stale
connection is reopened before the next message, while a connection loss after
DATA starts remains outcome-unknown and is never replayed automatically.
Systemic authentication, sender, and connectivity failures pause remaining
Campaign jobs instead of producing one failure per recipient. Deployment
operators can disable reuse or bound connection lifetime and reconnects with
`GOVOPLAN_SMTP_BATCH_REUSE`, `GOVOPLAN_SMTP_BATCH_MAX_MESSAGES`,
`GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS`, and
`GOVOPLAN_SMTP_BATCH_HEALTH_CHECK`.
SMTP effects decrypt only SMTP credentials; Sent-folder effects decrypt only
IMAP credentials. A connection loss after an effect starts is surfaced as an
unknown outcome. Campaign does not automatically retry an unknown IMAP append,
@@ -65,7 +87,7 @@ closed with guidance to store those credentials on a Mail profile and enable
inheritance.
Deleting a profile deactivates its non-secret tombstone metadata and scrubs
both encrypted SMTP and IMAP passwords immediately in the same transaction as
its encrypted SMTP, IMAP, JMAP, and POP3 credentials immediately in the same transaction as
a non-secret audit event. Destructive module retirement applies the same rule
to every remaining profile before any Mail table is dropped; a scrub or audit
failure blocks retirement.
@@ -122,10 +144,16 @@ Development mailbox routes are registered by the mail module only when the
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
does not contribute these routes directly.
POP3 and JMAP are deferred. The protocol decision is documented in
[docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md): stabilize
SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3
only for explicit legacy-download requirements. The same roadmap records the
JMAP is available as an opt-in, read-only mailbox transport after the stable
IMAP baseline. It discovers RFC 8620/8621 capabilities, lists folders,
performs server-side message search and pagination, reads bounded message
bodies and attachment metadata, and exposes bounded incremental Email changes.
IMAP behavior is unchanged, and SMTP remains the send transport. JMAP Session
and advertised API origins are governed independently and credentials stay in
Mail's encrypted envelopes. The explicitly enabled POP3 slice remains limited
to bounded, encrypted, duplicate-safe legacy import. The protocol boundary is
documented in [docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md).
The same roadmap records the
approved S/MIME-first, OpenPGP-additional message-protection profile and its
no-silent-downgrade requirement.
+21 -3
View File
@@ -9,11 +9,11 @@ mailbox, policy, and delivery-evidence consequences described here.
| Surface | Primary task | Archetype | Consequence | Pattern evidence |
| --- | --- | --- | --- | --- |
| `/mail` folder, message, and preview panes | Browse and inspect an authorized mailbox without changing provider state | Directory/explorer | Medium because message metadata and content are private, although navigation is read-only | Full-height three-pane workspace, bounded paging, stable keyboard selection, contextual Help Center link, explicit no-profile blocker |
| `/mail` toolbar, page filter, and pagination | Select a profile, refresh bounded indexes, and find a message on the current page | Explorer actions and local filtering | Low for refresh; medium for provider access | Shared actions expose loading/profile/folder blockers; profile transport summary is non-secret; loading and errors use Core components |
| `/mail` toolbar, page filter, and pagination | Select a profile, refresh the current bounded mailbox context, and find a message | Explorer actions and local filtering | Low for refresh; medium for provider access | Persistent `WorkspaceFrame`/`WorkspaceActionBar` keeps one right-aligned Reload; shared `FormField` profile selection; labelled Mailbox tools `Dialog` groups targeted refreshes and related diagnostics through `FormSection`; loading/profile/folder/permission blockers remain visible |
| 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 |
| 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 |
| 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 |
@@ -22,6 +22,9 @@ mailbox, policy, and delivery-evidence consequences described here.
- Loading, success, error, empty results, permission blockers, and destructive
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,
or campaign is selected. The blocker identifies the responsible actor and
destination instead of silently hiding the editor.
@@ -39,6 +42,15 @@ mailbox, policy, and delivery-evidence consequences described here.
copy distinguishes retained reusable credentials from scrubbed owned secrets.
- Mailbox browsing is read-only. Listing or previewing must not mark messages
read, move, delete, reply, or expose unbounded content.
- The mailbox Reload rechecks authorized profiles and refreshes the current
catalogue, bounded page, and still-selected message. IMAP retains its page;
JMAP refresh starts a new cursor chain while keeping the search. Selecting a
synthetic grouping refreshes only the catalogue. Refresh failures retain
usable data and a retry; request identities reject previous-profile/tenant
results. Folder expansion is independent of labels and refreshes. Advanced
targeted reads and bounce diagnostics remain in the labelled tools dialog,
not a second persistent toolbar. Escape in that dialog leaves message
selection intact.
## Accessibility, responsive, and privacy evidence
@@ -49,8 +61,14 @@ reasons are keyboard-focusable. Status always has text in addition to color.
Contextual links identify their destination to assistive technology.
Profile/policy grids collapse to one column below 900 px. The mailbox changes
from three panes to two below 1250 px and to a single-column toolbar and message
from three panes to two below 1280 px and to single-column message
rows below 760 px while preserving source order and independent scroll regions.
Core owns toolbar wrapping, with Reload right-aligned even on narrow screens.
The two-row mailbox layout shares the available height between list and preview
instead of reserving a fixed preview minimum that can squeeze message rows to
zero height. Narrow screens stack compact folders, a usable message index, and
the preview within a vertically scrollable bounded workspace. The action
header never scrolls away, and each pane retains its own bounded scroll region.
Long identities and transport summaries wrap or ellipsize inside stable bounds.
Profile and mailbox APIs return non-secret transport metadata and bounded
+336 -39
View File
@@ -23,12 +23,13 @@ See also [Mail protocol roadmap](MAIL_PROTOCOL_ROADMAP.md) and the Campaign
Mail owns:
- reusable SMTP/IMAP profile definitions and scope;
- encrypted SMTP/IMAP credentials and safe credential replacement;
- reusable SMTP/IMAP/JMAP profile definitions, dedicated legacy POP3 sources, and scope;
- encrypted SMTP/IMAP/JMAP/POP3 credentials and safe credential replacement;
- effective profile policy and visibility/authorization decisions;
- connection tests and protocol adapters;
- SMTP send and IMAP append operations exposed to consumers;
- read-only mailbox folder/message access and its bounded indexes; and
- read-only IMAP/JMAP mailbox folder/message access, bounded indexes, and encrypted
pending-review records imported from legacy POP3 sources; and
- the transport sanitization boundary and throttling behavior. A general
Mail-owned provider-attempt/diagnostic ledger remains planned.
@@ -43,6 +44,40 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the
module registry. Optional consumers provide narrow context through capabilities;
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.
Mail also registers a module-owned infrastructure dependency provider. Its
authorized Ops inventory lists every persisted SMTP endpoint and legacy SMTP
profile by stable non-secret reference, state and scope, together with numeric
credential-binding evidence. Before the host deployer changes or removes
`mail.smtp`, it requires a fresh, complete inventory from the same installation
and displays these dependencies in the plan. The inventory never contains
transport credentials or decrypted envelope data.
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.
Configuration packages remain SMTP-focused. A POP3 legacy source is a deliberate
operational migration action and is not silently exported, cloned, or enabled by
an SMTP profile package.
## Interface patterns and unavailable actions
Mail uses the platform's shared explorer, connection tree, adaptive form,
@@ -67,9 +102,27 @@ deactivating a profile may scrub Mail-owned credentials as described below.
### Profile
A profile is a reusable, named delivery identity with optional SMTP and IMAP
configuration. It has a stable id, lifecycle state, scope, owner context, and
non-secret connection metadata. Passwords are write-only encrypted values and
are never returned through list/read/capability responses.
configuration and one or more opt-in JMAP mailbox endpoints. It can additionally own a dedicated POP3 endpoint for an
explicit legacy-import workflow. It has a stable id, lifecycle state, scope,
owner context, and non-secret connection metadata. Passwords are write-only
encrypted values and 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.
Use readable Unicode names such as `Entwürfe`, not IMAP wire encodings such as
`Entw&APw-rfe`. Discovery decodes modified UTF-7 before detecting standard
folder roles; SELECT, STATUS, and Sent APPEND encode and quote the chosen name
for the active connection. Literal ampersands, quotes, backslashes, and Unicode
characters round-trip without renaming remote folders. Previously saved
wire-form names are resolved against that account's live folder list; if the
same string is also an actual readable folder name, the readable name wins.
Rediscovery and an explicit profile save replace old encoded configuration
values with readable names; background reads never rewrite configuration.
Profiles may be scoped to system, tenant, user, group, or campaign context.
Scope controls where a profile can be discovered; effective policy can narrow
@@ -79,14 +132,14 @@ operation: using, testing, managing, and managing secrets are separate rights.
### Policy
Mail policy controls approved profile ids, which lower scopes may define
profiles, allowed/denied hosts and addressing patterns, credential inheritance,
profiles, allowed/denied SMTP/IMAP/JMAP hosts and addressing patterns, credential inheritance,
and lower-level limits. The effective result is assembled from applicable
system, tenant, user/group, and campaign context. Denials and locked parent
limits cannot be relaxed by a lower scope.
### Transport identity and revision
Mail owns opaque random revisions for relevant SMTP/IMAP identity and
Mail owns opaque random revisions for relevant SMTP/IMAP/JMAP identity and
configuration. They are concurrency tokens, not deterministic hashes that a
consumer could use to guess a host or account. Credentials are excluded. A
consumer can freeze these revisions at build time and ask Mail to require the
@@ -99,6 +152,11 @@ authorization. Mail re-evaluates profile activity, visibility, policy, and
revision immediately before it resolves credentials and performs the
effect.
POP3 imports pin the endpoint/credential transport revision at preview time. A
changed revision or a missing provider UIDL stops import and requires a fresh
preview. POP3 does not participate in the ordinary mailbox folder/message
projection.
### Provider outcomes
SMTP acceptance, partial or complete recipient refusal, temporary/permanent
@@ -124,7 +182,7 @@ new idempotency key and links it to the prior command.
2. Choose from the profiles visible and authorized for the current tenant,
owner, group, and task context. Never enter or copy a profile id manually
when the UI can present a picker.
3. Review the safe summary: name, scope, active state, SMTP/IMAP availability,
3. Review the safe summary: name, scope, active state, SMTP/IMAP/JMAP availability,
and policy-relevant sender identity. Credentials and raw provider internals
are not visible.
4. Save the reference in the consuming module. If ownership or policy changes,
@@ -137,12 +195,25 @@ permission.
### Test a profile
An authorized profile test verifies connection and authentication for the
selected active/visible SMTP or IMAP profile using Mail-owned credentials. The
selected active/visible SMTP, IMAP, or JMAP profile using Mail-owned credentials. The
consumer-use path evaluates effective Mail policy separately. Use a
non-production provider and mailbox first. A successful connection test does
not prove policy authorization for a later Campaign context, deliverability,
recipient acceptance, SPF/DKIM/DMARC alignment, or future availability.
Campaign runtime authorization follows the protocol being used: SMTP batch and
single-message calls enforce the explicit SMTP credential policy, while
append-to-Sent enforces the IMAP credential policy. A valid SMTP call does not
need to carry an unrelated IMAP credential just because the profile supports
both protocols. Full campaign authoring validation and complete profile
summaries continue to require both configured selections when their policies
forbid inherited credentials. The selected protocol's missing credential,
inactive/unauthorized binding or stale transport revision still stops the
operation before decryption or provider contact. Resolving a policy rejection
never requires disabling TLS or weakening either credential policy. Correcting
this runtime check does not change stored configuration or approved builds,
reset job state, or retry/send messages automatically.
Testing a saved profile requires both `mail:profile:test` and
`mail:profile:use`, and the profile must be active. Profile creation or test
authority alone is not enough.
@@ -153,16 +224,122 @@ ordinary consumers to bypass reusable profiles.
### Read a mailbox
The persistent workspace header contains the profile selector, **Mailbox tools**,
Help, and one right-aligned **Reload**. These controls remain available when the
mailbox is empty or no usable profile is configured. Reload rechecks the permitted
profiles and refreshes the current folder catalogue and bounded message page;
it also rereads a selected message if that message remains on the page. IMAP
keeps the page offset. JMAP starts a fresh cursor chain at page one while keeping
the search term. An unavailable profile is replaced only by another currently
authorized active profile, or the explicit no-profile state.
Folder icons expand or collapse; labels select. Synthetic grouping labels select
the group without reading a nonexistent provider folder, and Reload in that
state refreshes only the folder catalogue. Refreshes keep user-controlled
expansion. A failed refresh preserves usable loaded data, shows the error, and
leaves Reload available for retry; late responses from an earlier profile or
tenant cannot replace the current context.
Mailbox context reads bypass browser response/promise reuse so an immediate
Reload really rechecks permissions and state. Mail's bounded server-side index
is unchanged; its explicit refresh flag and live/cached provenance still apply.
Failed pagination restores the page and page-size labels belonging to retained
rows. Dismissing or changing the preview while Reload is pending takes
precedence over its remembered selection.
On narrow screens, scroll vertically through folders, message list, and preview
within the mailbox workspace; the profile/tools/Reload header remains visible.
**Mailbox tools** groups the occasional profile-only, folder-only, and
message-only refreshes in a dialog, separate from **Bounce status**. Bounce
status requires `mail:bounce:read` or `mail:bounce:manage`; without either it
remains visible and disabled with an explanation. Escape closes this dialog
without clearing the selected message. These read controls do not grant profile
administration, send SMTP messages, APPEND messages, or change mailbox flags.
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
names are parsed and quoted defensively; Sent-folder discovery uses provider
flags and common names.
or JMAP folders, page through messages, and inspect a bounded full message.
IMAP folder names are decoded for display and encoded and quoted defensively
for mailbox commands; Sent-folder discovery uses provider flags and common
readable names. The default IMAP4rev1 mode uses modified UTF-7. A connection
that has explicitly enabled `UTF8=ACCEPT` uses UTF-8 instead; merely advertising
that capability does not change encoding. Invalid provider encodings produce
an explicit error rather than a replacement name that could address another
folder. Refresh any already-loaded folder list after upgrading. JMAP discovers the Session and Mail account,
uses `Mailbox/get` hierarchy and roles, runs text search with `Email/query`, and
uses `Email/get` for bounded summaries/details. `Email/changes` exposes a
bounded incremental cursor; an expired state tells the caller to perform a full
refresh. The list exposes the provider's `Seen`/`$seen` state 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.
The mailbox-name boundary follows [RFC 3501 section 5.1.3](https://www.rfc-editor.org/rfc/rfc3501.html#section-5.1.3)
and, only after explicit activation, [RFC 6855 section 3](https://www.rfc-editor.org/rfc/rfc6855.html#section-3).
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
not mutate read/unread, delete, move, or reply state. Message responses are
bounded by the deployment response policy; ordinary UI should avoid loading a
bounded by the endpoint response/body policy; ordinary UI should avoid loading a
whole large mailbox or attachment merely to show a list.
JMAP is opt-in per server endpoint. Its authenticated Session URL is governed
by `jmap_hosts`; an advertised API URL on another origin fails closed unless
that origin is explicitly listed for the endpoint. Bearer tokens or Basic
credentials are stored only in Mail credential envelopes. This slice does not
send through JMAP, mutate provider state, download attachment binaries, or add
calendar/contact/thread features.
### Import a legacy POP3 mailbox
POP3 is available only for bounded migration from a legacy server that cannot
provide IMAP or JMAP. It is disabled by default and is not a replacement for
the read-only mailbox UI.
1. An actor with profile-write, secret-management, and `mail:pop3:manage`
authority opens **Legacy POP3 import**, selects a Mail profile, and creates a
dedicated source. The UI stages the endpoint disabled, stores its encrypted
username/password credential, and enables it only after both operations
succeed.
2. The administrator explicitly enables legacy import, sets TLS mode, timeout,
maximum message and batch sizes, and preview body lines, and tests connection,
authentication, TLS, and provider message count. Plain transport remains
subject to deployment egress/security policy and should not be used across
an untrusted network.
3. An operator with `mail:profile:use` and `mail:pop3:import` refreshes a live
preview of at most 100 messages. Preview sends no `DELE`, changes no flags,
and exposes only bounded headers/body text. A server without stable UIDL
identifiers is rejected. If `TOP` is unavailable, Mail uses `RETR` only
inside the configured size bound and suppresses an oversized preview.
4. The operator selects messages. Mail downloads within the size gate and
creates encrypted local `pending_review` records. Tenant, profile, endpoint,
and UIDL form the duplicate boundary. Raw content never appears in list,
provider-state, audit, or DSAR output.
5. Source messages remain untouched by default. Delete-after-import requires
the endpoint's separate `allow_delete_after_import` policy, the operator's
`mail:pop3:delete` permission, an explicit per-batch choice, and destructive
confirmation. Mail commits the local import plus `mail.pop3.imported` audit
evidence before sending `DELE`. It separately records
`mail.pop3.source_deletion`; disconnect during `QUIT` is outcome-unknown and
must be reconciled before another destructive attempt.
The supplied **Mail legacy import operator** role can test, preview, and import
without deleting. The **Mail profile administrator** role also contains source
management and destructive-delete permissions; deployments should remove or
split `mail:pop3:delete` when operators must never delete provider messages.
Imported records follow configured Mail/records retention and require manual
review for a data-subject request or deletion decision.
Contextual help is available from the page and from each policy-sensitive
source setting, credential field, size limit, import action, and destructive
confirmation. Press F1 while a control has focus to open the Mail-owned German
reference for the exact POP3 context; the same topic remains available through
the page help action.
## Profile administration
### Roles
@@ -175,11 +352,16 @@ The supplied templates are:
and manage credentials only for the current account's own user-scoped
profiles, subject to the effective Mail policy.
- **Mail profile administrator:** additionally create/update profiles and
create/replace encrypted credentials across tenant-owned scopes.
create/replace encrypted credentials across tenant-owned scopes, configure
legacy POP3 imports, and—unless the template is narrowed—request source
deletion after import.
- **Mail legacy import operator:** test approved POP3 sources and preview/import
messages without permission to delete them at the provider.
The specific permissions are `mail:profile:read`, `mail:profile:use`,
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write_own`,
`mail:secret:manage_own`, `mail:profile:write`, and `mail:secret:manage`.
`mail:secret:manage_own`, `mail:profile:write`, `mail:secret:manage`,
`mail:pop3:manage`, `mail:pop3:import`, and `mail:pop3:delete`.
The `_own` permissions are enforced against the authenticated membership id and
never authorize a tenant, group, campaign, system, or another user's profile.
They also do not authorize profile-policy changes. System-scoped definitions
@@ -198,19 +380,21 @@ the deletion in the audit log.
The configured Help Center exposes **Create a custom Mail profile** only when
the current actor has broad or self-service profile-write authority and the
effective user-scope policy permits user profiles. It states the active SMTP/IMAP hostname
effective user-scope policy permits user profiles. It states the active SMTP/IMAP/JMAP hostname
allow-list groups and deny rules, plus the actor's separate credential, test,
use, and approval requirements. The Settings task creates in the current
account's user scope. Grant `mail:profile:write_own` for self-service;
`mail:profile:write` remains broad profile administration authority.
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
behavior, and timeouts. Sender/envelope/recipient constraints belong to
2. Configure SMTP and optional IMAP, or add an optional JMAP Session endpoint after creating the profile. Set TLS mode, account identity, standard folder
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.
3. Enter credentials only in the dedicated credential fields. Returned profile
data indicates whether credentials are configured without returning them.
4. Save and run SMTP/IMAP tests against a non-production target.
4. Save and run SMTP/IMAP/JMAP tests against a non-production target.
5. Verify the effective policy for every intended consumer context.
6. Communicate changes that alter the non-secret transport identity; prepared
consumer snapshots will deliberately stop until rebuilt.
@@ -218,11 +402,24 @@ account's user scope. Grant `mail:profile:write_own` for self-service;
An update that omits a password preserves the current encrypted password. A
credential replacement never depends on reading the old cleartext value back.
The shared credential editor resolves Mail server restrictions from the
authorized metadata catalogue when it opens. Names appear as loading completes;
no page refresh is required, and typing in a draft does not reload the catalogue.
Closing and reopening refreshes the available servers and can retry a temporary
metadata failure. Inactive servers remain labelled inactive; deleted or
unauthorized references remain visible as unavailable and are never silently
removed from the credential. Labels do not grant permission to use a server,
and the lookup does not retrieve secrets.
If saving a reusable credential fails, the editor shows the error beside the
unchanged draft. Retry explicitly after correcting the cause. Saving disables
editing and closing until the request finishes; a failed save never silently
discards a replacement secret that has not been stored.
### Delete a profile
Profile deletion is immediate for Mail-owned secrets and audit evidence:
1. Mail clears both encrypted SMTP and IMAP passwords in the same transaction.
1. Mail clears encrypted SMTP, IMAP, JMAP, and POP3 credential envelopes in the same transaction.
2. It deactivates the remaining non-secret tombstone state so historical stable
references can fail safely rather than resolve to another profile.
3. When owned encrypted secrets existed, it emits
@@ -243,16 +440,35 @@ Effective policy is contextual. Administrators should document:
- which profile ids are approved globally or for a tenant;
- whether tenant, user, group, or campaign-scoped profiles may be created;
- allowed and denied SMTP/IMAP hosts;
- allowed and denied SMTP/IMAP/JMAP hosts;
- permitted From, envelope sender (including bounce address), and envelope
recipient-domain patterns;
- whether SMTP/IMAP credentials inherit from the reusable profile; and
- whether SMTP/IMAP may use a default credential or require an explicit Mail-owned credential selection; and
- which lower-level settings are locked by a parent policy.
Campaign delivery requires reusable profile credentials. A legacy policy that
requires campaign-local credentials fails closed with guidance to store them on
the Mail profile and enable effective inheritance. This preserves compatibility
of the policy model without reopening a consumer-owned secret store.
In **Mail profile policy → Credential selection**, SMTP and IMAP have separate
controls. **Allow profile default credential** (`inherit: true`) permits either
the selected server's default credential or an explicit authorized Mail-owned
credential. **Require explicit Mail credential** (`inherit: false`) requires a
server and credential reference in Campaign Mail settings. Neither option
permits campaign-local passwords or copies a secret into Campaign.
**Inherit policy from parent** leaves the local value unset; it is different
from allowing a server's default credential. System policy always has a concrete
choice. Other scopes show the local choice alongside the saved effective result
and policy path. **Allow override** controls
`allow_lower_level_limits["smtp_credentials.inherit"]` and the equivalent IMAP
key. It is not a separate `allow_override` field in the credential object. A
parent's explicit-credential requirement may be changed by a child only while
that parent allows overrides. Locked fields and their override controls remain
read-only; a lower scope cannot unlock them. Campaign policy has no lower-level
override controls. Editing any policy still requires that scope's policy-write
permission and an unlocked workflow.
Policy saves retain their draft after a failed write and require an explicit
retry. If the policy was saved but a dependent screen refresh fails, the editor
reports that the policy was saved and advises reloading the display; it does not
report a failed save or repeat the accepted write.
Policy reads are available through system/tenant/context routes to suitably
authorized actors. Adaptive Docs exposes a safe explanation of the effective
@@ -264,10 +480,10 @@ tenant posture; it does not expose credential material.
Private-network connector access is controlled deployment-wide by
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Whether private or public targets
are allowed, SMTP and IMAP resolve, validate, and connect to the exact approved
address records at connection time while retaining the original hostname for
TLS SNI and certificate verification. A DNS change cannot redirect the socket
after validation.
are allowed, SMTP, IMAP, and legacy POP3 resolve, validate, and connect to the
exact approved address records at connection time while retaining the original
hostname for TLS SNI and certificate verification. A DNS change cannot redirect
the socket after validation.
Transports that cannot pin every connection peer or revalidate protocol-managed
redirects/referrals must fail before client construction. Do not weaken this
@@ -329,6 +545,78 @@ treated as an unknown provider mutation.
confirmed absence records verified recovery and permits only a new,
deliberate attempt identifier.
### Bounded IMAP append batches / Begrenzte IMAP-Ablagestapel
Campaign's bulk Sent-folder operation can use the optional
`mail.campaign_delivery.campaign_imap_batch(tenant_id=..., campaign_id=...)`
context. Opening this context has no provider effect. Mail opens a connection
only after an individual message passes its current authorization, selected
IMAP credential policy, both frozen transport revisions and durable recovery
checks. Subsequent messages reuse that authenticated connection and its detected
Sent folder, including the provider's original Unicode mailbox wire encoding.
Authorization and credential resolution are performed for every message, not
cached. Changing the authorized profile, selected references, folder or resolved
credentials releases the previous connection before the next APPEND.
Each message still receives one sequential APPEND and its own recovery evidence.
There is no parallel APPEND, MULTIAPPEND, automatic SMTP resend or replay after
APPEND starts. A lost APPEND reply remains outcome-unknown and requires explicit
mailbox reconciliation. Failures while connecting, before any APPEND, may use a
bounded reconnect; rejected authentication is not retried. A failure to finalize
accepted recovery evidence closes the batch. Cleanup/logout failure does not
turn an accepted APPEND into a failed one. Session state is scoped to the current
batch, never shared across tenants or campaigns, and is released on exit.
Deployment controls apply to batch connection reuse, not campaign authorization:
| Environment variable | Default | Range / effect |
| --- | --- | --- |
| `GOVOPLAN_IMAP_BATCH_REUSE` | `true` | `false`, `0`, `no` or `off` disables reuse. |
| `GOVOPLAN_IMAP_BATCH_MAX_MESSAGES` | `100` | 110,000 successful APPENDs per connection. |
| `GOVOPLAN_IMAP_BATCH_MAX_AGE_SECONDS` | `300` | 13,600 seconds; rotate before the next message, not during an APPEND. |
| `GOVOPLAN_IMAP_BATCH_IDLE_HEALTH_CHECK_SECONDS` | `30` | 03,600 seconds idle before a NOOP; 0 checks every reuse. |
| `GOVOPLAN_IMAP_BATCH_RECONNECT_ATTEMPTS` | `1` | 05 extra connection attempts, only before APPEND. |
Invalid numeric values use defaults; out-of-range numbers are clamped. An active
session keeps the policy with which it was created. Existing single-message
callers retain one connection per call and no automatic connection retries.
Older Mail capabilities without this optional context keep the single-message
behavior. Safe outcome fields include connection sequence, session reuse and
reconnect count; they contain no hosts, credentials, provider responses or MIME
content. Fewer logins and folder discoveries improve connection overhead, not
the provider's intrinsic per-message APPEND or durable-evidence latency.
Deutsch: Die Sammelablage im Gesendet-Ordner kann eine begrenzte authentifizierte
IMAP-Verbindung wiederverwenden. Der Stapelkontext allein verbindet sich nicht.
Vor jeder Nachricht prüft Mail erneut Berechtigung, IMAP-Zugangsdatenrichtlinie,
beide eingefrorenen Transportrevisionen und Wiederherstellungsnachweise; die
Zugangsdaten werden weiterhin je Nachricht aufgelöst. Profil, ausgewählte
Referenzen, Ordner oder aufgelöste Zugangsdaten dürfen nicht stillschweigend von
einer älteren Verbindung übernommen werden. Ordnererkennung und ursprüngliche
Provider-Kodierung bleiben ausschließlich an dieselbe Verbindung gebunden.
Jede Nachricht erhält weiterhin einen einzelnen, sequenziellen APPEND und einen
eigenen Nachweis. Es gibt kein paralleles APPEND, kein MULTIAPPEND und keine
automatische Wiederholung nach Beginn von APPEND. Ein unbekanntes Ergebnis muss
am Postfach abgeglichen werden; eine fehlende Gesendet-Kopie darf keinen erneuten
SMTP-Versand auslösen. Nur Verbindungsaufbau vor APPEND darf begrenzt wiederholt
werden, nicht eine abgelehnte Anmeldung. Ein Fehler beim Abschluss des
Wiederherstellungsnachweises schließt den Stapel; ein reiner Abmeldefehler macht
eine bestätigte Ablage nicht rückgängig. Mandanten und Kampagnen teilen keine
Stapelverbindung.
Die obigen Betriebsvariablen bedeuten standardmäßig: Wiederverwendung aktiv,
höchstens 100 Nachrichten bzw. 300 Sekunden pro Verbindung, NOOP nach 30 Sekunden
Leerlauf und höchstens einen zusätzlichen Verbindungsversuch vor APPEND. Der
Wechsel erfolgt vor der nächsten Nachricht, niemals mitten im APPEND. `0` beim
Leerlaufintervall prüft jede Wiederverwendung; deaktivierte Wiederverwendung
verwendet weiterhin einzelne APPENDs. Ungültige Zahlen verwenden den Standard,
Zahlen außerhalb des Wertebereichs werden begrenzt. Eine aktive Verbindung
behält ihre beim Aufbau gelesene Richtlinie. Einzelaufrufe und ältere optionale
Mail-Verträge bleiben kompatibel. Verbindungszähler enthalten keine Zugangsdaten
oder Providerdetails. Die Optimierung spart Verbindungsaufbau und Ordnersuche;
Provider-Ablage und dauerhafte Einzelnachweise benötigen weiterhin ihre Zeit.
### Delivery-status and calendar-reply sources
An authorized Mail bounce source scans a bounded IMAP UID range without
@@ -342,10 +630,11 @@ state transition or outbound synchronization effect.
### Backup, restore, and retirement
Backups contain encrypted credentials and therefore need the same protection as
the live database and key material. Restoring a Mail database without the
matching encryption key makes credentials unusable; restoring it with keys can
reactivate sensitive historical state and must be controlled.
Backups contain encrypted credentials and encrypted raw POP3 import records and
therefore need the same protection as the live database and key material.
Restoring a Mail database without the matching encryption key makes those
records unusable; restoring it with keys can reactivate sensitive historical
state and must be controlled.
Destructive module retirement first applies the same immediate credential
scrub/audit rule to every remaining profile, then drops Mail-owned tables after
@@ -395,7 +684,7 @@ mandatory release gate.
Security invariants:
- Decrypted SMTP/IMAP passwords never cross the Mail capability/API boundary.
- Decrypted SMTP/IMAP passwords and JMAP/POP3 credentials never cross the Mail capability/API boundary.
- Password fields are write-only and encrypted at rest; safe responses expose
configuration state, not values.
- Consumers persist stable profile references, not transport copies.
@@ -452,10 +741,18 @@ Before claiming a Mail composition is production-ready:
semantics (`govoplan-mail#16`).
- Final Campaign **test / single send / single resend** semantics; those are a
Campaign business-action contract built on Mail transport operations.
- JMAP mailbox synchronization/search; it is preferred only after the IMAP MVP
is stable.
- POP3 except for a future explicit legacy download/import requirement.
- JMAP provider-side mutation, submission, push, thread, calendar, contact,
attachment-binary, and automatic background-sync support; read-only mailbox
synchronization/search is implemented on the stable IMAP mailbox contract.
- Expanding POP3 beyond the implemented explicit legacy download/import
workflow; it has no folder, flag, search, or synchronization contract.
- 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
mutations; no such production path exists in the current read-only mailbox.
- Proof that process-local throttling coordinates multiple workers when Redis
+55 -21
View File
@@ -1,14 +1,20 @@
# Mail Protocol Roadmap
GovOPlaN Mail currently focuses on SMTP sending and IMAP mailbox access. POP3
and JMAP are deferred until the IMAP mailbox MVP is stable.
GovOPlaN Mail uses SMTP for sending and supports read-only mailbox access over
IMAP or JMAP. It also provides an explicitly enabled, bounded POP3
legacy-import path. The first JMAP slice is implemented on the stable,
protocol-neutral mailbox contract.
## Current Baseline
- SMTP is the send protocol.
- IMAP is the read/append protocol.
- Mail profile policy, encrypted credentials, mailbox folder parsing, test
buttons, and read-only mailbox UI are built around SMTP and IMAP.
- IMAP is the established read/append protocol and remains unchanged.
- JMAP is an opt-in read-only sync/search protocol; it is not used for sending
or append-to-Sent.
- POP3 is an optional legacy migration source, never a general mailbox
protocol or default profile endpoint.
- Mail profile policy, encrypted credentials, connection diagnostics, and the
read-only mailbox UI cover SMTP/IMAP/JMAP endpoints as applicable.
This baseline matches the first production use case: send campaign mail, append
sent copies when configured, and inspect mailboxes read-only.
@@ -37,8 +43,8 @@ the S/MIME profile is stable.
## JMAP
JMAP is the preferred future sync/search protocol where target mail servers
support it.
JMAP is the preferred sync/search protocol where target mail servers support
RFC 8620 Core and RFC 8621 Mail capabilities.
Reasons:
@@ -47,26 +53,54 @@ Reasons:
- modern search and thread models
- better fit for browser-facing mailbox UX through a server proxy
JMAP should be added only after:
The implemented first slice provides:
- the IMAP mailbox MVP has stable folder/message pagination behavior
- mail profile policy can express protocol-specific availability
- mailbox UI can handle protocol-neutral folder/message DTOs
- test infrastructure includes at least one reliable JMAP server target
- authenticated Session discovery with Bearer or Basic credentials;
- explicit account selection or primary Mail-account selection;
- folder hierarchy projection through `Mailbox/get`;
- server-side text search and pagination through `Email/query` plus bounded
summaries and details through `Email/get`;
- incremental state through bounded `Email/changes`, with an explicit full
refresh when the provider can no longer calculate changes;
- per-endpoint response and body-value bounds;
- a dedicated JMAP hostname policy and fail-closed cross-origin API discovery;
and
- protocol-neutral folder/message DTOs and mailbox UI selection while keeping
the IMAP path unchanged.
The current boundary is read-only. JMAP submission, mailbox/message mutation,
threads, calendars, contacts, push subscriptions, binary attachment download,
and automatic background synchronization are deferred until a separately
governed slice needs them.
## POP3
POP3 should remain legacy-only.
POP3 remains legacy-only. The bounded import slice is available when a concrete
deployment must retire a mailbox that cannot offer IMAP or JMAP.
Add it only when a concrete deployment requires mailbox download from a server
that cannot offer IMAP or JMAP. POP3 is a poor fit for the normal GovOPlaN
mailbox UX because it has limited folder, sync, and server-side state semantics.
It is disabled until an administrator creates a dedicated POP3 endpoint and
sets `legacy_import_enabled`. The endpoint has its own encrypted credential,
connection/TLS/authentication diagnostics, maximum message and batch sizes, preview body
limit, and a separate `allow_delete_after_import` policy. Stable UIDL support is
mandatory; Mail refuses import when a provider cannot supply it.
If implemented, POP3 should be scoped to explicit download/import workflows, not
general mailbox browsing.
Preview and ordinary import are non-destructive. Selected messages become
encrypted `pending_review` records with a content digest, pinned transport
revision, source UIDL, and audit evidence. Repeating a UIDL reports a duplicate.
Provider deletion requires both endpoint policy and `mail:pop3:delete`, is
chosen separately per batch, and runs only after the local import and its audit
event commit. A disconnect while POP3 `QUIT` commits deletions becomes
`outcome_unknown` and is never retried blindly.
POP3 does not supply folder, flag, thread, search, or synchronization semantics.
It is therefore excluded from the normal mailbox UI and from the recommended
ongoing Mail profile. Configuration-package export/import remains SMTP-focused;
legacy source rollout is an explicit operational action.
## Decision
Do not add POP3 or JMAP now. Stabilize SMTP/IMAP first, design protocol-neutral
mailbox DTOs, then prefer JMAP for modern servers and reserve POP3 for explicit
legacy download requirements.
Keep the implemented POP3 surface limited to governed legacy import. Do not
expand it into mailbox browsing. Keep the protocol-neutral mailbox DTOs and
use the implemented JMAP path for modern synchronization/search support when
an administrator explicitly configures it. Keep SMTP for sending and POP3
limited to governed legacy import.
+3 -3
View File
@@ -1,14 +1,14 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.16",
"version": "0.1.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.16",
"version": "0.1.27",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.16",
"version": "0.1.27",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-mail"
version = "0.1.16"
version = "0.1.27"
description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.16",
"govoplan-core>=0.1.45",
"pydantic>=2,<3",
"redis>=5,<6",
"SQLAlchemy>=2,<3",
+250 -5
View File
@@ -1,14 +1,18 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from typing import Any
from threading import get_ident
from typing import Any, Iterator
from sqlalchemy.orm import Session
from govoplan_core.core.mail import NotificationMailDeliveryRequest
from govoplan_core.core.modules import ModuleContext
from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
_assert_campaign_inherits_profile_credentials,
@@ -36,17 +40,32 @@ from govoplan_mail.backend.recovery import (
)
from govoplan_mail.backend.sending.imap import (
ImapAppendError,
ImapBatchSession,
ImapConfigurationError,
append_message_to_sent,
)
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
from govoplan_mail.backend.sending.smtp import (
SmtpBatchSession,
SmtpConfigurationError,
SmtpSendError,
send_email_bytes,
)
_ACTIVE_SMTP_BATCH: ContextVar[SmtpBatchSession | None] = ContextVar(
"govoplan_mail_active_smtp_batch",
default=None,
)
@dataclass(frozen=True, slots=True)
class CampaignSmtpDeliveryResult:
envelope_recipients: list[str]
refused_recipients: dict[str, dict[str, int | str]]
connection_sequence: int = 1
session_reused: bool = False
reconnect_count: int = 0
@property
def accepted_count(self) -> int:
@@ -56,6 +75,95 @@ class CampaignSmtpDeliveryResult:
@dataclass(frozen=True, slots=True)
class CampaignImapAppendResult:
folder: str
connection_sequence: int = 1
session_reused: bool = False
reconnect_count: int = 0
class CampaignImapBatchState:
"""Lazy transport reuse; authorization and recovery remain per message."""
def __init__(self, *, tenant_id: str, campaign_id: str):
self.tenant_id = tenant_id
self.campaign_id = campaign_id
self._session: ImapBatchSession | None = None
self._binding: tuple[Any, ...] | None = None
self._previous_connections = 0
self._previous_reconnects = 0
self._closed = False
self._owner_thread = get_ident()
@property
def connection_count(self) -> int:
return self._previous_connections + (self._session.connection_count if self._session else 0)
@property
def reconnect_count(self) -> int:
return self._previous_reconnects + (self._session.reconnect_count if self._session else 0)
def assert_scope(self, *, tenant_id: str, campaign_id: str) -> None:
if (
self._closed or get_ident() != self._owner_thread
or tenant_id != self.tenant_id or campaign_id != self.campaign_id
):
raise ImapConfigurationError("The IMAP batch does not match this campaign scope")
def session_for(self, config: ImapConfig, *, binding: tuple[Any, ...]) -> ImapBatchSession:
if self._closed:
raise ImapConfigurationError("The IMAP batch is closed")
if self._session is not None and (
self._binding != binding or not self._session.matches_config(config)
):
self._release_session()
if self._session is None:
self._session = ImapBatchSession(config)
self._binding = binding
return self._session
def _release_session(self) -> None:
if self._session is not None:
self._previous_connections += self._session.connection_count
self._previous_reconnects += self._session.reconnect_count
self._session.close()
self._session = None
def close(self) -> None:
self._closed = True
self._release_session()
_ACTIVE_IMAP_BATCH: ContextVar[CampaignImapBatchState | None] = ContextVar(
"govoplan_mail_active_imap_batch", default=None,
)
@contextmanager
def campaign_imap_batch(*, tenant_id: str, campaign_id: str) -> Iterator[CampaignImapBatchState]:
"""Open no connection until an individual append passes its current checks."""
state = CampaignImapBatchState(tenant_id=tenant_id, campaign_id=campaign_id)
token = _ACTIVE_IMAP_BATCH.set(state)
try:
yield state
finally:
_ACTIVE_IMAP_BATCH.reset(token)
state.close()
@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(
@@ -95,6 +203,9 @@ def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
message,
temporary=exc.temporary,
outcome_unknown=exc.outcome_unknown,
systemic=exc.systemic,
reason_code=exc.reason_code,
phase=exc.phase,
)
@@ -119,6 +230,7 @@ def _authorized_campaign_profile(
campaign_id: str,
profile_id: str,
selection: dict[str, str | None] | None = None,
credential_protocol: str | None = None,
):
profile = ensure_mail_profile_allowed_for_campaign(
session,
@@ -128,7 +240,7 @@ def _authorized_campaign_profile(
require_active=True,
)
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
_assert_campaign_inherits_profile_credentials(profile, policy, selection)
_assert_campaign_inherits_profile_credentials(profile, policy, selection, protocol=credential_protocol)
return profile
@@ -279,6 +391,107 @@ 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,
credential_protocol="smtp",
)
except MailProfileError:
raise
except Exception:
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
if _supports_hierarchy(session):
context = _campaign_hierarchy_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
try:
selected_smtp = select_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
)
except MailServerHierarchyError as exc:
raise MailProfileError(str(exc)) from exc
current_revision = selected_smtp.transport_revision
else:
context = None
current_revision = campaign_profile_transport_revisions(profile)["smtp"]
if current_revision != expected_smtp_transport_revision:
raise MailProfileError(
"The selected Mail profile's SMTP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before delivery."
)
try:
smtp = (
resolve_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
).config
if context is not None
else smtp_config_from_profile(profile)
)
except MailProfileError:
raise
except Exception:
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
try:
assert_mail_policy_allows_send(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
smtp=smtp,
imap=None,
envelope_sender=envelope_from,
from_header=from_header,
recipients=envelope_recipients,
)
except MailProfileError:
raise MailProfileError("Mail delivery is blocked by the effective Mail policy.") from None
smtp_session = SmtpBatchSession(smtp)
smtp_session.preflight()
token = _ACTIVE_SMTP_BATCH.set(smtp_session)
try:
yield CampaignSmtpBatchState(session=smtp_session)
finally:
_ACTIVE_SMTP_BATCH.reset(token)
smtp_session.close()
def send_campaign_email_bytes(
session: Session,
*,
@@ -308,6 +521,7 @@ def send_campaign_email_bytes(
campaign_id=campaign_id,
profile_id=profile_id,
selection=selection,
credential_protocol="smtp",
)
except MailProfileError:
raise
@@ -394,6 +608,7 @@ def send_campaign_email_bytes(
smtp_config=smtp,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
batch_session=_ACTIVE_SMTP_BATCH.get(),
)
except SmtpSendError as exc:
sanitized = _sanitized_smtp_error(exc)
@@ -420,6 +635,9 @@ def send_campaign_email_bytes(
sanitized_result = CampaignSmtpDeliveryResult(
envelope_recipients=list(result.envelope_recipients),
refused_recipients=_sanitized_refusals(result.refused_recipients),
connection_sequence=getattr(result, "connection_sequence", 0),
session_reused=getattr(result, "session_reused", False),
reconnect_count=getattr(result, "reconnect_count", 0),
)
if recovery is not None:
try:
@@ -453,6 +671,9 @@ def append_campaign_message_to_sent(
recovery_resource_type: str | None = None,
recovery_resource_id: str | None = None,
) -> CampaignImapAppendResult:
batch = _ACTIVE_IMAP_BATCH.get()
if batch is not None:
batch.assert_scope(tenant_id=tenant_id, campaign_id=campaign_id)
selection = _selection_payload(
profile_id=profile_id,
smtp_server_id=smtp_server_id,
@@ -467,6 +688,7 @@ def append_campaign_message_to_sent(
campaign_id=campaign_id,
profile_id=profile_id,
selection=selection,
credential_protocol="imap",
)
except MailProfileError:
raise
@@ -541,6 +763,15 @@ def append_campaign_message_to_sent(
)
except MailProfileError:
raise MailProfileError("Appending to Sent is blocked by the effective Mail policy.") from None
batch_session = None
if batch is not None:
batch_session = batch.session_for(
imap,
binding=(
tenant_id, campaign_id, profile_id, smtp_server_id, smtp_credential_id,
imap_server_id, imap_credential_id, smtp_revision, imap_revision, folder,
),
)
try:
recovery = begin_provider_effect_recovery(
kind="imap-append",
@@ -561,7 +792,12 @@ def append_campaign_message_to_sent(
outcome_unknown=True,
)
try:
result = append_message_to_sent(message_bytes, imap_config=imap, folder=folder)
if batch_session is None:
result = append_message_to_sent(message_bytes, imap_config=imap, folder=folder)
else:
result = append_message_to_sent(
message_bytes, imap_config=imap, folder=folder, batch_session=batch_session,
)
except ImapAppendError as exc:
sanitized = _sanitized_imap_error(exc)
if recovery is not None:
@@ -588,11 +824,18 @@ def append_campaign_message_to_sent(
try:
recovery.succeed_imap(folder=result.folder)
except Exception:
if batch is not None:
batch.close()
raise ImapAppendError(
"IMAP APPEND returned success, but durable recovery evidence could not be finalized.",
outcome_unknown=True,
) from None
return CampaignImapAppendResult(folder=result.folder)
return CampaignImapAppendResult(
folder=result.folder,
connection_sequence=batch.connection_count if batch else getattr(result, "connection_sequence", 1),
session_reused=getattr(result, "session_reused", False),
reconnect_count=batch.reconnect_count if batch else getattr(result, "reconnect_count", 0),
)
class MailCampaignCapability:
@@ -604,6 +847,8 @@ class MailCampaignCapability:
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
campaign_smtp_batch = staticmethod(campaign_smtp_batch)
campaign_imap_batch = staticmethod(campaign_imap_batch)
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
+116
View File
@@ -1,7 +1,13 @@
from __future__ import annotations
import urllib.parse
from typing import Literal
from pydantic import Field, model_validator
from govoplan_core.mail.config import (
ImapConfig,
ImapFolderMappings,
ImapServerConfig,
SmtpConfig,
SmtpServerConfig,
@@ -11,9 +17,119 @@ from govoplan_core.mail.config import (
normalize_split_transport_credentials,
)
class Pop3ServerConfig(StrictModel):
"""Server-only settings for an explicitly enabled legacy POP3 source."""
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
security: TransportSecurity = TransportSecurity.TLS
timeout_seconds: int = Field(default=30, ge=1, le=300)
max_message_bytes: int = Field(default=25 * 1024 * 1024, ge=1_024, le=50 * 1024 * 1024)
max_batch_bytes: int = Field(default=100 * 1024 * 1024, ge=1_048_576, le=500 * 1024 * 1024)
preview_body_lines: int = Field(default=20, ge=0, le=100)
legacy_import_enabled: bool = False
allow_delete_after_import: bool = False
@model_validator(mode="after")
def apply_default_port(self) -> "Pop3ServerConfig":
if self.port is None:
self.port = 995 if self.security == TransportSecurity.TLS else 110
if self.legacy_import_enabled and not str(self.host or "").strip():
raise ValueError(
"POP3 host is required when legacy import is enabled"
)
if self.max_batch_bytes < self.max_message_bytes:
raise ValueError(
"POP3 batch size limit cannot be lower than the per-message limit"
)
if self.allow_delete_after_import and not self.legacy_import_enabled:
raise ValueError(
"POP3 delete-after-import cannot be enabled while legacy import is disabled"
)
return self
class Pop3Config(Pop3ServerConfig):
username: str | None = None
password: str | None = None
class JmapServerConfig(StrictModel):
"""Server-only settings for an RFC 8620/8621 mailbox endpoint."""
session_url: str
account_id: str | None = Field(default=None, max_length=255)
auth_scheme: Literal["bearer", "basic"] = "bearer"
timeout_seconds: int = Field(default=20, ge=1, le=120)
max_response_bytes: int = Field(
default=5 * 1024 * 1024,
ge=64 * 1024,
le=25 * 1024 * 1024,
)
max_body_value_bytes: int = Field(
default=1 * 1024 * 1024,
ge=1_024,
le=5 * 1024 * 1024,
)
allowed_api_origins: list[str] = Field(default_factory=list, max_length=10)
@model_validator(mode="after")
def validate_urls(self) -> "JmapServerConfig":
self.session_url = _absolute_http_url(self.session_url, label="JMAP session URL")
session_origin = _http_origin(self.session_url)
origins: list[str] = []
for value in self.allowed_api_origins:
normalized = _http_origin(
_absolute_http_url(value, label="JMAP allowed API origin")
)
if normalized != session_origin and normalized not in origins:
origins.append(normalized)
self.allowed_api_origins = origins
if self.account_id is not None:
self.account_id = self.account_id.strip() or None
return self
class JmapConfig(JmapServerConfig):
username: str | None = Field(default=None, max_length=320)
password: str | None = None
@model_validator(mode="after")
def validate_credentials(self) -> "JmapConfig":
if self.auth_scheme == "basic" and not (self.username and self.password):
raise ValueError("JMAP Basic authentication requires username and password")
if self.auth_scheme == "bearer" and not self.password:
raise ValueError("JMAP Bearer authentication requires an access token")
return self
def _absolute_http_url(value: str, *, label: str) -> str:
parsed = urllib.parse.urlsplit(str(value or "").strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"{label} must be an absolute HTTP(S) URL")
if parsed.username or parsed.password:
raise ValueError(f"{label} must not include embedded credentials")
if parsed.fragment:
raise ValueError(f"{label} must not include a fragment")
return urllib.parse.urlunsplit(parsed)
def _http_origin(value: str) -> str:
parsed = urllib.parse.urlsplit(value)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
default_port = 443 if parsed.scheme == "https" else 80
suffix = "" if port == default_port else f":{port}"
return f"{parsed.scheme.lower()}://{(parsed.hostname or '').lower()}{suffix}"
__all__ = [
"ImapConfig",
"ImapFolderMappings",
"ImapServerConfig",
"JmapConfig",
"JmapServerConfig",
"Pop3Config",
"Pop3ServerConfig",
"SmtpConfig",
"SmtpServerConfig",
"StrictModel",
File diff suppressed because it is too large Load Diff
+69
View File
@@ -387,3 +387,72 @@ class MailBounceObservation(Base, TimestampMixin):
)
matched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class MailPop3Import(Base, TimestampMixin):
"""Governed local review record created from a legacy POP3 mailbox."""
__tablename__ = "mail_pop3_imports"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"profile_id",
"pop3_server_id",
"provider_uidl",
name="uq_mail_pop3_imports_source_uidl",
),
Index(
"ix_mail_pop3_imports_review",
"tenant_id",
"status",
"imported_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
profile_id: Mapped[str] = mapped_column(
ForeignKey("mail_server_profiles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
pop3_server_id: Mapped[str] = mapped_column(
ForeignKey("mail_server_endpoints.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
pop3_credential_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
transport_revision: Mapped[str] = mapped_column(String(120), nullable=False)
provider_uidl: Mapped[str] = mapped_column(String(500), nullable=False)
provider_message_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
raw_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
raw_message_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
message_id: Mapped[str | None] = mapped_column(String(998), nullable=True, index=True)
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
from_header: Mapped[str | None] = mapped_column(Text, nullable=True)
to_header: Mapped[str | None] = mapped_column(Text, nullable=True)
date: Mapped[str | None] = mapped_column(String(255), nullable=True)
body_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
status: Mapped[str] = mapped_column(
String(40), default="pending_review", nullable=False, index=True
)
imported_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
imported_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
deletion_requested: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
deletion_status: Mapped[str] = mapped_column(
String(40), default="not_requested", nullable=False, index=True
)
deletion_attempted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deletion_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
+18 -16
View File
@@ -26,7 +26,7 @@ MAIL_SECRET_MANAGE_SCOPE = "mail:secret:manage" # noqa: S105 # nosec B105
MAIL_SECRET_MANAGE_OWN_SCOPE = "mail:secret:manage_own" # noqa: S105 # nosec B105
MAIL_PROFILE_TEST_SCOPE = "mail:profile:test"
MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"))
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"), ("JMAP", "jmap_hosts"))
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
@@ -233,7 +233,7 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
title="Create a custom Mail profile",
summary=(
"Create a reusable profile in the current account's user-scoped Settings view, "
"within the active SMTP and IMAP hostname policy."
"within the active SMTP, IMAP, and JMAP hostname policy."
),
body="\n".join(authority_lines),
layer="configured",
@@ -345,12 +345,12 @@ def _custom_profile_authority_lines(
approval_required: bool,
) -> tuple[str, ...]:
credentials = (
"Credential authority: you may save or replace Mail-owned SMTP/IMAP passwords."
"Credential authority: you may save or replace Mail-owned SMTP/IMAP passwords and JMAP tokens or Basic credentials."
if can_manage_credentials
else "Credential authority: you may define the profile, but you cannot save or replace passwords; an actor with both profile-write and secret-management authority must do that when authentication requires one."
)
testing = (
"Test authority: you may run the profile's SMTP/IMAP connection tests after saving it as active."
"Test authority: you may run the profile's SMTP/IMAP/JMAP connection tests after saving it as active."
if can_test_profile
else "Test authority: creating the profile does not let you run connection tests; ask an actor with both profile-test and profile-use authority to verify an active profile."
)
@@ -376,18 +376,18 @@ def _custom_profile_steps(
) -> tuple[str, ...]:
steps = [
"Open Settings, choose Mail profiles, and select Add profile in the current account's user-scoped view.",
"Enter a stable name and configure SMTP plus optional IMAP hostnames that satisfy every host-policy statement shown above.",
"Enter a stable name and configure SMTP plus optional IMAP settings; add an optional JMAP server after the profile exists. Every endpoint must satisfy the host-policy statements shown above.",
]
steps.append(
"Enter the required SMTP/IMAP credentials in the dedicated password fields."
"Enter the required SMTP/IMAP credentials, or link an encrypted bearer token or Basic credential to the JMAP server."
if can_manage_credentials
else "Save the non-secret profile definition, then ask an actor with both profile-write and secret-management authority to add credentials if the server requires authentication."
)
steps.append("Save the profile; Mail validates the effective user-scope host policy again on the server.")
steps.append(
"Save the profile as active, then run the available SMTP and IMAP connection tests."
"Save the profile as active, then run the available SMTP, IMAP, and JMAP connection tests."
if can_test_profile
else "Ask an actor with both profile-test and profile-use authority to run the SMTP and IMAP connection tests after the profile is active."
else "Ask an actor with both profile-test and profile-use authority to run the SMTP, IMAP, and JMAP connection tests after the profile is active."
)
if approval_required:
steps.append("Ask a Mail administrator to add the new profile to the active approved-profile list.")
@@ -402,9 +402,9 @@ def _custom_profile_steps(
def _custom_profile_verification(*, can_test_profile: bool, can_use_profile: bool, approval_required: bool) -> str:
checks = ["Reopen My Mail profiles and confirm the saved profile remains in the current account's user-scoped view."]
checks.append(
"Confirm the authorized SMTP/IMAP tests succeed."
"Confirm the authorized SMTP/IMAP/JMAP tests succeed."
if can_test_profile
else "Have an authorized tester confirm the SMTP/IMAP tests succeed."
else "Have an authorized tester confirm the SMTP/IMAP/JMAP tests succeed."
)
if approval_required:
checks.append("Confirm an administrator approved the generated profile reference before expecting it in a picker.")
@@ -424,7 +424,7 @@ def _mail_policy_admin_text(policy: dict[str, Any], *, source_count: int) -> tup
locked_limit_count = sum(1 for value in _lower_limit_values(policy) if value is False)
if approved_profile_limit and not lower_scopes:
summary = "This tenant is in approved-profile mode: users can choose configured mail profiles, but lower scopes cannot bring arbitrary SMTP or IMAP servers."
summary = "This tenant is in approved-profile mode: users can choose configured mail profiles, but lower scopes cannot bring arbitrary SMTP, IMAP, or JMAP servers."
elif approved_profile_limit:
summary = "This tenant limits mail sending to approved profile ids, while selected lower scopes can still define profiles within policy limits."
elif lower_scopes:
@@ -451,10 +451,10 @@ def _mail_policy_user_text(policy: dict[str, Any]) -> tuple[str, str]:
body = "This is set by tenant policy. If the mail server you need is not offered, ask an administrator to add it as an approved mail profile."
elif approved_profile_limit:
summary = "You can use approved Mail profiles. Some scopes may also define additional reusable profiles."
body = "The available profiles are limited by tenant policy. Campaigns select one profile by reference; SMTP/IMAP settings and credentials remain managed in Mail."
body = "The available profiles are limited by tenant policy. Campaigns select one profile by reference; SMTP/IMAP/JMAP settings and credentials remain managed in Mail."
elif lower_scopes:
summary = "You may be able to define reusable Mail profiles in selected scopes, subject to tenant rules."
body = "GovOPlaN checks each profile before use. Campaigns reference an available profile and never store its SMTP/IMAP settings or credentials."
body = "GovOPlaN checks each profile before use. Campaigns reference an available profile and never store its SMTP/IMAP/JMAP settings or credentials."
else:
summary = "Mail servers are managed centrally for this tenant."
body = "You cannot add a personal, group, or campaign mail server here. Choose one of the configured options or ask an administrator to add another approved profile."
@@ -517,9 +517,11 @@ def _credential_line(policy: dict[str, Any]) -> str:
smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True))
imap_inherit = bool((policy.get("imap_credentials") or {}).get("inherit", True))
return (
f"Credential inheritance: SMTP {'inherits' if smtp_inherit else 'requires local credentials'}; "
f"IMAP {'inherits' if imap_inherit else 'requires local credentials'}. "
"Campaign delivery is available only for protocols that inherit credentials from the selected Mail profile."
f"Credential selection: SMTP {'allows a profile default or explicit Mail credential' if smtp_inherit else 'requires an explicit Mail credential'}; "
f"IMAP {'allows a profile default or explicit Mail credential' if imap_inherit else 'requires an explicit Mail credential'}. "
"Select the authorized server and credential in Campaign Mail settings when explicit selection is required. "
"Secrets remain in Mail. Policy administrators configure each protocol under Credential selection; "
"ancestor lower-level locks cannot be overridden."
)
+639
View File
@@ -0,0 +1,639 @@
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,
MailPop3Import,
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",
)
)
pop3_imports = _matching_pop3_imports(
db,
tenant_id=tenant_id,
email=email,
import_id=references.get("pop3_import"),
)
for imported in pop3_imports:
matching_headers = _matching_pop3_headers(imported, email)
append(
_record(
"mail_pop3_import",
imported.id,
"mail_imported_message",
imported.subject or "Imported legacy message",
{
"match_fields": list(matching_headers),
"subject": _bounded_text(imported.subject),
"matching_headers": matching_headers,
"date": imported.date,
"message_id": _bounded_text(imported.message_id),
"body_preview": _bounded_text(imported.body_preview),
"size_bytes": imported.size_bytes,
"status": imported.status,
"imported_at": _iso(imported.imported_at),
"deletion_requested": imported.deletion_requested,
"deletion_status": imported.deletion_status,
},
observed_at=imported.updated_at,
source_path="/mail/legacy-import",
)
)
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_pop3_imports(
session: Session,
*,
tenant_id: str,
email: str | None,
import_id: str | None,
) -> list[MailPop3Import]:
conditions = []
if import_id:
conditions.append(MailPop3Import.id == import_id)
if email:
pattern = f"%{_escape_like(email)}%"
conditions.extend(
func.lower(field).like(pattern, escape="\\")
for field in (
MailPop3Import.from_header,
MailPop3Import.to_header,
)
)
if not conditions:
return []
candidates = _bounded_rows(
session.query(MailPop3Import)
.filter(MailPop3Import.tenant_id == tenant_id, or_(*conditions))
.order_by(MailPop3Import.id)
)
return [
row
for row in candidates
if row.id == import_id or _matching_pop3_headers(row, email)
]
def _matching_pop3_headers(
row: MailPop3Import,
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)):
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_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",
"mail.pop3_import": "pop3_import",
}
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"]
@@ -0,0 +1,217 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'mail.bounce-processing': {'verification': 'Senden Sie eine Nachricht mit einer eindeutigen '
'Message-ID, nehmen Sie zweimal einen DSN auf und '
'überprüfen Sie eine korrelierte Beobachtung, während '
'die SMTP-Akzeptanz intakt bleibt.'},
'mail.privacy.data-subject-requests': {'limitations': ['Verschlüsselte ausgehende Nutzlasten '
'können vom Empfänger nicht ohne eine '
'unabhängig bestätigte '
'Mail-Befehlsreferenz durchsucht werden.',
'Die externe Mailbox-Löschung befindet '
'sich außerhalb des DSAR-Anbieters und '
'muss mit dem konfigurierten Anbieter '
'koordiniert werden.'],
'steps': ['Führen Sie die Mail-Provider-Suche und '
'Überprüfung von Mailbox-Index, POP3-Import, '
'Profil, Lieferung, Abgleich und '
'Bounce-Dispositionen aus.',
'Bewahren Sie den unveränderlichen '
'Transportnachweis mit seinem Grund auf.',
'Koordinieren Sie die genehmigte '
'Mailbox-Inhaltelöschung mit der autoritativen '
'externen Mailbox und aktualisieren Sie dann den '
'abgeleiteten Index.',
'Verwenden Sie '
'E-Mail-Profillebenszykluskontrollen für '
'genehmigte Änderungen des persönlichen Profils; '
'Bearbeiten Sie nicht direkt verschlüsselte '
'Nutzlast- oder Beweiszeilen.']},
'mail.profile-standard-folder-mappings': {'steps': ['Öffnen Sie einen editierbaren IMAP-Server in '
'einem wiederverwendbaren Mail-Profil.',
'Wählen Sie Ordner erkennen, um die vom '
'Anbieter sichtbaren Ordnernamen zu laden, '
'oder geben Sie genaue Namen manuell ein.',
'Wenden Sie die erkannten Zuordnungen an, '
'überprüfen Sie jede Rolle und lassen Sie '
'unsichere Rollen für automatisches Verhalten '
'leer.',
'Speichern Sie das Profil und laden Sie es '
'neu, um zu überprüfen, ob die Zuordnungen '
'beibehalten wurden.'],
'verification': 'Bestätigen Sie, dass ältere '
'Sent-only-Profile das gleiche '
'effektive Sent-Mapping zeigen, und '
'bestätigen Sie, dass ein '
'kampagnenlokaler Sent-Override '
'unverändert bleibt.'},
'mail.reference.campaign-delivery-contract': {'verification': 'Nachweisen Sie, dass veraltete '
'Revisionen vor der Entschlüsselung '
'fehlschlagen, der Batch-Preflight '
'vor DATA fehlschlägt, zwei '
'Nachrichten eine gesunde '
'Verbindung wiederverwenden, eine '
'veraltete Verbindung vor der '
'nächsten Nachricht wieder '
'verbunden wird, die Trennung nach '
'der DATA nie wiedergegeben wird, '
'systemische Ausfälle verbleibende '
'Jobs anhalten, Anbieterdetails '
'werden gelöscht und das Interface '
'/ Versionsgate wird übergeben.'},
'mail.reference.credentials-egress-retirement': {'verification': 'Testen Sie DNS-Rebinding und '
'verweigerte Adressen, beweisen '
'Sie, dass der verbundene Peer '
'angeheftet ist, injizieren Sie '
'Credential-Scrub- und '
'Auditfehler, wiederholen Sie '
'die Löschung für Idempotenz und '
'führen Sie den '
'Ruhestands-Preflight gegen '
'einen Snapshot aus.'},
'mail.workflow.choose-and-test-profile': {'outcome': 'Die verbrauchende Aufgabe verweist auf ein '
'verfügbares Mail-eigenes Profil und enthält '
'keine kopierte Transportkonfiguration oder '
'Anmeldeinformationen.',
'prerequisites': ['Mail ist installiert und Sie können '
'Profile lesen, verwenden und testen, '
'die im aktuellen Kontext sichtbar '
'sind.',
'Ein Profiladministrator hat '
'Anmeldeinformationen und effektive '
'Richtlinien konfiguriert.'],
'steps': ['Öffnen Sie Mail-Profile und wählen Sie ein '
'sichtbares aktives Profil.',
'Überprüfen Sie den Sicherheitsumfang, die '
'Verfügbarkeit von SMTP/IMAP/JMAP und die '
'Absenderidentität, ohne Anmeldewerte zu '
'erwarten.',
'Führen Sie den entsprechenden SMTP-, IMAP- '
'oder '
'JMAP-Konnektivitäts-/Authentifizierungstest '
'zuerst gegen ein Nicht-Produktionsziel aus.',
'Kehren Sie zur verbrauchenden Aufgabe zurück '
'und wählen Sie dasselbe Profil über den '
'Picker aus.'],
'verification': 'Laden Sie beide Oberflächen neu, '
'bestätigen Sie, dass nur die stabile '
'Referenz vom Verbraucher beibehalten '
'wird, und führen Sie die eigene '
'kontextbezogene Richtlinienvalidierung '
'des Verbrauchers durch.'},
'mail.workflow.legacy-pop3-import': {'fields': [{'label': 'Verkehrssicherheit',
'user_description': 'Verwenden Sie TLS oder '
'STARTTLS; unverschlüsselter '
'Transport unterliegt '
'weiterhin der '
'Deployment-Egress-Richtlinie '
'und ist nicht der sichere '
'Standard.'},
{'label': 'Grenzwerte für Nachrichten und Chargen',
'user_description': 'Bound sowohl jede '
'heruntergeladene Nachricht '
'als auch die gesamte '
'importierte Charge, bevor '
'der Anbieterinhalt in den '
'lokalen verschlüsselten '
'Speicher gelangt.'},
{'label': 'Ausdrücklich Legacy Import ermöglichen',
'user_description': 'Hält die Quelle inaktiv, '
'bis die Endpunktmetadaten '
'und ihre verschlüsselten '
'Anmeldeinformationen beide '
'gespeichert wurden.'},
{'label': 'Anträge auf Löschung nach Einfuhr',
'user_description': 'Erlaubt, aber wählt niemals '
'die separat autorisierte '
'destruktive Anforderung '
'aus; jede Charge benötigt '
'noch eine explizite '
'Bestätigung.'}],
'limitations': ['Ein POP3-Server muss stabile '
'UIDL-Identifikatoren bereitstellen; '
'ansonsten ist eine sichere doppelte '
'Verhinderung nicht verfügbar und der Import '
'wird abgelehnt.',
'POP3 hat keine Ordner- oder Flag-Semantik '
'und ist nicht das empfohlene Protokoll für '
'den laufenden Mailbox-Zugriff.',
'Die Quelllöschung kann nicht zurückgesetzt '
'werden und hat möglicherweise ein '
'unbekanntes Ergebnis, wenn die Verbindung '
'fehlschlägt, während QUIT Löschungen '
'festlegt.'],
'operational_consequences': ['Durch das Ändern von Host-, '
'Port-, Sicherheits-, '
'Kontoidentitäts- oder '
'Importlimits wird die '
'Transportrevision geändert und '
'veraltete Previews ungültig '
'gemacht.',
'Durch das Ermöglichen des '
'Löschens von Quellen entsteht '
'eine irreversible Grenze für '
'externe Effekte; unbekannte '
'QUIT-Ergebnisse erfordern eine '
'Versöhnung statt eines blinden '
'Wiederholens.',
'Die Deaktivierung von '
'Legacy-Importen führt zu '
'regulierten lokalen Importen '
'und deren Nachweisen, die zur '
'Überprüfung zur Verfügung '
'stehen, während der Zugang '
'neuer Anbieter verhindert '
'wird.'],
'steps': ['Bitten Sie einen Mail-Administrator, eine '
'dedizierte POP3-Altquelle zu konfigurieren und '
'explizit zu aktivieren.',
'Testen Sie die Quelle und aktualisieren Sie eine '
'begrenzte Vorschau; Es werden keine '
'Nachrichtenflags oder Löschstatus geändert.',
'Wählen Sie Nachrichten aus und importieren Sie '
'sie in verschlüsselte Pending-Review-Datensätze.',
'Verwenden Sie delete-after-import nur, wenn '
'Richtlinien und eine separate destruktive '
'Berechtigung dies zulassen, und versöhnen Sie '
'dann fehlgeschlagene oder unbekannte Ergebnisse.'],
'verification': 'Nachweisen Sie deaktivierte Richtlinien, '
'TLS- und Authentifizierungsdiagnostik, '
'zerstörungsfreie Vorschau / Import, '
'UIDL-Duplikate-Verhinderung, verschlüsselte '
'Rohdatenspeicherung, separate '
'Löschberechtigung, '
'Import-Vor-Löschen-Auditbestellung und '
'fehlgeschlagene oder ergebnisunbekannte '
'Löschnachweise.'},
'mail.workflow.read-mailbox': {'outcome': 'Die erforderliche Nachricht wurde inspiziert, ohne den '
'Status der Provider-Mailbox zu ändern.',
'prerequisites': ['Ein aktives sichtbares Profil hat IMAP oder '
'JMAP konfiguriert.',
'Sie können beide dieses profil verwenden und '
'seine mailbox lesen.'],
'steps': ['Öffnen Sie Mail und wählen Sie ein autorisiertes IMAP- '
'oder JMAP-fähiges Profil.',
'Neuladen rechts aktualisiert den gesamten aktuellen Kontext; '
'Postfachwerkzeuge enthält gezielte Aktualisierungen und '
'berechtigungsabhängige Rückläuferdiagnosen.',
'Wählen Sie einen Ordner aus, überprüfen Sie das '
'Live/Cache-Synchronisationslabel und stellen Sie den '
'begrenzten Nachrichtenindex auf die Seite; JMAP-Suchen '
'werden beim Anbieter ausgeführt.',
'Verwenden Sie den vom Anbieter abgeleiteten '
'Read/Unread-Indikator und öffnen Sie dann nur die für '
'die Aufgabe benötigte Nachricht.',
'Wechseln Sie bei Bedarf zwischen sicheren Klartext- und '
'isolierten HTML-Ansichten und überprüfen Sie die '
'Anhang- oder Nichtverfügbarkeitsangaben, bevor Sie die '
'Vorschau schließen.'],
'verification': 'Aktualisieren Sie das Provider-Postfach '
'unabhängig und bestätigen Sie, dass keine Lese-, '
'Verschiebe-, Lösch-, Antwort- oder Flag-Mutation '
'durch GovOPlaN verursacht wurde.'}}
+28 -9
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import fnmatch
import json
import re
import urllib.parse
from dataclasses import dataclass, field
from typing import Any, Iterable, Mapping
@@ -33,7 +34,7 @@ class MailProfileError(RuntimeError):
MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy"
PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"}
PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "envelope_senders", "from_headers", "recipient_domains")
PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "jmap_hosts", "envelope_senders", "from_headers", "recipient_domains")
PROFILE_SCOPE_ORDER = {"system": 0, "tenant": 1, "user": 2, "group": 2, "campaign": 3}
MAIL_POLICY_LIMIT_KEYS = (
"allowed_profile_ids",
@@ -44,11 +45,13 @@ MAIL_POLICY_LIMIT_KEYS = (
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.jmap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.jmap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains",
@@ -126,7 +129,7 @@ def slugify_profile_name(value: str) -> str:
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
password_was_supplied = "password" in config.model_fields_set
username = payload.pop("username", None)
@@ -711,11 +714,19 @@ def _domain_from_email(value: str | None) -> str | None:
return text.rsplit("@", 1)[1] or None
def _assert_transport_values_allowed(policy: EffectiveMailProfilePolicy, smtp: SmtpConfig | dict[str, Any] | None, imap: ImapConfig | dict[str, Any] | None) -> None:
def _assert_transport_values_allowed(
policy: EffectiveMailProfilePolicy,
smtp: SmtpConfig | dict[str, Any] | None,
imap: ImapConfig | dict[str, Any] | None,
jmap: dict[str, Any] | None = None,
) -> None:
smtp_host = smtp.host if isinstance(smtp, SmtpConfig) else smtp.get("host") if isinstance(smtp, dict) else None
imap_host = imap.host if isinstance(imap, ImapConfig) else imap.get("host") if isinstance(imap, dict) else None
jmap_url = jmap.get("session_url") if isinstance(jmap, dict) else None
jmap_host = urllib.parse.urlsplit(str(jmap_url)).hostname if jmap_url else None
_assert_policy_value(policy, "smtp_hosts", str(smtp_host) if smtp_host else None)
_assert_policy_value(policy, "imap_hosts", str(imap_host) if imap_host else None)
_assert_policy_value(policy, "jmap_hosts", str(jmap_host) if jmap_host else None)
def assert_mail_policy_allows_transport(
@@ -724,6 +735,7 @@ def assert_mail_policy_allows_transport(
tenant_id: str,
smtp: SmtpConfig | dict[str, Any] | None,
imap: ImapConfig | dict[str, Any] | None = None,
jmap: dict[str, Any] | None = None,
campaign_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
@@ -735,7 +747,7 @@ def assert_mail_policy_allows_transport(
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
_assert_transport_values_allowed(policy, smtp, imap)
_assert_transport_values_allowed(policy, smtp, imap, jmap)
def assert_mail_policy_allows_send(
@@ -1418,19 +1430,26 @@ def _assert_campaign_inherits_profile_credentials(
profile: MailServerProfile,
policy: EffectiveMailProfilePolicy,
selection: Mapping[str, str | None] | None = None,
*,
protocol: str | None = None,
) -> None:
for protocol in ("smtp", "imap"):
if not _profile_has_transport(profile, protocol):
# Authoring and complete transport summaries validate both protocols.
# An effect capability can require only the protocol it actually receives
# and uses; an SMTP call does not carry the campaign's IMAP selection.
if protocol is not None and protocol not in {"smtp", "imap"}:
raise MailProfileError("Credential policy protocol must be smtp or imap")
for selected_protocol in ((protocol,) if protocol is not None else ("smtp", "imap")):
if protocol is None and not _profile_has_transport(profile, selected_protocol):
continue
explicit_credential = (
selection or {}
).get(f"{protocol}_credential_id")
).get(f"{selected_protocol}_credential_id")
if (
not _credential_policy_for_protocol(policy, protocol).inherit
not _credential_policy_for_protocol(policy, selected_protocol).inherit
and not explicit_credential
):
raise MailProfileError(
f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} "
f"Campaign delivery cannot use the selected profile because the effective {selected_protocol.upper()} "
"credential policy requires an explicit credential selection for this campaign."
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
"""add governed POP3 legacy imports
Revision ID: a4c5d6e7f809
Revises: 93b4c5d6e7f8
Create Date: 2026-08-22 12:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a4c5d6e7f809"
down_revision = "93b4c5d6e7f8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"mail_pop3_imports",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("pop3_server_id", sa.String(length=36), nullable=False),
sa.Column("pop3_credential_id", sa.String(length=36), nullable=True),
sa.Column("transport_revision", sa.String(length=120), nullable=False),
sa.Column("provider_uidl", sa.String(length=500), nullable=False),
sa.Column("provider_message_number", sa.Integer(), nullable=True),
sa.Column("fingerprint", sa.String(length=64), nullable=False),
sa.Column("raw_sha256", sa.String(length=64), nullable=False),
sa.Column("raw_message_encrypted", sa.Text(), nullable=False),
sa.Column("message_id", sa.String(length=998), nullable=True),
sa.Column("subject", sa.Text(), nullable=True),
sa.Column("from_header", sa.Text(), nullable=True),
sa.Column("to_header", sa.Text(), nullable=True),
sa.Column("date", sa.String(length=255), nullable=True),
sa.Column("body_preview", sa.Text(), nullable=True),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("imported_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("imported_by_user_id", sa.String(length=36), nullable=True),
sa.Column("deletion_requested", sa.Boolean(), nullable=False),
sa.Column("deletion_status", sa.String(length=40), nullable=False),
sa.Column("deletion_attempted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deletion_error", sa.String(length=500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["profile_id"], ["mail_server_profiles.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["pop3_server_id"], ["mail_server_endpoints.id"], ondelete="RESTRICT"
),
sa.ForeignKeyConstraint(
["imported_by_user_id"], ["access_users.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"profile_id",
"pop3_server_id",
"provider_uidl",
name="uq_mail_pop3_imports_source_uidl",
),
)
for column in (
"tenant_id",
"profile_id",
"pop3_server_id",
"fingerprint",
"message_id",
"status",
"imported_at",
"imported_by_user_id",
"deletion_status",
):
op.create_index(
f"ix_mail_pop3_imports_{column}",
"mail_pop3_imports",
[column],
)
op.create_index(
"ix_mail_pop3_imports_review",
"mail_pop3_imports",
["tenant_id", "status", "imported_at"],
)
def downgrade() -> None:
op.drop_table("mail_pop3_imports")
+246
View File
@@ -0,0 +1,246 @@
from __future__ import annotations
import base64
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from govoplan_core.security.secrets import encrypt_secret
from govoplan_mail.backend.db.models import MailPop3Import, MailServerEndpoint
from govoplan_mail.backend.sending.pop3 import Pop3DownloadedMessage
class Pop3ImportError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class Pop3ImportResult:
imported: tuple[MailPop3Import, ...]
duplicates: tuple[MailPop3Import, ...]
def create_pop3_imports(
session: Session,
*,
tenant_id: str,
profile_id: str,
pop3_server_id: str,
pop3_credential_id: str | None,
transport_revision: str,
messages: Iterable[Pop3DownloadedMessage],
user_id: str | None,
deletion_requested: bool,
) -> Pop3ImportResult:
downloaded = tuple(messages)
if not downloaded:
raise Pop3ImportError("No POP3 messages were downloaded for import")
uidls = [item.uidl for item in downloaded]
if len(uidls) != len(set(uidls)):
raise Pop3ImportError("The POP3 download contained duplicate UIDL identifiers")
# Serialize imports per source before checking UIDLs. The database unique
# constraint remains the last line of defense, while this lock lets a
# concurrent request observe the first request's committed rows and report
# them as duplicates instead of surfacing an integrity error.
source = session.scalar(
select(MailServerEndpoint)
.where(
MailServerEndpoint.id == pop3_server_id,
MailServerEndpoint.profile_id == profile_id,
or_(
MailServerEndpoint.tenant_id == tenant_id,
MailServerEndpoint.tenant_id.is_(None),
),
MailServerEndpoint.protocol == "pop3",
)
.with_for_update()
)
if source is None:
raise Pop3ImportError("The selected POP3 source is unavailable")
existing = {
row.provider_uidl: row
for row in session.scalars(
select(MailPop3Import).where(
MailPop3Import.tenant_id == tenant_id,
MailPop3Import.profile_id == profile_id,
MailPop3Import.pop3_server_id == pop3_server_id,
MailPop3Import.provider_uidl.in_(uidls),
)
)
}
imported: list[MailPop3Import] = []
duplicates: list[MailPop3Import] = []
now = datetime.now(timezone.utc)
for message in downloaded:
duplicate = existing.get(message.uidl)
if duplicate is not None:
duplicates.append(duplicate)
continue
encrypted = encrypt_secret(base64.b64encode(message.raw).decode("ascii"))
if not encrypted:
raise Pop3ImportError("The downloaded POP3 message could not be encrypted")
summary = message.summary
row = MailPop3Import(
tenant_id=tenant_id,
profile_id=profile_id,
pop3_server_id=pop3_server_id,
pop3_credential_id=pop3_credential_id,
transport_revision=_required_revision(transport_revision),
provider_uidl=message.uidl,
provider_message_number=message.message_number,
fingerprint=_fingerprint(
tenant_id=tenant_id,
profile_id=profile_id,
pop3_server_id=pop3_server_id,
uidl=message.uidl,
raw_sha256=message.raw_sha256,
),
raw_sha256=message.raw_sha256,
raw_message_encrypted=encrypted,
message_id=summary.message_id,
subject=summary.subject,
from_header=summary.from_header,
to_header=summary.to_header,
date=summary.date,
body_preview=summary.body_preview,
size_bytes=len(message.raw),
status="pending_review",
imported_at=now,
imported_by_user_id=user_id,
deletion_requested=bool(deletion_requested),
deletion_status=("pending" if deletion_requested else "not_requested"),
)
session.add(row)
imported.append(row)
session.flush()
return Pop3ImportResult(imported=tuple(imported), duplicates=tuple(duplicates))
def list_pop3_imports(
session: Session,
*,
tenant_id: str,
profile_id: str | None = None,
profile_ids: Iterable[str] | None = None,
limit: int = 100,
) -> tuple[MailPop3Import, ...]:
statement = select(MailPop3Import).where(
MailPop3Import.tenant_id == tenant_id
)
if profile_id:
statement = statement.where(MailPop3Import.profile_id == profile_id)
elif profile_ids is not None:
allowed = tuple(dict.fromkeys(str(value) for value in profile_ids if value))
if not allowed:
return ()
statement = statement.where(MailPop3Import.profile_id.in_(allowed))
rows = session.scalars(
statement.order_by(
MailPop3Import.imported_at.desc(),
MailPop3Import.id.desc(),
).limit(max(1, min(int(limit), 500)))
)
return tuple(rows)
def mark_pop3_deletion_result(
session: Session,
*,
tenant_id: str,
import_ids: Iterable[str],
status: str,
error: str | None = None,
) -> tuple[MailPop3Import, ...]:
clean_status = str(status or "").strip().casefold()
if clean_status not in {"succeeded", "failed", "outcome_unknown"}:
raise Pop3ImportError("Unsupported POP3 deletion result")
ids = tuple(dict.fromkeys(str(value).strip() for value in import_ids if str(value).strip()))
if not ids:
return ()
rows = tuple(
session.scalars(
select(MailPop3Import)
.where(
MailPop3Import.tenant_id == tenant_id,
MailPop3Import.id.in_(ids),
MailPop3Import.deletion_requested.is_(True),
)
.with_for_update()
)
)
if len(rows) != len(ids):
raise Pop3ImportError("One or more POP3 import records are unavailable")
now = datetime.now(timezone.utc)
safe_error = _bounded_error(error)
for row in rows:
row.deletion_status = clean_status
row.deletion_attempted_at = now
row.deletion_error = safe_error
session.flush()
return rows
def pop3_import_payload(row: MailPop3Import) -> dict[str, object]:
return {
"id": row.id,
"profile_id": row.profile_id,
"pop3_server_id": row.pop3_server_id,
"transport_revision": row.transport_revision,
"provider_uidl": row.provider_uidl,
"message_id": row.message_id,
"subject": row.subject,
"from_header": row.from_header,
"to_header": row.to_header,
"date": row.date,
"body_preview": row.body_preview,
"size_bytes": row.size_bytes,
"raw_sha256": row.raw_sha256,
"status": row.status,
"imported_at": row.imported_at,
"deletion_requested": row.deletion_requested,
"deletion_status": row.deletion_status,
"deletion_attempted_at": row.deletion_attempted_at,
"deletion_error": row.deletion_error,
}
def _fingerprint(
*,
tenant_id: str,
profile_id: str,
pop3_server_id: str,
uidl: str,
raw_sha256: str,
) -> str:
material = "\x1f".join(
(tenant_id, profile_id, pop3_server_id, uidl, raw_sha256)
).encode("utf-8")
return hashlib.sha256(material).hexdigest()
def _required_revision(value: object) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > 120:
raise Pop3ImportError("A valid POP3 transport revision is required")
return clean
def _bounded_error(value: str | None) -> str | None:
clean = " ".join(str(value or "").split())
return clean[:500] or None
__all__ = [
"Pop3ImportError",
"Pop3ImportResult",
"create_pop3_imports",
"list_pop3_imports",
"mark_pop3_deletion_result",
"pop3_import_payload",
]
+211
View File
@@ -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",
]
+168 -8
View File
@@ -16,6 +16,7 @@ from govoplan_mail.backend.db.models import (
MailDeliveryCommand,
MailMailboxFolderIndex,
MailMailboxMessageIndex,
MailPop3Import,
MailServerEndpoint,
MailServerProfile,
)
@@ -23,6 +24,8 @@ from govoplan_mail.backend.db.models import (
SMTP_PROVIDER_ID = "mail.smtp_delivery"
IMAP_PROVIDER_ID = "mail.imap_mailbox"
JMAP_PROVIDER_ID = "mail.jmap_mailbox"
POP3_PROVIDER_ID = "mail.pop3_legacy_import"
_CURRENT_INDEX_WINDOW = timedelta(minutes=30)
@@ -38,6 +41,47 @@ def imap_provider_states(
return _mail_provider_states(context, protocol="imap")
def jmap_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _mail_provider_states(context, protocol="jmap")
def pop3_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
if not isinstance(context.session, Session):
raise RuntimeError("Mail provider state requires a database session.")
profiles = _profiles(context)
if not profiles:
return ()
profile_ids = tuple(item.id for item in profiles)
endpoints = _endpoints(
context.session,
profile_ids=profile_ids,
protocol="pop3",
)
endpoints_by_profile: dict[str, list[MailServerEndpoint]] = defaultdict(list)
for endpoint in endpoints:
endpoints_by_profile[endpoint.profile_id].append(endpoint)
metrics = _pop3_metrics(
context.session,
profile_ids=profile_ids,
tenant_id=context.tenant_id,
)
observed_at = datetime.now(UTC)
return tuple(
_pop3_state(
profile,
endpoints=endpoints_by_profile.get(profile.id, []),
metrics=metrics.get(profile.id, {}),
observed_at=observed_at,
)
for profile in profiles
if endpoints_by_profile.get(profile.id)
)
def _mail_provider_states(
context: ExternalProviderStateContext,
*,
@@ -69,15 +113,28 @@ def _mail_provider_states(
)
metrics = _imap_metrics(context.session, profile_ids=profile_ids)
if protocol == "jmap":
metrics = {
profile_id: {
key: value
for key, value in values.items()
if key in {"indexed_folders", "indexed_messages", "last_indexed_at"}
}
for profile_id, values in metrics.items()
}
provider_id = JMAP_PROVIDER_ID if protocol == "jmap" else IMAP_PROVIDER_ID
return tuple(
_imap_state(
profile,
endpoints=endpoints_by_profile.get(profile.id, []),
metrics=metrics.get(profile.id, {}),
observed_at=observed_at,
protocol=protocol,
provider_id=provider_id,
)
for profile in profiles
if endpoints_by_profile.get(profile.id) or _legacy_configured(profile, "imap")
if endpoints_by_profile.get(profile.id)
or (protocol == "imap" and _legacy_configured(profile, "imap"))
)
@@ -194,6 +251,37 @@ def _imap_metrics(
return result
def _pop3_metrics(
session: Session,
*,
profile_ids: tuple[str, ...],
tenant_id: str | None,
) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = defaultdict(dict)
statement = select(
MailPop3Import.profile_id,
MailPop3Import.deletion_status,
func.count(MailPop3Import.id),
func.max(MailPop3Import.imported_at),
).where(MailPop3Import.profile_id.in_(profile_ids))
if tenant_id is not None:
statement = statement.where(MailPop3Import.tenant_id == tenant_id)
rows = session.execute(
statement.group_by(
MailPop3Import.profile_id,
MailPop3Import.deletion_status,
)
)
for profile_id, deletion_status, count, last_imported_at in rows:
item = result[str(profile_id)]
item[f"deletion_{deletion_status}"] = int(count)
current = _aware(item.get("last_imported_at"))
candidate = _aware(last_imported_at)
if candidate is not None and (current is None or candidate > current):
item["last_imported_at"] = candidate
return result
def _smtp_state(
profile: MailServerProfile,
*,
@@ -259,10 +347,13 @@ def _imap_state(
endpoints: list[MailServerEndpoint],
metrics: dict[str, Any],
observed_at: datetime,
protocol: str = "imap",
provider_id: str = IMAP_PROVIDER_ID,
) -> ExternalProviderRuntimeState:
protocol_label = protocol.upper()
active = bool(profile.is_active) and (
any(item.is_active for item in endpoints)
or (not endpoints and _legacy_configured(profile, "imap"))
or (protocol == "imap" and not endpoints and _legacy_configured(profile, "imap"))
)
indexed_at = _aware(metrics.get("last_indexed_at"))
errors = int(metrics.get("bounce_source_errors", 0))
@@ -285,8 +376,8 @@ def _imap_state(
else "unknown"
)
return ExternalProviderRuntimeState(
provider_id=IMAP_PROVIDER_ID,
binding_ref=f"mail:profile:{profile.id}:imap",
provider_id=provider_id,
binding_ref=f"mail:profile:{profile.id}:{protocol}",
authority_mode="external_mirror",
observed_at=observed_at,
configured=True,
@@ -303,13 +394,13 @@ def _imap_state(
),
last_success_at=indexed_at or _aware(metrics.get("last_bounce_success_at")),
detail=(
"IMAP mailbox access is disabled."
f"{protocol_label} mailbox access is disabled."
if not active
else "IMAP mailbox or bounce-source errors require attention."
else f"{protocol_label} mailbox or bounce-source errors require attention."
if errors
else "IMAP mailbox state has not been indexed yet."
else f"{protocol_label} mailbox state has not been indexed yet."
if indexed_at is None
else "IMAP mailbox index state is available."
else f"{protocol_label} mailbox index state is available."
),
metrics={
"indexed_folders": int(metrics.get("indexed_folders", 0)),
@@ -321,6 +412,71 @@ def _imap_state(
)
def _pop3_state(
profile: MailServerProfile,
*,
endpoints: list[MailServerEndpoint],
metrics: dict[str, Any],
observed_at: datetime,
) -> ExternalProviderRuntimeState:
enabled_endpoints = [
item
for item in endpoints
if item.is_active and bool((item.config or {}).get("legacy_import_enabled"))
]
active = bool(profile.is_active) and bool(enabled_endpoints)
failed_deletions = int(metrics.get("deletion_failed", 0))
unknown_deletions = int(metrics.get("deletion_outcome_unknown", 0))
last_imported_at = _aware(metrics.get("last_imported_at"))
health = (
"inactive"
if not active
else "warning"
if failed_deletions or unknown_deletions
else "healthy"
if last_imported_at is not None
else "unknown"
)
return ExternalProviderRuntimeState(
provider_id=POP3_PROVIDER_ID,
binding_ref=f"mail:profile:{profile.id}:pop3",
authority_mode="governance_overlay",
observed_at=observed_at,
configured=True,
active=active,
health=health,
freshness="not_applicable",
conflict="pending" if unknown_deletions else "clear",
recovery=(
"not_applicable"
if not active
else "attention"
if failed_deletions or unknown_deletions
else "ready"
),
last_success_at=last_imported_at,
detail=(
"POP3 legacy import is disabled."
if not active
else "POP3 source deletion evidence requires attention."
if failed_deletions or unknown_deletions
else "POP3 legacy import is enabled but has no retained import yet."
if last_imported_at is None
else "POP3 governed import evidence is available."
),
metrics={
"active_endpoints": len(enabled_endpoints),
"imports": sum(
int(value)
for key, value in metrics.items()
if key.startswith("deletion_")
),
"failed_deletions": failed_deletions,
"outcome_unknown_deletions": unknown_deletions,
},
)
def _legacy_configured(profile: MailServerProfile, protocol: str) -> bool:
value = profile.smtp_config if protocol == "smtp" else profile.imap_config
return isinstance(value, dict) and bool(value)
@@ -336,7 +492,11 @@ def _aware(value: object | None) -> datetime | None:
__all__ = [
"IMAP_PROVIDER_ID",
"JMAP_PROVIDER_ID",
"POP3_PROVIDER_ID",
"SMTP_PROVIDER_ID",
"imap_provider_states",
"jmap_provider_states",
"pop3_provider_states",
"smtp_provider_states",
]
File diff suppressed because it is too large Load Diff
+130 -4
View File
@@ -197,7 +197,7 @@ class MailServerEndpointResponse(BaseModel):
id: str
profile_id: str
tenant_id: str | None = None
protocol: Literal["smtp", "imap"]
protocol: Literal["smtp", "imap", "jmap", "pop3"]
name: str
config: dict[str, Any] = Field(default_factory=dict)
scope_type: MailProfileScope
@@ -214,7 +214,7 @@ class MailServerEndpointResponse(BaseModel):
class MailServerEndpointCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
protocol: Literal["smtp", "imap"]
protocol: Literal["smtp", "imap", "jmap", "pop3"]
name: str = Field(min_length=1, max_length=255)
config: dict[str, Any] = Field(default_factory=dict)
inherit_to_lower_scopes: bool | None = None
@@ -344,9 +344,46 @@ class MailAddressLookupResponse(BaseModel):
candidates: list[MailAddressLookupCandidate] = Field(default_factory=list)
class MailAddressWriteTarget(BaseModel):
address_book_id: str
address_book_label: str | None = None
operation: str = "create_contact"
allowed: bool = False
reason: str
message: str
scope_type: str | None = None
scope_id: str | None = None
source_kind: str | None = None
read_only: bool = False
required_scopes: list[str] = Field(default_factory=list)
provenance: dict[str, Any] = Field(default_factory=dict)
class MailAddressWriteTargetResponse(BaseModel):
available: bool = False
targets: list[MailAddressWriteTarget] = Field(default_factory=list)
class MailContactCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
address_book_id: str = Field(min_length=1, max_length=36)
display_name: str | None = Field(default=None, max_length=255)
email: str = Field(min_length=3, max_length=320, pattern=r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
class MailContactCreateResponse(BaseModel):
contact_id: str
address_book_id: str
display_name: str
email: str | None = None
source_kind: str = "local"
provenance: dict[str, Any] = Field(default_factory=dict)
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
protocol: Literal["smtp", "imap", "jmap", "pop3"]
host: str | None = None
port: int | None = None
security: str | None = None
@@ -354,6 +391,82 @@ class MailConnectionTestResponse(BaseModel):
details: dict[str, Any] = Field(default_factory=dict)
class MailPop3PreviewRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
server_id: str = Field(min_length=1, max_length=36)
credential_id: str | None = Field(default=None, max_length=36)
limit: int = Field(default=50, ge=1, le=100)
class MailPop3MessagePreviewResponse(BaseModel):
message_number: int
uidl: str
subject: str | None = None
from_header: str | None = None
to_header: str | None = None
date: str | None = None
message_id: str | None = None
size_bytes: int = 0
body_preview: str | None = None
already_imported: bool = False
class MailPop3PreviewResponse(BaseModel):
profile_id: str
server_id: str
transport_revision: str
host: str
port: int
security: str
message_count: int
mailbox_size_bytes: int
delete_after_import_allowed: bool = False
messages: list[MailPop3MessagePreviewResponse] = Field(default_factory=list)
class MailPop3ImportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
server_id: str = Field(min_length=1, max_length=36)
credential_id: str | None = Field(default=None, max_length=36)
expected_transport_revision: str = Field(min_length=1, max_length=120)
uidls: list[str] = Field(min_length=1, max_length=100)
delete_after_import: bool = False
class MailPop3ImportRecordResponse(BaseModel):
id: str
profile_id: str
pop3_server_id: str
transport_revision: str
provider_uidl: str
message_id: str | None = None
subject: str | None = None
from_header: str | None = None
to_header: str | None = None
date: str | None = None
body_preview: str | None = None
size_bytes: int
raw_sha256: str
status: str
imported_at: datetime
deletion_requested: bool = False
deletion_status: str
deletion_attempted_at: datetime | None = None
deletion_error: str | None = None
class MailPop3ImportResponse(BaseModel):
imports: list[MailPop3ImportRecordResponse] = Field(default_factory=list)
duplicate_uidls: list[str] = Field(default_factory=list)
deletion_status: str = "not_requested"
class MailPop3ImportListResponse(BaseModel):
imports: list[MailPop3ImportRecordResponse] = Field(default_factory=list)
class MailImapFolderResponse(BaseModel):
name: str
flags: list[str] = Field(default_factory=list)
@@ -363,13 +476,14 @@ class MailImapFolderResponse(BaseModel):
class MailImapFolderListResponse(BaseModel):
ok: bool
protocol: Literal["imap"] = "imap"
protocol: Literal["imap", "jmap"] = "imap"
host: str | None = None
port: int | None = None
security: str | None = None
message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None
detected_folder_mappings: dict[str, str] = Field(default_factory=dict)
from_cache: bool = False
refreshing: bool = False
indexed_at: datetime | None = None
@@ -422,6 +536,18 @@ class MailMailboxMessageListResponse(BaseModel):
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
class MailMailboxChangesResponse(BaseModel):
profile_id: str
protocol: Literal["jmap"] = "jmap"
account_id: str
old_state: str
new_state: str
has_more_changes: bool = False
created: list[str] = Field(default_factory=list)
updated: list[str] = Field(default_factory=list)
destroyed: list[str] = Field(default_factory=list)
class MailMailboxBootstrapResponse(BaseModel):
profile_id: str
folder: str
+435 -99
View File
@@ -1,16 +1,21 @@
from __future__ import annotations
import base64
import binascii
import imaplib
import logging
import os
import re
import socket
import ssl
import time
from contextlib import contextmanager
from dataclasses import dataclass
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from typing import Any
from threading import Lock
from typing import Any, Iterator
from govoplan_core.security.outbound_http import (
OutboundHttpError,
@@ -103,6 +108,7 @@ class ImapFolderListResult:
security: str
folders: list[ImapMailboxInfo]
detected_sent_folder: str | None = None
detected_folder_mappings: dict[str, str] | None = None
@dataclass(frozen=True, slots=True)
@@ -211,6 +217,9 @@ class ImapAppendResult:
folder: str
bytes_appended: int
response: str | None = None
connection_sequence: int = 1
session_reused: bool = False
reconnect_count: int = 0
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
@@ -275,7 +284,75 @@ def _unquote_imap_token(value: str) -> str:
return value
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
def _encode_mailbox_name(name: str) -> str:
"""Encode a Unicode mailbox using RFC 3501 section 5.1.3 modified UTF-7."""
result: list[str] = []
pending: list[str] = []
def flush() -> None:
if pending:
encoded = base64.b64encode("".join(pending).encode("utf-16-be"))
result.append("&" + encoded.decode("ascii").rstrip("=").replace("/", ",") + "-")
pending.clear()
for char in name:
if " " <= char <= "~":
flush()
result.append("&-" if char == "&" else char)
else:
pending.append(char)
flush()
return "".join(result)
def _decode_mailbox_name(name: str, *, utf8_enabled: bool = False) -> str:
"""Decode mailbox names only, never message bodies or arbitrary IMAP text.
UTF8=ACCEPT changes mailbox names to UTF-8 (RFC 6855 section 3); an
advertised capability alone does not activate that mode. Invalid provider
names fail explicitly rather than silently selecting a replacement name.
"""
if utf8_enabled:
return name
result: list[str] = []
position = 0
try:
name.encode("ascii")
while position < len(name):
if name[position] != "&":
result.append(name[position])
position += 1
continue
end = name.find("-", position + 1)
if end < 0:
raise ValueError("unterminated modified UTF-7 shift")
encoded = name[position + 1:end]
if not encoded:
result.append("&")
else:
if not re.fullmatch(r"[A-Za-z0-9+,]+", encoded):
raise ValueError("invalid modified UTF-7 alphabet")
raw = base64.b64decode(encoded.replace(",", "/") + "=" * (-len(encoded) % 4), validate=True)
decoded = raw.decode("utf-16-be")
if any(" " <= char <= "~" for char in decoded):
raise ValueError("modified UTF-7 encodes a printable ASCII character")
canonical = base64.b64encode(raw).decode("ascii").rstrip("=").replace("/", ",")
if canonical != encoded:
raise ValueError("non-canonical modified UTF-7 base64")
result.append(decoded)
position = end + 1
except (ValueError, UnicodeError, binascii.Error) as exc:
raise ImapAppendError("IMAP server returned an invalid mailbox name encoding", temporary=False) from exc
return "".join(result)
def _extract_wire_mailbox_name(
list_response_line: bytes | str | tuple[bytes, bytes] | None,
*,
utf8_enabled: bool = False,
) -> tuple[str, set[str]] | None:
r"""Best-effort parser for IMAP LIST response lines.
RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox
@@ -291,7 +368,18 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
blindly taking the last quoted value.
"""
line = _decode_item(list_response_line).strip()
if list_response_line is None:
return None
literal = None
if isinstance(list_response_line, tuple):
list_response_line, literal = list_response_line
try:
line = (
list_response_line.decode("utf-8" if utf8_enabled else "ascii")
if isinstance(list_response_line, bytes) else list_response_line
).strip()
except UnicodeError as exc:
raise ImapAppendError("IMAP server returned an invalid mailbox name encoding", temporary=False) from exc
match = re.match(
r'^\((?P<flags>[^)]*)\)\s+'
r'(?P<delimiter>"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+'
@@ -302,6 +390,14 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
if match:
flags = {part.lower() for part in match.group("flags").split()}
mailbox = _unquote_imap_token(match.group("mailbox"))
if literal is not None:
literal_size = re.fullmatch(r"\{(\d+)\+?\}", match.group("mailbox"))
if not literal_size or int(literal_size.group(1)) != len(literal):
raise ImapAppendError("IMAP server returned an invalid mailbox name literal", temporary=False)
try:
mailbox = literal.decode("utf-8" if utf8_enabled else "ascii")
except UnicodeError as exc:
raise ImapAppendError("IMAP server returned an invalid mailbox name encoding", temporary=False) from exc
if mailbox:
return mailbox, flags
return None
@@ -317,25 +413,79 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
return None
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
for name, flags in parsed:
if "\\sent" in flags or "\\sentmail" in flags:
return name
def _extract_mailbox_name(
list_response_line: bytes | str | tuple[bytes, bytes] | None,
*,
utf8_enabled: bool = False,
) -> tuple[str, set[str]] | None:
extracted = _extract_wire_mailbox_name(list_response_line, utf8_enabled=utf8_enabled)
if extracted is None:
return None
name, flags = extracted
return _decode_mailbox_name(name, utf8_enabled=utf8_enabled), flags
common_names = [
"Sent",
"Sent Items",
"Sent Messages",
"Gesendet",
"Gesendete Elemente",
"INBOX.Sent",
"INBOX/Sent",
]
names = {name.lower(): name for name, _ in parsed}
for candidate in common_names:
if candidate.lower() in names:
return names[candidate.lower()]
return None
def _parsed_mailbox_listing(client: imaplib.IMAP4, data: list[Any]) -> list[tuple[str, set[str]]]:
utf8_enabled = getattr(client, "utf8_enabled", False) is True
wire_names: dict[str, str] = {}
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_wire_mailbox_name(item, utf8_enabled=utf8_enabled)
if extracted is None:
continue
wire_name, flags = extracted
name = _decode_mailbox_name(wire_name, utf8_enabled=utf8_enabled)
if name in wire_names and wire_names[name] != wire_name:
raise ImapAppendError("IMAP server returned ambiguous mailbox name encodings", temporary=False)
wire_names[name] = wire_name
parsed.append((name, flags))
# Connection-local only: never reuse names across users, profiles or modes.
client._govoplan_mailbox_names = wire_names # type: ignore[attr-defined]
client._govoplan_mailbox_names_utf8 = utf8_enabled # type: ignore[attr-defined]
return parsed
_STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = {
"inbox": ("\\inbox",),
"sent": ("\\sent", "\\sentmail"),
"drafts": ("\\drafts",),
"trash": ("\\trash",),
"archive": ("\\archive", "\\all"),
"junk": ("\\junk", "\\spam"),
}
_STANDARD_FOLDER_NAMES: dict[str, tuple[str, ...]] = {
"inbox": ("INBOX", "Posteingang"),
"sent": ("Sent", "Sent Items", "Sent Messages", "Gesendet", "Gesendete Elemente", "INBOX.Sent", "INBOX/Sent"),
"drafts": ("Drafts", "Entwürfe", "Entwuerfe"),
"trash": ("Trash", "Deleted Items", "Gelöscht", "Geloescht", "Papierkorb"),
"archive": ("Archive", "Archives", "Archiv"),
"junk": ("Junk", "Spam", "Junk Email", "Unerwünscht", "Unerwuenscht"),
}
def _detect_standard_folder_mappings(parsed: list[tuple[str, set[str]]]) -> dict[str, str]:
detected: dict[str, str] = {}
for role, accepted_flags in _STANDARD_FOLDER_FLAGS.items():
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:
@@ -343,13 +493,7 @@ def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
if typ != "OK" or not data:
return None
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
return _detect_sent_folder(parsed)
return _detect_sent_folder(_parsed_mailbox_listing(client, data))
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
@@ -402,12 +546,18 @@ def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
name = str(item["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))
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(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder="Sent",
detected_sent_folder=detected.get("sent"),
detected_folder_mappings=detected,
)
@@ -423,14 +573,9 @@ def _list_imap_folders_on_client(
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
parsed = _parsed_mailbox_listing(client, data or [])
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
for name, flags in parsed:
message_count, unseen_count = (
(None, None)
if not include_status or _has_folder_flag(flags, "noselect")
@@ -438,12 +583,14 @@ def _list_imap_folders_on_client(
)
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
detected = _detect_standard_folder_mappings(parsed)
return ImapFolderListResult(
host=host,
port=port,
security=security,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
detected_sent_folder=detected.get("sent"),
detected_folder_mappings=detected,
)
@@ -591,13 +738,40 @@ def _has_folder_flag(flags: set[str], flag: str) -> bool:
return any(item.casefold().lstrip("\\") == wanted for item in flags)
def _quote_mailbox_name(name: str) -> str:
return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
def _quote_mailbox_name(name: str, *, client: imaplib.IMAP4 | None = None) -> str:
if any(ord(char) < 32 or 127 <= ord(char) <= 159 or char in "\u2028\u2029" for char in name):
raise ImapConfigurationError("IMAP mailbox names must not contain control characters")
utf8_enabled = getattr(client, "utf8_enabled", False) is True
wire_names = getattr(client, "_govoplan_mailbox_names", None)
if getattr(client, "_govoplan_mailbox_names_utf8", None) is not utf8_enabled:
wire_names = None
if (
client is not None
and not utf8_enabled
and wire_names is None
and name.isascii()
and re.search(r"&[A-Za-z0-9+,]*-", name)
):
# Older configurations saved LIST's wire representation. Resolve those
# against this authenticated connection, without guessing or rewriting
# configuration. A genuine literal name always wins an ambiguous alias.
typ, data = client.list()
if typ != "OK":
raise ImapAppendError("IMAP folder listing failed while resolving a saved folder name", temporary=True)
_parsed_mailbox_listing(client, data or [])
wire_names = client._govoplan_mailbox_names # type: ignore[attr-defined]
if isinstance(wire_names, dict) and name in wire_names:
wire_name = wire_names[name]
elif not utf8_enabled and isinstance(wire_names, dict) and name in wire_names.values():
wire_name = name
else:
wire_name = name if utf8_enabled else _encode_mailbox_name(name)
return "\"" + wire_name.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]:
try:
typ, data = client.status(_quote_mailbox_name(folder), "(MESSAGES UNSEEN)")
typ, data = client.status(_quote_mailbox_name(folder, client=client), "(MESSAGES UNSEEN)")
except Exception:
return None, None
if typ != "OK":
@@ -756,7 +930,7 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -
def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]:
typ, data = client.select(_quote_mailbox_name(folder), readonly=True)
typ, data = client.select(_quote_mailbox_name(folder, client=client), readonly=True)
if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
selected_count = _decode_item(data[0] if data else None).strip()
@@ -1222,69 +1396,231 @@ def list_imap_uids_since(
_log_imap_cleanup_failure("listing watcher UIDs", cleanup_exc)
def _batch_env_int(name: str, default: int, *, minimum: int, maximum: int) -> int:
try:
value = int(os.environ.get(name, str(default)))
except ValueError:
return default
return min(maximum, max(minimum, value))
@dataclass(frozen=True, slots=True)
class ImapBatchPolicy:
reuse_connections: bool = True
max_messages_per_connection: int = 100
max_connection_age_seconds: int = 300
idle_health_check_seconds: int = 30
reconnect_attempts: int = 1
@classmethod
def from_environment(cls) -> ImapBatchPolicy:
return cls(
reuse_connections=os.environ.get("GOVOPLAN_IMAP_BATCH_REUSE", "true").strip().lower()
not in {"0", "false", "no", "off"},
max_messages_per_connection=_batch_env_int(
"GOVOPLAN_IMAP_BATCH_MAX_MESSAGES", 100, minimum=1, maximum=10000,
),
max_connection_age_seconds=_batch_env_int(
"GOVOPLAN_IMAP_BATCH_MAX_AGE_SECONDS", 300, minimum=1, maximum=3600,
),
idle_health_check_seconds=_batch_env_int(
"GOVOPLAN_IMAP_BATCH_IDLE_HEALTH_CHECK_SECONDS", 30, minimum=0, maximum=3600,
),
reconnect_attempts=_batch_env_int(
"GOVOPLAN_IMAP_BATCH_RECONNECT_ATTEMPTS", 1, minimum=0, maximum=5,
),
)
class ImapBatchSession:
"""A bounded, sequential transport session, never an APPEND retry queue.
Callers must authorize every message before invoking append. A failed APPEND
is never replayed here, even when the next independent message reconnects.
Folder discovery and its original wire names belong to this connection only.
"""
def __init__(self, imap_config: ImapConfig, *, policy: ImapBatchPolicy | None = None):
self._host, self._port = _require_imap_config(imap_config)
self._config = imap_config.model_copy(deep=True)
self.policy = policy or ImapBatchPolicy.from_environment()
self._client: imaplib.IMAP4 | None = None
self._mock_connected = False
self._closed = False
self._in_use = Lock()
self._connection_count = 0
self._connection_attempt_count = 0
self._messages_on_connection = 0
self._opened_at = 0.0
self._last_used_at = 0.0
self._folders: dict[str | None, tuple[str, str | bytes]] = {}
@property
def connection_count(self) -> int:
return self._connection_count
@property
def reconnect_count(self) -> int:
return max(0, self._connection_attempt_count - 1)
def matches_config(self, config: ImapConfig) -> bool:
return config == self._config
def __enter__(self) -> ImapBatchSession:
if self._closed:
raise ImapConfigurationError("The IMAP batch session is closed")
return self
def __exit__(self, *_args: Any) -> None:
self.close()
def _disconnect(self) -> None:
client, self._client = self._client, None
self._mock_connected = False
self._folders.clear()
self._messages_on_connection = 0
if client is not None:
try:
client.logout()
except Exception as exc:
_log_imap_cleanup_failure("closing append batch", exc)
try:
# IMAP close() closes the selected mailbox, not the socket.
client.shutdown()
except Exception as cleanup_exc:
_log_imap_cleanup_failure("shutting down append batch", cleanup_exc)
def close(self) -> None:
self._closed = True
self._disconnect()
@contextmanager
def _exclusive_append(self) -> Iterator[None]:
if not self._in_use.acquire(blocking=False):
raise ImapConfigurationError("An IMAP batch only supports sequential APPENDs")
try:
if self._closed:
raise ImapConfigurationError("The IMAP batch session is closed")
yield
finally:
self._in_use.release()
def _prepare_connection(self) -> None:
now = time.monotonic()
if self._client is not None or self._mock_connected:
if (
not self.policy.reuse_connections
or self._messages_on_connection >= self.policy.max_messages_per_connection
or now - self._opened_at >= self.policy.max_connection_age_seconds
):
self._disconnect()
elif self._client is not None and now - self._last_used_at >= self.policy.idle_health_check_seconds:
try:
typ, _data = self._client.noop()
if typ != "OK":
self._disconnect()
except (OSError, imaplib.IMAP4.error):
self._disconnect()
if self._client is not None or self._mock_connected:
return
for attempt in range(self.policy.reconnect_attempts + 1):
self._connection_attempt_count += 1
try:
if is_mock_imap_host(self._config.host):
self._mock_connected = True
else:
self._client = _open_imap(self._config)
self._connection_count += 1
self._opened_at = self._last_used_at = time.monotonic()
return
except (OSError, imaplib.IMAP4.abort):
# Connecting/authenticating has not issued APPEND. Never retry
# an authentication rejection or any error from APPEND itself.
if attempt >= self.policy.reconnect_attempts:
raise
def append(self, message_bytes: bytes, *, folder: str | None = None) -> ImapAppendResult:
with self._exclusive_append():
return self._append(message_bytes, folder=folder)
def _append(self, message_bytes: bytes, *, folder: str | None) -> ImapAppendResult:
append_started = False
try:
self._prepare_connection()
reused = self._messages_on_connection > 0
if self._mock_connected:
if consume_fail_next_imap():
raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False)
target_folder = folder or (
self._config.sent_folder if self._config.sent_folder != "auto" else "Sent"
) or "Sent"
record = record_imap_append(message_bytes, folder=target_folder, imap_host=self._config.host)
response = f"mock append stored as {record.id}"
else:
client = self._client
assert client is not None
if folder not in self._folders:
target_folder = _effective_sent_folder(
config=self._config, requested_folder=folder, client=client,
)
self._folders[folder] = (target_folder, _quote_mailbox_name(target_folder, client=client))
target_folder, mailbox_argument = self._folders[folder]
internal_date = imaplib.Time2Internaldate(time.time())
append_started = True
typ, data = client.append(mailbox_argument, "\\Seen", internal_date, message_bytes)
if typ != "OK":
raise ImapAppendError(
f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False,
)
response = "; ".join(_decode_item(item) for item in (data or [])) or None
self._messages_on_connection += 1
self._last_used_at = time.monotonic()
return ImapAppendResult(
host=self._host,
port=self._port,
security=self._config.security.value,
folder=target_folder,
bytes_appended=len(message_bytes),
response=response,
connection_sequence=self.connection_count,
session_reused=reused,
reconnect_count=self.reconnect_count,
)
except (ImapAppendError, ImapConfigurationError):
self._disconnect()
raise
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
self._disconnect()
raise ImapAppendError(
f"IMAP append failed: {exc}", temporary=not append_started, outcome_unknown=append_started,
) from exc
except imaplib.IMAP4.error as exc:
self._disconnect()
raise ImapAppendError(
f"IMAP append failed: {exc}", temporary=False, outcome_unknown=append_started,
) from exc
except Exception:
self._disconnect()
raise
def append_message_to_sent(
message_bytes: bytes,
*,
imap_config: ImapConfig,
folder: str | None = None,
batch_session: ImapBatchSession | None = None,
) -> ImapAppendResult:
"""Append a sent MIME message to the configured IMAP Sent folder.
"""APPEND one MIME message; SMTP remains authoritative and independent.
The SMTP send remains authoritative. APPEND is a separate best-effort step
and should not be used to decide whether an email was sent.
An explicitly scoped batch may reuse its authenticated connection. Neither
mode retries an APPEND after transmission has started.
"""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
if consume_fail_next_imap():
raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False)
target_folder = folder or (imap_config.sent_folder if imap_config.sent_folder and imap_config.sent_folder != "auto" else "Sent")
record = record_imap_append(message_bytes, folder=target_folder, imap_host=imap_config.host)
return ImapAppendResult(
host=host,
port=port,
security=imap_config.security.value,
folder=target_folder,
bytes_appended=len(message_bytes),
response=f"mock append stored as {record.id}",
)
client: imaplib.IMAP4 | None = None
append_started = False
try:
client = _open_imap(imap_config)
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
internal_date = imaplib.Time2Internaldate(time.time())
append_started = True
typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes)
if typ != "OK":
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
response = "; ".join(_decode_item(item) for item in (data or [])) or None
return ImapAppendResult(
host=host,
port=port,
security=imap_config.security.value,
folder=target_folder,
bytes_appended=len(message_bytes),
response=response,
)
except ImapAppendError:
raise
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
raise ImapAppendError(
f"IMAP append failed: {exc}",
temporary=not append_started,
outcome_unknown=append_started,
) from exc
except imaplib.IMAP4.error as exc:
raise ImapAppendError(
f"IMAP append failed: {exc}",
temporary=False,
outcome_unknown=append_started,
) from exc
finally:
if client is not None:
try:
client.logout()
except Exception as cleanup_exc:
_log_imap_cleanup_failure("appending sent message", cleanup_exc)
if batch_session is not None:
if not batch_session.matches_config(imap_config):
raise ImapConfigurationError("The IMAP batch configuration does not match this message")
return batch_session.append(message_bytes, folder=folder)
with ImapBatchSession(
imap_config, policy=ImapBatchPolicy(reuse_connections=False, reconnect_attempts=0),
) as single:
return single.append(message_bytes, folder=folder)
+847
View File
@@ -0,0 +1,847 @@
from __future__ import annotations
import base64
import json
import urllib.error
import urllib.parse
from dataclasses import dataclass
from typing import Any, Iterable, Mapping
from govoplan_core.security.http_fetch import fetch_http
from govoplan_mail.backend.config import JmapConfig
from govoplan_mail.backend.sending.imap import (
ImapMailboxAttachmentInfo,
ImapMailboxInfo,
ImapMailboxMessageDetail,
ImapMailboxMessageSummary,
)
JMAP_CORE_CAPABILITY = "urn:ietf:params:jmap:core"
JMAP_MAIL_CAPABILITY = "urn:ietf:params:jmap:mail"
_SUMMARY_PROPERTIES = [
"id",
"threadId",
"mailboxIds",
"keywords",
"size",
"receivedAt",
"sentAt",
"messageId",
"from",
"to",
"cc",
"subject",
"hasAttachment",
"preview",
]
_DETAIL_PROPERTIES = _SUMMARY_PROPERTIES + [
"replyTo",
"bcc",
"textBody",
"htmlBody",
"bodyValues",
"attachments",
]
class JmapConfigurationError(ValueError):
pass
class JmapAuthenticationError(RuntimeError):
pass
class JmapPermissionError(RuntimeError):
pass
class JmapCapabilityError(RuntimeError):
pass
class JmapProviderError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class JmapSession:
api_url: str
account_id: str
session_state: str
capabilities: tuple[str, ...]
account_capabilities: tuple[str, ...]
username: str | None
@dataclass(frozen=True, slots=True)
class JmapConnectionTestResult:
host: str
port: int
security: str
authenticated: bool
account_id: str
session_state: str
capabilities: tuple[str, ...]
@dataclass(frozen=True, slots=True)
class JmapFolderListResult:
host: str
port: int
security: str
folders: list[ImapMailboxInfo]
detected_sent_folder: str | None
detected_folder_mappings: dict[str, str]
protocol: str = "jmap"
@dataclass(frozen=True, slots=True)
class JmapMailboxMessageListResult:
host: str
port: int
security: str
folder: str
messages: list[ImapMailboxMessageSummary]
total_count: int
offset: int
limit: int
uidvalidity: str
cursor_reset: bool = False
@dataclass(frozen=True, slots=True)
class JmapMailboxBootstrapResult:
folders: JmapFolderListResult
messages: JmapMailboxMessageListResult
@dataclass(frozen=True, slots=True)
class JmapMailboxMessageResult:
host: str
port: int
security: str
folder: str
message: ImapMailboxMessageDetail
@dataclass(frozen=True, slots=True)
class JmapEmailChangesResult:
account_id: str
old_state: str
new_state: str
has_more_changes: bool
created: tuple[str, ...]
updated: tuple[str, ...]
destroyed: tuple[str, ...]
def discover_jmap(config: JmapConfig) -> JmapSession:
payload = _fetch_json(config.session_url, config=config, method="GET")
capabilities = _string_keys(payload.get("capabilities"), "JMAP capabilities")
if JMAP_CORE_CAPABILITY not in capabilities:
raise JmapCapabilityError("The server does not advertise the JMAP Core capability")
if JMAP_MAIL_CAPABILITY not in capabilities:
raise JmapCapabilityError("The server does not advertise the JMAP Mail capability")
accounts = _object(payload.get("accounts"), "JMAP accounts")
account_id = _select_account_id(payload, accounts, config.account_id)
account = _object(accounts.get(account_id), "JMAP account")
account_capabilities = _string_keys(
account.get("accountCapabilities"),
"JMAP account capabilities",
)
if JMAP_MAIL_CAPABILITY not in account_capabilities:
raise JmapCapabilityError("The selected account does not support JMAP Mail")
api_url = _resolve_session_url(
config,
_required_text(payload.get("apiUrl"), "JMAP Session is missing apiUrl"),
label="JMAP apiUrl",
)
return JmapSession(
api_url=api_url,
account_id=account_id,
session_state=_required_text(
payload.get("state"),
"JMAP Session is missing state",
),
capabilities=tuple(sorted(capabilities)),
account_capabilities=tuple(sorted(account_capabilities)),
username=_optional_text(payload.get("username")),
)
def test_jmap_connection(*, jmap_config: JmapConfig) -> JmapConnectionTestResult:
session = discover_jmap(jmap_config)
_jmap_call(
jmap_config,
session,
[("Mailbox/get", {"accountId": session.account_id, "ids": []}, "mailboxes")],
)
host, port, security = _transport_coordinates(jmap_config.session_url)
return JmapConnectionTestResult(
host=host,
port=port,
security=security,
authenticated=True,
account_id=session.account_id,
session_state=session.session_state,
capabilities=session.capabilities,
)
def list_jmap_folders(*, jmap_config: JmapConfig) -> JmapFolderListResult:
session = discover_jmap(jmap_config)
mailboxes = _get_mailboxes(jmap_config, session)
paths = _mailbox_paths(mailboxes)
folders: list[ImapMailboxInfo] = []
mappings: dict[str, str] = {}
for mailbox in sorted(mailboxes, key=lambda item: paths[str(item["id"])].casefold()):
mailbox_id = str(mailbox["id"])
path = paths[mailbox_id]
role = _optional_text(mailbox.get("role"))
flags = [_jmap_role_flag(role)] if role else []
folders.append(
ImapMailboxInfo(
name=path,
flags=[flag for flag in flags if flag],
message_count=_optional_nonnegative_int(mailbox.get("totalEmails")),
unseen_count=_optional_nonnegative_int(mailbox.get("unreadEmails")),
)
)
if role in {"inbox", "sent", "drafts", "trash", "archive", "junk"}:
mappings[role] = path
host, port, security = _transport_coordinates(jmap_config.session_url)
return JmapFolderListResult(
host=host,
port=port,
security=security,
folders=folders,
detected_sent_folder=mappings.get("sent"),
detected_folder_mappings=mappings,
)
def list_jmap_messages(
*,
jmap_config: JmapConfig,
folder: str,
limit: int = 50,
offset: int = 0,
expected_query_state: str | None = None,
query: str | None = None,
) -> JmapMailboxMessageListResult:
clean_limit = max(1, min(int(limit), 100))
clean_offset = max(0, min(int(offset), 100_000))
clean_query = str(query or "").strip()
if len(clean_query) > 500:
raise JmapConfigurationError("JMAP mailbox search is limited to 500 characters")
session = discover_jmap(jmap_config)
mailboxes = _get_mailboxes(jmap_config, session)
mailbox, paths = _resolve_mailbox(mailboxes, folder)
query_payload: dict[str, Any] = {
"accountId": session.account_id,
"filter": {"inMailbox": mailbox["id"]},
"sort": [{"property": "receivedAt", "isAscending": False}],
"position": clean_offset,
"limit": clean_limit,
"calculateTotal": True,
}
if clean_query:
query_payload["filter"] = {
"operator": "AND",
"conditions": [
{"inMailbox": mailbox["id"]},
{"text": clean_query},
],
}
query_result = _method_result(
_jmap_call(
jmap_config,
session,
[("Email/query", query_payload, "query")],
),
name="Email/query",
call_id="query",
)
query_state = _required_text(
query_result.get("queryState"),
"JMAP Email/query response is missing queryState",
)
cursor_reset = bool(expected_query_state and expected_query_state != query_state)
if cursor_reset and clean_offset:
clean_offset = 0
query_payload["position"] = 0
query_result = _method_result(
_jmap_call(
jmap_config,
session,
[("Email/query", query_payload, "query-reset")],
),
name="Email/query",
call_id="query-reset",
)
query_state = _required_text(
query_result.get("queryState"),
"JMAP Email/query response is missing queryState",
)
ids = _string_list(query_result.get("ids"), "JMAP Email/query ids", maximum=100)
emails: list[dict[str, Any]] = []
if ids:
get_result = _method_result(
_jmap_call(
jmap_config,
session,
[(
"Email/get",
{
"accountId": session.account_id,
"ids": ids,
"properties": _SUMMARY_PROPERTIES,
},
"emails",
)],
),
name="Email/get",
call_id="emails",
)
emails = _object_list(get_result.get("list"), "JMAP Email/get list", maximum=100)
by_id = {str(item.get("id")): item for item in emails if item.get("id") is not None}
folder_path = paths[str(mailbox["id"])]
messages = [
_email_summary(by_id[email_id], folder=folder_path)
for email_id in ids
if email_id in by_id
]
host, port, security = _transport_coordinates(jmap_config.session_url)
total = query_result.get("total")
total_count = int(total) if isinstance(total, int) and total >= 0 else clean_offset + len(messages)
return JmapMailboxMessageListResult(
host=host,
port=port,
security=security,
folder=folder_path,
messages=messages,
total_count=total_count,
offset=clean_offset,
limit=clean_limit,
uidvalidity=query_state,
cursor_reset=cursor_reset,
)
def load_jmap_mailbox_bootstrap(
*,
jmap_config: JmapConfig,
folder: str = "INBOX",
limit: int = 50,
offset: int = 0,
) -> JmapMailboxBootstrapResult:
folders = list_jmap_folders(jmap_config=jmap_config)
selected = folder
names = {item.name for item in folders.folders}
if selected not in names:
selected = (
folders.detected_folder_mappings.get("inbox")
or (folders.folders[0].name if folders.folders else folder)
)
messages = list_jmap_messages(
jmap_config=jmap_config,
folder=selected,
limit=limit,
offset=offset,
)
return JmapMailboxBootstrapResult(folders=folders, messages=messages)
def get_jmap_message(
*,
jmap_config: JmapConfig,
folder: str,
email_id: str,
) -> JmapMailboxMessageResult:
clean_id = _required_text(email_id, "JMAP Email id is required")
if len(clean_id) > 255:
raise JmapConfigurationError("JMAP Email id is too long")
session = discover_jmap(jmap_config)
mailbox, paths = _resolve_mailbox(_get_mailboxes(jmap_config, session), folder)
mailbox_id = str(mailbox["id"])
canonical_folder = paths[mailbox_id]
result = _method_result(
_jmap_call(
jmap_config,
session,
[(
"Email/get",
{
"accountId": session.account_id,
"ids": [clean_id],
"properties": _DETAIL_PROPERTIES,
"bodyProperties": [
"partId",
"blobId",
"size",
"name",
"type",
"charset",
"disposition",
"cid",
],
"fetchTextBodyValues": True,
"fetchHTMLBodyValues": True,
"maxBodyValueBytes": jmap_config.max_body_value_bytes,
},
"email",
)],
),
name="Email/get",
call_id="email",
)
values = _object_list(result.get("list"), "JMAP Email/get list", maximum=1)
if not values:
raise JmapProviderError("JMAP message not found")
email = values[0]
mailbox_ids = email.get("mailboxIds")
if not isinstance(mailbox_ids, dict) or mailbox_ids.get(mailbox_id) is not True:
raise JmapProviderError("JMAP message is not available in the requested mailbox")
summary = _email_summary(email, folder=canonical_folder)
body_values = _object(email.get("bodyValues") or {}, "JMAP Email bodyValues")
body_text = _body_value(email.get("textBody"), body_values)
body_html = _body_value(email.get("htmlBody"), body_values)
attachments = [
ImapMailboxAttachmentInfo(
filename=_optional_text(item.get("name")),
content_type=_optional_text(item.get("type")) or "application/octet-stream",
size_bytes=_optional_nonnegative_int(item.get("size")) or 0,
)
for item in _object_list(
email.get("attachments") or [],
"JMAP Email attachments",
maximum=1_000,
)
]
headers = {
key: value
for key, value in {
"From": summary.from_header,
"To": summary.to_header,
"Cc": summary.cc_header,
"Bcc": _format_addresses(email.get("bcc")),
"Reply-To": _format_addresses(email.get("replyTo")),
"Message-ID": summary.message_id,
"Date": summary.date,
"Subject": summary.subject,
}.items()
if value
}
detail = ImapMailboxMessageDetail(
uid=summary.uid,
folder=summary.folder,
subject=summary.subject,
from_header=summary.from_header,
to_header=summary.to_header,
cc_header=summary.cc_header,
date=summary.date,
message_id=summary.message_id,
flags=summary.flags,
size_bytes=summary.size_bytes,
body_preview=summary.body_preview,
body_text=body_text,
body_html=body_html,
headers=headers,
attachments=attachments,
)
host, port, security = _transport_coordinates(jmap_config.session_url)
return JmapMailboxMessageResult(
host=host,
port=port,
security=security,
folder=canonical_folder,
message=detail,
)
def get_jmap_email_changes(
*,
jmap_config: JmapConfig,
since_state: str,
max_changes: int = 500,
) -> JmapEmailChangesResult:
clean_state = _required_text(since_state, "JMAP Email change state is required")
if len(clean_state) > 1_000:
raise JmapConfigurationError("JMAP Email change state is too long")
clean_max = max(1, min(int(max_changes), 1_000))
session = discover_jmap(jmap_config)
result = _method_result(
_jmap_call(
jmap_config,
session,
[(
"Email/changes",
{
"accountId": session.account_id,
"sinceState": clean_state,
"maxChanges": clean_max,
},
"changes",
)],
),
name="Email/changes",
call_id="changes",
)
return JmapEmailChangesResult(
account_id=session.account_id,
old_state=_required_text(result.get("oldState"), "JMAP changes is missing oldState"),
new_state=_required_text(result.get("newState"), "JMAP changes is missing newState"),
has_more_changes=bool(result.get("hasMoreChanges")),
created=tuple(_string_list(result.get("created"), "JMAP created ids", maximum=clean_max)),
updated=tuple(_string_list(result.get("updated"), "JMAP updated ids", maximum=clean_max)),
destroyed=tuple(_string_list(result.get("destroyed"), "JMAP destroyed ids", maximum=clean_max)),
)
def _get_mailboxes(config: JmapConfig, session: JmapSession) -> list[dict[str, Any]]:
result = _method_result(
_jmap_call(
config,
session,
[(
"Mailbox/get",
{
"accountId": session.account_id,
"properties": [
"id",
"name",
"parentId",
"role",
"sortOrder",
"isSubscribed",
"totalEmails",
"unreadEmails",
],
},
"mailboxes",
)],
),
name="Mailbox/get",
call_id="mailboxes",
)
rows = _object_list(result.get("list"), "JMAP Mailbox/get list", maximum=10_000)
for row in rows:
_required_text(row.get("id"), "JMAP Mailbox is missing id")
_required_text(row.get("name"), "JMAP Mailbox is missing name")
return rows
def _jmap_call(
config: JmapConfig,
session: JmapSession,
calls: Iterable[tuple[str, Mapping[str, Any], str]],
) -> dict[str, Any]:
method_calls = [[name, dict(arguments), call_id] for name, arguments, call_id in calls]
if not method_calls or len(method_calls) > 32:
raise JmapConfigurationError("A JMAP request must contain between 1 and 32 method calls")
body = json.dumps(
{
"using": [JMAP_CORE_CAPABILITY, JMAP_MAIL_CAPABILITY],
"methodCalls": method_calls,
},
separators=(",", ":"),
).encode("utf-8")
return _fetch_json(session.api_url, config=config, method="POST", body=body)
def _fetch_json(
url: str,
*,
config: JmapConfig,
method: str,
body: bytes | None = None,
) -> dict[str, Any]:
try:
response = fetch_http(
url,
timeout=config.timeout_seconds,
label="JMAP endpoint",
method=method,
headers={
"Accept": "application/json",
"Authorization": _authorization_header(config),
**({"Content-Type": "application/json"} if body is not None else {}),
},
body=body,
max_bytes=config.max_response_bytes,
)
except urllib.error.HTTPError as exc:
if exc.code == 401:
raise JmapAuthenticationError("JMAP authentication failed") from exc
if exc.code == 403:
raise JmapPermissionError("JMAP access is forbidden for this credential") from exc
raise JmapProviderError(f"JMAP provider returned HTTP {exc.code}") from exc
except (JmapAuthenticationError, JmapPermissionError, JmapProviderError):
raise
except Exception as exc:
raise JmapProviderError("JMAP provider is unavailable") from exc
try:
payload = json.loads(response.body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise JmapProviderError("JMAP provider returned invalid JSON") from exc
return _object(payload, "JMAP response")
def _method_result(payload: Mapping[str, Any], *, name: str, call_id: str) -> dict[str, Any]:
responses = payload.get("methodResponses")
if not isinstance(responses, list):
raise JmapProviderError("JMAP response is missing methodResponses")
for item in responses:
if not isinstance(item, list) or len(item) != 3:
continue
response_name, arguments, response_id = item
if response_id != call_id:
continue
if response_name == "error":
error = _object(arguments, "JMAP method error")
error_type = _optional_text(error.get("type")) or "unknown"
if error_type in {"accountNotFound", "forbidden"}:
raise JmapPermissionError(f"JMAP {name} was denied ({error_type})")
if error_type in {"unknownMethod", "unknownCapability"}:
raise JmapCapabilityError(f"JMAP {name} is unsupported ({error_type})")
if error_type == "cannotCalculateChanges":
raise JmapCapabilityError("JMAP incremental state expired; perform a full refresh")
raise JmapProviderError(f"JMAP {name} failed ({error_type})")
if response_name != name:
raise JmapProviderError(f"JMAP returned {response_name!r} for {name}")
return _object(arguments, f"JMAP {name} response")
raise JmapProviderError(f"JMAP response did not include call {call_id!r}")
def _select_account_id(
session_payload: Mapping[str, Any],
accounts: Mapping[str, Any],
configured: str | None,
) -> str:
if configured:
if configured not in accounts:
raise JmapPermissionError("The configured JMAP account is not available")
return configured
primary = session_payload.get("primaryAccounts")
if isinstance(primary, dict) and primary.get(JMAP_MAIL_CAPABILITY):
account_id = str(primary[JMAP_MAIL_CAPABILITY])
if account_id in accounts:
return account_id
capable = [
str(account_id)
for account_id, value in accounts.items()
if isinstance(value, dict)
and JMAP_MAIL_CAPABILITY
in _string_keys(value.get("accountCapabilities"), "JMAP account capabilities")
]
if len(capable) == 1:
return capable[0]
if not capable:
raise JmapCapabilityError("No accessible account supports JMAP Mail")
raise JmapConfigurationError("Configure a JMAP account id because multiple mail accounts are available")
def _resolve_session_url(config: JmapConfig, value: str, *, label: str) -> str:
candidate = urllib.parse.urljoin(config.session_url, value)
candidate_origin = _origin(candidate)
allowed = {_origin(config.session_url), *config.allowed_api_origins}
if candidate_origin not in allowed:
raise JmapConfigurationError(
f"{label} uses unapproved origin {candidate_origin}; add it to allowed_api_origins"
)
return candidate
def _authorization_header(config: JmapConfig) -> str:
if config.auth_scheme == "bearer":
return f"Bearer {config.password}"
raw = f"{config.username}:{config.password}".encode("utf-8")
return f"Basic {base64.b64encode(raw).decode('ascii')}"
def _transport_coordinates(url: str) -> tuple[str, int, str]:
parsed = urllib.parse.urlsplit(url)
return (
parsed.hostname or "",
parsed.port or (443 if parsed.scheme == "https" else 80),
parsed.scheme,
)
def _origin(value: str) -> str:
parsed = urllib.parse.urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise JmapConfigurationError("JMAP Session advertised an invalid HTTP(S) URL")
if parsed.username or parsed.password or parsed.fragment:
raise JmapConfigurationError("JMAP Session advertised an unsafe URL")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
default = 443 if parsed.scheme == "https" else 80
suffix = "" if port == default else f":{port}"
return f"{parsed.scheme.lower()}://{parsed.hostname.lower()}{suffix}"
def _mailbox_paths(mailboxes: list[dict[str, Any]]) -> dict[str, str]:
by_id = {str(item["id"]): item for item in mailboxes}
paths: dict[str, str] = {}
def path_for(mailbox_id: str, stack: tuple[str, ...] = ()) -> str:
if mailbox_id in paths:
return paths[mailbox_id]
if mailbox_id in stack:
raise JmapProviderError("JMAP mailbox hierarchy contains a cycle")
mailbox = by_id[mailbox_id]
name = _required_text(mailbox.get("name"), "JMAP Mailbox is missing name")
parent_id = _optional_text(mailbox.get("parentId"))
if parent_id and parent_id in by_id:
value = f"{path_for(parent_id, (*stack, mailbox_id))}/{name}"
else:
value = name
paths[mailbox_id] = value
return value
for mailbox_id in by_id:
path_for(mailbox_id)
return paths
def _resolve_mailbox(
mailboxes: list[dict[str, Any]],
folder: str,
) -> tuple[dict[str, Any], dict[str, str]]:
paths = _mailbox_paths(mailboxes)
clean = str(folder or "INBOX").strip()
for item in mailboxes:
mailbox_id = str(item["id"])
role = _optional_text(item.get("role"))
if mailbox_id == clean or paths[mailbox_id] == clean:
return item, paths
if clean.casefold() == "inbox" and role == "inbox":
return item, paths
raise JmapConfigurationError(f"JMAP mailbox {clean!r} is not available")
def _jmap_role_flag(role: str | None) -> str:
return {
"inbox": "\\Inbox",
"sent": "\\Sent",
"drafts": "\\Drafts",
"trash": "\\Trash",
"archive": "\\Archive",
"junk": "\\Junk",
}.get(role or "", "")
def _email_summary(email: Mapping[str, Any], *, folder: str) -> ImapMailboxMessageSummary:
email_id = _required_text(email.get("id"), "JMAP Email is missing id")
message_ids = email.get("messageId")
message_id = None
if isinstance(message_ids, list) and message_ids:
message_id = _optional_text(message_ids[0])
elif isinstance(message_ids, str):
message_id = _optional_text(message_ids)
keywords = email.get("keywords") if isinstance(email.get("keywords"), dict) else {}
flags = [
flag
for keyword, flag in (
("$seen", "\\Seen"),
("$flagged", "\\Flagged"),
("$answered", "\\Answered"),
("$draft", "\\Draft"),
)
if keywords.get(keyword) is True
]
return ImapMailboxMessageSummary(
uid=email_id,
folder=folder,
subject=_optional_text(email.get("subject")),
from_header=_format_addresses(email.get("from")),
to_header=_format_addresses(email.get("to")),
cc_header=_format_addresses(email.get("cc")),
date=_optional_text(email.get("receivedAt")) or _optional_text(email.get("sentAt")),
message_id=message_id,
flags=flags,
size_bytes=_optional_nonnegative_int(email.get("size")),
body_preview=_optional_text(email.get("preview")),
attachment_count=(1 if email.get("hasAttachment") is True else 0),
)
def _format_addresses(value: object) -> str | None:
if not isinstance(value, list):
return None
parts: list[str] = []
for item in value[:1_000]:
if not isinstance(item, dict):
continue
name = _optional_text(item.get("name"))
email = _optional_text(item.get("email"))
if name and email:
parts.append(f"{name} <{email}>")
elif email or name:
parts.append(email or name or "")
return ", ".join(parts) or None
def _body_value(parts: object, values: Mapping[str, Any]) -> str | None:
if not isinstance(parts, list):
return None
result: list[str] = []
for part in parts[:1_000]:
if not isinstance(part, dict):
continue
part_id = _optional_text(part.get("partId"))
body = values.get(part_id) if part_id else None
if isinstance(body, dict) and isinstance(body.get("value"), str):
result.append(body["value"])
return "\n".join(result) or None
def _object(value: object, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise JmapProviderError(f"{label} must be an object")
return dict(value)
def _object_list(value: object, label: str, *, maximum: int) -> list[dict[str, Any]]:
if not isinstance(value, list) or len(value) > maximum:
raise JmapProviderError(f"{label} must be an array with at most {maximum} items")
if not all(isinstance(item, dict) for item in value):
raise JmapProviderError(f"{label} contains an invalid item")
return [dict(item) for item in value]
def _string_keys(value: object, label: str) -> set[str]:
if not isinstance(value, dict):
raise JmapProviderError(f"{label} must be an object")
return {str(key) for key in value}
def _string_list(value: object, label: str, *, maximum: int) -> list[str]:
if not isinstance(value, list) or len(value) > maximum:
raise JmapProviderError(f"{label} must be an array with at most {maximum} items")
if not all(isinstance(item, str) and item for item in value):
raise JmapProviderError(f"{label} contains an invalid id")
return list(value)
def _required_text(value: object, message: str) -> str:
text = str(value).strip() if isinstance(value, str) else ""
if not text:
raise JmapProviderError(message)
return text
def _optional_text(value: object) -> str | None:
if not isinstance(value, str):
return None
return value.strip() or None
def _optional_nonnegative_int(value: object) -> int | None:
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
+492
View File
@@ -0,0 +1,492 @@
from __future__ import annotations
import hashlib
import poplib
import socket
import ssl
from dataclasses import dataclass
from email import policy
from email.message import Message
from email.parser import BytesParser
from typing import Iterable
from govoplan_core.security.outbound_http import (
OutboundHttpError,
create_outbound_connection,
validate_outbound_host,
)
from govoplan_mail.backend.config import Pop3Config, TransportSecurity
class _OutboundPolicyPOP3(poplib.POP3):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
return create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="POP3 legacy import",
)
class _OutboundPolicyPOP3SSL(poplib.POP3_SSL):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
sock = create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="POP3 legacy import",
)
try:
return self.context.wrap_socket(sock, server_hostname=self.host)
except Exception:
sock.close()
raise
class Pop3ConfigurationError(ValueError):
pass
class Pop3ProviderError(RuntimeError):
def __init__(self, message: str, *, outcome_unknown: bool = False):
super().__init__(message)
self.outcome_unknown = outcome_unknown
@dataclass(frozen=True, slots=True)
class Pop3LoginTestResult:
host: str
port: int
security: str
authenticated: bool
message_count: int
mailbox_size_bytes: int
@dataclass(frozen=True, slots=True)
class Pop3MessageSummary:
message_number: int
uidl: str
subject: str | None
from_header: str | None
to_header: str | None
date: str | None
message_id: str | None
size_bytes: int
body_preview: str | None
@dataclass(frozen=True, slots=True)
class Pop3PreviewResult:
host: str
port: int
security: str
message_count: int
mailbox_size_bytes: int
messages: tuple[Pop3MessageSummary, ...]
@dataclass(frozen=True, slots=True)
class Pop3DownloadedMessage:
message_number: int
uidl: str
raw: bytes
raw_sha256: str
summary: Pop3MessageSummary
@dataclass(frozen=True, slots=True)
class Pop3DeletionResult:
deleted_uidls: tuple[str, ...]
def _require_pop3_config(config: Pop3Config) -> tuple[str, int]:
if not config.legacy_import_enabled:
raise Pop3ConfigurationError(
"POP3 legacy import is disabled for the selected server"
)
if not config.host:
raise Pop3ConfigurationError("POP3 host is required")
if not config.port:
raise Pop3ConfigurationError("POP3 port is required")
if not config.username or not config.password:
raise Pop3ConfigurationError("POP3 username and password are required")
return config.host, config.port
def _open_pop3(config: Pop3Config) -> poplib.POP3:
host, port = _require_pop3_config(config)
try:
validate_outbound_host(host, port=port, label="POP3 legacy import")
except OutboundHttpError as exc:
raise Pop3ConfigurationError(str(exc)) from exc
context = ssl.create_default_context()
client: poplib.POP3 | None = None
try:
if config.security == TransportSecurity.TLS:
client = _OutboundPolicyPOP3SSL(
host=host,
port=port,
timeout=config.timeout_seconds,
context=context,
)
else:
client = _OutboundPolicyPOP3(
host=host,
port=port,
timeout=config.timeout_seconds,
)
if config.security == TransportSecurity.STARTTLS:
client.stls(context=context)
client.user(config.username)
client.pass_(config.password)
return client
except ssl.SSLError as exc:
_close_without_commit(client)
raise Pop3ProviderError("POP3 TLS negotiation failed") from exc
except poplib.error_proto as exc:
_close_without_commit(client)
raise Pop3ProviderError("POP3 authentication failed") from exc
except (OSError, socket.error) as exc:
_close_without_commit(client)
raise Pop3ProviderError("POP3 connection failed") from exc
except Exception:
_close_without_commit(client)
raise
def test_pop3_login(*, pop3_config: Pop3Config) -> Pop3LoginTestResult:
client = _open_pop3(pop3_config)
try:
message_count, mailbox_size = client.stat()
return Pop3LoginTestResult(
host=str(pop3_config.host),
port=int(pop3_config.port or 0),
security=pop3_config.security.value,
authenticated=True,
message_count=int(message_count),
mailbox_size_bytes=int(mailbox_size),
)
except poplib.error_proto as exc:
raise Pop3ProviderError("POP3 mailbox statistics are unavailable") from exc
finally:
_quit_without_deletions(client)
def preview_pop3_messages(
*,
pop3_config: Pop3Config,
limit: int = 50,
) -> Pop3PreviewResult:
clean_limit = max(1, min(int(limit), 100))
client = _open_pop3(pop3_config)
try:
message_count, mailbox_size = client.stat()
uidls = _uidl_map(client)
sizes = _size_map(client)
selected_numbers = sorted(uidls, reverse=True)[:clean_limit]
messages = tuple(
_preview_message(
client,
message_number=number,
uidl=uidls[number],
size_bytes=sizes.get(number, 0),
body_lines=pop3_config.preview_body_lines,
max_message_bytes=pop3_config.max_message_bytes,
)
for number in selected_numbers
)
return Pop3PreviewResult(
host=str(pop3_config.host),
port=int(pop3_config.port or 0),
security=pop3_config.security.value,
message_count=int(message_count),
mailbox_size_bytes=int(mailbox_size),
messages=messages,
)
except poplib.error_proto as exc:
raise Pop3ProviderError("POP3 message preview failed") from exc
finally:
_quit_without_deletions(client)
def download_pop3_messages(
*,
pop3_config: Pop3Config,
uidls: Iterable[str],
) -> tuple[Pop3DownloadedMessage, ...]:
selected_uidls = tuple(dict.fromkeys(_required_uidl(value) for value in uidls))
if not selected_uidls:
raise Pop3ConfigurationError("Select at least one POP3 message to import")
if len(selected_uidls) > 100:
raise Pop3ConfigurationError("At most 100 POP3 messages can be imported at once")
client = _open_pop3(pop3_config)
try:
uidl_by_number = _uidl_map(client)
number_by_uidl = {uidl: number for number, uidl in uidl_by_number.items()}
missing = [uidl for uidl in selected_uidls if uidl not in number_by_uidl]
if missing:
raise Pop3ProviderError(
"One or more previewed POP3 messages are no longer available; refresh the preview"
)
sizes = _size_map(client)
advertised_batch_size = sum(
max(0, int(sizes.get(number_by_uidl[uidl], 0)))
for uidl in selected_uidls
)
if advertised_batch_size > pop3_config.max_batch_bytes:
raise Pop3ProviderError(
"The selected POP3 messages exceed the configured batch size limit"
)
downloaded: list[Pop3DownloadedMessage] = []
downloaded_bytes = 0
for uidl in selected_uidls:
number = number_by_uidl[uidl]
advertised_size = sizes.get(number, 0)
if advertised_size > pop3_config.max_message_bytes:
raise Pop3ProviderError(
f"POP3 message {uidl} exceeds the configured import size limit"
)
_response, lines, _octets = client.retr(number)
raw = _message_bytes(lines)
if len(raw) > pop3_config.max_message_bytes:
raise Pop3ProviderError(
f"POP3 message {uidl} exceeds the configured import size limit"
)
downloaded_bytes += len(raw)
if downloaded_bytes > pop3_config.max_batch_bytes:
raise Pop3ProviderError(
"The selected POP3 messages exceed the configured batch size limit"
)
summary = _message_summary(
raw,
message_number=number,
uidl=uidl,
size_bytes=len(raw),
)
downloaded.append(
Pop3DownloadedMessage(
message_number=number,
uidl=uidl,
raw=raw,
raw_sha256=hashlib.sha256(raw).hexdigest(),
summary=summary,
)
)
return tuple(downloaded)
except poplib.error_proto as exc:
raise Pop3ProviderError("POP3 message download failed") from exc
finally:
_quit_without_deletions(client)
def delete_pop3_messages(
*,
pop3_config: Pop3Config,
uidls: Iterable[str],
) -> Pop3DeletionResult:
selected_uidls = tuple(dict.fromkeys(_required_uidl(value) for value in uidls))
if not selected_uidls:
return Pop3DeletionResult(deleted_uidls=())
if not pop3_config.allow_delete_after_import:
raise Pop3ConfigurationError(
"POP3 delete-after-import is disabled for the selected server"
)
client = _open_pop3(pop3_config)
quit_started = False
try:
number_by_uidl = {
uidl: number for number, uidl in _uidl_map(client).items()
}
missing = [uidl for uidl in selected_uidls if uidl not in number_by_uidl]
if missing:
raise Pop3ProviderError(
"One or more imported POP3 messages are no longer available for deletion"
)
for uidl in selected_uidls:
client.dele(number_by_uidl[uidl])
quit_started = True
client.quit()
return Pop3DeletionResult(deleted_uidls=selected_uidls)
except Pop3ProviderError:
_close_without_commit(client)
raise
except poplib.error_proto as exc:
_close_without_commit(client)
raise Pop3ProviderError(
"POP3 deletion outcome is unknown" if quit_started else "POP3 deletion was rejected",
outcome_unknown=quit_started,
) from exc
except Exception as exc:
_close_without_commit(client)
raise Pop3ProviderError(
"POP3 deletion outcome is unknown" if quit_started else "POP3 deletion failed",
outcome_unknown=quit_started,
) from exc
def _uidl_map(client: poplib.POP3) -> dict[int, str]:
_response, lines, _octets = client.uidl()
result: dict[int, str] = {}
for raw_line in lines:
parts = bytes(raw_line).decode("utf-8", errors="replace").split(maxsplit=1)
if len(parts) != 2 or not parts[0].isdigit():
continue
uidl = _required_uidl(parts[1])
result[int(parts[0])] = uidl
if not result:
raise Pop3ProviderError(
"The POP3 server does not provide stable UIDL identifiers; safe import is unavailable"
)
return result
def _size_map(client: poplib.POP3) -> dict[int, int]:
_response, lines, _octets = client.list()
result: dict[int, int] = {}
for raw_line in lines:
parts = bytes(raw_line).decode("ascii", errors="ignore").split(maxsplit=1)
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
result[int(parts[0])] = int(parts[1])
return result
def _preview_message(
client: poplib.POP3,
*,
message_number: int,
uidl: str,
size_bytes: int,
body_lines: int,
max_message_bytes: int,
) -> Pop3MessageSummary:
raw: bytes | None = None
try:
_response, lines, _octets = client.top(message_number, body_lines)
raw = _message_bytes(lines)
except (poplib.error_proto, AttributeError):
# TOP is optional. Never use RETR as a preview fallback when the
# advertised message already exceeds the configured download bound.
if size_bytes > max_message_bytes:
return Pop3MessageSummary(
message_number=message_number,
uidl=uidl,
subject=None,
from_header=None,
to_header=None,
date=None,
message_id=None,
size_bytes=size_bytes,
body_preview=None,
)
_response, lines, _octets = client.retr(message_number)
raw = _message_bytes(lines)
if len(raw) > min(max_message_bytes, 256 * 1024):
return Pop3MessageSummary(
message_number=message_number,
uidl=uidl,
subject=None,
from_header=None,
to_header=None,
date=None,
message_id=None,
size_bytes=size_bytes,
body_preview=None,
)
return _message_summary(
raw,
message_number=message_number,
uidl=uidl,
size_bytes=size_bytes,
)
def _message_summary(
raw: bytes,
*,
message_number: int,
uidl: str,
size_bytes: int,
) -> Pop3MessageSummary:
message = BytesParser(policy=policy.default).parsebytes(raw)
return Pop3MessageSummary(
message_number=message_number,
uidl=uidl,
subject=_header(message, "Subject"),
from_header=_header(message, "From"),
to_header=_header(message, "To"),
date=_header(message, "Date"),
message_id=_header(message, "Message-ID"),
size_bytes=max(0, int(size_bytes)),
body_preview=_body_preview(message),
)
def _header(message: Message, name: str) -> str | None:
value = message.get(name)
if value is None:
return None
text = str(value).strip()
return text[:2_000] or None
def _body_preview(message: Message) -> str | None:
body = message.get_body(preferencelist=("plain",)) if message.is_multipart() else message
if body is None:
return None
try:
text = body.get_content()
except Exception:
payload = body.get_payload(decode=True)
text = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else str(payload or "")
normalized = " ".join(str(text).split())
return normalized[:500] or None
def _message_bytes(lines: Iterable[bytes]) -> bytes:
return b"\r\n".join(bytes(line) for line in lines) + b"\r\n"
def _required_uidl(value: object) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > 500 or any(char.isspace() for char in clean):
raise Pop3ConfigurationError("POP3 UIDL must be a non-empty token")
return clean
def _quit_without_deletions(client: poplib.POP3) -> None:
try:
client.quit()
except Exception:
_close_without_commit(client)
def _close_without_commit(client: poplib.POP3 | None) -> None:
if client is None:
return
try:
client.rset()
except Exception:
pass
try:
client.close()
except Exception:
pass
__all__ = [
"Pop3ConfigurationError",
"Pop3DeletionResult",
"Pop3DownloadedMessage",
"Pop3LoginTestResult",
"Pop3MessageSummary",
"Pop3PreviewResult",
"Pop3ProviderError",
"delete_pop3_messages",
"download_pop3_messages",
"preview_pop3_messages",
"test_pop3_login",
]
+352 -8
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import logging
import os
import smtplib
import ssl
from dataclasses import dataclass
@@ -64,10 +65,22 @@ class SmtpSendError(RuntimeError):
started, so automatic retry is intentionally forbidden.
"""
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False):
def __init__(
self,
message: str,
*,
temporary: bool = False,
outcome_unknown: bool = False,
systemic: bool = False,
reason_code: str | None = None,
phase: str = "send",
):
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
self.systemic = systemic
self.reason_code = reason_code
self.phase = phase
@dataclass(frozen=True, slots=True)
@@ -86,12 +99,315 @@ class SmtpSendResult:
envelope_from: str
envelope_recipients: list[str]
refused_recipients: dict[str, tuple[int, bytes | str]]
connection_sequence: int = 1
session_reused: bool = False
reconnect_count: int = 0
@property
def accepted_count(self) -> int:
return len(self.envelope_recipients) - len(self.refused_recipients)
@dataclass(frozen=True, slots=True)
class SmtpBatchPolicy:
reuse_connections: bool = True
max_messages_per_connection: int = 100
reconnect_attempts: int = 1
health_check_before_reuse: bool = True
@classmethod
def from_environment(cls) -> "SmtpBatchPolicy":
return cls(
reuse_connections=_environment_bool("GOVOPLAN_SMTP_BATCH_REUSE", True),
max_messages_per_connection=_environment_int(
"GOVOPLAN_SMTP_BATCH_MAX_MESSAGES",
default=100,
minimum=1,
maximum=10_000,
),
reconnect_attempts=_environment_int(
"GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS",
default=1,
minimum=0,
maximum=5,
),
health_check_before_reuse=_environment_bool(
"GOVOPLAN_SMTP_BATCH_HEALTH_CHECK",
True,
),
)
@dataclass(frozen=True, slots=True)
class SmtpBatchPreflightResult:
ready: bool
authenticated: bool
connection_sequence: int
reconnect_count: int
class SmtpBatchSession:
"""Bounded reusable SMTP connection for one already-authorized batch."""
def __init__(
self,
smtp_config: SmtpConfig,
*,
policy: SmtpBatchPolicy | None = None,
) -> None:
self.smtp_config = smtp_config
self.policy = policy or SmtpBatchPolicy.from_environment()
self._smtp: smtplib.SMTP | None = None
self._connection_sequence = 0
self._reconnect_count = 0
self._messages_on_connection = 0
self._closed = False
@property
def connection_count(self) -> int:
return self._connection_sequence
@property
def reconnect_count(self) -> int:
return self._reconnect_count
def preflight(self) -> SmtpBatchPreflightResult:
"""Validate DNS/egress/connectivity/TLS/auth before a provider effect."""
if self._closed:
raise SmtpSendError(
"SMTP batch session is closed.",
systemic=True,
reason_code="batch_session_closed",
phase="preflight",
)
_require_smtp_config(self.smtp_config)
if is_mock_smtp_host(self.smtp_config.host):
if self._connection_sequence == 0:
self._connection_sequence = 1
return self._preflight_result()
if self._smtp is None:
self._connect_with_retries()
return self._preflight_result()
def send(
self,
message: EmailMessage | bytes,
*,
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
host, port, recipients = _prepare_smtp_send(
smtp_config=self.smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
if is_mock_smtp_host(self.smtp_config.host):
preflight = self.preflight()
_accepted, refused = _send_mock_smtp_payload(
message,
smtp_config=self.smtp_config,
envelope_from=envelope_from,
envelope_recipients=recipients,
)
self._messages_on_connection += 1
return _smtp_send_result(
smtp_config=self.smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
connection_sequence=preflight.connection_sequence,
session_reused=self._messages_on_connection > 1,
reconnect_count=preflight.reconnect_count,
)
reused = self._prepare_connection_for_send()
smtp = self._smtp
if smtp is None: # Defensive: preflight either opens or raises.
raise SmtpSendError(
"SMTP preflight did not establish a connection.",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
)
try:
if isinstance(message, bytes):
refused = smtp.sendmail(envelope_from, recipients, message)
else:
refused = smtp.send_message(
message,
from_addr=envelope_from,
to_addrs=recipients,
)
except smtplib.SMTPRecipientsRefused as exc:
raise SmtpSendError(
f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}",
temporary=False,
reason_code="smtp_recipients_refused",
) from exc
except smtplib.SMTPSenderRefused as exc:
self._discard_connection()
raise SmtpSendError(
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_sender_refused",
) from exc
except smtplib.SMTPResponseException as exc:
disconnected = int(exc.smtp_code) == 421
if disconnected:
self._discard_connection()
raise SmtpSendError(
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=disconnected,
reason_code="smtp_connection_closed" if disconnected else "smtp_message_rejected",
) from exc
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
self._discard_connection()
raise SmtpSendError(
f"SMTP outcome is unknown after transmission started: {exc}",
outcome_unknown=True,
systemic=True,
reason_code="smtp_connection_lost_after_transmission",
) from exc
self._messages_on_connection += 1
result = _smtp_send_result(
smtp_config=self.smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
connection_sequence=self._connection_sequence,
session_reused=reused,
reconnect_count=self._reconnect_count,
)
if not self.policy.reuse_connections:
self._discard_connection()
return result
def close(self) -> None:
self._closed = True
self._discard_connection()
def __enter__(self) -> "SmtpBatchSession":
self.preflight()
return self
def __exit__(self, _exc_type, _exc, _traceback) -> None:
self.close()
def _preflight_result(self) -> SmtpBatchPreflightResult:
return SmtpBatchPreflightResult(
ready=True,
authenticated=bool(self.smtp_config.username and self.smtp_config.password),
connection_sequence=self._connection_sequence,
reconnect_count=self._reconnect_count,
)
def _prepare_connection_for_send(self) -> bool:
reused = self._smtp is not None and self._messages_on_connection > 0
if self._smtp is not None and self._messages_on_connection >= self.policy.max_messages_per_connection:
self._discard_connection()
reused = False
elif reused and self.policy.health_check_before_reuse:
try:
code, _message = self._smtp.noop()
if int(code) >= 400:
raise smtplib.SMTPServerDisconnected(f"SMTP NOOP returned {code}")
except (OSError, smtplib.SMTPException):
self._discard_connection()
reused = False
self.preflight()
return reused and self._smtp is not None
def _connect_with_retries(self) -> None:
last_error: BaseException | None = None
for attempt in range(self.policy.reconnect_attempts + 1):
try:
smtp = _open_smtp(self.smtp_config)
except smtplib.SMTPAuthenticationError as exc:
raise SmtpSendError(
"SMTP authentication failed during batch preflight.",
systemic=True,
reason_code="smtp_authentication_failed",
phase="preflight",
) from exc
except SmtpConfigurationError:
raise
except smtplib.SMTPResponseException as exc:
temporary = 400 <= int(exc.smtp_code) < 500
last_error = exc
if not temporary or attempt >= self.policy.reconnect_attempts:
raise SmtpSendError(
"SMTP server rejected batch preflight.",
temporary=temporary,
systemic=True,
reason_code="smtp_preflight_rejected",
phase="preflight",
) from exc
continue
except (OSError, smtplib.SMTPException) as exc:
last_error = exc
if attempt >= self.policy.reconnect_attempts:
raise SmtpSendError(
"SMTP connectivity is unavailable during batch preflight.",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
) from exc
continue
self._smtp = smtp
if attempt > 0 or self._connection_sequence > 0:
self._reconnect_count += 1
self._connection_sequence += 1
self._messages_on_connection = 0
return
raise SmtpSendError(
f"SMTP batch preflight failed: {last_error}",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
)
def _discard_connection(self) -> None:
smtp, self._smtp = self._smtp, None
self._messages_on_connection = 0
if smtp is None:
return
try:
smtp.quit()
except Exception as quit_exc:
_log_smtp_cleanup_failure("closing batch connection", quit_exc)
try:
smtp.close()
except Exception as close_exc:
_log_smtp_cleanup_failure("closing batch socket", close_exc)
def _environment_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().casefold() in {"1", "true", "yes", "on"}
def _environment_int(name: str, *, default: int, minimum: int, maximum: int) -> int:
value = os.getenv(name)
try:
parsed = int(value) if value is not None else default
except ValueError:
parsed = default
return max(minimum, min(maximum, parsed))
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
@@ -324,15 +640,27 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
temporary=False,
systemic=True,
reason_code="smtp_authentication_failed",
phase="preflight",
) from exc
except smtplib.SMTPResponseException as exc:
raise SmtpSendError(
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_preflight_rejected",
phase="preflight",
) from exc
except (OSError, smtplib.SMTPException) as exc:
# No message transmission has begun yet; a later explicit retry is safe.
raise SmtpSendError(f"SMTP connection failed: {exc}", temporary=True) from exc
raise SmtpSendError(
f"SMTP connection failed: {exc}",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
) from exc
try:
if isinstance(message, bytes):
@@ -352,6 +680,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_sender_refused",
) from exc
except smtplib.SMTPResponseException as exc:
# An explicit SMTP response means the server rejected the transaction;
@@ -359,6 +689,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=int(exc.smtp_code) == 421,
reason_code="smtp_connection_closed" if int(exc.smtp_code) == 421 else "smtp_message_rejected",
) from exc
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
# A connection loss after DATA began can happen after the server accepted
@@ -366,6 +698,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP outcome is unknown after transmission started: {exc}",
outcome_unknown=True,
systemic=True,
reason_code="smtp_connection_lost_after_transmission",
) from exc
finally:
try:
@@ -387,6 +721,9 @@ def _smtp_send_result(
envelope_from: str,
envelope_recipients: list[str],
refused: dict[str, tuple[int, bytes]],
connection_sequence: int = 1,
session_reused: bool = False,
reconnect_count: int = 0,
) -> SmtpSendResult:
return SmtpSendResult(
host=host,
@@ -395,6 +732,9 @@ def _smtp_send_result(
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
connection_sequence=connection_sequence,
session_reused=session_reused,
reconnect_count=reconnect_count,
)
@@ -404,15 +744,19 @@ def send_email_bytes(
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
batch_session: SmtpBatchSession | None = None,
) -> SmtpSendResult:
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
return _send_smtp_payload(
message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
if batch_session is not None:
if batch_session.smtp_config != smtp_config:
raise SmtpConfigurationError("SMTP batch session does not match the resolved transport.")
return batch_session.send(
message_bytes,
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(
+42 -16
View File
@@ -23,6 +23,10 @@ from govoplan_core.security.secrets import decrypt_secret
from govoplan_mail.backend.config import (
ImapConfig,
ImapServerConfig,
JmapConfig,
JmapServerConfig,
Pop3Config,
Pop3ServerConfig,
SmtpConfig,
SmtpServerConfig,
)
@@ -34,7 +38,7 @@ from govoplan_mail.backend.db.models import (
)
MAIL_SERVER_PROTOCOLS = frozenset({"smtp", "imap"})
MAIL_SERVER_PROTOCOLS = frozenset({"smtp", "imap", "jmap", "pop3"})
class MailServerHierarchyError(RuntimeError):
@@ -68,7 +72,7 @@ class ResolvedMailTransport:
profile: MailServerProfile
server: MailServerEndpoint | None
credential: CredentialEnvelope | None
config: SmtpConfig | ImapConfig
config: SmtpConfig | ImapConfig | JmapConfig | Pop3Config
transport_revision: str
@@ -358,7 +362,7 @@ def initialize_profile_hierarchy(
for protocol, value in (("smtp", smtp), ("imap", imap)):
if value is None or protocol in existing:
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 = {
"username": raw.pop("username", None),
"password": raw.pop("password", None),
@@ -951,6 +955,10 @@ def resolve_mail_transport(
)
)
if server is None:
if clean_protocol in {"jmap", "pop3"}:
raise MailServerHierarchyError(
f"The selected Mail profile has no active {clean_protocol.upper()} server"
)
return _legacy_resolved_transport(profile, clean_protocol)
binding, credential = _selected_server_credential(
session,
@@ -978,16 +986,19 @@ def resolve_mail_transport(
if clean_protocol == "smtp":
payload["username"] = profile.smtp_username
payload["password"] = decrypt_secret(profile.smtp_password_encrypted)
else:
elif clean_protocol == "imap":
payload["username"] = profile.imap_username
payload["password"] = decrypt_secret(profile.imap_password_encrypted)
config: SmtpConfig | ImapConfig
config: SmtpConfig | ImapConfig | JmapConfig | Pop3Config
try:
config = (
SmtpConfig.model_validate(payload)
if clean_protocol == "smtp"
else ImapConfig.model_validate(payload)
)
if clean_protocol == "smtp":
config = SmtpConfig.model_validate(payload)
elif clean_protocol == "imap":
config = ImapConfig.model_validate(payload)
elif clean_protocol == "jmap":
config = JmapConfig.model_validate(payload)
else:
config = Pop3Config.model_validate(payload)
except Exception as exc:
raise MailServerHierarchyError(
f"The selected {clean_protocol.upper()} server configuration is invalid"
@@ -1030,6 +1041,14 @@ def select_mail_transport(
)
)
if server is None:
if clean_protocol in {"jmap", "pop3"}:
return SelectedMailTransport(
profile=profile,
server=None,
credential=None,
available=False,
transport_revision="unconfigured",
)
legacy_config = (
profile.smtp_config
if clean_protocol == "smtp"
@@ -1209,6 +1228,8 @@ def _server_matches_legacy_transport(
) -> bool:
if not server.is_default:
return False
if server.protocol not in {"smtp", "imap"}:
return False
if server.protocol == "smtp":
legacy_config = profile.smtp_config or {}
legacy_revision = profile.smtp_transport_revision
@@ -1302,11 +1323,14 @@ def _validated_server_config(
payload.pop("password", None)
payload.pop("enabled", None)
try:
model = (
SmtpServerConfig.model_validate(payload)
if protocol == "smtp"
else ImapServerConfig.model_validate(payload)
)
if protocol == "smtp":
model = SmtpServerConfig.model_validate(payload)
elif protocol == "imap":
model = ImapServerConfig.model_validate(payload)
elif protocol == "jmap":
model = JmapServerConfig.model_validate(payload)
else:
model = Pop3ServerConfig.model_validate(payload)
except Exception as exc:
raise MailServerHierarchyError(
f"Invalid {protocol.upper()} server configuration"
@@ -1347,7 +1371,9 @@ def _server_scope(
def _normalize_protocol(value: str) -> str:
clean = str(value or "").strip().casefold()
if clean not in MAIL_SERVER_PROTOCOLS:
raise MailServerHierarchyError("Mail server protocol must be smtp or imap")
raise MailServerHierarchyError(
"Mail server protocol must be smtp, imap, jmap or pop3"
)
return clean
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from pydantic import ValidationError
from govoplan_mail.backend.router import (
create_mail_address_contact,
list_mail_address_write_targets,
lookup_mail_addresses,
)
from govoplan_mail.backend.schemas import MailContactCreateRequest
class _Session:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
self.rollbacks += 1
class _Writer:
def __init__(self, *, allowed: bool = True, read_only: bool = False) -> None:
self.allowed = allowed
self.read_only = read_only
self.created_payload = None
self.created_provenance = None
def list_write_targets(self, _session, _principal, *, operation):
return (
SimpleNamespace(
address_book_id="book-1",
address_book_label="Personal contacts",
operation=operation,
allowed=self.allowed,
reason="allowed" if self.allowed else "read_only_source",
message="Contact can be added." if self.allowed else "This source is read-only.",
scope_type="user",
scope_id="user-1",
source_kind="local" if self.allowed else "ldap",
read_only=self.read_only,
required_scopes=("addresses:contacts:write",),
provenance={"policy": "addresses"},
),
)
def can_write_to_address_book(self, _session, _principal, *, address_book_id, operation):
return self.list_write_targets(_session, _principal, operation=operation)[0]
def create_contact(self, _session, _principal, *, address_book_id, payload, provenance):
self.created_payload = payload
self.created_provenance = provenance
return SimpleNamespace(
contact_id="contact-1",
address_book_id=address_book_id,
display_name=payload["display_name"],
email=payload["emails"][0]["email"],
source_kind="local",
provenance=provenance,
)
def _principal():
return SimpleNamespace(has=lambda scope: scope == "mail:profile:use")
class MailAddressIntegrationTests(unittest.TestCase):
def test_optional_capabilities_fail_open_for_mail(self) -> None:
with patch("govoplan_mail.backend.router._registry_capability", return_value=None):
lookup = lookup_mail_addresses(query="ada", limit=25, session=_Session(), principal=_principal())
targets = list_mail_address_write_targets(session=_Session(), principal=_principal())
self.assertFalse(lookup.available)
self.assertEqual(lookup.candidates, [])
self.assertFalse(targets.available)
self.assertEqual(targets.targets, [])
def test_write_target_preserves_read_only_decision(self) -> None:
writer = _Writer(allowed=False, read_only=True)
with patch("govoplan_mail.backend.router._registry_capability", return_value=writer):
response = list_mail_address_write_targets(session=_Session(), principal=_principal())
self.assertTrue(response.available)
self.assertFalse(response.targets[0].allowed)
self.assertTrue(response.targets[0].read_only)
self.assertEqual(response.targets[0].reason, "read_only_source")
def test_blocked_target_cannot_be_bypassed_by_create(self) -> None:
session = _Session()
writer = _Writer(allowed=False, read_only=True)
with (
patch("govoplan_mail.backend.router._registry_capability", return_value=writer),
self.assertRaises(HTTPException) as raised,
):
create_mail_address_contact(
MailContactCreateRequest(
address_book_id="book-1",
display_name="Ada Lovelace",
email="ada@example.test",
),
session=session,
principal=_principal(),
)
self.assertEqual(raised.exception.status_code, 422)
self.assertEqual(session.commits, 0)
def test_allowed_create_uses_writer_and_records_consumer_provenance(self) -> None:
session = _Session()
writer = _Writer()
with patch("govoplan_mail.backend.router._registry_capability", return_value=writer):
result = create_mail_address_contact(
MailContactCreateRequest(
address_book_id="book-1",
display_name="Ada Lovelace",
email="ada@example.test",
),
session=session,
principal=_principal(),
)
self.assertEqual(result.contact_id, "contact-1")
self.assertEqual(writer.created_payload["emails"][0]["email"], "ada@example.test")
self.assertEqual(writer.created_provenance["consumer_module"], "mail")
self.assertEqual(session.commits, 1)
def test_proxy_rejects_invalid_email_before_calling_writer(self) -> None:
with self.assertRaises(ValidationError):
MailContactCreateRequest(address_book_id="book-1", email="not-an-email")
if __name__ == "__main__":
unittest.main()
+233
View File
@@ -0,0 +1,233 @@
"""Batch transport reuse must never become an authorization or evidence cache."""
from __future__ import annotations
import imaplib
from dataclasses import asdict
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from govoplan_core.security.credential_envelopes import CredentialEnvelope
from govoplan_mail.backend import capabilities, server_hierarchy
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerEndpoint
from govoplan_mail.backend.mail_profiles import MailProfileError
from govoplan_mail.backend.sending import imap as transport
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError
from test_campaign_protocol_authorization import hierarchy, imap_args # noqa: F401
def client():
return SimpleNamespace(
utf8_enabled=False,
append=Mock(return_value=("OK", [b"provider internal secret response"])),
logout=Mock(return_value=("BYE", [])),
noop=Mock(return_value=("OK", [])),
)
def recovery():
return SimpleNamespace(replayed=False, succeed_imap=Mock(), reject=Mock(), unknown=Mock())
def test_reuses_transport_but_decrypts_and_authorizes_each_message_and_records_each_effect(hierarchy):
connection = client()
effects = [recovery(), recovery()]
with (
patch.object(transport, "_open_imap", return_value=connection) as open_connection,
patch.object(capabilities, "begin_provider_effect_recovery", side_effect=effects) as begin,
patch.object(capabilities, "_authorized_campaign_profile", wraps=capabilities._authorized_campaign_profile) as authorize,
patch.object(capabilities, "assert_mail_policy_allows_send", wraps=capabilities.assert_mail_policy_allows_send) as policy,
patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt,
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1") as batch,
):
assert batch.connection_count == 0
open_connection.assert_not_called()
results = [capabilities.append_campaign_message_to_sent(
hierarchy.session, **imap_args(message_bytes=f"message-{index}".encode(), recovery_effect_id=f"effect-{index}"),
) for index in range(2)]
assert batch.connection_count == 1
assert batch.reconnect_count == 0
assert authorize.call_count == policy.call_count == 2
assert [call.kwargs["credential_id"] for call in decrypt.call_args_list] == ["imap-credential"] * 2
assert [call.kwargs["effect_id"] for call in begin.call_args_list] == ["effect-0", "effect-1"]
assert [item.session_reused for item in results] == [False, True]
assert [item.connection_sequence for item in results] == [1, 1]
assert "secret" not in repr([asdict(item) for item in results])
assert "imap.example" not in repr(results)
open_connection.assert_called_once()
connection.logout.assert_called_once()
for effect in effects:
effect.succeed_imap.assert_called_once_with(folder="Sent")
effect.reject.assert_not_called()
effect.unknown.assert_not_called()
@pytest.mark.parametrize("change", ["credential_revoked", "smtp_revision_changed", "imap_revision_changed"])
def test_next_message_rechecks_current_authority_and_frozen_revisions_before_decryption(hierarchy, change):
connection = client()
with (
patch.object(transport, "_open_imap", return_value=connection),
patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt,
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"),
):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args())
if change == "credential_revoked":
hierarchy.session.get(CredentialEnvelope, "imap-credential").is_active = False
else:
protocol = change.split("_")[0]
hierarchy.session.get(MailServerEndpoint, f"{protocol}-server").transport_revision = "changed"
hierarchy.session.commit()
with pytest.raises(MailProfileError):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(message_bytes=b"blocked"))
assert decrypt.call_count == 1
connection.append.assert_called_once()
def test_effective_credential_policy_is_not_cached_by_warm_batch(hierarchy):
for scope in ("system", "tenant"):
hierarchy.session.get(MailProfilePolicy, f"{scope}-policy").policy = {
"smtp_credentials": {"inherit": False}, "imap_credentials": {"inherit": True},
}
hierarchy.session.commit()
connection = client()
with (
patch.object(transport, "_open_imap", return_value=connection),
patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt,
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"),
):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(imap_credential_id=None))
hierarchy.session.get(MailProfilePolicy, "tenant-policy").policy = {
"imap_credentials": {"inherit": False},
}
hierarchy.session.commit()
with pytest.raises(MailProfileError, match="explicit credential"):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(imap_credential_id=None))
assert decrypt.call_count == 1
connection.append.assert_called_once()
def test_copying_context_to_a_worker_cannot_share_the_batch_connection(hierarchy):
from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
with (
patch.object(capabilities, "_authorized_campaign_profile") as authorize,
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"),
ThreadPoolExecutor(max_workers=1) as executor,
):
future = executor.submit(copy_context().run, capabilities.append_campaign_message_to_sent, hierarchy.session, **imap_args())
with pytest.raises(ImapConfigurationError, match="scope"):
future.result(timeout=5)
authorize.assert_not_called()
@pytest.mark.parametrize("scope", [{"tenant_id": "other"}, {"campaign_id": "other"}])
def test_cross_scope_batch_is_rejected_before_authorization_decryption_evidence_or_network(hierarchy, scope):
with (
patch.object(capabilities, "_authorized_campaign_profile") as authorize,
patch.object(capabilities, "begin_provider_effect_recovery") as begin,
patch.object(transport, "_open_imap") as open_connection,
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"),
pytest.raises(ImapConfigurationError, match="scope"),
):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(**scope))
authorize.assert_not_called()
begin.assert_not_called()
open_connection.assert_not_called()
def test_changed_resolved_secret_does_not_reuse_previous_authenticated_connection(hierarchy):
from govoplan_core.security.secrets import encrypt_secret
connections = [client(), client()]
with (
patch.object(transport, "_open_imap", side_effect=connections) as open_connection,
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1") as batch,
):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args())
hierarchy.session.get(CredentialEnvelope, "imap-credential").secret_data_encrypted = encrypt_secret('{"password":"new fake password"}')
hierarchy.session.commit()
result = capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(message_bytes=b"new credential"))
assert batch.connection_count == result.connection_sequence == 2
assert not result.session_reused
assert open_connection.call_count == 2
assert open_connection.call_args_list[0].args[0].password != open_connection.call_args_list[1].args[0].password
for connection in connections:
connection.logout.assert_called_once()
connection.append.assert_called_once()
def test_nested_context_restores_outer_connection_and_always_cleans_up(hierarchy):
outer_client, inner_client = client(), client()
with patch.object(transport, "_open_imap", side_effect=[outer_client, inner_client]) as open_connection:
with capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1") as outer:
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args())
with pytest.raises(ValueError, match="caller"):
with capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args())
raise ValueError("caller failure")
inner_client.logout.assert_called_once()
assert capabilities._ACTIVE_IMAP_BATCH.get() is outer
result = capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args())
assert result.session_reused
assert capabilities._ACTIVE_IMAP_BATCH.get() is None
assert open_connection.call_count == 2
assert outer_client.append.call_count == 2
outer_client.logout.assert_called_once()
def test_known_success_evidence_does_not_replay_even_with_warm_connection(hierarchy):
connection = client()
first, replayed = recovery(), recovery()
replayed.replayed = True
with (
patch.object(transport, "_open_imap", return_value=connection),
patch.object(capabilities, "begin_provider_effect_recovery", side_effect=[first, replayed]),
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"),
):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(recovery_effect_id="same-effect"))
with pytest.raises(ImapAppendError, match="already succeeded") as caught:
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(recovery_effect_id="same-effect"))
assert caught.value.outcome_unknown
connection.append.assert_called_once()
replayed.succeed_imap.assert_not_called()
def test_ambiguous_effect_is_recorded_once_sanitized_and_never_replayed(hierarchy):
connection = client()
connection.append.side_effect = imaplib.IMAP4.abort("provider host and secret")
effect = recovery()
with (
patch.object(transport, "_open_imap", return_value=connection) as open_connection,
patch.object(capabilities, "begin_provider_effect_recovery", return_value=effect),
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"),
pytest.raises(ImapAppendError) as caught,
):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(recovery_effect_id="effect-1"))
assert caught.value.outcome_unknown
assert "secret" not in str(caught.value)
assert "secret" not in repr(effect.unknown.call_args)
effect.unknown.assert_called_once()
effect.succeed_imap.assert_not_called()
effect.reject.assert_not_called()
open_connection.assert_called_once()
connection.append.assert_called_once()
connection.logout.assert_called_once()
def test_evidence_finalization_failure_closes_batch_and_prevents_further_effects(hierarchy):
connection = client()
effect = recovery()
effect.succeed_imap.side_effect = OSError("evidence store unavailable")
with (
patch.object(transport, "_open_imap", return_value=connection),
patch.object(capabilities, "begin_provider_effect_recovery", return_value=effect),
capabilities.campaign_imap_batch(tenant_id="tenant-1", campaign_id="campaign-1"),
):
with pytest.raises(ImapAppendError) as caught:
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(recovery_effect_id="effect-1"))
assert caught.value.outcome_unknown
with pytest.raises(ImapConfigurationError):
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(message_bytes=b"must not run"))
connection.append.assert_called_once()
connection.logout.assert_called_once()
@@ -0,0 +1,187 @@
"""Real stored hierarchy and policy; provider calls are replaced, never live."""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.core.campaigns import CampaignMailPolicyContext
from govoplan_core.db.base import Base
from govoplan_core.security.credential_envelopes import CredentialEnvelope
from govoplan_core.security.secrets import encrypt_secret
from govoplan_core.tenancy.scope import Tenant
from govoplan_mail.backend import capabilities, mail_profiles, server_hierarchy
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerCredentialBinding, MailServerEndpoint, MailServerProfile
from govoplan_mail.backend.sending import smtp as smtp_module
@pytest.fixture
def hierarchy(tmp_path):
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'smtp-policy.db'}")
if "access_users" not in Base.metadata.tables:
Table("access_users", Base.metadata, Column("id", String(36), primary_key=True))
for table in (Base.metadata.tables["access_users"], SystemSettings.__table__, Tenant.__table__,
CredentialEnvelope.__table__, MailServerProfile.__table__, MailServerEndpoint.__table__,
MailServerCredentialBinding.__table__, MailProfilePolicy.__table__):
table.create(engine)
with Session(engine) as session:
session.add(SystemSettings(id="global", settings={}))
session.add(Tenant(id="tenant-1", slug="test", name="Test", settings={}))
profile = MailServerProfile(id="profile-1", tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1", name="Test mail", slug="test",
smtp_config={"host": "smtp.example.test", "port": 587, "security": "starttls"},
imap_config={"host": "imap.example.test", "port": 993, "security": "tls"},
smtp_transport_revision="smtp-current", imap_transport_revision="imap-current", inherit_to_lower_scopes=True)
session.add(profile)
for protocol, port in (("smtp", 587), ("imap", 993)):
server = MailServerEndpoint(id=f"{protocol}-server", profile_id=profile.id, tenant_id="tenant-1", protocol=protocol,
name=protocol, scope_type="tenant", scope_id="tenant-1", inherit_to_lower_scopes=True,
is_default=True, is_active=True, transport_revision=f"{protocol}-current",
config={"host": f"{protocol}.example.test", "port": port, "security": "starttls" if protocol == "smtp" else "tls"})
credential = CredentialEnvelope(id=f"{protocol}-credential", tenant_id="tenant-1", scope_type="tenant", scope_id="tenant-1",
name=protocol, credential_kind="username_password", public_data={"username": f"{protocol}-user"},
secret_data_encrypted=encrypt_secret(json.dumps({"password": f"fake-{protocol}-password"})), secret_keys=["password"],
allowed_modules=["mail"], allowed_server_refs=[f"mail:{protocol}-server"], inherit_to_lower_scopes=True, is_active=True)
session.add_all([server, credential, MailServerCredentialBinding(id=f"{protocol}-binding", server_id=server.id,
credential_id=credential.id, is_default=True)])
for scope in ("system", "tenant"):
session.add(MailProfilePolicy(id=f"{scope}-policy", tenant_id=None if scope == "system" else "tenant-1",
scope_type=scope, scope_id=None if scope == "system" else "tenant-1",
policy={"smtp_credentials": {"inherit": False}, "imap_credentials": {"inherit": False}}))
session.commit()
context = CampaignMailPolicyContext(id="campaign-1", tenant_id="tenant-1")
provider = SimpleNamespace(get_campaign_mail_policy_context=lambda *_args, **_kwargs: context)
# Only the optional Campaign context provider and external I/O are mocked.
# Profiles, policy inheritance, endpoint/credential ACLs and decryption are real.
with patch.object(mail_profiles, "_campaign_policy_provider", return_value=provider), \
patch("socket.create_connection", side_effect=AssertionError("No live network in regression tests")):
yield SimpleNamespace(session=session, profile=profile, context=context)
engine.dispose()
def smtp_args(**overrides):
return {"tenant_id": "tenant-1", "campaign_id": "campaign-1", "profile_id": "profile-1",
"envelope_from": "sender@example.test", "envelope_recipients": ["recipient@example.test"], "from_header": "sender@example.test",
"expected_smtp_transport_revision": "smtp-current", "smtp_server_id": "smtp-server", "smtp_credential_id": "smtp-credential", **overrides}
def selection():
return {"smtp_server_id": "smtp-server", "smtp_credential_id": "smtp-credential", "imap_server_id": "imap-server", "imap_credential_id": "imap-credential"}
def test_smtp_batch_accepts_explicit_smtp_when_other_protocol_requires_explicit_selection(hierarchy):
# The full frozen selection is valid; runtime SMTP deliberately carries only SMTP.
summary = capabilities.campaign_profile_delivery_summary(hierarchy.session, tenant_id="tenant-1", campaign_id="campaign-1", profile_id="profile-1", **selection())
assert summary["smtp_available"] and summary["imap_available"]
fake_connection = Mock()
with patch.object(smtp_module, "_open_smtp", return_value=fake_connection) as opener, \
patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt:
with capabilities.campaign_smtp_batch(hierarchy.session, **smtp_args()) as batch:
assert batch.status == "ready"
assert batch.connection_count == 1
assert [call.kwargs["credential_id"] for call in decrypt.call_args_list] == ["smtp-credential"]
assert opener.call_args.args[0].username == "smtp-user"
fake_connection.sendmail.assert_not_called()
fake_connection.send_message.assert_not_called()
def test_smtp_single_uses_same_selected_protocol_authorization(hierarchy):
result = SimpleNamespace(envelope_recipients=["recipient@example.test"], refused_recipients={})
with patch.object(capabilities, "send_email_bytes", return_value=result) as send, \
patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt:
sent = capabilities.send_campaign_email_bytes(hierarchy.session, message_bytes=b"frozen test message", **smtp_args())
assert sent.accepted_count == 1
assert [call.kwargs["credential_id"] for call in decrypt.call_args_list] == ["smtp-credential"]
assert send.call_args.kwargs["smtp_config"].username == "smtp-user"
def imap_args(**overrides):
return {"tenant_id": "tenant-1", "campaign_id": "campaign-1", "profile_id": "profile-1",
"message_bytes": b"frozen test message", "folder": "Sent",
"expected_smtp_transport_revision": "smtp-current", "expected_imap_transport_revision": "imap-current",
# An IMAP-only call need not submit an unrelated SMTP credential.
"smtp_server_id": "smtp-server", "imap_server_id": "imap-server", "imap_credential_id": "imap-credential", **overrides}
def test_imap_append_checks_and_decrypts_only_selected_protocol(hierarchy):
with patch.object(capabilities, "append_message_to_sent", return_value=SimpleNamespace(folder="Sent")) as append, \
patch.object(server_hierarchy, "resolve_credential_envelope", wraps=server_hierarchy.resolve_credential_envelope) as decrypt:
result = capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args())
assert result.folder == "Sent"
assert [call.kwargs["credential_id"] for call in decrypt.call_args_list] == ["imap-credential"]
assert append.call_args.kwargs["imap_config"].username == "imap-user"
@pytest.mark.parametrize("operation", ["smtp_batch", "smtp_single", "imap_append", "imap_endpoint_only"])
def test_selected_protocol_still_requires_explicit_credentials_before_decryption_or_provider(hierarchy, operation):
if operation == "imap_endpoint_only":
hierarchy.profile.imap_config = None # Current endpoint exists without the legacy mirror.
hierarchy.session.commit()
with patch.object(server_hierarchy, "resolve_credential_envelope", side_effect=AssertionError("Policy must reject before decryption")) as decrypt, \
patch.object(smtp_module, "_open_smtp", side_effect=AssertionError("No network")) as opener, \
patch.object(capabilities, "send_email_bytes", side_effect=AssertionError("No SMTP")) as send, \
patch.object(capabilities, "append_message_to_sent", side_effect=AssertionError("No IMAP")) as append:
with pytest.raises(mail_profiles.MailProfileError, match=f"effective {'SMTP' if operation.startswith('smtp') else 'IMAP'}"):
if operation == "smtp_batch":
with capabilities.campaign_smtp_batch(hierarchy.session, **smtp_args(smtp_credential_id=None)): pass
elif operation == "smtp_single":
capabilities.send_campaign_email_bytes(hierarchy.session, message_bytes=b"test", **smtp_args(smtp_credential_id=None))
else:
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(imap_credential_id=None))
decrypt.assert_not_called(); opener.assert_not_called(); send.assert_not_called(); append.assert_not_called()
@pytest.mark.parametrize("operation", ["smtp_batch", "smtp_single", "imap_append"])
@pytest.mark.parametrize("mutation", ["stale_revision", "inactive_credential", "wrong_server", "wrong_tenant"])
def test_selected_transport_revision_and_credential_authority_remain_fail_closed(hierarchy, operation, mutation):
protocol = "smtp" if operation.startswith("smtp") else "imap"
overrides = {}
if mutation == "stale_revision":
overrides[f"expected_{protocol}_transport_revision"] = "stale-build-revision"
elif mutation == "wrong_server":
overrides[f"{protocol}_credential_id"] = "imap-credential" if protocol == "smtp" else "smtp-credential"
else:
credential = hierarchy.session.get(CredentialEnvelope, f"{protocol}-credential")
if mutation == "inactive_credential": credential.is_active = False
else: credential.tenant_id = "other-tenant"
hierarchy.session.commit()
with patch.object(server_hierarchy, "resolve_credential_envelope", side_effect=AssertionError("Reject stale/unauthorized before decrypt")) as decrypt, \
patch.object(smtp_module, "_open_smtp", side_effect=AssertionError("No network")) as opener, \
patch.object(capabilities, "send_email_bytes", side_effect=AssertionError("No SMTP")) as send, \
patch.object(capabilities, "append_message_to_sent", side_effect=AssertionError("No IMAP")) as append:
with pytest.raises(mail_profiles.MailProfileError):
if operation == "smtp_batch":
with capabilities.campaign_smtp_batch(hierarchy.session, **smtp_args(**overrides)): pass
elif operation == "smtp_single":
capabilities.send_campaign_email_bytes(hierarchy.session, message_bytes=b"test", **smtp_args(**overrides))
else:
capabilities.append_campaign_message_to_sent(hierarchy.session, **imap_args(**overrides))
decrypt.assert_not_called(); opener.assert_not_called(); send.assert_not_called(); append.assert_not_called()
@pytest.mark.parametrize("missing", ["smtp_credential_id", "imap_credential_id"])
def test_full_authoring_and_summary_still_check_both_protocols_without_decryption(hierarchy, missing):
complete = selection()
raw = {"server": {"mail_profile_id": "profile-1", **complete}}
mail_profiles.assert_campaign_mail_policy_allows_json(hierarchy.session, tenant_id="tenant-1", campaign_id="campaign-1", raw_json=raw)
incomplete = {**complete, missing: None}
with patch.object(server_hierarchy, "resolve_credential_envelope", side_effect=AssertionError("Summary must not decrypt")) as decrypt:
with pytest.raises(mail_profiles.MailProfileError, match="explicit credential selection"):
capabilities.campaign_profile_delivery_summary(hierarchy.session, tenant_id="tenant-1", campaign_id="campaign-1", profile_id="profile-1", **incomplete)
with pytest.raises(mail_profiles.MailProfileError, match="explicit credential selection"):
mail_profiles.assert_campaign_mail_policy_allows_json(hierarchy.session, tenant_id="tenant-1", campaign_id="campaign-1", raw_json={"server": {"mail_profile_id": "profile-1", **incomplete}})
decrypt.assert_not_called()
def test_batch_still_enforces_all_recipient_domains_before_connection(hierarchy):
row = hierarchy.session.get(MailProfilePolicy, "system-policy")
row.policy = {**row.policy, "blacklist": {"recipient_domains": ["blocked.example"]}}
hierarchy.session.commit()
with patch.object(smtp_module, "_open_smtp", side_effect=AssertionError("Forbidden recipient must never connect")) as opener:
with pytest.raises(mail_profiles.MailProfileError, match="effective Mail policy"):
with capabilities.campaign_smtp_batch(hierarchy.session, **smtp_args(envelope_recipients=["ok@example.test", "no@blocked.example"])): pass
opener.assert_not_called()
+410
View File
@@ -0,0 +1,410 @@
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,
MAIL_INFRASTRUCTURE_DEPENDENCY_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)
self.assertIn(
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
manifest.capability_factories,
)
description = self.provider.describe()
self.assertEqual(("smtp_profile",), description.fragment_types)
def test_inventory_reports_actual_smtp_endpoint_and_credential_binding(self) -> None:
fragment = ConfigurationPackageFragment(
module_id="mail",
fragment_type="smtp_profile",
fragment_id="inventory-smtp",
payload={"credential_envelope_id": "credential-1"},
)
self.provider.apply(fragment, {}, self.context)
dependencies = self.provider.infrastructure_dependencies()
self.assertEqual(1, len(dependencies))
self.assertEqual("mail.smtp", dependencies[0].capability_id)
self.assertEqual("smtp_endpoint", dependencies[0].dependency_type)
self.assertEqual(1, dependencies[0].metrics["credential_binding_count"])
self.assertNotIn("test-mail", str(dependencies[0].to_dict()))
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()
+42 -1
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationContext
from govoplan_mail.backend.documentation import (
_credential_line,
documentation_configuration_states,
documentation_topics,
)
@@ -26,6 +27,46 @@ class _Principal:
class MailRuntimeDocumentationTests(unittest.TestCase):
def test_mailbox_toolbar_contract_documents_context_refresh_and_read_only_bounds_in_both_languages(self) -> None:
from govoplan_mail.backend.manifest import manifest
topic = next(item for item in manifest.documentation if item.id == "mail.workflow.read-mailbox")
self.assertEqual(set(topic.documentation_types), {"user", "admin"})
self.assertTrue({"mail.mailbox.reload", "mail.mailbox.tools"}.issubset(topic.metadata["help_contexts"]))
for phrase in ("one right-aligned Reload", "Mailbox tools", "IMAP retains the current page", "JMAP starts a fresh cursor chain", "Failed refreshes preserve usable loaded data", "ignore late reads", "do not grant profile administration rights"):
self.assertIn(phrase, topic.body)
for phrase in ("genau einmal Neuladen rechts", "Postfachwerkzeuge", "IMAP behält die Seite", "JMAP beginnt", "Verspätete Antworten", "Fehlgeschlagene Aktualisierungen", "keine Profilverwaltungsrechte"):
self.assertIn(phrase, topic.translations["de"]["body"])
def test_imap_batch_contract_documents_bounds_and_per_message_safety_in_both_languages(self) -> None:
from govoplan_mail.backend.manifest import manifest
topic = next(item for item in manifest.documentation if item.id == "mail.reference.campaign-delivery-contract")
for body in (topic.body, topic.translations["de"]["body"]):
self.assertIn("campaign_imap_batch", body)
self.assertIn("GOVOPLAN_IMAP_BATCH_REUSE", body)
self.assertIn("MULTIAPPEND", body)
self.assertIn("100", body)
self.assertIn("300", body)
self.assertIn("permissions and recovery evidence are never cached", topic.body)
self.assertIn("no automatic APPEND replay", topic.body)
self.assertIn("Berechtigungen und Nachweise werden nicht zwischengespeichert", topic.translations["de"]["body"])
def test_campaign_contract_documents_protocol_scoped_runtime_and_complete_validation(self) -> None:
from govoplan_mail.backend.manifest import manifest
topic = next(item for item in manifest.documentation if item.id == "mail.reference.campaign-delivery-contract")
self.assertIn("Runtime credential-selection checks are protocol-scoped", topic.body)
self.assertIn("still check both configured protocols", topic.body)
self.assertIn("before credential decryption or provider contact", topic.body)
self.assertIn("protokollbezogen", topic.translations["de"]["body"])
self.assertIn("weiterhin beide konfigurierten Protokolle", topic.translations["de"]["body"])
def test_credential_policy_guidance_describes_explicit_mail_references_not_local_secrets(self) -> None:
text = _credential_line({"smtp_credentials": {"inherit": False}, "imap_credentials": {"inherit": True}})
self.assertIn("SMTP requires an explicit Mail credential", text)
self.assertIn("IMAP allows a profile default or explicit Mail credential", text)
self.assertIn("Secrets remain in Mail", text)
self.assertNotIn("local credentials", text)
self.assertNotIn("only for protocols that inherit", text)
def setUp(self) -> None:
self.session = Session()
@@ -182,7 +223,7 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
topics = {topic.id: topic for topic in get_manifest().documentation}
self.assertEqual(topics["mail.workflow.choose-and-test-profile"].metadata["help_contexts"], ["mail.profiles", "app.settings"])
self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list", "mail.mailbox"])
self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list", "mail.mailbox", "mail.mailbox.reload", "mail.mailbox.tools"])
self.assertIn("mail.admin.profiles", topics["mail.profiles-and-policy"].metadata["help_contexts"])
self.assertIn("mail.bounce-processing", topics["mail.bounce-processing"].metadata["help_contexts"])
+419
View File
@@ -0,0 +1,419 @@
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,
MailPop3Import,
MailServerEndpoint,
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__,
MailServerEndpoint.__table__,
MailMailboxMessageIndex.__table__,
MailPop3Import.__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,
)
pop3_server = MailServerEndpoint(
id="pop3-server-subject",
profile_id=profile.id,
tenant_id="tenant-1",
protocol="pop3",
name="Legacy POP3",
config={"host": "pop3-secret-do-not-export"},
scope_type="tenant",
scope_id="tenant-1",
)
pop3_import = MailPop3Import(
id="pop3-import-subject",
tenant_id="tenant-1",
profile_id=profile.id,
pop3_server_id=pop3_server.id,
pop3_credential_id="credential-secret-do-not-export",
transport_revision="pop3-revision-secret-do-not-export",
provider_uidl="provider-uidl-secret-do-not-export",
fingerprint="e" * 64,
raw_sha256="f" * 64,
raw_message_encrypted="pop3-message-cipher-do-not-export",
message_id="pop3-message-id",
subject="Imported subject notice",
from_header="Legacy office <legacy@example.test>",
to_header="Subject Person <subject@example.test>",
date="2026-08-19",
body_preview="Imported message preview for the subject",
size_bytes=84,
status="pending_review",
imported_at=now,
deletion_requested=False,
deletion_status="not_requested",
)
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,
pop3_server,
pop3_import,
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",
"mail_pop3_import",
}.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",
"pop3-secret-do-not-export",
"credential-secret-do-not-export",
"pop3-revision-secret-do-not-export",
"provider-uidl-secret-do-not-export",
"pop3-message-cipher-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()
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
import imaplib
import unittest
from dataclasses import replace
from unittest.mock import Mock, patch
from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.sending.imap import (
ImapAppendError,
ImapBatchPolicy,
ImapBatchSession,
ImapConfigurationError,
append_message_to_sent,
)
class BatchClient:
utf8_enabled = False
def __init__(self, *, wire_folder=b"Gesendete &APw-bermittlung"):
self.login = Mock(return_value=("OK", []))
self.list = Mock(return_value=("OK", [b'(\\Sent) "/" "' + wire_folder + b'"']))
self.noop = Mock(return_value=("OK", []))
self.append = Mock(return_value=("OK", [b"APPEND complete"]))
self.logout = Mock(return_value=("BYE", []))
self.shutdown = Mock()
def config(**changes):
return ImapConfig(
host="imap.example.test", port=993, security="tls",
username="service", password="secret", sent_folder="auto",
).model_copy(update=changes)
class ImapBatchTests(unittest.TestCase):
def test_one_login_discovery_and_logout_for_many_sequential_appends(self):
client = BatchClient()
with (
patch("govoplan_mail.backend.sending.imap.validate_outbound_host"),
patch("govoplan_mail.backend.sending.imap._OutboundPolicyIMAP4SSL", return_value=client) as connect,
ImapBatchSession(config()) as batch,
):
results = [append_message_to_sent(bytes([i]), imap_config=config(), batch_session=batch) for i in range(50)]
self.assertEqual(batch.connection_count, 1)
self.assertEqual(batch.reconnect_count, 0)
client.logout.assert_not_called()
connect.assert_called_once()
client.login.assert_called_once_with("service", "secret")
client.list.assert_called_once()
client.noop.assert_not_called()
client.logout.assert_called_once()
self.assertEqual(client.append.call_count, 50)
self.assertEqual([call.args[3] for call in client.append.call_args_list], [bytes([i]) for i in range(50)])
self.assertEqual({call.args[0] for call in client.append.call_args_list}, {'"Gesendete &APw-bermittlung"'})
self.assertEqual({item.folder for item in results}, {"Gesendete übermittlung"})
self.assertEqual([item.session_reused for item in results], [False] + [True] * 49)
self.assertEqual({item.connection_sequence for item in results}, {1})
def test_count_rotation_rediscovers_original_wire_names_on_new_connection(self):
first = BatchClient(wire_folder=b"&U,BTFw-&ZeVnLIqe-")
second = BatchClient(wire_folder=b"&U,BTF2XlZyyKng-")
with (
patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[first, second]) as connect,
ImapBatchSession(config(), policy=replace(ImapBatchPolicy(), max_messages_per_connection=2)) as batch,
):
results = [batch.append(b"message") for _ in range(3)]
self.assertEqual(connect.call_count, 2)
first.list.assert_called_once()
second.list.assert_called_once()
first.logout.assert_called_once()
second.logout.assert_called_once()
self.assertEqual([item.connection_sequence for item in results], [1, 1, 2])
self.assertEqual([item.session_reused for item in results], [False, True, False])
self.assertEqual(results[-1].reconnect_count, 1)
self.assertEqual(first.append.call_args.args[0], '"&U,BTFw-&ZeVnLIqe-"')
self.assertEqual(second.append.call_args.args[0], '"&U,BTF2XlZyyKng-"')
def test_age_rotation_is_checked_before_next_append(self):
clock = [0.0]
first, second = BatchClient(), BatchClient()
with (
patch("govoplan_mail.backend.sending.imap.time.monotonic", side_effect=lambda: clock[0]),
patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[first, second]),
ImapBatchSession(config(), policy=replace(ImapBatchPolicy(), max_connection_age_seconds=20)) as batch,
):
batch.append(b"one")
clock[0] = 20.0
self.assertFalse(batch.append(b"two").session_reused)
first.noop.assert_not_called()
self.assertEqual(first.append.call_count + second.append.call_count, 2)
def test_idle_probe_can_reconnect_before_append_without_replaying_prior_message(self):
clock = [0.0]
first, second = BatchClient(), BatchClient()
first.noop.side_effect = imaplib.IMAP4.abort("gone")
with (
patch("govoplan_mail.backend.sending.imap.time.monotonic", side_effect=lambda: clock[0]),
patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[first, second]),
ImapBatchSession(config()) as batch,
):
batch.append(b"one")
clock[0] = 31.0
result = batch.append(b"two")
first.noop.assert_called_once()
self.assertEqual(first.append.call_args.args[3], b"one")
self.assertEqual(second.append.call_args.args[3], b"two")
self.assertEqual(result.reconnect_count, 1)
def test_healthy_idle_probe_reuses_connection(self):
client = BatchClient()
with (
patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client),
ImapBatchSession(config(), policy=replace(ImapBatchPolicy(), idle_health_check_seconds=0)) as batch,
):
batch.append(b"one")
self.assertTrue(batch.append(b"two").session_reused)
client.noop.assert_called_once()
def test_only_pre_effect_connection_failures_are_retried(self):
client = BatchClient()
with (
patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[OSError("offline"), client]) as connect,
ImapBatchSession(config()) as batch,
):
result = batch.append(b"one")
self.assertEqual(connect.call_count, 2)
client.append.assert_called_once()
self.assertEqual(result.reconnect_count, 1)
def test_pre_effect_reconnects_are_bounded_and_not_unknown(self):
with (
patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=OSError("offline")) as connect,
ImapBatchSession(config()) as batch,
self.assertRaises(ImapAppendError) as caught,
):
batch.append(b"one")
self.assertEqual(connect.call_count, 2)
self.assertTrue(caught.exception.temporary)
self.assertFalse(caught.exception.outcome_unknown)
def test_authentication_rejection_is_not_retried(self):
with (
patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=imaplib.IMAP4.error("bad login")) as connect,
ImapBatchSession(config()) as batch,
self.assertRaises(ImapAppendError) as caught,
):
batch.append(b"one")
connect.assert_called_once()
self.assertFalse(caught.exception.temporary)
self.assertFalse(caught.exception.outcome_unknown)
def test_ambiguous_append_is_never_replayed_and_connection_is_discarded(self):
first, second = BatchClient(), BatchClient()
first.append.side_effect = imaplib.IMAP4.abort("accepted but reply lost")
with (
patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=[first, second]) as connect,
ImapBatchSession(config()) as batch,
):
with self.assertRaises(ImapAppendError) as caught:
batch.append(b"uncertain")
self.assertTrue(caught.exception.outcome_unknown)
self.assertFalse(caught.exception.temporary)
self.assertEqual(connect.call_count, 1)
first.logout.assert_called_once()
batch.append(b"different independently claimed message")
first.append.assert_called_once()
second.append.assert_called_once()
self.assertNotEqual(first.append.call_args.args[3], second.append.call_args.args[3])
def test_definitive_append_rejection_is_not_retried_or_unknown(self):
client = BatchClient()
client.append.return_value = ("NO", [b"quota"])
with (
patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client) as connect,
ImapBatchSession(config()) as batch,
self.assertRaises(ImapAppendError) as caught,
):
batch.append(b"one")
self.assertFalse(caught.exception.outcome_unknown)
connect.assert_called_once()
client.append.assert_called_once()
def test_logout_failure_does_not_reverse_accepted_message(self):
client = BatchClient()
client.logout.side_effect = OSError("gone")
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client):
result = append_message_to_sent(b"one", imap_config=config())
self.assertEqual(result.bytes_appended, 3)
client.shutdown.assert_called_once()
def test_disabling_reuse_keeps_individual_appends_and_closes_every_connection(self):
clients = [BatchClient() for _ in range(3)]
with (
patch("govoplan_mail.backend.sending.imap._open_imap", side_effect=clients),
ImapBatchSession(config(), policy=replace(ImapBatchPolicy(), reuse_connections=False)) as batch,
):
results = [batch.append(b"one") for _ in clients]
self.assertEqual([item.connection_sequence for item in results], [1, 2, 3])
self.assertFalse(any(item.session_reused for item in results))
for client in clients:
client.append.assert_called_once()
client.logout.assert_called_once()
def test_config_mismatch_closed_and_overlapping_calls_fail_before_provider(self):
with patch("govoplan_mail.backend.sending.imap._open_imap") as connect:
batch = ImapBatchSession(config())
with self.assertRaises(ImapConfigurationError):
append_message_to_sent(b"one", imap_config=config(password="different"), batch_session=batch)
with batch._exclusive_append(), self.assertRaises(ImapConfigurationError):
batch.append(b"overlap")
batch.close()
with self.assertRaises(ImapConfigurationError):
batch.append(b"closed")
connect.assert_not_called()
def test_environment_values_are_bounded(self):
with patch.dict("os.environ", {
"GOVOPLAN_IMAP_BATCH_REUSE": "false",
"GOVOPLAN_IMAP_BATCH_MAX_MESSAGES": "0",
"GOVOPLAN_IMAP_BATCH_MAX_AGE_SECONDS": "invalid",
"GOVOPLAN_IMAP_BATCH_IDLE_HEALTH_CHECK_SECONDS": "999999",
"GOVOPLAN_IMAP_BATCH_RECONNECT_ATTEMPTS": "999999",
}):
policy = ImapBatchPolicy.from_environment()
self.assertFalse(policy.reuse_connections)
self.assertEqual(policy.max_messages_per_connection, 1)
self.assertEqual(policy.max_connection_age_seconds, 300)
self.assertEqual(policy.idle_health_check_seconds, 3600)
self.assertEqual(policy.reconnect_attempts, 5)
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.sending.imap import (
ImapAppendError,
ImapConfigurationError,
_decode_mailbox_name,
_encode_mailbox_name,
_extract_mailbox_name,
_list_imap_folders_on_client,
_quote_mailbox_name,
_select_readonly,
append_message_to_sent,
)
class MailboxClient:
utf8_enabled = False
capabilities = ("IMAP4REV1", "UTF8=ACCEPT")
untagged_responses = {"EXISTS": [b"2"], "UIDVALIDITY": [b"1"]}
def __init__(self, listing=None):
self.listing = listing or []
self.list_calls = 0
self.status_calls = []
self.select_calls = []
self.append_calls = []
self.logged_out = False
def list(self):
self.list_calls += 1
return "OK", self.listing
def status(self, mailbox, items):
self.status_calls.append((mailbox, items))
return "OK", [b'"mailbox" (MESSAGES 2 UNSEEN 1)']
def select(self, mailbox, readonly=False):
self.select_calls.append((mailbox, readonly))
return "OK", [b"2"]
def response(self, code):
return "OK", [b"1"] if code == "UIDVALIDITY" else []
def append(self, mailbox, flags, date_time, message):
self.append_calls.append((mailbox, flags, message))
return "OK", [b"APPEND complete"]
def logout(self):
self.logged_out = True
return "BYE", []
class ImapMailboxEncodingTests(unittest.TestCase):
def test_rfc_and_real_provider_vectors_round_trip(self):
# RFC 3501 section 5.1.3 plus the reported German folder and UTF-16
# surrogate pairs. A plain '+' is not a shift in modified UTF-7.
for display, wire in (
("INBOX", "INBOX"),
("Entwürfe", "Entw&APw-rfe"),
("R&D", "R&-D"),
("+Plus & Sons", "+Plus &- Sons"),
("~peter/mail/台北/日本語", "~peter/mail/&U,BTFw-/&ZeVnLIqe-"),
("📨", "&2D3c6A-"),
('A \\ "B"', 'A \\ "B"'),
):
with self.subTest(display=display):
self.assertEqual(_encode_mailbox_name(display), wire)
self.assertEqual(_decode_mailbox_name(wire), display)
def test_list_decodes_names_before_standard_folder_detection_and_status(self):
client = MailboxClient([
b'(\\HasNoChildren) "/" "Entw&APw-rfe"',
b'(\\HasNoChildren) "/" "Gel&APY-scht"',
b'(\\HasNoChildren) "/" "R&-D"',
])
result = _list_imap_folders_on_client(
client, host="imap.example.org", port=993, security="tls", include_status=True,
)
self.assertEqual([folder.name for folder in result.folders], ["Entwürfe", "Gelöscht", "R&D"])
self.assertEqual(result.detected_folder_mappings, {"drafts": "Entwürfe", "trash": "Gelöscht"})
self.assertTrue(all(folder.message_count == 2 and folder.unseen_count == 1 for folder in result.folders))
self.assertEqual([call[0] for call in client.status_calls], [
'"Entw&APw-rfe"', '"Gel&APY-scht"', '"R&-D"',
])
self.assertEqual(client.list_calls, 1)
def test_literal_names_are_decoded_without_stripping_or_unquoting_content(self):
for wire, expected in (
(b"Entw&APw-rfe", "Entwürfe"),
(b'"R&-D" ', '"R&D" '),
):
with self.subTest(wire=wire):
line = b'(\\Drafts) "/" {' + str(len(wire)).encode("ascii") + b"}"
self.assertEqual(_extract_mailbox_name((line, wire)), (expected, {"\\drafts"}))
self.assertIsNone(_extract_mailbox_name(b""))
self.assertIsNone(_extract_mailbox_name(None))
def test_list_rejects_wrong_literal_lengths_and_invalid_provider_encoding(self):
with self.assertRaisesRegex(ImapAppendError, "literal"):
_extract_mailbox_name((b'() "/" {99}', b"INBOX"))
for wire in (b"&APw", b"&!bad-", b"&AGE-", b"&AA-", b"&APx-", b"&2AA-", b"Entw\xffrfe"):
with self.subTest(wire=wire), self.assertRaisesRegex(ImapAppendError, "encoding"):
_extract_mailbox_name(b'() "/" "' + wire + b'"')
def test_select_encodes_unicode_and_quotes_protocol_metacharacters(self):
client = MailboxClient()
folder = 'Entwürfe / R&D / "Q" \\'
self.assertEqual(_select_readonly(client, folder), (2, "1"))
self.assertEqual(client.select_calls, [('"Entw&APw-rfe / R&-D / \\"Q\\" \\\\"', True)])
self.assertEqual(client.list_calls, 0)
def test_quoted_name_escaping_round_trips_independently_of_charset_encoding(self):
for folder in ('Entwürfe "R&D"', ' \\"quoted"\\ ', 'R&D', '📨/日本語', 'back\\slash'):
with self.subTest(folder=folder):
quoted = _quote_mailbox_name(folder)
self.assertEqual(_extract_mailbox_name('() "/" ' + quoted), (folder, set()))
def test_saved_wire_names_resolve_without_double_encoding(self):
client = MailboxClient([b'() "/" "Entw&APw-rfe"'])
_select_readonly(client, "Entw&APw-rfe")
_select_readonly(client, "Entwürfe")
self.assertEqual(client.select_calls, [('"Entw&APw-rfe"', True)] * 2)
self.assertEqual(client.list_calls, 1)
def test_literal_name_wins_when_legacy_alias_is_ambiguous(self):
client = MailboxClient([
b'() "/" "Entw&APw-rfe"',
b'() "/" "Entw&-APw-rfe"',
])
_select_readonly(client, "Entw&APw-rfe")
self.assertEqual(client.select_calls, [('"Entw&-APw-rfe"', True)])
def test_saved_ampersand_and_literal_ampersand_remain_distinct(self):
client = MailboxClient([b'() "/" "R&-D"'])
_select_readonly(client, "R&-D")
_select_readonly(client, "R&D")
self.assertEqual(client.select_calls, [('"R&-D"', True)] * 2)
def test_provider_wire_form_is_preserved_on_the_listed_connection(self):
client = MailboxClient([b'() "/" "&U,BTFw-&ZeVnLIqe-"'])
result = _list_imap_folders_on_client(
client, host="imap.example.org", port=993, security="tls", include_status=True,
)
self.assertEqual(result.folders[0].name, "台北日本語")
self.assertEqual(client.status_calls[0][0], '"&U,BTFw-&ZeVnLIqe-"')
def test_utf8_mode_preserves_literal_ampersands_and_does_not_decode_again(self):
client = MailboxClient(['() "/" "Entwürfe &APw-"'.encode("utf-8")])
client.utf8_enabled = True
result = _list_imap_folders_on_client(
client, host="imap.example.org", port=993, security="tls", include_status=True,
)
self.assertEqual(result.folders[0].name, "Entwürfe &APw-")
_select_readonly(client, result.folders[0].name)
self.assertEqual(client.select_calls, [('"Entwürfe &APw-"', True)])
self.assertEqual(client.status_calls[0][0], '"Entwürfe &APw-"')
def test_utf8_capability_alone_does_not_change_the_encoding(self):
client = MailboxClient()
self.assertEqual(_quote_mailbox_name("Entwürfe", client=client), '"Entw&APw-rfe"')
with self.assertRaisesRegex(ImapAppendError, "encoding"):
_extract_mailbox_name('() "/" "Entwürfe"'.encode("utf-8"))
def test_changing_utf8_mode_invalidates_cached_wire_names(self):
client = MailboxClient([b'() "/" "Entw&APw-rfe"'])
_list_imap_folders_on_client(
client, host="imap.example.org", port=993, security="tls", include_status=False,
)
client.utf8_enabled = True
self.assertEqual(_quote_mailbox_name("Entwürfe", client=client), '"Entwürfe"')
def test_select_rejects_control_characters_before_sending_any_command(self):
for folder in ("INBOX\r\nLOGOUT", "bad\x00folder", "bad\x7ffolder", "bad\u2028folder"):
client = MailboxClient()
with self.subTest(folder=folder), self.assertRaisesRegex(ImapConfigurationError, "control"):
_select_readonly(client, folder)
self.assertEqual(client.select_calls, [])
def test_append_auto_detects_unicode_sent_folder_and_uses_original_wire_name(self):
client = MailboxClient([b'(\\Sent) "/" "Gesendet &APw-"'])
config = ImapConfig(host="imap.example.org", sent_folder="auto")
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client):
result = append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config)
self.assertEqual(result.folder, "Gesendet ü")
self.assertEqual(client.append_calls[0][0], '"Gesendet &APw-"')
self.assertTrue(client.logged_out)
def test_append_explicit_unicode_and_saved_wire_names_target_the_same_mailbox(self):
for folder in ("Entwürfe", "Entw&APw-rfe"):
with self.subTest(folder=folder):
client = MailboxClient([b'() "/" "Entw&APw-rfe"'])
config = ImapConfig(host="imap.example.org", sent_folder=folder)
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client):
append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config)
self.assertEqual(client.append_calls[0][0], '"Entw&APw-rfe"')
def test_failed_legacy_lookup_never_selects_or_appends_to_a_guessed_mailbox(self):
client = MailboxClient()
with patch.object(client, "list", return_value=("NO", [b"Denied"])), self.assertRaisesRegex(
ImapAppendError, "resolving a saved folder name",
):
_select_readonly(client, "Entw&APw-rfe")
self.assertEqual(client.select_calls, [])
def test_append_saved_wire_name_failure_is_not_an_unknown_append_outcome(self):
client = MailboxClient()
config = ImapConfig(host="imap.example.org", sent_folder="Entw&APw-rfe")
with (
patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client),
patch.object(client, "list", side_effect=OSError("connection lost")),
self.assertRaises(ImapAppendError) as caught,
):
append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config)
self.assertTrue(caught.exception.temporary)
self.assertFalse(caught.exception.outcome_unknown)
self.assertEqual(client.append_calls, [])
self.assertTrue(client.logged_out)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -8,6 +8,7 @@ from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.sending.imap import (
ImapAppendError,
ImapConfigurationError,
_detect_standard_folder_mappings,
_detect_sent_folder,
_extract_mailbox_name,
_fetch_message_by_uid,
@@ -18,6 +19,7 @@ from govoplan_mail.backend.sending.imap import (
_select_readonly,
_sequence_set,
append_message_to_sent,
list_imap_folders,
list_imap_messages,
list_imap_uids_since,
)
@@ -79,6 +81,45 @@ class ImapFolderParserTests(unittest.TestCase):
"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):
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
+344
View File
@@ -0,0 +1,344 @@
from __future__ import annotations
import base64
import json
import unittest
import urllib.error
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_mail.backend.config import JmapConfig, JmapServerConfig
from govoplan_mail.backend.sending.jmap import (
JMAP_CORE_CAPABILITY,
JMAP_MAIL_CAPABILITY,
JmapAuthenticationError,
JmapCapabilityError,
JmapConfigurationError,
discover_jmap,
get_jmap_email_changes,
get_jmap_message,
list_jmap_folders,
list_jmap_messages,
test_jmap_connection,
)
def _response(payload: dict) -> SimpleNamespace:
return SimpleNamespace(
status=200,
headers={"Content-Type": "application/json"},
body=json.dumps(payload).encode("utf-8"),
)
def _session(*, api_url: str = "https://jmap.example.test/api") -> dict:
return {
"capabilities": {
JMAP_CORE_CAPABILITY: {"maxCallsInRequest": 32},
JMAP_MAIL_CAPABILITY: {},
},
"accounts": {
"account-1": {
"name": "Example",
"isPersonal": True,
"isReadOnly": False,
"accountCapabilities": {JMAP_MAIL_CAPABILITY: {}},
}
},
"primaryAccounts": {JMAP_MAIL_CAPABILITY: "account-1"},
"username": "reader@example.test",
"apiUrl": api_url,
"downloadUrl": "https://jmap.example.test/download/{accountId}/{blobId}/{name}",
"uploadUrl": "https://jmap.example.test/upload/{accountId}",
"eventSourceUrl": "https://jmap.example.test/events",
"state": "session-state-1",
}
def _mailboxes() -> list[dict]:
return [
{
"id": "mb-inbox",
"name": "Inbox",
"parentId": None,
"role": "inbox",
"sortOrder": 10,
"isSubscribed": True,
"totalEmails": 2,
"unreadEmails": 1,
},
{
"id": "mb-projects",
"name": "Projects",
"parentId": None,
"role": None,
"sortOrder": 20,
"isSubscribed": True,
"totalEmails": 1,
"unreadEmails": 0,
},
{
"id": "mb-project-2026",
"name": "2026",
"parentId": "mb-projects",
"role": None,
"sortOrder": 1,
"isSubscribed": True,
"totalEmails": 1,
"unreadEmails": 0,
},
{
"id": "mb-sent",
"name": "Sent",
"parentId": None,
"role": "sent",
"sortOrder": 30,
"isSubscribed": True,
"totalEmails": 4,
"unreadEmails": 0,
},
]
def _email(email_id: str = "email-1", *, detail: bool = False) -> dict:
value = {
"id": email_id,
"threadId": "thread-1",
"mailboxIds": {"mb-inbox": True},
"keywords": {"$seen": False, "$flagged": True},
"size": 1234,
"receivedAt": "2026-08-22T10:30:00Z",
"sentAt": "2026-08-22T10:29:00Z",
"messageId": ["message-1@example.test"],
"from": [{"name": "Sender", "email": "sender@example.test"}],
"to": [{"name": "Reader", "email": "reader@example.test"}],
"cc": [],
"subject": "A governed message",
"hasAttachment": True,
"preview": "A bounded preview",
}
if detail:
value.update(
{
"replyTo": [{"email": "reply@example.test"}],
"bcc": [],
"textBody": [{"partId": "text", "type": "text/plain"}],
"htmlBody": [{"partId": "html", "type": "text/html"}],
"bodyValues": {
"text": {"value": "Plain body", "isTruncated": False},
"html": {"value": "<p>HTML body</p>", "isTruncated": False},
},
"attachments": [
{
"partId": "attachment",
"blobId": "blob-1",
"name": "evidence.pdf",
"type": "application/pdf",
"size": 44,
}
],
}
)
return value
class _JmapProvider:
def __init__(self, *, query_states: list[str] | None = None) -> None:
self.requests: list[tuple[str, str, dict[str, str], dict | None]] = []
self.query_states = list(query_states or ["query-state-1"])
def __call__(self, url: str, **kwargs):
body = json.loads(kwargs["body"].decode("utf-8")) if kwargs.get("body") else None
self.requests.append((url, kwargs.get("method", "GET"), kwargs.get("headers", {}), body))
if kwargs.get("method") == "GET":
return _response(_session())
method, arguments, call_id = body["methodCalls"][0]
if method == "Mailbox/get":
payload = {"accountId": "account-1", "state": "mailbox-state-1", "list": _mailboxes(), "notFound": []}
elif method == "Email/query":
state = self.query_states.pop(0) if self.query_states else "query-state-1"
payload = {
"accountId": "account-1",
"queryState": state,
"canCalculateChanges": True,
"position": arguments["position"],
"ids": ["email-1", "email-2"][arguments["position"] : arguments["position"] + arguments["limit"]],
"total": 2,
"limit": arguments["limit"],
}
elif method == "Email/get":
detail = bool(arguments.get("fetchTextBodyValues"))
payload = {
"accountId": "account-1",
"state": "email-state-1",
"list": [_email(email_id, detail=detail) for email_id in arguments["ids"]],
"notFound": [],
}
elif method == "Email/changes":
payload = {
"accountId": "account-1",
"oldState": arguments["sinceState"],
"newState": "email-state-2",
"hasMoreChanges": False,
"created": ["email-new"],
"updated": ["email-1"],
"destroyed": ["email-old"],
}
else: # pragma: no cover - fixture guard
raise AssertionError(method)
return _response({"methodResponses": [[method, payload, call_id]], "sessionState": "session-state-1"})
class JmapTransportTests(unittest.TestCase):
def config(self, **overrides) -> JmapConfig:
return JmapConfig(
session_url="https://jmap.example.test/.well-known/jmap",
password="access-token",
**overrides,
)
def test_server_configuration_rejects_embedded_credentials_and_normalizes_origins(self) -> None:
with self.assertRaisesRegex(ValueError, "embedded credentials"):
JmapServerConfig(session_url="https://user:secret@example.test/jmap")
config = JmapServerConfig(
session_url="https://jmap.example.test/.well-known/jmap",
allowed_api_origins=["https://api.example.test/path", "https://api.example.test"],
)
self.assertEqual(config.allowed_api_origins, ["https://api.example.test"])
def test_discovery_selects_primary_mail_account_and_bearer_auth(self) -> None:
provider = _JmapProvider()
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=provider):
result = discover_jmap(self.config())
self.assertEqual(result.account_id, "account-1")
self.assertIn(JMAP_MAIL_CAPABILITY, result.account_capabilities)
self.assertEqual(provider.requests[0][2]["Authorization"], "Bearer access-token")
self.assertNotIn("access-token", repr(result))
def test_basic_authentication_is_supported_without_exposing_credentials(self) -> None:
provider = _JmapProvider()
config = self.config(auth_scheme="basic", username="reader")
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=provider):
test_jmap_connection(jmap_config=config)
expected = base64.b64encode(b"reader:access-token").decode("ascii")
self.assertEqual(provider.requests[0][2]["Authorization"], f"Basic {expected}")
def test_cross_origin_api_url_is_fail_closed_unless_allowlisted(self) -> None:
provider = _JmapProvider()
provider.requests = []
with patch(
"govoplan_mail.backend.sending.jmap.fetch_http",
return_value=_response(_session(api_url="https://api.example.test/jmap")),
):
with self.assertRaisesRegex(JmapConfigurationError, "unapproved origin"):
discover_jmap(self.config())
def test_lists_hierarchical_mailboxes_with_roles_and_counts(self) -> None:
provider = _JmapProvider()
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=provider):
result = list_jmap_folders(jmap_config=self.config())
self.assertEqual(result.protocol, "jmap")
self.assertEqual(result.detected_folder_mappings["inbox"], "Inbox")
self.assertEqual(result.detected_sent_folder, "Sent")
self.assertIn("Projects/2026", [item.name for item in result.folders])
inbox = next(item for item in result.folders if item.name == "Inbox")
self.assertEqual((inbox.message_count, inbox.unseen_count), (2, 1))
def test_query_search_and_get_share_protocol_neutral_message_shape(self) -> None:
provider = _JmapProvider()
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=provider):
result = list_jmap_messages(
jmap_config=self.config(),
folder="INBOX",
limit=1,
query="governed",
)
self.assertEqual(result.folder, "Inbox")
self.assertEqual(result.total_count, 2)
self.assertEqual(result.uidvalidity, "query-state-1")
self.assertEqual(result.messages[0].uid, "email-1")
self.assertEqual(result.messages[0].from_header, "Sender <sender@example.test>")
query_call = next(request[3]["methodCalls"][0] for request in provider.requests if request[3] and request[3]["methodCalls"][0][0] == "Email/query")
self.assertEqual(query_call[1]["filter"]["conditions"][1], {"text": "governed"})
def test_changed_query_state_restarts_the_page_without_skipping(self) -> None:
provider = _JmapProvider(query_states=["new-state", "newer-state"])
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=provider):
result = list_jmap_messages(
jmap_config=self.config(),
folder="Inbox",
limit=1,
offset=1,
expected_query_state="old-state",
)
self.assertTrue(result.cursor_reset)
self.assertEqual(result.offset, 0)
query_positions = [
request[3]["methodCalls"][0][1]["position"]
for request in provider.requests
if request[3] and request[3]["methodCalls"][0][0] == "Email/query"
]
self.assertEqual(query_positions, [1, 0])
def test_detail_returns_bounded_body_values_and_attachment_metadata(self) -> None:
provider = _JmapProvider()
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=provider):
result = get_jmap_message(
jmap_config=self.config(),
folder="Inbox",
email_id="email-1",
)
self.assertEqual(result.message.body_text, "Plain body")
self.assertEqual(result.message.body_html, "<p>HTML body</p>")
self.assertEqual(result.message.attachments[0].filename, "evidence.pdf")
detail_call = provider.requests[-1][3]["methodCalls"][0][1]
self.assertEqual(detail_call["maxBodyValueBytes"], 1024 * 1024)
def test_incremental_changes_are_bounded_and_preserve_server_state(self) -> None:
provider = _JmapProvider()
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=provider):
result = get_jmap_email_changes(
jmap_config=self.config(),
since_state="email-state-1",
max_changes=25,
)
self.assertEqual(result.new_state, "email-state-2")
self.assertEqual(result.created, ("email-new",))
change_call = provider.requests[-1][3]["methodCalls"][0][1]
self.assertEqual(change_call["maxChanges"], 25)
def test_authentication_and_expired_change_state_have_distinct_diagnostics(self) -> None:
http_error = urllib.error.HTTPError(
"https://jmap.example.test/.well-known/jmap",
401,
"Unauthorized",
{},
None,
)
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=http_error):
with self.assertRaisesRegex(JmapAuthenticationError, "authentication failed"):
discover_jmap(self.config())
def expired(url: str, **kwargs):
if kwargs.get("method") == "GET":
return _response(_session())
body = json.loads(kwargs["body"])
call_id = body["methodCalls"][0][2]
return _response(
{
"methodResponses": [
["error", {"type": "cannotCalculateChanges"}, call_id]
]
}
)
with patch("govoplan_mail.backend.sending.jmap.fetch_http", side_effect=expired):
with self.assertRaisesRegex(JmapCapabilityError, "full refresh"):
get_jmap_email_changes(
jmap_config=self.config(),
since_state="expired-state",
)
if __name__ == "__main__":
unittest.main()
+101 -1
View File
@@ -290,6 +290,51 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
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):
profile = SimpleNamespace(
id="profile-1",
@@ -518,7 +563,7 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
"credential-1",
)
def test_campaign_delivery_fails_when_policy_requires_local_credentials(self):
def test_campaign_delivery_fails_when_policy_requires_missing_explicit_credentials(self):
profile = SimpleNamespace(imap_config=None)
policy = EffectiveMailProfilePolicy(
smtp_credentials=EffectiveCredentialPolicy(inherit=False),
@@ -527,6 +572,44 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
with self.assertRaisesRegex(MailProfileError, "explicit credential selection"):
_assert_campaign_inherits_profile_credentials(profile, policy)
def test_explicit_mail_credentials_satisfy_independent_protocol_selection_policy(self):
profile = SimpleNamespace(imap_config={"host": "imap.example.test"})
policy = EffectiveMailProfilePolicy(
smtp_credentials=EffectiveCredentialPolicy(inherit=False),
imap_credentials=EffectiveCredentialPolicy(inherit=False),
)
with self.assertRaisesRegex(MailProfileError, "effective IMAP"):
_assert_campaign_inherits_profile_credentials(profile, policy, {"smtp_credential_id": "smtp-credential"})
_assert_campaign_inherits_profile_credentials(profile, policy, {
"smtp_credential_id": "smtp-credential", "imap_credential_id": "imap-credential",
})
# Allowing a default never forbids an explicitly selected Mail credential.
_assert_campaign_inherits_profile_credentials(profile, EffectiveMailProfilePolicy(), {
"smtp_credential_id": "smtp-credential", "imap_credential_id": "imap-credential",
})
def test_credential_selection_false_is_overridable_only_without_an_ancestor_lock(self):
for locked in (False, True):
with self.subTest(locked=locked):
policy = EffectiveMailProfilePolicy()
_merge_policy(policy, {
"smtp_credentials": {"inherit": False},
"allow_lower_level_limits": {"smtp_credentials.inherit": not locked},
}, source="system")
_merge_policy(policy, {
"smtp_credentials": {"inherit": True},
"allow_lower_level_limits": {"smtp_credentials.inherit": True},
}, source="tenant", source_id="tenant-1")
self.assertEqual(policy.smtp_credentials.inherit, not locked)
self.assertEqual(policy.allow_lower_level_limits["smtp_credentials.inherit"], not locked)
def test_null_credential_selection_inherits_the_parent_choice(self):
policy = EffectiveMailProfilePolicy()
_merge_policy(policy, {"smtp_credentials": {"inherit": False}}, source="system")
_merge_policy(policy, {"smtp_credentials": {"inherit": None}}, source="tenant", source_id="tenant-1")
self.assertFalse(policy.smtp_credentials.inherit)
self.assertEqual(policy.smtp_credentials.inherit_source, "system")
def test_merge_policy_respects_locked_lower_level_limits(self):
policy = EffectiveMailProfilePolicy()
_merge_policy(
@@ -547,6 +630,23 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
self.assertEqual(policy.blacklist_patterns["smtp_hosts"], ["*.blocked.example"])
self.assertFalse(policy.allow_lower_level_limits["blacklist.smtp_hosts"])
def test_jmap_hostname_policy_has_independent_deny_and_lower_scope_lock(self):
policy = EffectiveMailProfilePolicy()
_merge_policy(
policy,
{
"blacklist": {"jmap_hosts": ["*.blocked.example"]},
"allow_lower_level_limits": {"blacklist.jmap_hosts": False},
},
source="system",
)
allowed, reason = policy.value_allowed("jmap_hosts", "mail.blocked.example")
self.assertFalse(allowed)
self.assertIn("jmap_hosts", reason or "")
self.assertFalse(policy.allow_lower_level_limits["blacklist.jmap_hosts"])
def test_parent_lock_violations_are_reported_per_field(self):
violations = _policy_parent_lock_violations(
{
+73
View File
@@ -9,6 +9,15 @@ from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manife
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:
manifest = get_manifest()
@@ -16,6 +25,7 @@ class MailManifestTests(unittest.TestCase):
self.assertEqual(manifest.id, "mail")
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.contact_writer", {interface.name for interface in manifest.requires_interfaces})
self.assertIn(
{
"name": "campaigns.access",
@@ -43,7 +53,22 @@ class MailManifestTests(unittest.TestCase):
permissions = {permission.scope for permission in manifest.permissions}
self.assertIn("mail:profile:write_own", permissions)
self.assertIn("mail:secret:manage_own", permissions)
self.assertTrue(
{"mail:pop3:manage", "mail:pop3:import", "mail:pop3:delete"}.issubset(
permissions
)
)
roles = {template.slug: template for template in manifest.role_templates}
self.assertIn("mail:pop3:delete", roles["mail_profile_admin"].permissions)
self.assertEqual(
set(roles["mail_legacy_import_operator"].permissions),
{
"mail:profile:read",
"mail:profile:use",
"mail:profile:test",
"mail:pop3:import",
},
)
self.assertEqual(
set(roles["mail_profile_self_service"].permissions),
{
@@ -62,8 +87,56 @@ class MailManifestTests(unittest.TestCase):
"mail.workflow.read-mailbox",
"mail.reference.credentials-egress-retirement",
"mail.reference.campaign-delivery-contract",
"mail.address-book-integration",
"mail.workflow.legacy-pop3-import",
}.issubset(topics)
)
pop3_topic = topics["mail.workflow.legacy-pop3-import"]
self.assertEqual(("user", "admin"), pop3_topic.documentation_types)
self.assertIn("mail:pop3:import", pop3_topic.conditions[0].any_scopes)
self.assertTrue(
{
"mail.pop3",
"mail.pop3.source-editor",
"mail.pop3.action.reload",
"mail.pop3.action.save-source",
"mail.pop3.action.import",
"mail.pop3.field.transport-security",
"mail.pop3.field.max-message-size",
"mail.pop3.field.password",
"mail.pop3.field.delete-after-import",
"mail.pop3.confirm-delete-source",
}.issubset(pop3_topic.metadata["help_contexts"])
)
self.assertGreaterEqual(len(pop3_topic.metadata["fields"]), 4)
self.assertGreaterEqual(
len(pop3_topic.metadata["operational_consequences"]),
3,
)
for topic in manifest.documentation:
with self.subTest(topic_id=topic.id):
german = topic.translations.get("de", {})
for attribute in ("title", "summary", "body"):
self.assertTrue(
str(german.get(attribute, "")).strip(),
f"{topic.id} is missing its German {attribute}",
)
pop3_provider = next(
item
for item in manifest.external_providers
if item.id == "mail.pop3_legacy_import"
)
self.assertIn("delete", pop3_provider.operations)
self.assertTrue(pop3_provider.behavior.outcome_unknown_supported)
self.assertIn("mail.pop3.source_deletion", pop3_provider.behavior.audit_event_types)
self.assertTrue(
any(
route.path == "/mail/legacy-import"
for route in manifest.frontend.routes # type: ignore[union-attr]
)
)
ownership = topics["mail.profile-ownership-and-consumers"]
self.assertEqual(ownership.metadata["kind"], "reference")
self.assertEqual(ownership.metadata["route"], "/settings?section=mail-profiles")
+440
View File
@@ -0,0 +1,440 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
import poplib
import ssl
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, User
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
from govoplan_core.security.secrets import decrypt_secret
from govoplan_mail.backend.config import Pop3Config, TransportSecurity
from govoplan_mail.backend.db.models import (
MailPop3Import,
MailServerEndpoint,
MailServerProfile,
)
from govoplan_mail.backend.pop3_imports import (
Pop3ImportResult,
create_pop3_imports,
list_pop3_imports,
)
from govoplan_mail.backend.router import import_profile_pop3_messages
from govoplan_mail.backend.schemas import MailPop3ImportRequest
from govoplan_mail.backend.sending.pop3 import (
Pop3ConfigurationError,
Pop3DownloadedMessage,
Pop3MessageSummary,
Pop3ProviderError,
_open_pop3,
delete_pop3_messages,
download_pop3_messages,
preview_pop3_messages,
)
_RAW = (
b"Subject: Legacy notice\r\n"
b"From: Office <office@example.test>\r\n"
b"To: Subject <subject@example.test>\r\n"
b"Message-ID: <legacy-1@example.test>\r\n"
b"\r\n"
b"A bounded legacy message.\r\n"
)
class _Pop3Client:
def __init__(self, *, quit_error: Exception | None = None) -> None:
self.deletions: list[int] = []
self.quit_calls = 0
self.rset_calls = 0
self.close_calls = 0
self.quit_error = quit_error
def stat(self):
return 1, len(_RAW)
def uidl(self):
return b"+OK", [b"1 uid-1"], 1
def list(self):
return b"+OK", [f"1 {len(_RAW)}".encode("ascii")], 1
def top(self, _number, _lines):
return b"+OK", _RAW.rstrip(b"\r\n").split(b"\r\n"), len(_RAW)
def retr(self, _number):
return b"+OK", _RAW.rstrip(b"\r\n").split(b"\r\n"), len(_RAW)
def dele(self, number):
self.deletions.append(number)
def quit(self):
self.quit_calls += 1
if self.quit_error is not None:
raise self.quit_error
return b"+OK"
def rset(self):
self.rset_calls += 1
def close(self):
self.close_calls += 1
def _config(**changes) -> Pop3Config:
values = {
"host": "pop3.example.test",
"security": "tls",
"username": "legacy-user",
"password": "legacy-password",
"legacy_import_enabled": True,
}
values.update(changes)
return Pop3Config.model_validate(values)
def _download(uidl: str = "uid-1") -> Pop3DownloadedMessage:
summary = Pop3MessageSummary(
message_number=1,
uidl=uidl,
subject="Legacy notice",
from_header="Office <office@example.test>",
to_header="Subject <subject@example.test>",
date="Sat, 22 Aug 2026 10:00:00 +0200",
message_id="<legacy-1@example.test>",
size_bytes=len(_RAW),
body_preview="A bounded legacy message.",
)
import hashlib
return Pop3DownloadedMessage(
message_number=1,
uidl=uidl,
raw=_RAW,
raw_sha256=hashlib.sha256(_RAW).hexdigest(),
summary=summary,
)
class Pop3TransportTests(unittest.TestCase):
def test_legacy_import_is_disabled_until_explicitly_enabled(self) -> None:
with self.assertRaisesRegex(Pop3ConfigurationError, "disabled"):
preview_pop3_messages(
pop3_config=_config(legacy_import_enabled=False),
limit=10,
)
with self.assertRaisesRegex(ValueError, "batch size limit"):
_config(max_message_bytes=2 * 1024 * 1024, max_batch_bytes=1024 * 1024)
def test_preview_and_download_are_non_destructive(self) -> None:
preview_client = _Pop3Client()
with patch(
"govoplan_mail.backend.sending.pop3._open_pop3",
return_value=preview_client,
):
preview = preview_pop3_messages(pop3_config=_config(), limit=10)
self.assertEqual(["uid-1"], [item.uidl for item in preview.messages])
self.assertEqual([], preview_client.deletions)
self.assertEqual(1, preview_client.quit_calls)
download_client = _Pop3Client()
with patch(
"govoplan_mail.backend.sending.pop3._open_pop3",
return_value=download_client,
):
downloaded = download_pop3_messages(
pop3_config=_config(), uidls=("uid-1",)
)
self.assertEqual(_RAW, downloaded[0].raw)
self.assertEqual([], download_client.deletions)
self.assertEqual(1, download_client.quit_calls)
def test_source_deletion_needs_policy_and_commits_with_quit(self) -> None:
with self.assertRaisesRegex(Pop3ConfigurationError, "disabled"):
delete_pop3_messages(pop3_config=_config(), uidls=("uid-1",))
client = _Pop3Client()
with patch(
"govoplan_mail.backend.sending.pop3._open_pop3", return_value=client
):
result = delete_pop3_messages(
pop3_config=_config(allow_delete_after_import=True),
uidls=("uid-1",),
)
self.assertEqual(("uid-1",), result.deleted_uidls)
self.assertEqual([1], client.deletions)
self.assertEqual(1, client.quit_calls)
def test_quit_failure_marks_deletion_outcome_unknown(self) -> None:
client = _Pop3Client(quit_error=poplib.error_proto("connection lost"))
with (
patch(
"govoplan_mail.backend.sending.pop3._open_pop3",
return_value=client,
),
self.assertRaises(Pop3ProviderError) as captured,
):
delete_pop3_messages(
pop3_config=_config(allow_delete_after_import=True),
uidls=("uid-1",),
)
self.assertTrue(captured.exception.outcome_unknown)
self.assertEqual([1], client.deletions)
def test_tls_and_authentication_failures_are_sanitized(self) -> None:
with (
patch(
"govoplan_mail.backend.sending.pop3.validate_outbound_host"
),
patch(
"govoplan_mail.backend.sending.pop3._OutboundPolicyPOP3SSL",
side_effect=ssl.SSLError("private TLS detail"),
),
self.assertRaisesRegex(Pop3ProviderError, "TLS negotiation failed"),
):
_open_pop3(_config())
auth_client = _Pop3Client()
auth_client.user = lambda _value: None # type: ignore[attr-defined]
auth_client.pass_ = lambda _value: (_ for _ in ()).throw( # type: ignore[attr-defined]
poplib.error_proto("private auth detail")
)
with (
patch(
"govoplan_mail.backend.sending.pop3.validate_outbound_host"
),
patch(
"govoplan_mail.backend.sending.pop3._OutboundPolicyPOP3",
return_value=auth_client,
),
self.assertRaisesRegex(Pop3ProviderError, "authentication failed"),
):
_open_pop3(_config(security=TransportSecurity.PLAIN))
class Pop3PersistenceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=(
Account.__table__,
User.__table__,
MailServerProfile.__table__,
MailServerEndpoint.__table__,
MailPop3Import.__table__,
),
)
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
self.profile = MailServerProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Legacy source",
slug="legacy-source",
smtp_config={},
)
self.server = MailServerEndpoint(
id="server-1",
profile_id=self.profile.id,
tenant_id="tenant-1",
protocol="pop3",
name="Legacy POP3",
config={"legacy_import_enabled": True},
scope_type="tenant",
scope_id="tenant-1",
transport_revision="revision-1",
)
self.session.add_all((self.profile, self.server))
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_import_is_encrypted_and_duplicate_uidl_is_reused(self) -> None:
first = create_pop3_imports(
self.session,
tenant_id="tenant-1",
profile_id=self.profile.id,
pop3_server_id=self.server.id,
pop3_credential_id=None,
transport_revision="revision-1",
messages=(_download(),),
user_id=None,
deletion_requested=False,
)
self.session.commit()
self.assertEqual(1, len(first.imported))
encrypted = first.imported[0].raw_message_encrypted
self.assertNotIn("Legacy notice", encrypted)
self.assertEqual(
_RAW,
base64.b64decode(decrypt_secret(encrypted) or ""),
)
self.assertEqual("not_requested", first.imported[0].deletion_status)
repeated = create_pop3_imports(
self.session,
tenant_id="tenant-1",
profile_id=self.profile.id,
pop3_server_id=self.server.id,
pop3_credential_id=None,
transport_revision="revision-1",
messages=(_download(),),
user_id=None,
deletion_requested=False,
)
self.assertEqual((), repeated.imported)
self.assertEqual((first.imported[0].id,), tuple(row.id for row in repeated.duplicates))
self.assertEqual(
(first.imported[0].id,),
tuple(
row.id
for row in list_pop3_imports(
self.session,
tenant_id="tenant-1",
profile_ids=(self.profile.id,),
)
),
)
self.assertEqual(
(),
list_pop3_imports(
self.session,
tenant_id="tenant-1",
profile_ids=("unrelated-profile",),
),
)
class _RouteSession:
def __init__(self, events: list[str]) -> None:
self.events = events
def commit(self) -> None:
self.events.append("commit")
def rollback(self) -> None:
self.events.append("rollback")
class Pop3ImportRouteTests(unittest.TestCase):
def test_local_import_and_audit_commit_before_source_deletion(self) -> None:
events: list[str] = []
now = datetime.now(UTC)
row = SimpleNamespace(
id="import-1",
profile_id="profile-1",
pop3_server_id="server-1",
transport_revision="revision-1",
provider_uidl="uid-1",
message_id="<legacy-1@example.test>",
subject="Legacy notice",
from_header="office@example.test",
to_header="subject@example.test",
date="2026-08-22",
body_preview="A bounded legacy message.",
size_bytes=len(_RAW),
raw_sha256=_download().raw_sha256,
status="pending_review",
imported_at=now,
deletion_requested=True,
deletion_status="pending",
deletion_attempted_at=None,
deletion_error=None,
)
resolved = SimpleNamespace(
config=_config(allow_delete_after_import=True),
server=SimpleNamespace(id="server-1"),
credential=None,
transport_revision="revision-1",
)
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
scopes=frozenset(
{
"mail:profile:use",
"mail:pop3:import",
"mail:pop3:delete",
}
),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
payload = MailPop3ImportRequest(
server_id="server-1",
expected_transport_revision="revision-1",
uidls=["uid-1"],
delete_after_import=True,
)
def audit(*_args, **_kwargs) -> None:
events.append("audit")
def delete(**_kwargs):
self.assertEqual(["audit", "commit"], events)
events.append("delete")
def mark(*_args, **_kwargs):
events.append("mark")
row.deletion_status = "succeeded"
row.deletion_attempted_at = now
return (row,)
with (
patch(
"govoplan_mail.backend.router._resolve_profile_pop3_transport",
return_value=(SimpleNamespace(id="profile-1"), resolved),
),
patch(
"govoplan_mail.backend.router.download_pop3_messages",
return_value=(_download(),),
),
patch(
"govoplan_mail.backend.router.create_pop3_imports",
return_value=Pop3ImportResult(imported=(row,), duplicates=()),
),
patch("govoplan_mail.backend.router.audit_event", side_effect=audit),
patch("govoplan_mail.backend.router.delete_pop3_messages", side_effect=delete),
patch(
"govoplan_mail.backend.router.mark_pop3_deletion_result",
side_effect=mark,
),
):
result = import_profile_pop3_messages(
"profile-1",
payload,
principal=principal,
session=_RouteSession(events), # type: ignore[arg-type]
)
self.assertEqual("succeeded", result.deletion_status)
self.assertEqual(
["audit", "commit", "delete", "mark", "commit", "audit", "commit"],
events,
)
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -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()
+79
View File
@@ -821,6 +821,85 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
self.assertFalse(response.refreshing)
self.assertEqual(session.commits, 1)
def test_jmap_mailbox_list_uses_protocol_neutral_response_and_provider_search(self) -> None:
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
jmap = SimpleNamespace(session_url="https://jmap.example.test/.well-known/jmap")
provider_result = SimpleNamespace(
host="jmap.example.test",
port=443,
security="https",
folder="INBOX",
messages=[],
total_count=0,
offset=0,
limit=25,
uidvalidity="query-state-1",
cursor_reset=False,
)
with (
patch("govoplan_mail.backend.router._jmap_config_for_principal", return_value=jmap),
patch("govoplan_mail.backend.router.list_jmap_messages", return_value=provider_result) as provider,
patch("govoplan_mail.backend.router._next_jmap_mailbox_cursor", return_value=(None, True)),
):
response = router.list_profile_mailbox_messages(
"profile-1",
folder="INBOX",
limit=25,
offset=0,
cursor=None,
refresh=False,
protocol="jmap",
q="budget",
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
provider.assert_called_once_with(
jmap_config=jmap,
folder="INBOX",
limit=25,
offset=0,
expected_query_state=None,
query="budget",
)
self.assertEqual("profile-1", response.profile_id)
self.assertEqual("INBOX", response.folder)
self.assertEqual([], response.messages)
self.assertTrue(response.cursor_stable)
def test_jmap_changes_maps_bounded_incremental_state(self) -> None:
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
jmap = SimpleNamespace(session_url="https://jmap.example.test/.well-known/jmap")
changes = SimpleNamespace(
account_id="account-1",
old_state="state-1",
new_state="state-2",
has_more_changes=False,
created=("email-1",),
updated=("email-2",),
destroyed=("email-3",),
)
with (
patch("govoplan_mail.backend.router._jmap_config_for_principal", return_value=jmap),
patch("govoplan_mail.backend.router.get_jmap_email_changes", return_value=changes) as provider,
):
response = router.get_profile_mailbox_changes(
"profile-1",
since_state="state-1",
max_changes=50,
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
provider.assert_called_once_with(
jmap_config=jmap,
since_state="state-1",
max_changes=50,
)
self.assertEqual("jmap", response.protocol)
self.assertEqual(["email-1"], response.created)
self.assertEqual("state-2", response.new_state)
def test_unauthorized_profile_test_fails_before_credentials_or_provider_effect(self) -> None:
principal = _Principal({"mail:profile:test", "mail:profile:use"})
with (
+106 -2
View File
@@ -14,14 +14,19 @@ from govoplan_mail.backend.db.models import (
MailDeliveryCommand,
MailMailboxFolderIndex,
MailMailboxMessageIndex,
MailPop3Import,
MailServerEndpoint,
MailServerProfile,
)
from govoplan_mail.backend.manifest import manifest
from govoplan_mail.backend.provider_state import (
IMAP_PROVIDER_ID,
JMAP_PROVIDER_ID,
POP3_PROVIDER_ID,
SMTP_PROVIDER_ID,
imap_provider_states,
jmap_provider_states,
pop3_provider_states,
smtp_provider_states,
)
@@ -38,6 +43,7 @@ class MailProviderStateTests(unittest.TestCase):
MailMailboxFolderIndex.__table__,
MailMailboxMessageIndex.__table__,
MailBounceSource.__table__,
MailPop3Import.__table__,
),
)
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
@@ -120,17 +126,115 @@ class MailProviderStateTests(unittest.TestCase):
),
)
self.assertEqual(
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID},
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID, JMAP_PROVIDER_ID, POP3_PROVIDER_ID},
{item.id for item in manifest.external_providers},
)
self.assertEqual(
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID},
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID, JMAP_PROVIDER_ID, POP3_PROVIDER_ID},
{
item.provider_id
for item in manifest.external_provider_state_providers
},
)
def test_jmap_state_reuses_protocol_neutral_index_without_endpoint_details(self) -> None:
self.session.add(
MailServerEndpoint(
id="jmap-server-1",
profile_id=self.profile.id,
tenant_id="tenant-1",
protocol="jmap",
name="JMAP",
config={"session_url": "https://jmap.example.test/.well-known/jmap"},
scope_type="tenant",
scope_id="tenant-1",
is_active=True,
)
)
self.session.flush()
state = jmap_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual(JMAP_PROVIDER_ID, state.provider_id)
self.assertEqual("healthy", state.health)
self.assertEqual("current", state.freshness)
self.assertEqual("mail:profile:profile-1:jmap", state.binding_ref)
self.assertNotIn("jmap.example.test", str(state.to_dict()))
def test_pop3_state_is_disabled_by_default_and_projects_deletion_evidence(self) -> None:
endpoint = MailServerEndpoint(
id="pop3-server-1",
profile_id=self.profile.id,
tenant_id="tenant-1",
protocol="pop3",
name="Legacy POP3",
config={"host": "pop3.example.test", "legacy_import_enabled": False},
scope_type="tenant",
scope_id="tenant-1",
is_active=True,
)
self.session.add(endpoint)
self.session.flush()
disabled = pop3_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertFalse(disabled.active)
self.assertEqual("inactive", disabled.health)
endpoint.config = {
"host": "pop3.example.test",
"legacy_import_enabled": True,
}
self.session.add(
MailPop3Import(
id="pop3-import-1",
tenant_id="tenant-1",
profile_id=self.profile.id,
pop3_server_id=endpoint.id,
transport_revision=endpoint.transport_revision,
provider_uidl="uid-1",
fingerprint="a" * 64,
raw_sha256="b" * 64,
raw_message_encrypted="ciphertext-do-not-project",
size_bytes=42,
imported_at=datetime.now(UTC),
deletion_requested=True,
deletion_status="outcome_unknown",
)
)
self.session.add(
MailPop3Import(
id="pop3-import-tenant-2",
tenant_id="tenant-2",
profile_id=self.profile.id,
pop3_server_id=endpoint.id,
transport_revision=endpoint.transport_revision,
provider_uidl="uid-tenant-2",
fingerprint="c" * 64,
raw_sha256="d" * 64,
raw_message_encrypted="other-tenant-ciphertext",
size_bytes=42,
imported_at=datetime.now(UTC),
deletion_requested=True,
deletion_status="failed",
)
)
self.session.flush()
state = pop3_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertTrue(state.active)
self.assertEqual("warning", state.health)
self.assertEqual("pending", state.conflict)
self.assertEqual(1, state.metrics["outcome_unknown_deletions"])
self.assertEqual(0, state.metrics["failed_deletions"])
self.assertNotIn("pop3.example.test", str(state.to_dict()))
self.assertNotIn("ciphertext-do-not-project", str(state.to_dict()))
if __name__ == "__main__":
unittest.main()
+107
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import smtplib
import unittest
from unittest.mock import patch
@@ -7,6 +8,9 @@ from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import SmtpConfig
from govoplan_mail.backend.sending.smtp import (
SmtpConfigurationError,
SmtpBatchPolicy,
SmtpBatchSession,
SmtpSendError,
_open_smtp,
_prepare_smtp_send,
_smtp_send_result,
@@ -70,6 +74,109 @@ class SmtpSendHelperTests(unittest.TestCase):
self.assertEqual(result.accepted_count, 1)
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
def test_batch_preflight_reuses_one_authenticated_connection(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp()
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp) as opener:
with SmtpBatchSession(config) as batch:
first = batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
second = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
opener.assert_called_once_with(config)
self.assertFalse(first.session_reused)
self.assertTrue(second.session_reused)
self.assertEqual(1, second.connection_sequence)
self.assertEqual([b"first", b"second"], smtp.messages)
self.assertTrue(smtp.quit_called)
def test_batch_reconnects_before_next_message_when_health_check_fails(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
first_smtp = _FakeSmtp(noop_error_on_call=1)
second_smtp = _FakeSmtp()
policy = SmtpBatchPolicy(reconnect_attempts=1)
with patch(
"govoplan_mail.backend.sending.smtp._open_smtp",
side_effect=[first_smtp, second_smtp],
) as opener:
with SmtpBatchSession(config, policy=policy) as batch:
batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
result = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
self.assertEqual(2, opener.call_count)
self.assertEqual(2, result.connection_sequence)
self.assertEqual(1, result.reconnect_count)
self.assertEqual([b"first"], first_smtp.messages)
self.assertEqual([b"second"], second_smtp.messages)
def test_preflight_retries_a_transient_connection_failure(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp()
with patch(
"govoplan_mail.backend.sending.smtp._open_smtp",
side_effect=[OSError("temporary DNS failure"), smtp],
) as opener:
with SmtpBatchSession(config, policy=SmtpBatchPolicy(reconnect_attempts=1)) as batch:
result = batch.send(b"message", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
self.assertEqual(2, opener.call_count)
self.assertEqual(1, result.reconnect_count)
def test_connection_loss_after_send_starts_is_unknown_and_never_replayed(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp(send_error=smtplib.SMTPServerDisconnected("lost"))
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp), self.assertRaises(SmtpSendError) as raised:
with SmtpBatchSession(config) as batch:
batch.send(b"one", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
self.assertTrue(raised.exception.outcome_unknown)
self.assertTrue(raised.exception.systemic)
self.assertEqual("smtp_connection_lost_after_transmission", raised.exception.reason_code)
self.assertEqual(1, smtp.send_calls)
def test_authentication_preflight_is_systemic_and_blocks_batch(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
error = smtplib.SMTPAuthenticationError(535, b"bad credentials")
with patch("govoplan_mail.backend.sending.smtp._open_smtp", side_effect=error), self.assertRaises(SmtpSendError) as raised:
SmtpBatchSession(config).preflight()
self.assertTrue(raised.exception.systemic)
self.assertFalse(raised.exception.temporary)
self.assertEqual("preflight", raised.exception.phase)
self.assertEqual("smtp_authentication_failed", raised.exception.reason_code)
class _FakeSmtp:
def __init__(self, *, noop_error_on_call: int | None = None, send_error: BaseException | None = None):
self.noop_error_on_call = noop_error_on_call
self.send_error = send_error
self.noop_calls = 0
self.send_calls = 0
self.messages: list[bytes] = []
self.quit_called = False
def noop(self):
self.noop_calls += 1
if self.noop_error_on_call == self.noop_calls:
raise smtplib.SMTPServerDisconnected("stale")
return 250, b"ok"
def sendmail(self, _sender, _recipients, message):
self.send_calls += 1
if self.send_error is not None:
raise self.send_error
self.messages.append(message)
return {}
def send_message(self, message, **_kwargs):
return self.sendmail(None, None, message.as_bytes())
def quit(self):
self.quit_called = True
return 221, b"bye"
def close(self):
return None
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -1,17 +1,17 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.16",
"version": "0.1.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.16",
"version": "0.1.27",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.16",
"version": "0.1.27",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.16",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
@@ -26,7 +26,7 @@
}
},
"scripts": {
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-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": {
"typescript": "^5.7.2"
@@ -6,9 +6,11 @@ function read(relativePath) {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
}
const profiles = read("../src/features/mail/MailProfileManagement.tsx");
const profiles = `${read("../src/features/mail/MailProfileManagement.tsx")}\n${read("../src/features/mail/MailProfilePolicyEditor.tsx")}`;
const mailbox = read("../src/features/mail/MailboxPage.tsx");
const mailApi = read("../src/api/mail.ts");
const bounces = read("../src/features/mail/MailBouncePage.tsx");
const legacyImport = read("../src/features/mail/MailLegacyImportPage.tsx");
const moduleSource = read("../src/module.ts");
const styles = read("../src/styles/mail-profiles.css");
const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md");
@@ -22,11 +24,43 @@ assert.match(profiles, /disabledReason: credentialMutationBlocker/);
assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/);
assert.match(profiles, /disabledReason=\{policySaveBlocker\}/);
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, /DocumentationHelpLink/);
assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
assert.match(mailbox, /folder_mappings\?\.inbox/);
assert.match(mailbox, /detected_folder_mappings\?\.inbox/);
const openFolderNode = mailbox.slice(mailbox.indexOf(" function openFolderNode("), mailbox.indexOf(" function toggleFolderNode("));
assert.doesNotMatch(openFolderNode, /toggleFolderNode\(/, "Folder labels select; only the folder button expands or collapses.");
assert.match(openFolderNode, /setSelectedFolderGroup\(\{ id: node\.id, label: node\.label \}\)/);
assert.match(openFolderNode, /messageDetailRequestRef\.current \+= 1/, "Selecting a grouping invalidates pending message detail.");
assert.match(mailbox, /selectedFolderGroup\?\.id \?\? findFolderNodeId/);
assert.match(mailbox, /<WorkspaceFrame[^>]*height="viewport"/);
assert.match(mailbox, /<WorkspaceActionBar\s+variant="workspace"\s+scope="workspace"[\s\S]*refreshable[\s\S]*onReload: \(\) => void reloadMailbox\(\)/);
assert.ok(mailbox.indexOf("<WorkspaceActionBar") < mailbox.indexOf('<aside className="file-tree-panel"'), "Mailbox actions remain outside all selectable panes.");
assert.equal((mailbox.match(/<WorkspaceActionBar/g) ?? []).length, 1, "One persistent page-wide action bar.");
assert.doesNotMatch(mailbox, /mailbox-toolbar|<ToolbarGroup/);
assert.match(mailbox, /const MAILBOX_READ_OPTIONS = \{ cache: "no-store" \}/);
assert.match(mailbox, /listMailServerProfiles\(settings, false, undefined, MAILBOX_READ_OPTIONS\)/);
for (const helper of ["listMailServerProfiles", "listMailboxFolders", "bootstrapMailbox", "listMailboxMessages", "getMailboxMessage"]) {
const start = mailApi.indexOf(`export async function ${helper}(`);
const end = mailApi.indexOf("\nexport async function ", start + 1);
const source = mailApi.slice(start, end < 0 ? undefined : end);
assert.match(source, /options\?: MailboxReadOptions/, `${helper} supports a scoped fresh read.`);
assert.match(source, /\}\), options\)/, `${helper} passes the read options to Core without global invalidation.`);
}
assert.match(mailbox, /<Dialog open=\{mailToolsOpen\}[\s\S]*mailbox_refresh_tools[\s\S]*refresh_mailbox_profiles[\s\S]*refresh_mailbox_folder_catalogue[\s\S]*refresh_mailbox_message_index[\s\S]*mailbox_related_tools/);
const refreshMailbox = mailbox.slice(mailbox.indexOf(" async function reloadMailbox("), mailbox.indexOf(" function selectProfile("));
assert.match(refreshMailbox, /await loadProfiles\(\)[\s\S]*if \(selectedFolderGroup\) await refreshFolderCatalogue\(result.selected\)/);
assert.match(refreshMailbox, /requestAuthority !== authorityRef.current/);
assert.match(refreshMailbox, /profileId === selectedProfileIdRef.current/);
assert.match(refreshMailbox, /if \(!preservePreview\) setSelectedMessage\(null\)/, "Failed preview refresh keeps the previously loaded message.");
assert.doesNotMatch(refreshMailbox, /sendMail|appendMail|moveMail|deleteMail|setFlags|createMail/, "Mailbox reload must remain provider-read-only.");
assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
assert.match(bounces, /DocumentationHelpLink/);
@@ -34,12 +68,42 @@ assert.match(bounces, /topicId: "mail\.bounce-processing"/);
assert.match(bounces, /<ConfirmDialog[\s\S]*confirmLabel="Remove watcher"[\s\S]*tone="danger"/);
assert.match(bounces, /disabledReason=\{saveWatcherBlocker\}/);
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}`, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
for (const sharedComponent of ["PageLayout", "PageActionBar", "SelectionList", "DataGrid", "Dialog", "ConfirmDialog", "ToggleSwitch", "StatusBadge"]) {
assert.match(legacyImport, new RegExp(`\\b${sharedComponent}\\b`));
}
assert.match(legacyImport, /archetype="collection"/);
assert.match(legacyImport, /variant="collection"[\s\S]*refreshable[\s\S]*reloadAction=/);
assert.match(legacyImport, /topicId: "mail\.workflow\.legacy-pop3-import"/);
assert.match(legacyImport, /helpContextId="mail\.pop3"[\s\S]*helpTopicId="mail\.workflow\.legacy-pop3-import"/);
for (const helpContextId of [
"mail.pop3.source-editor",
"mail.pop3.action.reload",
"mail.pop3.action.save-source",
"mail.pop3.action.import",
"mail.pop3.field.transport-security",
"mail.pop3.field.max-message-size",
"mail.pop3.field.max-batch-size",
"mail.pop3.field.password",
"mail.pop3.field.delete-after-import",
"mail.pop3.confirm-delete-source",
]) {
assert.match(
legacyImport,
new RegExp(`helpContextId(?:=|:\\s*)"${helpContextId.replaceAll(".", "\\.")}"`)
);
}
assert.match(legacyImport, /data-help-context-id="mail\.pop3\.field\.message-selection"/);
assert.match(legacyImport, /<ConfirmDialog[\s\S]*tone="danger"[\s\S]*onConfirm=\{\(\) => void runImport\(\)\}/);
assert.match(legacyImport, /legacy_import_enabled: false[\s\S]*is_active: false[\s\S]*createMailServerCredential[\s\S]*updateMailServerEndpoint/);
assert.match(legacyImport, /expected_transport_revision: preview\.transport_revision/);
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}`, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
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: 1250px\)[\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: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
assert.doesNotMatch(styles, /\.mailbox-toolbar/, "Core owns workspace toolbar wrapping and action ordering.");
assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-message-row[\s\S]*grid-template-columns: 1fr/);
for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) {
assert.match(migration, new RegExp(archetype.replace("/", "\\/")));
@@ -23,3 +23,15 @@ assert(
!styles.includes(".mailbox-search-field button"),
"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"
);
+249 -15
View File
@@ -65,6 +65,41 @@ export type MailAddressLookupResponse = {
candidates: MailAddressLookupCandidate[];
};
export type MailAddressWriteTarget = {
address_book_id: string;
address_book_label?: string | null;
operation: string;
allowed: boolean;
reason: string;
message: string;
scope_type?: string | null;
scope_id?: string | null;
source_kind?: string | null;
read_only: boolean;
required_scopes: string[];
provenance: Record<string, unknown>;
};
export type MailAddressWriteTargetResponse = {
available: boolean;
targets: MailAddressWriteTarget[];
};
export type MailContactCreatePayload = {
address_book_id: string;
display_name?: string | null;
email: string;
};
export type MailContactCreateResponse = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
source_kind: string;
provenance: Record<string, unknown>;
};
export type MailMailboxAttachment = {
filename?: string | null;
content_type: string;
@@ -129,6 +164,20 @@ export type MailMailboxMessageResponse = {
message: MailMailboxMessageDetail;
};
export type MailMailboxProtocol = "imap" | "jmap";
export type MailMailboxChangesResponse = {
profile_id: string;
protocol: "jmap";
account_id: string;
old_state: string;
new_state: string;
has_more_changes: boolean;
created: string[];
updated: string[];
destroyed: string[];
};
export type MailSettingsDeltaResponse = {
profiles: MailServerProfile[];
policy?: MailProfilePolicyResponse | null;
@@ -209,11 +258,28 @@ export async function lookupMailAddresses(settings: ApiSettings, query: string,
return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
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)
});
}
type MailboxReadOptions = Pick<RequestInit, "cache" | "signal">;
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string, options?: MailboxReadOptions): Promise<MailServerProfile[]> {
const response = await apiFetch<{ profiles?: MailServerProfile[] | null }>(settings, apiPath("/api/v1/mail/profiles", {
include_inactive: includeInactive ? true : undefined,
campaign_id: campaignId
});
}), options);
return response.profiles ?? [];
}
export async function fetchMailSettingsDelta(
@@ -247,7 +313,7 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
export type MailServerEndpointPayload = {
protocol: "smtp" | "imap";
protocol: "smtp" | "imap" | "jmap" | "pop3";
name: string;
config: Record<string, unknown>;
inherit_to_lower_scopes?: boolean | null;
@@ -255,6 +321,77 @@ export type MailServerEndpointPayload = {
is_active?: boolean;
};
export type MailPop3ServerConfig = {
host?: string | null;
port?: number | null;
security?: MailSecurity | string;
timeout_seconds?: number;
max_message_bytes?: number;
max_batch_bytes?: number;
preview_body_lines?: number;
legacy_import_enabled?: boolean;
allow_delete_after_import?: boolean;
};
export type MailPop3ServerEndpoint = Omit<MailServerEndpoint, "protocol" | "config"> & {
protocol: "pop3";
config: MailPop3ServerConfig;
};
export type MailPop3MessagePreview = {
message_number: number;
uidl: string;
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
date?: string | null;
message_id?: string | null;
size_bytes: number;
body_preview?: string | null;
already_imported: boolean;
};
export type MailPop3PreviewResponse = {
profile_id: string;
server_id: string;
transport_revision: string;
host: string;
port: number;
security: string;
message_count: number;
mailbox_size_bytes: number;
delete_after_import_allowed: boolean;
messages: MailPop3MessagePreview[];
};
export type MailPop3ImportRecord = {
id: string;
profile_id: string;
pop3_server_id: string;
transport_revision: string;
provider_uidl: string;
message_id?: string | null;
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
date?: string | null;
body_preview?: string | null;
size_bytes: number;
raw_sha256: string;
status: string;
imported_at: string;
deletion_requested: boolean;
deletion_status: string;
deletion_attempted_at?: string | null;
deletion_error?: string | null;
};
export type MailPop3ImportResponse = {
imports: MailPop3ImportRecord[];
duplicate_uidls: string[];
deletion_status: string;
};
export type MailCredentialCreatePayload = {
name: string;
description?: string | null;
@@ -454,6 +591,80 @@ export async function testMailProfileImap(
);
}
export async function testMailProfileJmap(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(
settings,
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-jmap`, {
server_id: serverId,
credential_id: credentialId,
campaign_id: campaignId
})
);
}
export async function testMailProfilePop3(
settings: ApiSettings,
profileId: string,
serverId: string,
credentialId?: string | null
): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(
settings,
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-pop3`, {
server_id: serverId,
credential_id: credentialId
})
);
}
export async function previewMailProfilePop3(
settings: ApiSettings,
profileId: string,
payload: { server_id: string; credential_id?: string | null; limit?: number }
): Promise<MailPop3PreviewResponse> {
return apiPostJson<MailPop3PreviewResponse>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/pop3/preview`,
payload
);
}
export async function importMailProfilePop3(
settings: ApiSettings,
profileId: string,
payload: {
server_id: string;
credential_id?: string | null;
expected_transport_revision: string;
uidls: string[];
delete_after_import?: boolean;
}
): Promise<MailPop3ImportResponse> {
return apiPostJson<MailPop3ImportResponse>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/pop3/import`,
payload
);
}
export async function listMailPop3Imports(
settings: ApiSettings,
profileId?: string | null,
limit = 100
): Promise<MailPop3ImportRecord[]> {
const response = await apiFetch<{ imports: MailPop3ImportRecord[] }>(
settings,
apiPath("/api/v1/mail/pop3/imports", { profile_id: profileId, limit })
);
return response.imports;
}
export async function listMailProfileImapFolders(
settings: ApiSettings,
profileId: string,
@@ -471,11 +682,12 @@ export async function listMailProfileImapFolders(
);
}
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false, protocol: MailMailboxProtocol = "imap", options?: MailboxReadOptions): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, {
include_status: includeStatus ? true : undefined,
refresh: refresh ? true : undefined
}));
refresh: refresh ? true : undefined,
protocol
}), options);
}
export async function bootstrapMailbox(
@@ -484,14 +696,17 @@ export async function bootstrapMailbox(
folder = "INBOX",
limit = 50,
offset = 0,
refresh = false
refresh = false,
protocol: MailMailboxProtocol = "imap",
options?: MailboxReadOptions
): Promise<MailMailboxBootstrapResponse> {
return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, {
folder,
limit,
offset,
refresh: refresh ? true : undefined
}));
refresh: refresh ? true : undefined,
protocol
}), options);
}
export async function listMailboxMessages(
@@ -501,24 +716,43 @@ export async function listMailboxMessages(
limit = 50,
offset = 0,
cursor?: string | null,
refresh = false
refresh = false,
protocol: MailMailboxProtocol = "imap",
query?: string | null,
options?: MailboxReadOptions
): Promise<MailMailboxMessageListResponse> {
return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
folder,
limit,
offset,
cursor,
refresh: refresh ? true : undefined
}));
refresh: refresh ? true : undefined,
protocol,
q: query || undefined
}), options);
}
export async function getMailboxMessage(
settings: ApiSettings,
profileId: string,
folder: string,
uid: string
uid: string,
protocol: MailMailboxProtocol = "imap",
options?: MailboxReadOptions
): Promise<MailMailboxMessageResponse> {
return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder }));
return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder, protocol }), options);
}
export async function getMailboxChanges(
settings: ApiSettings,
profileId: string,
sinceState: string,
maxChanges = 500
): Promise<MailMailboxChangesResponse> {
return apiFetch<MailMailboxChangesResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/changes`, {
since_state: sinceState,
max_changes: maxChanges
}));
}
export async function testSmtpSettings(
+34 -32
View File
@@ -1,18 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
import {
import { FormGrid, ContentGrid,
ActionBlockerHint,
Button,
Card,
ConfirmDialog,
DataGrid,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
LoadingFrame,
PageScrollViewport,
PageTitle,
PageActionBar,
PageLayout,
StatusBadge,
TableActionGroup,
ToggleSwitch,
@@ -194,33 +192,37 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
];
return (
<PageScrollViewport>
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
<div className="button-row compact-actions">
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
<Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading bounce processing">
<div className="dashboard-grid">
<Card title="Watched mailboxes">
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
</Card>
<Card title="Delivery-status observations">
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
</Card>
</div>
</LoadingFrame>
</div>
<>
<PageLayout
archetype="collection"
title="Bounce processing"
description="Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands."
actions={<PageActionBar
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), disabled: Boolean(pageMutationBlocker), disabledReason: pageMutationBlocker }}
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
helpAction={<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />}
createAction={<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>}
/>}
loading={loading}
loadingLabel="Loading bounce processing"
error={error}
success={message}
documentationType="admin"
>
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="Watched mailboxes">
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
</Card>
<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></>}>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
{imapProfiles.length === 0 &&
<ActionBlockerHint
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" />
</FormField>
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
</div>
</FormGrid>
</Dialog>
<ConfirmDialog
@@ -254,6 +256,6 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
busy={Boolean(busy)}
onCancel={() => setDeleteSource(null)}
onConfirm={() => void removeSource()} />
</PageScrollViewport>
</>
);
}
@@ -0,0 +1,624 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Pencil, Plus, ShieldCheck } from "lucide-react";
import {
ActionBlockerHint,
Button,
Card,
ConfirmDialog,
ContentGrid,
DataGrid,
Dialog,
DocumentationHelpLink,
FormField,
FormGrid,
PageActionBar,
PageLayout,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatusBadge,
TableActionGroup,
ToggleSwitch,
adminErrorMessage,
formatDateTime,
hasScope,
useGuardedNavigate,
type ApiSettings,
type AuthInfo,
type DataGridColumn
} from "@govoplan/core-webui";
import {
createMailServerCredential,
createMailServerEndpoint,
importMailProfilePop3,
listMailPop3Imports,
listMailServerProfiles,
previewMailProfilePop3,
testMailProfilePop3,
updateMailServerCredential,
updateMailServerEndpoint,
type MailCredentialEnvelope,
type MailPop3ImportRecord,
type MailPop3MessagePreview,
type MailPop3PreviewResponse,
type MailPop3ServerEndpoint,
type MailServerProfile
} from "../../api/mail";
const DOCUMENTATION = {
topicId: "mail.workflow.legacy-pop3-import",
documentationType: "admin"
} as const;
type Source = {
profile: MailServerProfile;
server: MailPop3ServerEndpoint;
};
type SourceDraft = {
profileId: string;
name: string;
host: string;
port: string;
security: "tls" | "starttls" | "plain";
timeoutSeconds: string;
maxMessageMiB: string;
maxBatchMiB: string;
previewBodyLines: string;
username: string;
password: string;
enabled: boolean;
allowDeleteAfterImport: boolean;
};
const EMPTY_DRAFT: SourceDraft = {
profileId: "",
name: "Legacy POP3 source",
host: "",
port: "995",
security: "tls",
timeoutSeconds: "30",
maxMessageMiB: "25",
maxBatchMiB: "100",
previewBodyLines: "20",
username: "",
password: "",
enabled: true,
allowDeleteAfterImport: false
};
export default function MailLegacyImportPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const navigate = useGuardedNavigate();
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [imports, setImports] = useState<MailPop3ImportRecord[]>([]);
const [selectedSourceId, setSelectedSourceId] = useState("");
const [preview, setPreview] = useState<MailPop3PreviewResponse | null>(null);
const [selectedUidls, setSelectedUidls] = useState<string[]>([]);
const [deleteAfterImport, setDeleteAfterImport] = useState(false);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [sourceDialogOpen, setSourceDialogOpen] = useState(false);
const [editingSourceId, setEditingSourceId] = useState<string | null>(null);
const [sourceDraft, setSourceDraft] = useState<SourceDraft>(EMPTY_DRAFT);
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
const canImport = hasScope(auth, "mail:pop3:import");
const canDelete = hasScope(auth, "mail:pop3:delete");
const canManage = hasScope(auth, "mail:pop3:manage");
const canManageSecrets = hasScope(auth, "mail:secret:manage");
const sources = useMemo(() => pop3Sources(profiles), [profiles]);
const selectedSource = sources.find((item) => item.server.id === selectedSourceId) ?? sources[0] ?? null;
const selectedCredential = selectedSource ? defaultCredential(selectedSource.server) : null;
const configurableProfiles = profiles.filter((profile) => profileCanBeConfigured(auth, profile));
const sourceCanBeConfigured = selectedSource ? profileCanBeConfigured(auth, selectedSource.profile) : false;
async function load() {
setLoading(true);
setError("");
try {
const [nextProfiles, nextImports] = await Promise.all([
listMailServerProfiles(settings, canManage),
canImport ? listMailPop3Imports(settings, null, 200) : Promise.resolve([])
]);
const nextSources = pop3Sources(nextProfiles);
setProfiles(nextProfiles);
setImports(nextImports);
setSelectedSourceId((current) =>
nextSources.some((item) => item.server.id === current)
? current
: nextSources[0]?.server.id ?? ""
);
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, canImport]);
useEffect(() => {
setPreview(null);
setSelectedUidls([]);
setDeleteAfterImport(false);
}, [selectedSourceId]);
async function runConnectionTest() {
if (!selectedSource) return;
setBusy("test");
setError("");
setSuccess("");
try {
const result = await testMailProfilePop3(
settings,
selectedSource.profile.id,
selectedSource.server.id,
selectedCredential?.id
);
if (!result.ok) throw new Error(result.message);
setSuccess(
`POP3 authentication succeeded. The mailbox currently reports ${String(result.details.message_count ?? 0)} message(s).`
);
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setBusy("");
}
}
async function refreshPreview() {
if (!selectedSource || !canImport) return;
setBusy("preview");
setError("");
setSuccess("");
try {
const next = await previewMailProfilePop3(settings, selectedSource.profile.id, {
server_id: selectedSource.server.id,
credential_id: selectedCredential?.id,
limit: 100
});
setPreview(next);
setSelectedUidls((current) =>
current.filter((uidl) => next.messages.some((item) => item.uidl === uidl && !item.already_imported))
);
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setBusy("");
}
}
async function runImport() {
if (!selectedSource || !preview || selectedUidls.length === 0) return;
setDeleteConfirmationOpen(false);
setBusy("import");
setError("");
setSuccess("");
try {
const result = await importMailProfilePop3(settings, selectedSource.profile.id, {
server_id: selectedSource.server.id,
credential_id: selectedCredential?.id,
expected_transport_revision: preview.transport_revision,
uidls: selectedUidls,
delete_after_import: deleteAfterImport
});
setSuccess(
`${result.imports.length} message(s) imported; ${result.duplicate_uidls.length} duplicate(s) skipped. Source deletion: ${result.deletion_status.replaceAll("_", " ")}.`
);
setSelectedUidls([]);
const [nextPreview, nextImports] = await Promise.all([
previewMailProfilePop3(settings, selectedSource.profile.id, {
server_id: selectedSource.server.id,
credential_id: selectedCredential?.id,
limit: 100
}),
listMailPop3Imports(settings, null, 200)
]);
setPreview(nextPreview);
setImports(nextImports);
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setBusy("");
}
}
function openSourceDialog(source: Source | null) {
const credential = source ? defaultCredential(source.server) : null;
setEditingSourceId(source?.server.id ?? null);
setSourceDraft(source ? sourceDraftFromSource(source, credential) : {
...EMPTY_DRAFT,
profileId: selectedSource?.profile.id ?? configurableProfiles[0]?.id ?? ""
});
setSourceDialogOpen(true);
}
async function saveSource() {
const profile = profiles.find((item) => item.id === sourceDraft.profileId);
if (!profile) return;
const existing = sources.find((item) => item.server.id === editingSourceId) ?? null;
const existingCredential = existing ? defaultCredential(existing.server) : null;
setBusy("source");
setError("");
setSuccess("");
try {
if (existing) {
if (canManageSecrets && (sourceDraft.password || !existingCredential)) {
if (existingCredential) {
await updateMailServerCredential(
settings,
existing.profile.id,
existing.server.id,
existingCredential.id,
{
name: `${sourceDraft.name.trim()} credential`,
username: sourceDraft.username.trim(),
...(sourceDraft.password ? { password: sourceDraft.password } : {})
}
);
} else {
await createMailServerCredential(
settings,
existing.profile.id,
existing.server.id,
credentialPayload(sourceDraft, existing.server.id)
);
}
}
await updateMailServerEndpoint(settings, existing.profile.id, existing.server.id, {
name: sourceDraft.name.trim(),
config: sourceConfig(sourceDraft),
is_active: true
});
setSuccess("Legacy POP3 source updated.");
} else {
const disabledServer = await createMailServerEndpoint(settings, profile.id, {
protocol: "pop3",
name: sourceDraft.name.trim(),
config: {
...sourceConfig(sourceDraft),
legacy_import_enabled: false,
allow_delete_after_import: false
},
is_default: false,
is_active: false
});
await createMailServerCredential(
settings,
profile.id,
disabledServer.id,
credentialPayload(sourceDraft, disabledServer.id)
);
await updateMailServerEndpoint(settings, profile.id, disabledServer.id, {
config: sourceConfig(sourceDraft),
is_active: true
});
setSelectedSourceId(disabledServer.id);
setSuccess("Legacy POP3 source created. It was enabled only after its encrypted credential was stored.");
}
setSourceDialogOpen(false);
await load();
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setBusy("");
}
}
const sourceBlocker = sourceSaveBlocker({
draft: sourceDraft,
existingCredential: editingSourceId ? defaultCredential(sources.find((item) => item.server.id === editingSourceId)?.server) : null,
canManageSecrets
});
const importBlocker = !selectedSource
? "Select an enabled legacy POP3 source."
: !preview
? "Refresh the live preview before importing."
: selectedUidls.length === 0
? "Select at least one message that has not already been imported."
: deleteAfterImport && (!canDelete || !preview.delete_after_import_allowed)
? "Source deletion needs both the destructive permission and an endpoint policy that allows it."
: "";
const previewColumns: DataGridColumn<MailPop3MessagePreview>[] = [
{
id: "select",
header: "Select",
width: 82,
render: (item) => <input
type="checkbox"
aria-label={`Select ${item.subject || item.uidl}`}
data-help-context-id="mail.pop3.field.message-selection"
data-help-module-id="mail"
checked={selectedUidls.includes(item.uidl)}
disabled={Boolean(busy) || item.already_imported}
onChange={() => setSelectedUidls((current) =>
current.includes(item.uidl)
? current.filter((uidl) => uidl !== item.uidl)
: [...current, item.uidl]
)} />
},
{
id: "subject",
header: "Message",
width: "minmax(240px, 1.3fr)",
filterable: true,
value: (item) => `${item.subject || ""} ${item.from_header || ""}`,
render: (item) => <span><strong>{item.subject || "No subject"}</strong><br /><small>{item.from_header || "Unknown sender"}</small></span>
},
{ id: "date", header: "Provider date", width: "minmax(170px, .8fr)", value: (item) => item.date || "", render: (item) => item.date || "Unknown" },
{ id: "size", header: "Size", width: 105, value: (item) => item.size_bytes, render: (item) => formatBytes(item.size_bytes) },
{
id: "state",
header: "State",
width: 130,
value: (item) => item.already_imported ? "imported" : "available",
render: (item) => <StatusBadge status={item.already_imported ? "inactive" : "success"} label={item.already_imported ? "imported" : "available"} />
}
];
const importColumns: DataGridColumn<MailPop3ImportRecord>[] = [
{ id: "imported", header: "Imported", width: "minmax(170px, .8fr)", value: (item) => item.imported_at, render: (item) => formatDateTime(item.imported_at) },
{ id: "subject", header: "Message", width: "minmax(240px, 1.2fr)", filterable: true, value: (item) => `${item.subject || ""} ${item.from_header || ""}`, render: (item) => <span><strong>{item.subject || "No subject"}</strong><br /><small>{item.from_header || "Unknown sender"}</small></span> },
{ id: "review", header: "Review state", width: 140, value: (item) => item.status, render: (item) => <StatusBadge status="warning" label={item.status.replaceAll("_", " ")} /> },
{ id: "deletion", header: "Source deletion", width: 165, value: (item) => item.deletion_status, render: (item) => <StatusBadge status={deletionTone(item.deletion_status)} label={item.deletion_status.replaceAll("_", " ")} /> }
];
return (
<>
<PageLayout
archetype="collection"
title="Legacy POP3 import"
description="Migrate bounded messages into encrypted local review records. POP3 is disabled by default and is not recommended for ongoing mailbox access."
helpContextId="mail.pop3"
helpModuleId="mail"
helpTopicId="mail.workflow.legacy-pop3-import"
actions={<PageActionBar
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), helpContextId: "mail.pop3.action.reload", helpModuleId: "mail", helpTopicId: "mail.workflow.legacy-pop3-import", disabled: Boolean(busy), disabledReason: busy ? "Wait for the current POP3 action to finish." : undefined }}
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
helpAction={<DocumentationHelpLink reference={DOCUMENTATION} />}
createAction={canManage ? <Button helpContextId="mail.pop3.action.create-source" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => openSourceDialog(null)} disabled={Boolean(busy) || configurableProfiles.length === 0} disabledReason={configurableProfiles.length === 0 ? "Create or gain write access to a Mail profile first." : undefined}><Plus size={16} aria-hidden="true" /> Add legacy source</Button> : undefined}
/>}
loading={loading}
loadingLabel="Loading legacy POP3 sources"
error={error}
success={success}
documentationType="admin"
>
<ContentGrid columns={2} collapseAt="workspace" align="stretch">
<Card title="Legacy sources">
{sources.length === 0 ? <ActionBlockerHint reason={{
summary: "No POP3 legacy source is configured.",
details: "Mail never derives or enables POP3 from an SMTP or IMAP profile.",
requiredAction: canManage ? "Add a dedicated legacy source to an existing Mail profile." : "Ask a Mail profile administrator to configure and explicitly enable a source.",
actor: "Mail profile administrator",
target: "Legacy POP3 import"
}} documentation={DOCUMENTATION} /> : <SelectionList label="POP3 legacy sources" variant="navigation">
{sources.map((source) => <SelectionListItem key={source.server.id} selected={source.server.id === selectedSource?.server.id} onClick={() => setSelectedSourceId(source.server.id)}>
<SelectionListItemContent
title={source.server.name}
description={`${source.profile.name} · ${source.server.config.host || "Host missing"}`}
leading={<ShieldCheck size={18} />}
/>
</SelectionListItem>)}
</SelectionList>}
</Card>
<Card
title={selectedSource?.server.name || "Selected source"}
actions={selectedSource && canManage && sourceCanBeConfigured ? <TableActionGroup actions={[{
id: "edit",
label: "Configure source",
icon: <Pencil aria-hidden="true" />,
disabled: Boolean(busy),
disabledReason: busy ? "Wait for the current POP3 action to finish." : "",
helpContextId: "mail.pop3.action.configure-source",
helpModuleId: "mail",
helpTopicId: "mail.workflow.legacy-pop3-import",
onClick: () => openSourceDialog(selectedSource)
}]} /> : undefined}
>
{selectedSource ? <FormGrid columns={2} collapseAt="standard">
<FormField label="Profile"><span>{selectedSource.profile.name}</span></FormField>
<FormField label="Policy"><StatusBadge status={selectedSource.server.config.legacy_import_enabled ? "success" : "inactive"} label={selectedSource.server.config.legacy_import_enabled ? "explicitly enabled" : "disabled"} /></FormField>
<FormField label="Transport"><span>{selectedSource.server.config.security || "tls"} · {String(selectedSource.server.config.port || 995)}</span></FormField>
<FormField label="Credential"><span>{selectedCredential ? String(selectedCredential.public_data?.username || selectedCredential.name) : "No credential"}</span></FormField>
<Button helpContextId="mail.pop3.action.test" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" onClick={() => void runConnectionTest()} disabled={Boolean(busy) || !selectedCredential || !selectedSource.server.config.legacy_import_enabled} disabledReason={!selectedCredential ? "Store an encrypted POP3 credential first." : !selectedSource.server.config.legacy_import_enabled ? "Explicitly enable legacy import first." : busy ? "Wait for the current POP3 action to finish." : undefined}>Test connection</Button>
{canImport ? <Button helpContextId="mail.pop3.action.preview" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => void refreshPreview()} disabled={Boolean(busy) || !selectedCredential || !selectedSource.server.config.legacy_import_enabled} disabledReason={!selectedCredential ? "Store an encrypted POP3 credential first." : !selectedSource.server.config.legacy_import_enabled ? "Explicitly enable legacy import first." : busy ? "Wait for the current POP3 action to finish." : undefined}>Refresh live preview</Button> : null}
</FormGrid> : <p className="muted">Select or configure a legacy source.</p>}
</Card>
</ContentGrid>
<Card title="Live provider preview">
{preview ? <>
<p className="muted">Provider reports {preview.message_count} message(s), {formatBytes(preview.mailbox_size_bytes)} total. Preview and ordinary import do not delete source messages.</p>
<DataGrid id="mail-pop3-preview" rows={preview.messages} columns={previewColumns} getRowKey={(item) => item.uidl} emptyText="The legacy mailbox contains no messages." />
<FormGrid columns={2} collapseAt="standard" spacing="block">
<ToggleSwitch helpContextId="mail.pop3.field.delete-after-import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={deleteAfterImport} disabled={!canDelete || !preview.delete_after_import_allowed || Boolean(busy)} onChange={setDeleteAfterImport} label="Delete newly imported messages at the source" help="Destructive and separately governed. The local encrypted import is committed first." />
<Button helpContextId="mail.pop3.action.import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant={deleteAfterImport ? "danger" : "primary"} onClick={() => deleteAfterImport ? setDeleteConfirmationOpen(true) : void runImport()} disabled={Boolean(busy) || Boolean(importBlocker)} disabledReason={importBlocker || (busy ? "Wait for the current POP3 action to finish." : undefined)}>Import {selectedUidls.length || "selected"} message(s)</Button>
</FormGrid>
</> : <p className="muted">Refresh a source to obtain a bounded, non-destructive preview.</p>}
</Card>
<Card title="Governed local imports">
<DataGrid id="mail-pop3-imports" rows={imports} columns={importColumns} getRowKey={(item) => item.id} emptyText="No legacy messages have been imported." />
</Card>
</PageLayout>
<Dialog
open={sourceDialogOpen}
title={editingSourceId ? "Configure legacy POP3 source" : "Add legacy POP3 source"}
helpContextId="mail.pop3.source-editor"
helpModuleId="mail"
helpTopicId="mail.workflow.legacy-pop3-import"
onClose={() => !busy && setSourceDialogOpen(false)}
footer={<><Button onClick={() => setSourceDialogOpen(false)} disabled={Boolean(busy)}>Cancel</Button><Button helpContextId="mail.pop3.action.save-source" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => void saveSource()} disabled={Boolean(busy) || Boolean(sourceBlocker)} disabledReason={sourceBlocker || (busy ? "Wait for the source to be saved." : undefined)}>Save source</Button></>}
>
<FormGrid columns={2} collapseAt="standard">
<FormField label="Mail profile" help="The profile supplies scope and lifecycle ownership for this dedicated source." helpContextId="mail.pop3.field.profile" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import">
<select value={sourceDraft.profileId} disabled={Boolean(editingSourceId) || Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, profileId: event.target.value }))}>
<option value="">Select a profile</option>
{configurableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profile.scope_type})</option>)}
</select>
</FormField>
<FormField label="Source name" helpContextId="mail.pop3.field.name" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.name} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, name: event.target.value }))} /></FormField>
<FormField label="POP3 host" helpContextId="mail.pop3.field.host" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.host} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, host: event.target.value }))} placeholder="pop3.example.org" /></FormField>
<FormField label="Port" helpContextId="mail.pop3.field.port" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={65535} value={sourceDraft.port} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, port: event.target.value }))} /></FormField>
<FormField label="Transport security" helpContextId="mail.pop3.field.transport-security" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import">
<select value={sourceDraft.security} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, security: event.target.value as SourceDraft["security"], port: event.target.value === "tls" ? "995" : "110" }))}>
<option value="tls">TLS</option>
<option value="starttls">STARTTLS</option>
<option value="plain">Plain (deployment policy may deny)</option>
</select>
</FormField>
<FormField label="Timeout (seconds)" helpContextId="mail.pop3.field.timeout" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={300} value={sourceDraft.timeoutSeconds} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, timeoutSeconds: event.target.value }))} /></FormField>
<FormField label="Maximum message size (MiB)" helpContextId="mail.pop3.field.max-message-size" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={50} value={sourceDraft.maxMessageMiB} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, maxMessageMiB: event.target.value }))} /></FormField>
<FormField label="Maximum batch size (MiB)" helpContextId="mail.pop3.field.max-batch-size" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={500} value={sourceDraft.maxBatchMiB} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, maxBatchMiB: event.target.value }))} /></FormField>
<FormField label="Preview body lines" helpContextId="mail.pop3.field.preview-body-lines" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={0} max={100} value={sourceDraft.previewBodyLines} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, previewBodyLines: event.target.value }))} /></FormField>
<FormField label="Username" helpContextId="mail.pop3.field.username" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.username} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, username: event.target.value }))} autoComplete="username" /></FormField>
<FormField label={editingSourceId ? "New password (optional)" : "Password"} helpContextId="mail.pop3.field.password" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="password" value={sourceDraft.password} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, password: event.target.value }))} autoComplete="new-password" /></FormField>
<ToggleSwitch helpContextId="mail.pop3.field.enabled" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={sourceDraft.enabled} disabled={Boolean(busy)} onChange={(enabled) => setSourceDraft((draft) => ({ ...draft, enabled, allowDeleteAfterImport: enabled ? draft.allowDeleteAfterImport : false }))} label="Explicitly enable legacy import" help="Off is the product default." />
<ToggleSwitch helpContextId="mail.pop3.field.allow-delete-after-import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={sourceDraft.allowDeleteAfterImport} disabled={Boolean(busy) || !sourceDraft.enabled} onChange={(allowDeleteAfterImport) => setSourceDraft((draft) => ({ ...draft, allowDeleteAfterImport }))} label="Permit delete-after-import requests" help="Operators still need a separate destructive permission and must choose deletion per batch." />
</FormGrid>
</Dialog>
<ConfirmDialog
open={deleteConfirmationOpen}
title="Import and delete source messages"
message={`Mail will first commit and audit ${selectedUidls.length} encrypted local import(s), then ask the POP3 server to delete only those newly imported messages. Provider deletion cannot be undone and may require reconciliation if its outcome is unknown.`}
confirmLabel="Import, then delete source"
helpContextId="mail.pop3.confirm-delete-source"
helpModuleId="mail"
helpTopicId="mail.workflow.legacy-pop3-import"
tone="danger"
busy={Boolean(busy)}
onCancel={() => setDeleteConfirmationOpen(false)}
onConfirm={() => void runImport()}
/>
</>
);
}
function pop3Sources(profiles: MailServerProfile[]): Source[] {
return profiles.flatMap((profile) =>
((profile.servers ?? []) as unknown as MailPop3ServerEndpoint[])
.filter((server) => server.protocol === "pop3")
.map((server) => ({ profile, server }))
);
}
function defaultCredential(server: MailPop3ServerEndpoint | undefined): MailCredentialEnvelope | null {
if (!server) return null;
return server.credentials.find((credential) => credential.is_default)
?? server.credentials.find((credential) => credential.is_active)
?? server.credentials[0]
?? null;
}
function profileCanBeConfigured(auth: AuthInfo, profile: MailServerProfile): boolean {
if (profile.scope_type === "system") return hasScope(auth, "system:settings:write");
if (hasScope(auth, "mail:profile:write")) return true;
return profile.scope_type === "user"
&& profile.scope_id === auth.user.id
&& hasScope(auth, "mail:profile:write_own");
}
function sourceDraftFromSource(source: Source, credential: MailCredentialEnvelope | null): SourceDraft {
const config = source.server.config;
return {
profileId: source.profile.id,
name: source.server.name,
host: String(config.host ?? ""),
port: String(config.port ?? (config.security === "tls" ? 995 : 110)),
security: config.security === "starttls" || config.security === "plain" ? config.security : "tls",
timeoutSeconds: String(config.timeout_seconds ?? 30),
maxMessageMiB: String(Math.max(1, Math.round(Number(config.max_message_bytes ?? 25 * 1024 * 1024) / 1024 / 1024))),
maxBatchMiB: String(Math.max(1, Math.round(Number(config.max_batch_bytes ?? 100 * 1024 * 1024) / 1024 / 1024))),
previewBodyLines: String(config.preview_body_lines ?? 20),
username: String(credential?.public_data?.username ?? ""),
password: "",
enabled: Boolean(config.legacy_import_enabled),
allowDeleteAfterImport: Boolean(config.allow_delete_after_import)
};
}
function sourceConfig(draft: SourceDraft): Record<string, unknown> {
return {
host: draft.host.trim(),
port: Number(draft.port),
security: draft.security,
timeout_seconds: Number(draft.timeoutSeconds),
max_message_bytes: Number(draft.maxMessageMiB) * 1024 * 1024,
max_batch_bytes: Number(draft.maxBatchMiB) * 1024 * 1024,
preview_body_lines: Number(draft.previewBodyLines),
legacy_import_enabled: draft.enabled,
allow_delete_after_import: draft.allowDeleteAfterImport
};
}
function credentialPayload(draft: SourceDraft, serverId: string) {
return {
name: `${draft.name.trim()} credential`,
credential_kind: "username_password",
username: draft.username.trim(),
password: draft.password,
allowed_modules: ["mail"],
allowed_server_refs: [`mail:${serverId}`],
is_default: true
};
}
function sourceSaveBlocker({
draft,
existingCredential,
canManageSecrets
}: {
draft: SourceDraft;
existingCredential: MailCredentialEnvelope | null;
canManageSecrets: boolean;
}): string {
if (!draft.profileId) return "Select a Mail profile.";
if (!draft.name.trim()) return "Enter a source name.";
if (!draft.host.trim()) return "Enter the POP3 host.";
if (!boundedInteger(draft.port, 1, 65535)) return "Enter a valid POP3 port.";
if (!boundedInteger(draft.timeoutSeconds, 1, 300)) return "Enter a timeout from 1 to 300 seconds.";
if (!boundedInteger(draft.maxMessageMiB, 1, 50)) return "Enter a message limit from 1 to 50 MiB.";
if (!boundedInteger(draft.maxBatchMiB, 1, 500)) return "Enter a batch limit from 1 to 500 MiB.";
if (Number(draft.maxBatchMiB) < Number(draft.maxMessageMiB)) return "The batch limit cannot be lower than the per-message limit.";
if (!boundedInteger(draft.previewBodyLines, 0, 100)) return "Enter 0 to 100 preview body lines.";
if (draft.allowDeleteAfterImport && !draft.enabled) return "Enable legacy import before permitting source deletion.";
if (!existingCredential && !canManageSecrets) return "Managing the encrypted POP3 credential requires Mail secret authority.";
if (!existingCredential && !draft.username.trim()) return "Enter the POP3 username.";
if (!existingCredential && !draft.password) return "Enter the POP3 password.";
return "";
}
function boundedInteger(value: string, minimum: number, maximum: number): boolean {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum;
}
function deletionTone(status: string): "success" | "warning" | "error" | "inactive" {
if (status === "succeeded") return "success";
if (status === "failed" || status === "outcome_unknown") return "error";
if (status === "pending") return "warning";
return "inactive";
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,712 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ActionToolbar, ActionBlockerHint, AdminSelectionList, DocumentationHelpLink, FieldLabel, LoadingFrame, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, ToggleSwitch, normalizePolicySourcePathItems, type MailJmapTransportSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { getMailProfilePolicy, mailProfilePatternKeys, mailProfilePolicyLimitKeys, updateMailProfilePolicy, type MailCredentialPolicy, type MailImapTestPayload, type MailProfilePatternKey, type MailProfilePatternRules, type MailProfilePolicy, type MailProfilePolicyLimitKey, type MailProfileScope, type MailServerProfile, type MailSmtpTestPayload } from "../../api/mail";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { i18nMessage, usePlatformLanguage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type MailProfilePolicyEditorProps = {
settings: ApiSettings;
scopeType: MailProfileScope;
scopeId?: string | null;
campaignId?: string | null;
profiles: MailServerProfile[];
ownerUserId?: string | null;
ownerGroupId?: string | null;
canWrite: boolean;
locked?: boolean;
title?: string;
description?: string;
onSaved?: () => void | Promise<void>;
};
type PolicyFlagValue = "inherit" | "allow" | "deny";
export const MAIL_PROFILE_DOCUMENTATION = {
topicId: "mail.profiles-and-policy",
documentationType: "admin"
} as const;
const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "i18n:govoplan-mail.smtp_hostnames.36eb51d8",
imap_hosts: "i18n:govoplan-mail.imap_hostnames.ac9c1d78",
jmap_hosts: "JMAP hostnames",
envelope_senders: "i18n:govoplan-mail.envelope_senders.269065cd",
from_headers: "i18n:govoplan-mail.from_headers.b3ea473b",
recipient_domains: "i18n:govoplan-mail.recipient_domains.cb9b7b44"
};
const blankPolicy: MailProfilePolicy = {
allowed_profile_ids: [],
allow_user_profiles: null,
allow_group_profiles: null,
allow_campaign_profiles: null,
smtp_credentials: {},
imap_credentials: {},
whitelist: {},
blacklist: {},
allow_lower_level_limits: {}
};
export function MailProfilePolicyEditor({
settings,
scopeType,
scopeId = null,
campaignId = null,
profiles,
ownerUserId = null,
ownerGroupId = null,
canWrite,
locked = false,
title = "i18n:govoplan-mail.mail_profile_policy.f2ac4b92",
description = "i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4",
onSaved
}: MailProfilePolicyEditorProps) {
const { translateText } = usePlatformLanguage();
const [policy, setPolicy] = useState<MailProfilePolicy>(blankPolicy);
const [effectivePolicy, setEffectivePolicy] = useState<MailProfilePolicy | null>(null);
const [parentPolicy, setParentPolicy] = useState<MailProfilePolicy | null>(null);
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
const [savedPolicyKey, setSavedPolicyKey] = useState(policyDraftKey(blankPolicy));
const [loading, setLoading] = useState(false);
const [policyLoaded, setPolicyLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [refreshWarning, setRefreshWarning] = useState("");
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const scopeReady = !requiresTarget || Boolean(scopeId);
const policyDirty = scopeReady && policyDraftKey(policy) !== savedPolicyKey;
useUnsavedDraftGuard({
dirty: policyDirty,
onSave: savePolicy,
onDiscard: () => setPolicy(JSON.parse(savedPolicyKey) as MailProfilePolicy)
});
useEffect(() => {void loadPolicy();}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]);
async function loadPolicy() {
setPolicyLoaded(false);
setError("");
setSuccess("");
setRefreshWarning("");
if (!scopeReady) {
setPolicy(blankPolicy);
setSavedPolicyKey(policyDraftKey(blankPolicy));
setEffectivePolicy(null);
setParentPolicy(null);
setEffectivePolicySources([]);
return;
}
setLoading(true);
try {
const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId);
const loadedPolicy = normalizePolicy(response.policy);
setPolicy(loadedPolicy);
setSavedPolicyKey(policyDraftKey(loadedPolicy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
setEffectivePolicySources(response.effective_policy_sources ?? []);
setPolicyLoaded(true);
} catch (err) {
setPolicy(blankPolicy);
setSavedPolicyKey(policyDraftKey(blankPolicy));
setEffectivePolicy(null);
setParentPolicy(null);
setEffectivePolicySources([]);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
async function savePolicy(): Promise<boolean> {
if (!scopeReady || !canWrite || locked || loading || !policyLoaded || busy) return false;
setBusy(true);
setError("");
setSuccess("");
setRefreshWarning("");
try {
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicyForSave(policy, parentPolicy, scopeType), scopeId);
const savedPolicy = normalizePolicy(response.policy);
setPolicy(savedPolicy);
setSavedPolicyKey(policyDraftKey(savedPolicy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
setEffectivePolicySources(response.effective_policy_sources ?? []);
setSuccess("i18n:govoplan-mail.mail_profile_policy_saved.666847bf");
try {
await onSaved?.();
} catch (refreshError) {
// The policy write has committed. A dependent refresh must never turn
// this into a failed save or encourage replaying the accepted write.
setRefreshWarning(i18nMessage("i18n:govoplan-mail.policy_saved_refresh_failed", { value0: errorMessage(refreshError) }));
}
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setBusy(false);
}
}
const candidateProfiles = useMemo(
() => profileCandidatesForPolicy(profiles, scopeType, scopeId, ownerUserId, ownerGroupId),
[ownerGroupId, ownerUserId, profiles, scopeId, scopeType]
);
const isSystem = scopeType === "system";
const displayPolicy = useMemo(() => isSystem ? concreteSystemPolicy(policy) : policy, [isSystem, policy]);
const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
const disabled = locked || busy || loading || !policyLoaded || !canWrite || !scopeReady;
const policySaveBlocker = mailPolicyDisabledReason(locked, canWrite, scopeReady, loading || !policyLoaded, busy, policyDirty);
const parentAllowedProfileIds = parentPolicy?.allowed_profile_ids?.length ? new Set(parentPolicy.allowed_profile_ids) : null;
const parentBlocksUserProfiles = parentPolicy?.allow_user_profiles === false;
const parentBlocksGroupProfiles = parentPolicy?.allow_group_profiles === false;
const parentBlocksCampaignProfiles = parentPolicy?.allow_campaign_profiles === false;
const showAllowColumn = scopeType !== "campaign";
const showEffectiveColumn = !isSystem;
const profileAllowListLocked = !parentAllowsMailLimit("allowed_profile_ids");
const blockedProfileDefinitions = [
parentBlocksUserProfiles ? "user" : "",
parentBlocksGroupProfiles ? "group" : "",
parentBlocksCampaignProfiles ? "i18n:govoplan-mail.campaign_local_settings.920ecb62" : ""].
filter(Boolean).join(", ");
const effectivePolicyPath = effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType);
function patchPolicy(patch: Partial<MailProfilePolicy>) {
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
}
function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) {
patchPolicy({ [key]: flagToBoolean(value) });
}
function setPattern(kind: "whitelist" | "blacklist", key: MailProfilePatternKey, text: string) {
const nextRules = { ...(policy[kind] ?? {}) };
const parsed = parsePatternList(text);
if (parsed.length > 0) nextRules[key] = parsed;else
delete nextRules[key];
patchPolicy({ [kind]: nextRules });
}
function parentAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean {
return !parentPolicy || parentPolicy.allow_lower_level_limits?.[key] !== false;
}
function localAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean {
const localValue = policy.allow_lower_level_limits?.[key];
if (localValue !== undefined) return localValue && parentAllowsMailLimit(key);
return parentAllowsMailLimit(key);
}
function setAllowLowerLevelLimit(key: MailProfilePolicyLimitKey, allowed: boolean) {
patchPolicy({ allow_lower_level_limits: { ...(policy.allow_lower_level_limits ?? {}), [key]: allowed } });
}
function lowerLevelLimitToggle(key: MailProfilePolicyLimitKey, label: ReactNode = "i18n:govoplan-mail.allow_override.ffa6e9a0"): ReactNode | undefined {
if (!showAllowColumn) return undefined;
const parentLocked = !parentAllowsMailLimit(key);
return (
<ToggleSwitch
checked={localAllowsMailLimit(key)}
disabled={disabled || parentLocked}
onChange={(checked) => setAllowLowerLevelLimit(key, checked)}
label={label} />);
}
return (
<Card
title={title}
actions={
<div className="button-row compact-actions">
<DocumentationHelpLink reference={MAIL_PROFILE_DOCUMENTATION} />
<Button onClick={() => void loadPolicy()} disabled={loading || busy || !scopeReady} disabledReason={loading ? "Mail policy is already loading." : busy ? "Wait for the current policy change to finish." : !scopeReady ? "Select a policy target before reloading." : undefined}>{loading ? "i18n:govoplan-mail.loading.33ce4174" : "i18n:govoplan-mail.reload.cce71553"}</Button>
<Button variant="primary" onClick={() => void savePolicy()} disabled={Boolean(policySaveBlocker)} disabledReason={policySaveBlocker}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_policy.77d67ce3"}</Button>
</div>
}>
<LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_mail_profile_policy.b746a2e8">
<div className="mail-policy-editor">
{(locked || !canWrite || !scopeReady) &&
<ActionBlockerHint
tone={locked ? "warning" : "info"}
reason={mailPolicyBlockerReason(locked, canWrite, scopeReady)}
documentation={MAIL_PROFILE_DOCUMENTATION} />
}
{description && <p className="muted small-note mail-policy-description">{description}</p>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
{refreshWarning && <DismissibleAlert tone="warning" resetKey={refreshWarning}>{refreshWarning}</DismissibleAlert>}
<section className="mail-policy-section policy-section">
<ActionToolbar surface="section-header" className="subsection-heading split">
<h3>i18n:govoplan-mail.profile_allow_list.507dfe6c</h3>
<div className="button-row compact-actions">
{lowerLevelLimitToggle("allowed_profile_ids")}
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || profileAllowListLocked || selectedProfileIds.size === 0}>i18n:govoplan-mail.clear_allow_list.f69c8c67</Button>
</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>
<AdminSelectionList
options={candidateProfiles.map((profile) => ({
id: profile.id,
label: profile.name,
description: `${scopeLabel(profile)} · ${transportLabel(profile.smtp)}`,
disabled: disabled || profileAllowListLocked || Boolean(parentAllowedProfileIds && !parentAllowedProfileIds.has(profile.id) && !selectedProfileIds.has(profile.id))
}))}
selected={[...selectedProfileIds]}
onChange={(allowedProfileIds) => patchPolicy({ allowed_profile_ids: [...allowedProfileIds].sort() })}
emptyText="i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85"
/>
{parentAllowedProfileIds && <p className="muted small-note">i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179 {parentAllowedProfileIds.size} i18n:govoplan-mail.profile_s.742e9200</p>}
</section>
<section className="mail-policy-section policy-section">
<h3>i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d</h3>
<PolicyTable className="mail-policy-table" rowClassName="mail-policy-row" headerClassName="mail-policy-row-header" fieldLabel="i18n:govoplan-mail.policy.bb9cf141" settingLabel={isSystem ? "i18n:govoplan-mail.value.8dce170d" : "i18n:govoplan-mail.local_setting.967607a9"} effectiveLabel="i18n:govoplan-mail.effective_policy.feedb950" lowerLevelLabel="i18n:govoplan-mail.lower_levels.940821ee" showAllowColumn={showAllowColumn} showEffectiveColumn={showEffectiveColumn}>
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.user_profiles.57730285"
help={policyHelp("i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_user_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_user_profiles")} allowDisabled={parentBlocksUserProfiles} onChange={(value) => setFlag("allow_user_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_user_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_user_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_user_profiles", effectivePolicyPath) : undefined} />
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.group_profiles.74568838"
help={policyHelp("i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_group_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_group_profiles")} allowDisabled={parentBlocksGroupProfiles} onChange={(value) => setFlag("allow_group_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_group_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_group_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_group_profiles", effectivePolicyPath) : undefined} />
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.campaign_local_settings.eb0f1061"
help={policyHelp("i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_campaign_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_campaign_profiles")} allowDisabled={parentBlocksCampaignProfiles} onChange={(value) => setFlag("allow_campaign_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_campaign_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_campaign_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_campaign_profiles", effectivePolicyPath) : undefined} />
</PolicyTable>
{blockedProfileDefinitions && <PolicyLockedHint>i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a {blockedProfileDefinitions} i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d</PolicyLockedHint>}
</section>
<section className="mail-policy-section policy-section" data-testid="mail-credential-policy">
<h3>i18n:govoplan-mail.credential_selection_policy</h3>
<p className="muted small-note">i18n:govoplan-mail.credential_selection_policy_help</p>
<PolicyTable className="mail-policy-table" rowClassName="mail-policy-row" headerClassName="mail-policy-row-header" fieldLabel="i18n:govoplan-mail.policy.bb9cf141" settingLabel={isSystem ? "i18n:govoplan-mail.value.8dce170d" : "i18n:govoplan-mail.local_setting.967607a9"} effectiveLabel="i18n:govoplan-mail.effective_policy.feedb950" lowerLevelLabel="i18n:govoplan-mail.lower_levels.940821ee" showAllowColumn={showAllowColumn} showEffectiveColumn={showEffectiveColumn}>
{(["smtp", "imap"] as const).map((protocol) => {
const key = `${protocol}_credentials` as const;
const limitKey = `${key}.inherit` as MailProfilePolicyLimitKey;
const parentLocked = !parentAllowsMailLimit(limitKey);
const local = displayPolicy[key]?.inherit;
return <PolicyRow key={key}
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label={`i18n:govoplan-mail.${protocol}_credential_selection`}
help={policyHelp("i18n:govoplan-mail.credential_selection_policy_help")}
control={<select aria-label={translateText(`i18n:govoplan-mail.${protocol}_credential_selection`)}
value={parentLocked || local == null ? "inherit" : local ? "profile" : "explicit"}
disabled={disabled || parentLocked}
onChange={(event) => patchPolicy({ [key]: { inherit: event.target.value === "inherit" ? null : event.target.value === "profile" } })}>
{(!isSystem || parentLocked) && <option value="inherit">i18n:govoplan-mail.credential_policy_parent</option>}
<option value="profile">i18n:govoplan-mail.credential_policy_profile</option>
<option value="explicit">i18n:govoplan-mail.credential_policy_explicit</option>
</select>}
effective={showEffectiveColumn ? effectivePolicy ? credentialSelectionLabel(effectivePolicy[key]?.inherit) : "i18n:govoplan-mail.loading.b04ba49f" : undefined}
effectiveHelp={showEffectiveColumn ? <PolicyPathHelp lines={mailCredentialPolicyPathLines(key, normalizePolicySourcePathItems(effectivePolicyPath))} /> : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle(limitKey)}</div> : undefined} />;
})}
</PolicyTable>
{(["smtp_credentials.inherit", "imap_credentials.inherit"] as const).some((key) => !parentAllowsMailLimit(key)) && <PolicyLockedHint>i18n:govoplan-mail.credential_policy_parent_locked</PolicyLockedHint>}
</section>
<section className="mail-policy-section policy-section">
<h3>i18n:govoplan-mail.wildcard_rules.54fb3fc0</h3>
<div className={`mail-policy-pattern-table policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
<div className="mail-policy-pattern-row policy-row mail-policy-row-header policy-row-header">
<span>i18n:govoplan-mail.policy_target.a19dcee9</span>
<span>i18n:govoplan-mail.whitelist.53c2ad30</span>
<span>i18n:govoplan-mail.blacklist.7b2dd04c</span>
{showAllowColumn && <span>i18n:govoplan-mail.lower_levels.940821ee</span>}
</div>
{mailProfilePatternKeys.map((key) =>
<div className="mail-policy-pattern-row policy-row" key={key}>
<div className="mail-policy-field-label policy-field-label">
<FieldLabel className="mail-policy-field-title policy-field-title" help={policyHelp(patternPolicyNote(key))}>{patternLabels[key]}</FieldLabel>
</div>
<PatternTextareaControl value={patternsToText(policy.whitelist?.[key])} disabled={disabled || !parentAllowsMailLimit(`whitelist.${key}` as MailProfilePolicyLimitKey)} onChange={(text) => setPattern("whitelist", key, text)} />
<PatternTextareaControl value={patternsToText(policy.blacklist?.[key])} disabled={disabled || !parentAllowsMailLimit(`blacklist.${key}` as MailProfilePolicyLimitKey)} onChange={(text) => setPattern("blacklist", key, text)} />
{showAllowColumn &&
<div className="mail-policy-pattern-limits">
{lowerLevelLimitToggle(`whitelist.${key}` as MailProfilePolicyLimitKey, "i18n:govoplan-mail.whitelist.53c2ad30")}
{lowerLevelLimitToggle(`blacklist.${key}` as MailProfilePolicyLimitKey, "i18n:govoplan-mail.blacklist.7b2dd04c")}
</div>
}
</div>
)}
</div>
</section>
{showEffectiveColumn && effectivePolicy &&
<section className="mail-policy-section policy-section mail-policy-effective">
<h3>i18n:govoplan-mail.policy_path.1ba91ee5</h3>
<PolicySourcePath items={effectivePolicyPath} />
<p className="muted small-note">i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d</p>
</section>
}
</div>
</LoadingFrame>
</Card>);
}
function PolicyFlagControl({ value, disabled, includeInherit = true, inheritOnly = false, allowDisabled = false, onChange }: {value: PolicyFlagValue;disabled: boolean;includeInherit?: boolean;inheritOnly?: boolean;allowDisabled?: boolean;onChange: (value: PolicyFlagValue) => void;}) {
const selectedValue = inheritOnly ? "inherit" : value;
return (
<select value={selectedValue} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
{(includeInherit || inheritOnly) && <option value="inherit">i18n:govoplan-mail.inherit.18f99833</option>}
{!inheritOnly && <option value="allow" disabled={allowDisabled}>i18n:govoplan-mail.explicit_allow.6a7946f8</option>}
{!inheritOnly && <option value="deny">i18n:govoplan-mail.deny.53577bb5</option>}
</select>);
}
function PatternTextareaControl({ value, disabled, onChange }: {value: string;disabled: boolean;onChange: (value: string) => void;}) {
return <textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" />;
}
function mailPolicyDisabledReason(
locked: boolean,
canWrite: boolean,
scopeReady: boolean,
loading: boolean,
busy: boolean,
dirty: boolean
): string {
if (locked) return "This policy is locked by the owning workflow or a higher-scope decision.";
if (!canWrite) return "Mail policy administration permission is required to save changes.";
if (!scopeReady) return "Select a policy target before saving.";
if (loading) return "Wait until the effective Mail policy has loaded.";
if (busy) return "Wait for the current policy change to finish.";
if (!dirty) return "There are no unsaved Mail policy changes.";
return "";
}
function mailPolicyBlockerReason(locked: boolean, _canWrite: boolean, scopeReady: boolean) {
if (locked) {
return {
summary: "This Mail policy is locked in the current context.",
details: "The effective values remain visible, but this workflow or a higher-scope decision owns the editable policy.",
requiredAction: "Change the owning policy or leave the governed workflow before editing.",
actor: "The administrator or workflow owner responsible for the source policy",
target: "The source shown in the effective policy path"
};
}
if (!scopeReady) {
return {
summary: "Select a target before editing Mail policy.",
details: "User, group, and campaign policy must be resolved against one concrete target.",
requiredAction: "Choose the target in the scope selector.",
actor: "Mail policy administrator",
target: "The target selector above"
};
}
return {
summary: "You can review effective Mail policy here, but cannot change it.",
details: "Policy changes require Mail policy administration authority at this scope.",
requiredAction: "Ask an authorized administrator to apply the change.",
actor: "System or tenant policy administrator",
target: "Administration > Mail profiles and policy"
};
}
function normalizePolicy(value: MailProfilePolicy | null | undefined): MailProfilePolicy {
return {
allowed_profile_ids: [...(value?.allowed_profile_ids ?? [])].filter(Boolean),
allow_user_profiles: value?.allow_user_profiles ?? null,
allow_group_profiles: value?.allow_group_profiles ?? null,
allow_campaign_profiles: value?.allow_campaign_profiles ?? null,
smtp_credentials: normalizeCredentialPolicy(value?.smtp_credentials),
imap_credentials: normalizeCredentialPolicy(value?.imap_credentials),
whitelist: normalizeRules(value?.whitelist),
blacklist: normalizeRules(value?.blacklist),
allow_lower_level_limits: normalizeMailLowerLevelLimits(value?.allow_lower_level_limits)
};
}
function policyDraftKey(policy: MailProfilePolicy): string {
return JSON.stringify(normalizePolicy(policy));
}
function normalizePolicyForSave(policy: MailProfilePolicy, parentPolicy: MailProfilePolicy | null, scopeType: MailProfileScope): MailProfilePolicy {
const normalized = normalizePolicy(policy);
if (scopeType === "system") return concreteSystemPolicy(normalized);
const localLimits = { ...(normalized.allow_lower_level_limits ?? {}) };
const parentLimits = parentPolicy?.allow_lower_level_limits ?? null;
function parentAllows(key: MailProfilePolicyLimitKey): boolean {
return !parentLimits || parentLimits[key] !== false;
}
function clearLimit(key: MailProfilePolicyLimitKey) {
delete localLimits[key];
}
if (!parentAllows("allowed_profile_ids")) {
normalized.allowed_profile_ids = [];
clearLimit("allowed_profile_ids");
}
for (const key of ["allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"] as const) {
if (!parentAllows(key)) {
normalized[key] = null;
clearLimit(key);
}
}
for (const protocol of ["smtp_credentials", "imap_credentials"] as const) {
const credential = normalizeCredentialPolicy(normalized[protocol]);
const inheritKey = `${protocol}.inherit` as MailProfilePolicyLimitKey;
if (!parentAllows(inheritKey)) {
credential.inherit = null;
clearLimit(inheritKey);
}
normalized[protocol] = credential;
}
for (const key of mailProfilePatternKeys) {
const whitelistKey = `whitelist.${key}` as MailProfilePolicyLimitKey;
const blacklistKey = `blacklist.${key}` as MailProfilePolicyLimitKey;
if (!parentAllows(whitelistKey)) {
delete normalized.whitelist?.[key];
clearLimit(whitelistKey);
}
if (!parentAllows(blacklistKey)) {
delete normalized.blacklist?.[key];
clearLimit(blacklistKey);
}
}
if (scopeType === "campaign") {
normalized.allow_lower_level_limits = {};
} else {
normalized.allow_lower_level_limits = localLimits;
}
return normalized;
}
function normalizeMailLowerLevelLimits(value: MailProfilePolicy["allow_lower_level_limits"]): Partial<Record<MailProfilePolicyLimitKey, boolean>> {
const result: Partial<Record<MailProfilePolicyLimitKey, boolean>> = {};
for (const key of mailProfilePolicyLimitKeys) {
if (typeof value?.[key] === "boolean") result[key] = value[key];
}
return result;
}
function fullMailLowerLevelLimits(value: MailProfilePolicy["allow_lower_level_limits"]): Record<MailProfilePolicyLimitKey, boolean> {
const result = {} as Record<MailProfilePolicyLimitKey, boolean>;
for (const key of mailProfilePolicyLimitKeys) {
result[key] = value?.[key] !== false;
}
return result;
}
function concreteSystemPolicy(policy: MailProfilePolicy): MailProfilePolicy {
const normalized = normalizePolicy(policy);
return {
...normalized,
allow_user_profiles: normalized.allow_user_profiles ?? true,
allow_group_profiles: normalized.allow_group_profiles ?? true,
allow_campaign_profiles: normalized.allow_campaign_profiles ?? true,
smtp_credentials: concreteSystemCredentialPolicy(normalized.smtp_credentials),
imap_credentials: concreteSystemCredentialPolicy(normalized.imap_credentials),
allow_lower_level_limits: fullMailLowerLevelLimits(normalized.allow_lower_level_limits)
};
}
function concreteSystemCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
const normalized = normalizeCredentialPolicy(value);
return { inherit: normalized.inherit ?? true };
}
function normalizeCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
return { inherit: typeof value?.inherit === "boolean" ? value.inherit : null };
}
function normalizeRules(value: MailProfilePatternRules | null | undefined): MailProfilePatternRules {
const result: MailProfilePatternRules = {};
for (const key of mailProfilePatternKeys) {
const patterns = (value?.[key] ?? []).map((pattern) => pattern.trim()).filter(Boolean);
if (patterns.length > 0) result[key] = patterns;
}
return result;
}
function profileCandidatesForPolicy(profiles: MailServerProfile[], scopeType: MailProfileScope, scopeId: string | null, ownerUserId: string | null, ownerGroupId: string | null): MailServerProfile[] {
return profiles.
filter((profile) => {
if (scopeType === "system") return profile.scope_type === "system";
if (profile.scope_type === "system" || profile.scope_type === "tenant") return true;
if (scopeType === "user") return profile.scope_type === "user" && profile.scope_id === scopeId;
if (scopeType === "group") return profile.scope_type === "group" && profile.scope_id === scopeId;
if (scopeType === "campaign") {
if (profile.scope_type === "campaign") return profile.scope_id === scopeId;
if (profile.scope_type === "user") return Boolean(ownerUserId) && profile.scope_id === ownerUserId;
if (profile.scope_type === "group") return Boolean(ownerGroupId) && profile.scope_id === ownerGroupId;
}
return false;
}).
sort((a, b) => `${scopeOrder(a.scope_type)}:${a.name}`.localeCompare(`${scopeOrder(b.scope_type)}:${b.name}`));
}
export function scopeOrder(scopeType: MailProfileScope): number {
if (scopeType === "system") return 0;
if (scopeType === "tenant") return 1;
if (scopeType === "user" || scopeType === "group") return 2;
return 3;
}
function policyHelp(description: string): ReactNode {
return <span>{description}</span>;
}
function credentialSelectionLabel(inherit: boolean | null | undefined): string {
return inherit === false ? "i18n:govoplan-mail.credential_policy_explicit" : "i18n:govoplan-mail.credential_policy_profile";
}
function mailCredentialPolicyPathLines(key: "smtp_credentials" | "imap_credentials", items: NormalizedPolicySourcePathItem[]): string[] {
const lines: string[] = [];
for (const [index, item] of items.entries()) {
const policy = policySourceRecord(item);
const value = asRecord(policy[key]).inherit;
const label = typeof value === "boolean" ? credentialSelectionLabel(value) : "i18n:govoplan-mail.credential_policy_parent";
const locked = asRecord(policy.allow_lower_level_limits)[`${key}.inherit`] === false;
lines.push(i18nMessage(locked ? "i18n:govoplan-mail.credential_policy_path_locked" : "i18n:govoplan-mail.credential_policy_path", { value0: `${policyPathPrefix(index)}${item.label}`, value1: label }));
if (locked) break;
}
return lines;
}
function mailBooleanPolicyPathHelp(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", sources: PolicySourcePathItem[]): ReactNode {
return <PolicyPathHelp lines={mailBooleanPolicyPathLines(key, normalizePolicySourcePathItems(sources))} />;
}
function mailBooleanPolicyPathLines(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", items: NormalizedPolicySourcePathItem[]): string[] {
if (items.length === 0) return ["i18n:govoplan-mail.system_allow.ed6744b1"];
const lines: string[] = [];
for (const [index, item] of items.entries()) {
const policy = policySourceRecord(item);
const rawValue = policy[key];
const value = rawValue === true ? "i18n:govoplan-mail.allow.3ad0e369" : rawValue === false ? "i18n:govoplan-mail.deny.53577bb5" : "i18n:govoplan-mail.inherit.18f99833";
const lowerLocked = asRecord(policy.allow_lower_level_limits)[key] === false;
const stops = rawValue === false || lowerLocked;
lines.push(`${policyPathPrefix(index)}${item.label}: ${stops ? `${value} without override` : value}`);
if (stops) break;
}
return lines;
}
function policySourceRecord(item: NormalizedPolicySourcePathItem): Record<string, unknown> {
return asRecord(item.policy);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function policyPathPrefix(index: number): string {
return index === 0 ? "" : `${" ".repeat(index - 1)}> `;
}
function effectiveBooleanLabel(value: boolean | null | undefined, policy: MailProfilePolicy | null): string {
if (!policy) return "i18n:govoplan-mail.loading.b04ba49f";
return value ? "i18n:govoplan-mail.allowed.77c7b490" : "i18n:govoplan-mail.blocked.99613c74";
}
function patternPolicyNote(key: MailProfilePatternKey): string {
if (key === "smtp_hosts") return "i18n:govoplan-mail.smtp_server_host_patterns.cf6120c3";
if (key === "imap_hosts") return "i18n:govoplan-mail.imap_server_host_patterns.52b20b83";
if (key === "envelope_senders") return "i18n:govoplan-mail.smtp_envelope_sender_patterns.8c1fd95e";
if (key === "from_headers") return "i18n:govoplan-mail.visible_from_header_patterns.ea77d99d";
return "i18n:govoplan-mail.recipient_domain_patterns.68466f5b";
}
function booleanToFlag(value: boolean | null | undefined): PolicyFlagValue {
if (value === true) return "allow";
if (value === false) return "deny";
return "inherit";
}
function flagToBoolean(value: PolicyFlagValue): boolean | null {
if (value === "allow") return true;
if (value === "deny") return false;
return null;
}
function parsePatternList(value: string): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const item of value.split(/[\n,]+/)) {
const pattern = item.trim();
if (pattern && !seen.has(pattern)) {
seen.add(pattern);
result.push(pattern);
}
}
return result;
}
function patternsToText(value: string[] | undefined): string {
return (value ?? []).join("\n");
}
function mailPolicySourcePath(scopeType: MailProfileScope): string[] {
if (scopeType === "system") return ["i18n:govoplan-mail.system.bc0792d8"];
if (scopeType === "tenant") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78"];
if (scopeType === "user") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.user.9f8a2389"];
if (scopeType === "group") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.group.171a0606"];
return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.owner_policy.1e8df143", "i18n:govoplan-mail.campaign.69390e16"];
}
export function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | MailJmapTransportSettings | null | undefined): string {
if (!transport) return "i18n:govoplan-mail.not_configured.811931bb";
if ("session_url" in transport) return transport.session_url || "No Session URL";
const host = transport.host || "i18n:govoplan-mail.no_host.4c710d7d";
const port = transport.port ? `:${transport.port}` : "";
return `${host}${port}`;
}
export function scopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "i18n:govoplan-mail.system.bc0792d8";
if (profile.scope_type === "tenant") return "i18n:govoplan-mail.tenant.3ca93c78";
if (profile.scope_type === "user") return "i18n:govoplan-mail.user.9f8a2389";
if (profile.scope_type === "group") return "i18n:govoplan-mail.group.171a0606";
return "i18n:govoplan-mail.campaign.69390e16";
}
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+210
View File
@@ -0,0 +1,210 @@
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 MailMailboxProtocol,
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 && quickAccessMailboxProtocol(item));
if (!profile) return { messages: [], available: false };
const protocol = quickAccessMailboxProtocol(profile) ?? "imap";
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false, protocol);
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 quickAccessMailboxProtocol(profile: { imap?: unknown; servers?: Array<{ protocol: string; is_active: boolean; is_default?: boolean }> }): MailMailboxProtocol | null {
if (profile.servers?.some((server) => server.protocol === "jmap" && server.is_active)) return "jmap";
if (profile.imap || profile.servers?.some((server) => server.protocol === "imap" && server.is_active)) return "imap";
return null;
}
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);
}
+562 -123
View File
@@ -1,15 +1,22 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import {
import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react";
import { useLocation } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
CountBadge,
DataGridPaginationBar,
DocumentationHelpLink,
Dialog,
DismissibleAlert,
ExplorerTree,
FormField,
FormSection,
IconButton,
LoadingIndicator,
MessageDisplayPanel,
WorkspaceActionBar,
WorkspaceFrame,
hasAnyScope,
formatDateTime,
i18nMessage,
@@ -19,31 +26,47 @@ import {
} from "@govoplan/core-webui";
import {
bootstrapMailbox,
createMailAddressContact,
getMailboxMessage,
listMailAddressWriteTargets,
listMailboxMessages,
listMailboxFolders,
listMailServerProfiles,
type MailAddressWriteTarget,
type MailImapFolderResponse,
type MailMailboxMessageDetail,
type MailMailboxMessageSummary,
type MailMailboxProtocol,
type MailServerProfile } from
"../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch";
import { mailboxHeaderAddresses } from "./mailAddressIntegration";
const MAILBOX_DOCUMENTATION = {
topicId: "mail.workflow.read-mailbox",
documentationType: "user"
} as const;
// Each context read revalidates the current principal and explicit Reload must
// not reuse Core's short-lived GET response/in-flight promise cache. Mail's
// bounded provider index remains available unless the refresh flag is set.
const MAILBOX_READ_OPTIONS = { cache: "no-store" } as const;
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const navigate = useGuardedNavigate();
const location = useLocation();
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
const [selectedFolder, setSelectedFolder] = useState("INBOX");
const [selectedFolderGroup, setSelectedFolderGroup] = useState<Pick<MailFolderNode, "id" | "label"> | null>(null);
const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
const [messagePage, setMessagePage] = useState(1);
const [messagePageSize, setMessagePageSize] = useState(10);
const [messageQuery, setMessageQuery] = useState("");
@@ -58,43 +81,84 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const [folderError, setFolderError] = useState("");
const [messageError, setMessageError] = useState("");
const [detailError, setDetailError] = useState("");
const [mailToolsOpen, setMailToolsOpen] = useState(false);
const [reloadingMailbox, setReloadingMailbox] = useState(false);
const profileRequestRef = useRef(0);
const mailboxReloadRequestRef = useRef(0);
const selectedProfileIdRef = useRef(selectedProfileId);
selectedProfileIdRef.current = selectedProfileId;
const authorityKey = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.user.id, auth.active_tenant?.id ?? auth.tenant.id]);
const authorityRef = useRef(authorityKey);
authorityRef.current = authorityKey;
const revealedFolderRef = useRef("");
const selectedMessageKeyRef = useRef("");
const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0);
const loadedMessagePageRef = useRef<{ profileId: string; folder: string; page: number; pageSize: number } | null>(null);
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
const skipNextMessageLoadRef = useRef(false);
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
const mailboxProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile)), [profiles]);
const selectedMailboxProtocol = mailboxProtocolForProfile(selectedProfile) ?? "imap";
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
const selectedFolderNodeId = useMemo(() => selectedFolderGroup?.id ?? findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder, selectedFolderGroup]);
const filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]);
const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize));
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
const shellBusy = loadingProfiles || loadingFolders || loadingMessages || reloadingMailbox;
const noMailboxProfiles = !loadingProfiles && mailboxProfiles.length === 0;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
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 syncState = mailboxSyncState(messageProvenance);
const folderEmptyText = folderError || (noMailboxProfiles ? "No IMAP- or JMAP-enabled Mail profiles are available." : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
const messageEmptyText = messageError || (selectedFolderGroup ? "i18n:govoplan-mail.folder_group_selection" : !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 loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094";
const profileReloadBlocker = loadingProfiles ? "Mail profiles are already loading." : "";
const profileReloadBlocker = shellBusy ? "i18n:govoplan-mail.mailbox_refresh_in_progress" : "";
const folderReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing folders."
: loadingFolders || loadingMessages
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing folders."
: shellBusy
? "Wait for the current mailbox refresh to finish."
: "";
const messageReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing messages."
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing messages."
: !selectedFolder || !foldersReady
? "Select a loaded mailbox folder before refreshing messages."
: loadingMessages
: shellBusy
? "Messages are already loading."
: "";
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
selectProfile("");
setProfiles([]);
setMailToolsOpen(false);
void loadProfiles();
return () => {
profileRequestRef.current += 1;
folderRequestRef.current += 1;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
mailboxReloadRequestRef.current += 1;
};
}, [authorityKey]);
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 && mailboxProtocolForProfile(profile));
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(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
@@ -106,13 +170,41 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => {
if (selectedMailboxProtocol !== "jmap" || !selectedProfileId || !selectedFolder || !foldersReady) return;
const requestAuthority = authorityKey;
const profileId = selectedProfileId;
const listRequest = messageListRequestRef.current;
const handle = window.setTimeout(() => {
if (requestAuthority !== authorityRef.current || profileId !== selectedProfileIdRef.current || listRequest !== messageListRequestRef.current) return;
setMessagePage(1);
mailboxPageCursorsRef.current = {};
void loadMessages(selectedProfileId, selectedFolder, 1, messagePageSize);
}, 250);
return () => window.clearTimeout(handle);
}, [messageQuery, selectedFolderGroup]);
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(() => {
const handlePreviewShortcut = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (mailToolsOpen || isEditableTarget(event.target)) return;
if (event.key === "Escape") {
if (selectedMessage || selectedMessageKey || pendingMessageKey || detailError) {
event.preventDefault();
messageDetailRequestRef.current += 1;
setLoadingMessage(false);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
@@ -136,187 +228,320 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
window.addEventListener("keydown", handlePreviewShortcut);
return () => window.removeEventListener("keydown", handlePreviewShortcut);
}, [detailError, filteredMessages, loadingMessage, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]);
}, [detailError, filteredMessages, loadingMessage, mailToolsOpen, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]);
useEffect(() => {
const revealKey = `${selectedProfileId}:${selectedFolder}`;
if (revealedFolderRef.current === revealKey) return;
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
if (ancestorIds.length === 0) return;
revealedFolderRef.current = revealKey;
setExpandedFolders((current) => {
const next = new Set(current);
ancestorIds.forEach((id) => next.add(id));
return next;
});
}, [folderTree, selectedFolder]);
}, [folderTree, selectedFolder, selectedProfileId]);
async function loadProfiles() {
const requestId = ++profileRequestRef.current;
const requestAuthority = authorityRef.current;
setLoadingProfiles(true);
setError("");
try {
const loaded = await listMailServerProfiles(settings);
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
const loaded = await listMailServerProfiles(settings, false, undefined, MAILBOX_READ_OPTIONS);
if (requestId !== profileRequestRef.current || requestAuthority !== authorityRef.current) return null;
const usable = loaded.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile));
const requestedProfileId = parseMailboxLaunch(location.search).profileId;
setProfiles(loaded);
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
if (usable.length === 0) {
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
}
const currentId = selectedProfileIdRef.current;
const next = usable.find((profile) => profile.id === currentId)
?? usable.find((profile) => profile.id === requestedProfileId)
?? usable[0] ?? null;
const changed = (next?.id ?? "") !== currentId;
if (changed) selectProfile(next?.id ?? "");
return { selected: next, changed };
} catch (err) {
if (requestId !== profileRequestRef.current || requestAuthority !== authorityRef.current) return null;
setError(errorText(err));
return null;
} finally {
setLoadingProfiles(false);
if (requestId === profileRequestRef.current && requestAuthority === authorityRef.current) setLoadingProfiles(false);
}
}
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
async function reloadMailbox() {
const requestId = ++mailboxReloadRequestRef.current;
setReloadingMailbox(true);
try {
const result = await loadProfiles();
if (!result || requestId !== mailboxReloadRequestRef.current || result.changed || !result.selected) return;
if (selectedFolderGroup) await refreshFolderCatalogue(result.selected);
else await loadMailboxBootstrap(result.selected.id, true, result.selected);
} finally {
if (requestId === mailboxReloadRequestRef.current) setReloadingMailbox(false);
}
}
async function refreshFolderCatalogue(profile = selectedProfile) {
if (!profile) return;
const requestId = ++folderRequestRef.current;
const requestAuthority = authorityRef.current;
setLoadingFolders(true);
setFolderError("");
setError("");
try {
const response = await listMailboxFolders(settings, profile.id, true, true, mailboxProtocolForProfile(profile) ?? "imap", MAILBOX_READ_OPTIONS);
if (requestId !== folderRequestRef.current || requestAuthority !== authorityRef.current || profile.id !== selectedProfileIdRef.current) return;
if (!response.ok) throw new Error(response.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
setFolders(response.folders ?? []);
setFoldersLoadedForProfile(profile.id);
} catch (err) {
if (requestId !== folderRequestRef.current || requestAuthority !== authorityRef.current || profile.id !== selectedProfileIdRef.current) return;
setFolderError(errorText(err));
setError(errorText(err));
} finally {
if (requestId === folderRequestRef.current && requestAuthority === authorityRef.current) setLoadingFolders(false);
}
}
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false, profileOverride?: MailServerProfile) {
if (!profileId) return;
const targetProfile = profileOverride ?? profiles.find((profile) => profile.id === profileId) ?? null;
const mailboxProtocol = mailboxProtocolForProfile(targetProfile) ?? "imap";
const profileInbox = mailboxProtocol === "imap" ? targetProfile?.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 messageRequestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1;
const requestAuthority = authorityRef.current;
const isCurrent = () => folderRequestId === folderRequestRef.current
&& messageRequestId === messageListRequestRef.current
&& requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current;
const preserve = refresh && foldersLoadedForProfile === profileId;
// A refreshed JMAP query starts a new cursor chain; IMAP offsets can retain
// the current page without silently losing the user's mailbox context.
const requestedPage = preserve && mailboxProtocol !== "jmap" ? messagePage : 1;
const rememberedMessageKey = preserve ? selectedMessageKeyRef.current : "";
const detailRequestId = ++messageDetailRequestRef.current;
setLoadingMessage(false);
setPendingMessageKey("");
setLoadingFolders(true);
setLoadingMessages(true);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
if (!preserve) {
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
}
setFolderError("");
setMessageError("");
setDetailError("");
setError("");
try {
const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, (requestedPage - 1) * messagePageSize, refresh, mailboxProtocol, MAILBOX_READ_OPTIONS);
if (!isCurrent()) return;
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 loadedMessages = response.messages.messages ?? [];
const total = response.messages.total_count ?? loadedMessages.length;
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
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";
}
let messageResponse = mailboxProtocol === "jmap" && messageQuery.trim()
? await listMailboxMessages(settings, profileId, nextFolder, messagePageSize, 0, null, refresh, mailboxProtocol, messageQuery, MAILBOX_READ_OPTIONS)
: response.messages;
if (!isCurrent()) return;
const total = messageResponse.total_count ?? messageResponse.messages?.length ?? 0;
const nextPage = Math.min(requestedPage, Math.max(1, Math.ceil(total / messagePageSize)));
if (nextPage !== requestedPage) {
messageResponse = await listMailboxMessages(settings, profileId, nextFolder, messagePageSize, (nextPage - 1) * messagePageSize, null, refresh, mailboxProtocol, null, MAILBOX_READ_OPTIONS);
if (!isCurrent()) return;
}
const loadedMessages = messageResponse.messages ?? [];
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, mailboxProtocol === "jmap" ? messageQuery : "");
mailboxPageCursorsRef.current = {
[`${cursorKey}:1`]: null,
[`${cursorKey}:2`]: response.messages.next_cursor ?? null
[`${cursorKey}:${nextPage}`]: null,
[`${cursorKey}:${nextPage + 1}`]: messageResponse.next_cursor ?? null
};
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== 1;
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== nextPage;
loadedMessagePageRef.current = { profileId, folder: nextFolder, page: nextPage, pageSize: messagePageSize };
setFolders(foldersWithCounts);
setExpandedFolders(new Set());
if (!preserve) setExpandedFolders(new Set());
setSelectedFolderGroup(null);
setSelectedFolder(nextFolder);
setMessagePage(nextPage);
setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages);
setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(messageResponse));
const retainedMessage = loadedMessages.find((message) => mailboxMessageKey(message.folder || nextFolder, message.uid) === rememberedMessageKey);
// Explicit preview navigation or Escape during a refresh wins over the
// remembered selection; completing a read must not undo the user's action.
if (detailRequestId === messageDetailRequestRef.current && selectedMessageKeyRef.current === rememberedMessageKey) {
if (retainedMessage) await openMessage(retainedMessage, profileId, mailboxProtocol, true);
else if (rememberedMessageKey) {
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
}
}
} catch (err) {
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
if (!isCurrent()) return;
const message = errorText(err);
skipNextMessageLoadRef.current = false;
setFolderError(message);
setMessageError(message);
setError(message);
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
if (!preserve) {
loadedMessagePageRef.current = null;
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
}
} finally {
if (folderRequestId === folderRequestRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current) setLoadingMessages(false);
if (folderRequestId === folderRequestRef.current && requestAuthority === authorityRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current && requestAuthority === authorityRef.current) setLoadingMessages(false);
}
}
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) {
if (!profileId || !folder) return;
const mailboxProtocol = mailboxProtocolForProfile(profiles.find((profile) => profile.id === profileId) ?? null) ?? "imap";
const requestId = ++messageListRequestRef.current;
const requestAuthority = authorityRef.current;
const isCurrent = () => requestId === messageListRequestRef.current && requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current;
const offset = (Math.max(1, page) - 1) * pageSize;
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
const cursorKey = mailboxCursorKey(profileId, folder, pageSize, mailboxProtocol === "jmap" ? messageQuery : "");
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
setLoadingMessages(true);
if (!refresh) setMessageProvenance(null);
setMessageError("");
setDetailError("");
setError("");
try {
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor, refresh);
if (requestId !== messageListRequestRef.current) return;
const response = await listMailboxMessages(
settings,
profileId,
folder,
pageSize,
offset,
cursor,
refresh,
mailboxProtocol,
mailboxProtocol === "jmap" ? messageQuery : null,
MAILBOX_READ_OPTIONS
);
if (!isCurrent()) return;
const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length;
loadedMessagePageRef.current = { profileId, folder, page, pageSize };
if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null;
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
setMessages(loaded);
setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(response));
setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
messageDetailRequestRef.current += 1;
setLoadingMessage(false);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
selectedMessageKeyRef.current = "";
}
} catch (err) {
if (requestId !== messageListRequestRef.current) return;
if (!isCurrent()) return;
const message = errorText(err);
setMessageError(message);
setError(message);
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
const committed = loadedMessagePageRef.current;
if (committed?.profileId === profileId && committed.folder === folder && (committed.page !== page || committed.pageSize !== pageSize)) {
// Retained rows must keep their actual page/size labels after a failed
// pagination read; restoring controls is not a second provider request.
skipNextMessageLoadRef.current = true;
setMessagePage(committed.page);
setMessagePageSize(committed.pageSize);
}
// A failed read is not an empty mailbox. Keep the last loaded index and
// selection visible until a successful refresh or an explicit switch.
} finally {
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
if (isCurrent()) setLoadingMessages(false);
}
}
async function openMessage(message: MailMailboxMessageSummary) {
if (!selectedProfileId) return;
async function openMessage(message: MailMailboxMessageSummary, profileId = selectedProfileId, protocol = selectedMailboxProtocol, preservePreview = false) {
if (!profileId) return;
const folderName = message.folder || selectedFolder;
const requestId = ++messageDetailRequestRef.current;
const requestAuthority = authorityRef.current;
const isCurrent = () => requestId === messageDetailRequestRef.current && requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current;
const nextKey = mailboxMessageKey(folderName, message.uid);
setSelectedMessageKeyState(nextKey);
selectedMessageKeyRef.current = nextKey;
setPendingMessageKey(nextKey);
setSelectedMessage(null);
if (!preservePreview) setSelectedMessage(null);
setDetailError("");
setLoadingMessage(true);
setError("");
try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
if (requestId !== messageDetailRequestRef.current) return;
const response = await getMailboxMessage(settings, profileId, folderName, message.uid, protocol, MAILBOX_READ_OPTIONS);
if (!isCurrent()) return;
setSelectedMessage(response.message);
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
setPendingMessageKey("");
} catch (err) {
if (requestId !== messageDetailRequestRef.current) return;
if (!isCurrent()) return;
const messageText = errorText(err);
setDetailError(messageText);
setError(messageText);
setPendingMessageKey("");
} finally {
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
if (isCurrent()) setLoadingMessage(false);
}
}
function selectProfile(profileId: string) {
loadedMessagePageRef.current = null;
mailboxReloadRequestRef.current += 1;
folderRequestRef.current += 1;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
setSelectedProfileId(profileId);
selectedProfileIdRef.current = profileId;
setReloadingMailbox(false);
setSelectedFolderGroup(null);
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedFolder("INBOX");
setMessageQuery("");
setError("");
setFolderError("");
setMessageError("");
setDetailError("");
revealedFolderRef.current = "";
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
@@ -331,8 +556,16 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
function openFolderNode(node: MailFolderNode) {
if (node.folderName) {
setSelectedFolderGroup(null);
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else
{
loadedMessagePageRef.current = null;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
setLoadingMessage(false);
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedFolder(node.folderName);
setMessagePage(1);
setSelectedMessage(null);
@@ -343,7 +576,26 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
return;
}
toggleFolderNode(node);
// Synthetic parent folders have no provider mailbox to open. Selecting
// their label must not toggle expansion or fetch an invented folder name.
loadedMessagePageRef.current = null;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
setSelectedFolderGroup({ id: node.id, label: node.label });
setSelectedFolder("");
setMessages([]);
setMessageTotalCount(0);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setLoadingMessage(false);
setLoadingMessages(false);
setMessageError("");
setDetailError("");
skipNextMessageLoadRef.current = false;
}
function toggleFolderNode(node: MailFolderNode) {
@@ -370,9 +622,34 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
<WorkspaceFrame as="main" height="viewport" surface="plain" label="i18n:govoplan-mail.mailbox_workspace" className="mailbox-page" helpContextId="mail.mailbox" helpModuleId="mail" helpTopicId="mail.workflow.read-mailbox">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<WorkspaceActionBar
variant="workspace"
scope="workspace"
label="i18n:govoplan-mail.mail_actions.c08b5f08"
refreshable
reloadAction={{
onReload: () => void reloadMailbox(),
loading: shellBusy,
state: error ? "reload-failed" : "current",
helpContextId: "mail.mailbox.reload",
helpModuleId: "mail",
helpTopicId: "mail.workflow.read-mailbox"
}}
contextActions={<>
<FormField label="i18n:govoplan-mail.mailbox_profile" className="mailbox-profile-field">
<select value={selectedProfileId} disabled={loadingProfiles || mailboxProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{mailboxProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_mailbox_profiles</option>}
{mailboxProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</FormField>
<Button type="button" onClick={() => setMailToolsOpen(true)} helpContextId="mail.mailbox.tools" helpModuleId="mail" helpTopicId="mail.workflow.read-mailbox">i18n:govoplan-mail.mailbox_tools</Button>
</>}
helpAction={<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />}
/>
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
<div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
@@ -395,7 +672,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<span className="mailbox-tree-node-label">
<span className="mailbox-tree-node-main">
<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>
{flagText && <small>{flagText}</small>}
</span>);
@@ -407,44 +684,13 @@ 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">
<div className="file-list-sticky">
<div className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
<label className="mailbox-profile-field">
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{imapProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_imap_profiles_available.d64589f8</option>}
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
<div className="mailbox-toolbar-actions">
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
<Activity size={16} aria-hidden="true" />
Bounce status
</Button>}
<Button onClick={() => void loadProfiles()} disabled={Boolean(profileReloadBlocker)} disabledReason={profileReloadBlocker} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.profiles.0c2a9300
</Button>
<Button onClick={() => void loadMailboxBootstrap(selectedProfileId, true)} disabled={Boolean(folderReloadBlocker)} disabledReason={folderReloadBlocker} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.folders.19adc47b
</Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true)} disabled={Boolean(messageReloadBlocker)} disabledReason={messageReloadBlocker} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.messages.f1702b46
</Button>
</div>
</div>
{noImapProfiles &&
{noMailboxProfiles &&
<ActionBlockerHint
className="mailbox-profile-blocker"
reason={{
summary: "No IMAP-enabled Mail profile is available.",
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP server and credential.",
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP-enabled profile.",
summary: "No mailbox-enabled Mail profile is available.",
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP or JMAP server and credential.",
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP- or JMAP-enabled profile.",
actor: "Mail profile administrator",
target: "Settings or Administration > Mail profiles"
}}
@@ -453,7 +699,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<nav className="file-breadcrumbs" aria-label="i18n:govoplan-mail.current_mailbox_folder.55e2aea5">
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "i18n:govoplan-mail.mail.92379cbb"}</span>
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolderGroup?.label ?? selectedFolder}</span></span>
</nav>
<div className="mailbox-filter-row">
@@ -468,6 +714,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<span>{messageCountLabel}</span>
<span>{selectedFolder}</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>}
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
</div>
@@ -486,10 +739,11 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
const selected = key === selectedMessageKey;
const loadingSelected = loadingMessage && key === pendingMessageKey;
const read = isMailboxMessageRead(message.flags);
return (
<div
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"
tabIndex={0}
onClick={() => void openMessage(message)}
@@ -502,7 +756,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<div className="file-list-name-cell">
<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>
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
<small>{message.from_header || "-"}</small>
@@ -511,6 +765,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</div>
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
<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}
<span>{formatBytes(message.size_bytes)}</span>
</span>
@@ -554,6 +812,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}))}
emptyText={previewEmptyText} />
<MailboxContactActions settings={settings} message={selectedMessage} />
</div>
</section>
@@ -563,17 +823,178 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</div>
}
</div>
</div>);
<Dialog open={mailToolsOpen} title="i18n:govoplan-mail.mailbox_tools" description={selectedProfile ? `${selectedProfile.name} · ${transportLabel(selectedProfile)}` : "i18n:govoplan-mail.no_mailbox_profiles"} onClose={() => setMailToolsOpen(false)}
footer={<Button type="button" onClick={() => setMailToolsOpen(false)}>i18n:govoplan-core.close.bbfa773e</Button>}>
<FormSection title="i18n:govoplan-mail.mailbox_refresh_tools" description="i18n:govoplan-mail.mailbox_refresh_tools_help">
<ActionToolbar surface="plain">
<Button type="button" onClick={() => { setMailToolsOpen(false); void loadProfiles(); }} disabledReason={profileReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_profiles</Button>
<Button type="button" onClick={() => { setMailToolsOpen(false); void refreshFolderCatalogue(); }} disabledReason={folderReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_folder_catalogue</Button>
<Button type="button" onClick={() => { setMailToolsOpen(false); void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true); }} disabledReason={messageReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_message_index</Button>
</ActionToolbar>
</FormSection>
<FormSection variant="separated" title="i18n:govoplan-mail.mailbox_related_tools">
<ActionToolbar surface="plain"><Button type="button" onClick={() => { setMailToolsOpen(false); navigate("/mail/bounces"); }} disabledReason={!hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) ? "i18n:govoplan-mail.mailbox_bounce_permission" : ""}><Activity size={16} aria-hidden="true" /> i18n:govoplan-mail.mailbox_bounce_status</Button></ActionToolbar>
</FormSection>
</Dialog>
</WorkspaceFrame>);
}
function MailboxContactActions({
settings,
message
}: {
settings: ApiSettings;
message: MailMailboxMessageDetail | null;
}) {
const [available, setAvailable] = useState(false);
const [loaded, setLoaded] = useState(false);
const [targets, setTargets] = useState<MailAddressWriteTarget[]>([]);
const [selectedTargetId, setSelectedTargetId] = useState("");
const [creatingEmail, setCreatingEmail] = useState("");
const [addedEmails, setAddedEmails] = useState<Set<string>>(() => new Set());
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
let active = true;
setLoaded(false);
void listMailAddressWriteTargets(settings)
.then((response) => {
if (!active) return;
const writable = response.targets.filter((target) => target.allowed);
setAvailable(response.available);
setTargets(response.targets);
setSelectedTargetId((current) => writable.some((target) => target.address_book_id === current)
? current
: writable[0]?.address_book_id || "");
setError("");
})
.catch((loadError) => {
if (!active) return;
setAvailable(false);
setTargets([]);
setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => {
if (active) setLoaded(true);
});
return () => { active = false; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
setAddedEmails(new Set());
setError("");
setSuccess("");
}, [message?.folder, message?.uid]);
if (!message || !loaded || !available) return null;
const writableTargets = targets.filter((target) => target.allowed);
const blockedTargets = targets.filter((target) => !target.allowed);
const addresses = uniqueMailboxAddresses([
...mailboxHeaderAddresses(message.from_header),
...mailboxHeaderAddresses(message.to_header),
...mailboxHeaderAddresses(message.cc_header)
]);
async function addContact(address: { name?: string | null; email: string }) {
if (!selectedTargetId || creatingEmail) return;
setCreatingEmail(address.email);
setError("");
setSuccess("");
try {
const result = await createMailAddressContact(settings, {
address_book_id: selectedTargetId,
display_name: address.name || address.email,
email: address.email
});
setAddedEmails((current) => new Set(current).add(address.email));
setSuccess(i18nMessage("i18n:govoplan-mail.contact_added", { value0: result.display_name }));
} catch (createError) {
setError(createError instanceof Error ? createError.message : String(createError));
} finally {
setCreatingEmail("");
}
}
return (
<section className="mailbox-contact-actions" aria-label="i18n:govoplan-mail.address_book_actions">
<h4>i18n:govoplan-mail.address_book_actions</h4>
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
{writableTargets.length > 0 ? (
<label className="mailbox-contact-target">
<span>i18n:govoplan-mail.save_contacts_to</span>
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
{writableTargets.map((target) => (
<option key={target.address_book_id} value={target.address_book_id}>
{target.address_book_label || target.address_book_id}
</option>
))}
</select>
</label>
) : (
<p className="form-help">i18n:govoplan-mail.no_writable_address_book</p>
)}
<div className="mailbox-contact-candidates">
{addresses.map((address) => {
const added = addedEmails.has(address.email);
return (
<Button
key={address.email}
className="compact"
disabled={!selectedTargetId || Boolean(creatingEmail) || added}
disabledReason={!selectedTargetId ? blockedTargets[0]?.message || "i18n:govoplan-mail.no_writable_address_book" : undefined}
onClick={() => void addContact(address)}
>
{added ? <Check size={15} aria-hidden="true" /> : <UserPlus size={15} aria-hidden="true" />}
{added ? "i18n:govoplan-mail.contact_added_short" : i18nMessage("i18n:govoplan-mail.add_value_to_contacts", { value0: address.name || address.email })}
</Button>
);
})}
</div>
{blockedTargets.length > 0 ? (
<details className="mailbox-contact-policy">
<summary>i18n:govoplan-mail.unavailable_address_books</summary>
<ul>
{blockedTargets.map((target) => (
<li key={target.address_book_id}>
<strong>{target.address_book_label || target.address_book_id}</strong>: {target.message}
</li>
))}
</ul>
</details>
) : null}
</section>
);
}
function uniqueMailboxAddresses<T extends { email: string }>(addresses: T[]): T[] {
const seen = new Set<string>();
return addresses.filter((address) => {
const email = address.email.toLocaleLowerCase();
if (seen.has(email)) return false;
seen.add(email);
return true;
});
}
function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`;
}
function mailboxCursorKey(profileId: string, folder: string, pageSize: number): string {
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
function mailboxCursorKey(profileId: string, folder: string, pageSize: number, query: string): string {
return `${profileId}::${folder || "INBOX"}::${pageSize}::${query.trim()}`;
}
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[] {
@@ -627,11 +1048,29 @@ function displayFolderFlag(flag: string): string | null {
}
function transportLabel(profile: MailServerProfile): string {
const jmap = preferredJmapServer(profile);
if (jmap) {
const sessionUrl = "session_url" in jmap.config ? jmap.config.session_url : null;
return `JMAP · ${String(sessionUrl || "configured endpoint")}`;
}
const imap = profile.imap;
if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3";
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
}
function preferredJmapServer(profile: MailServerProfile | null): NonNullable<MailServerProfile["servers"]>[number] | null {
if (!profile) return null;
const servers = (profile.servers ?? []).filter((server) => server.protocol === "jmap" && server.is_active);
return servers.find((server) => server.is_default) ?? servers[0] ?? null;
}
function mailboxProtocolForProfile(profile: MailServerProfile | null): MailMailboxProtocol | null {
if (!profile) return null;
if (preferredJmapServer(profile)) return "jmap";
if (profile.imap || (profile.servers ?? []).some((server) => server.protocol === "imap" && server.is_active)) return "imap";
return null;
}
function formatBytes(value?: number | null): string {
if (!value) return "-";
if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });
@@ -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(",")}`;
}
@@ -1,4 +1,4 @@
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "jmap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePolicy = {
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
@@ -8,6 +8,7 @@ export type MailProfilePolicy = {
export type MailPolicyValidationInput = {
smtpHost?: string | null;
imapHost?: string | null;
jmapHost?: string | null;
envelopeSender?: string | null;
fromHeader?: string | null;
recipientDomains?: Array<string | null | undefined> | null;
@@ -24,6 +25,7 @@ export type MailPolicyValidationMessage = {
const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "i18n:govoplan-mail.smtp_host.2d4a434b",
imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751",
jmap_hosts: "JMAP host",
envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0",
from_headers: "i18n:govoplan-mail.from_header.bb78e85d",
recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf"
@@ -43,6 +45,7 @@ input: MailPolicyValidationInput)
const checks: ValueCheck[] = [
{ key: "smtp_hosts", value: input.smtpHost ?? "" },
{ key: "imap_hosts", value: input.imapHost ?? "" },
{ key: "jmap_hosts", value: input.jmapHost ?? "" },
{ key: "envelope_senders", value: input.envelopeSender ?? "" },
{ key: "from_headers", value: input.fromHeader ?? "" },
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))).
@@ -1,5 +1,5 @@
export type MailProfileProtocol = "smtp" | "imap";
export type MailProfileEditSection = MailProfileProtocol;
export type MailProfileProtocol = "smtp" | "imap" | "jmap";
export type MailProfileEditSection = "smtp" | "imap";
export type MailProfilePanelMode = "all" | "server" | "credentials";
export type MailProfileCreateStage =
| "profile"
@@ -54,9 +54,11 @@ export type MailProfileTargetedUpdateParts = {
profile: Record<string, unknown>;
smtp: Record<string, unknown>;
imap: Record<string, unknown> | null;
jmap?: Record<string, unknown> | null;
credentials: {
smtp: MailProfileTransportCredentialsLike;
imap: MailProfileTransportCredentialsLike;
jmap?: MailProfileTransportCredentialsLike;
};
clearImap: boolean;
};
@@ -72,7 +74,7 @@ export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike)
}
export function mailProfileEditTargetInitialSection(target: MailProfileEditTarget): MailProfileEditSection {
if (target.kind === "server" || target.kind === "credentials") return target.protocol;
if (target.kind === "server" || target.kind === "credentials") return target.protocol === "jmap" ? "imap" : target.protocol;
return "smtp";
}
@@ -84,7 +86,7 @@ export function mailProfileEditTargetPanelMode(target: MailProfileEditTarget): M
}
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] {
if (target.kind === "server" || target.kind === "credentials") return [target.protocol];
if (target.kind === "server" || target.kind === "credentials") return target.protocol === "jmap" ? [] : [target.protocol];
if (target.kind === "create") return ["smtp", "imap"];
return [];
}
@@ -94,7 +96,7 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT
}
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
return target.kind !== "profile";
return target.kind !== "profile" && !((target.kind === "server" || target.kind === "credentials") && target.protocol === "jmap");
}
export function mailProfileCreateStagePanel(
@@ -152,12 +154,16 @@ export function mailProfileTargetedUpdatePayload(
if (target.kind === "profile") return parts.profile;
if (target.kind === "server") {
if (target.protocol === "smtp") return { smtp: parts.smtp };
if (target.protocol === "jmap") return { jmap: parts.jmap ?? null };
return parts.imap === null
? { imap: null, clear_imap: parts.clearImap }
: { imap: parts.imap };
}
if (target.kind === "credentials") {
return { credentials: { [target.protocol]: parts.credentials[target.protocol] } };
const credentials = target.protocol === "jmap"
? parts.credentials.jmap ?? {}
: parts.credentials[target.protocol];
return { credentials: { [target.protocol]: credentials } };
}
throw new Error("Create is not an update target");
}
@@ -4,6 +4,7 @@ import {
type ApiSettings,
type CredentialReferenceSelectorContext,
type CredentialReferenceSelectorsUiCapability,
type DeltaDeletedItem,
type ReferenceOption,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
@@ -24,7 +25,11 @@ export function createMailServerReferenceProvider(
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
cataloguePromise ??= loadProfiles(settings, context, signal)
if (signal.aborted) throw abortError();
// The catalogue belongs to this provider, not to its first subscriber.
// StrictMode, closing an editor, or a new search can cancel one caller
// while another is still awaiting the same authorized metadata request.
cataloguePromise ??= loadProfiles(settings, context)
.then(mailServerOptions)
.catch((error: unknown) => {
cataloguePromise = null;
@@ -95,8 +100,7 @@ function mailServerOptions(
async function loadProfiles(
settings: ApiSettings,
context: CredentialReferenceSelectorContext,
signal: AbortSignal
context: CredentialReferenceSelectorContext
): Promise<MailServerProfile[]> {
let watermark: string | null = null;
let profiles: MailServerProfile[] = [];
@@ -109,7 +113,6 @@ async function loadProfiles(
since: first ? null : watermark,
limit: 200
});
if (signal.aborted) throw abortError();
profiles = response.full
? response.profiles
: mergeProfiles(profiles, response.profiles, response.deleted);
@@ -123,12 +126,12 @@ async function loadProfiles(
function mergeProfiles(
current: readonly MailServerProfile[],
changed: readonly MailServerProfile[],
deleted: readonly { resource_type: string; resource_id: string }[]
deleted: readonly DeltaDeletedItem[]
): MailServerProfile[] {
const removed = new Set(
deleted
.filter((item) => item.resource_type === "mail_profile")
.map((item) => item.resource_id)
.map((item) => item.id)
);
const merged = new Map(
current
+17
View File
@@ -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";
}
+59
View File
@@ -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;
}
+88 -2
View File
@@ -2,6 +2,31 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-mail.mailbox_workspace": "Mailbox workspace",
"i18n:govoplan-mail.mailbox_refresh_in_progress": "Wait for the current mailbox refresh to finish.",
"i18n:govoplan-mail.mailbox_profile": "Mailbox profile",
"i18n:govoplan-mail.no_mailbox_profiles": "No mailbox profiles available",
"i18n:govoplan-mail.mailbox_tools": "Mailbox tools",
"i18n:govoplan-mail.mailbox_refresh_tools": "Refresh a specific part",
"i18n:govoplan-mail.mailbox_refresh_tools_help": "The page Reload refreshes the current mailbox context. Use these targeted reads only when you need to refresh account availability, the folder catalogue, or the current message page separately. No read/unread flags or other provider state are changed.",
"i18n:govoplan-mail.refresh_mailbox_profiles": "Refresh available profiles",
"i18n:govoplan-mail.refresh_mailbox_folder_catalogue": "Refresh folders only",
"i18n:govoplan-mail.refresh_mailbox_message_index": "Refresh messages only",
"i18n:govoplan-mail.mailbox_related_tools": "Related Mail tools",
"i18n:govoplan-mail.mailbox_bounce_status": "Bounce status",
"i18n:govoplan-mail.mailbox_bounce_permission": "Permission to read or manage Mail bounce processing is required.",
"i18n:govoplan-mail.folder_group_selection": "This grouping contains mailbox folders. Use its folder icon to expand it, then select a mailbox folder to view messages.",
"i18n:govoplan-mail.credential_selection_policy": "Credential selection",
"i18n:govoplan-mail.credential_selection_policy_help": "Allow the selected Mail server's default credential, or require the campaign to explicitly select an authorized Mail-owned credential. Both choices keep secrets in Mail; neither permits campaign-local passwords. Inherit policy uses the parent scope's decision.",
"i18n:govoplan-mail.smtp_credential_selection": "SMTP credential selection",
"i18n:govoplan-mail.imap_credential_selection": "IMAP credential selection",
"i18n:govoplan-mail.credential_policy_parent": "Inherit policy from parent",
"i18n:govoplan-mail.credential_policy_profile": "Allow profile default credential",
"i18n:govoplan-mail.credential_policy_explicit": "Require explicit Mail credential",
"i18n:govoplan-mail.credential_policy_parent_locked": "An ancestor has locked credential selection. Change the controlling policy shown in the policy path; lower scopes cannot override or unlock it.",
"i18n:govoplan-mail.credential_policy_path": "{value0}: {value1}",
"i18n:govoplan-mail.credential_policy_path_locked": "{value0}: {value1}; lower-level override locked",
"i18n:govoplan-mail.policy_saved_refresh_failed": "Mail policy was saved, but refreshing dependent data failed: {value0}. Reload to refresh the display; the saved policy does not need to be submitted again.",
"i18n:govoplan-mail.active.a733b809": "Active",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow",
@@ -17,6 +42,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.bytes_b": "{value0} B",
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
"i18n:govoplan-mail.cached_index_refreshing.75f18a6c": "Cached index refreshing",
"i18n:govoplan-mail.cached_mailbox_index.16fe75d1": "Cached mailbox index",
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "profiles scoped to campaigns",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles",
"i18n:govoplan-mail.campaigns": "Campaigns",
@@ -38,7 +65,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Controls credential inheritance for compatible consumers. Campaign delivery requires credentials to remain on and inherit from the selected Mail profile.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Controls credential inheritance for compatible consumers. Campaign delivery requires credentials to remain on and inherit from the selected Mail profile.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Controls whether consumers may use a selected Mail server's default credentials or must explicitly select authorized Mail-owned credentials. Explicit selection remains allowed when defaults are permitted; Campaign never stores local passwords.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Description",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Development mock mailbox",
@@ -85,6 +112,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
"i18n:govoplan-mail.live_provider_response.39c46538": "Live provider response",
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
"i18n:govoplan-mail.local.dc99d54d": "Local",
@@ -95,6 +123,19 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.compose": "Compose",
"i18n:govoplan-mail.recipients": "Recipients",
"i18n:govoplan-mail.address_suggestions_unavailable": "Address-book suggestions are unavailable. You can still enter an email address manually.",
"i18n:govoplan-mail.open_mail_application": "Open mail application",
"i18n:govoplan-mail.address_book_actions": "Address-book actions",
"i18n:govoplan-mail.save_contacts_to": "Save contacts to",
"i18n:govoplan-mail.no_writable_address_book": "No writable address book is available for your account.",
"i18n:govoplan-mail.contact_added": "{value0} was added to contacts.",
"i18n:govoplan-mail.contact_added_short": "Added",
"i18n:govoplan-mail.add_value_to_contacts": "Add {value0} to contacts",
"i18n:govoplan-mail.unavailable_address_books": "Unavailable address books and policy reasons",
"i18n:govoplan-mail.open_mail": "Open Mail",
"i18n:govoplan-mail.quick_access_description": "Recent mailbox messages and mail actions.",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
@@ -139,6 +180,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
"i18n:govoplan-mail.read.80ca1564": "Read",
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
@@ -180,6 +222,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.to.ae79ea1e": "To",
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
"i18n:govoplan-mail.unread.66c78634": "Unread",
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
"i18n:govoplan-mail.user.9f8a2389": "User",
"i18n:govoplan-mail.users": "Users",
@@ -199,6 +242,31 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.working.049ac820": "Working..."
},
"de": {
"i18n:govoplan-mail.mailbox_workspace": "Postfach-Arbeitsbereich",
"i18n:govoplan-mail.mailbox_refresh_in_progress": "Warten Sie, bis die aktuelle Postfachaktualisierung abgeschlossen ist.",
"i18n:govoplan-mail.mailbox_profile": "Postfachprofil",
"i18n:govoplan-mail.no_mailbox_profiles": "Keine Postfachprofile verfügbar",
"i18n:govoplan-mail.mailbox_tools": "Postfachwerkzeuge",
"i18n:govoplan-mail.mailbox_refresh_tools": "Einen bestimmten Bereich aktualisieren",
"i18n:govoplan-mail.mailbox_refresh_tools_help": "Neuladen aktualisiert den aktuellen Postfachkontext. Diese gezielten Leseaktionen aktualisieren bei Bedarf nur die verfügbaren Profile, den Ordnerkatalog oder die aktuelle Nachrichtenseite. Gelesen-/Ungelesen-Kennzeichen und andere Anbieterzustände bleiben unverändert.",
"i18n:govoplan-mail.refresh_mailbox_profiles": "Verfügbare Profile aktualisieren",
"i18n:govoplan-mail.refresh_mailbox_folder_catalogue": "Nur Ordner aktualisieren",
"i18n:govoplan-mail.refresh_mailbox_message_index": "Nur Nachrichten aktualisieren",
"i18n:govoplan-mail.mailbox_related_tools": "Weitere Mail-Werkzeuge",
"i18n:govoplan-mail.mailbox_bounce_status": "Zustellrückläufer",
"i18n:govoplan-mail.mailbox_bounce_permission": "Eine Berechtigung zum Lesen oder Verwalten der Rückläuferverarbeitung in Mail ist erforderlich.",
"i18n:govoplan-mail.folder_group_selection": "Diese Gruppe enthält Postfachordner. Klappen Sie sie über das Ordnersymbol auf und wählen Sie einen Postfachordner aus, um Nachrichten anzuzeigen.",
"i18n:govoplan-mail.credential_selection_policy": "Auswahl der Zugangsdaten",
"i18n:govoplan-mail.credential_selection_policy_help": "Die Standard-Zugangsdaten des ausgewählten Mail-Servers zulassen oder eine ausdrückliche Auswahl berechtigter Mail-Zugangsdaten in der Kampagne verlangen. Beide Optionen belassen Geheimnisse in Mail; kampagnenlokale Passwörter sind nicht erlaubt. Richtlinie erben übernimmt die Entscheidung des übergeordneten Bereichs.",
"i18n:govoplan-mail.smtp_credential_selection": "SMTP-Zugangsdaten auswählen",
"i18n:govoplan-mail.imap_credential_selection": "IMAP-Zugangsdaten auswählen",
"i18n:govoplan-mail.credential_policy_parent": "Richtlinie vom übergeordneten Bereich erben",
"i18n:govoplan-mail.credential_policy_profile": "Standard-Zugangsdaten des Profils zulassen",
"i18n:govoplan-mail.credential_policy_explicit": "Ausdrückliche Mail-Zugangsdaten verlangen",
"i18n:govoplan-mail.credential_policy_parent_locked": "Ein übergeordneter Bereich hat die Auswahl der Zugangsdaten gesperrt. Die maßgebliche Richtlinie im Richtlinienpfad ändern; untergeordnete Bereiche können diese Vorgabe weder überschreiben noch entsperren.",
"i18n:govoplan-mail.credential_policy_path": "{value0}: {value1}",
"i18n:govoplan-mail.credential_policy_path_locked": "{value0}: {value1}; Überschreiben in unteren Bereichen gesperrt",
"i18n:govoplan-mail.policy_saved_refresh_failed": "Die Mail-Richtlinie wurde gespeichert, aber abhängige Daten konnten nicht aktualisiert werden: {value0}. Zum Aktualisieren der Anzeige neu laden; die gespeicherte Richtlinie muss nicht erneut gesendet werden.",
"i18n:govoplan-mail.active.a733b809": "Aktiv",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow",
@@ -214,6 +282,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.bytes_b": "{value0} B",
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
"i18n:govoplan-mail.cached_index_refreshing.75f18a6c": "Zwischengespeicherter Index wird aktualisiert",
"i18n:govoplan-mail.cached_mailbox_index.16fe75d1": "Zwischengespeicherter Postfachindex",
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "kampagnenbezogene Profile",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Kampagnenbezogene Profile",
"i18n:govoplan-mail.campaigns": "Kampagnen",
@@ -235,7 +305,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Steuert die Vererbung fuer kompatible Verbraucher. Kampagnen muessen die Zugangsdaten aus dem ausgewaehlten Mailprofil erben.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Steuert die Vererbung fuer kompatible Verbraucher. Kampagnen muessen die Zugangsdaten aus dem ausgewaehlten Mailprofil erben.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Legt fest, ob Verbraucher die Standard-Zugangsdaten eines ausgewählten Mailservers verwenden dürfen oder ausdrücklich berechtigte, von Mail verwaltete Zugangsdaten auswählen müssen. Eine ausdrückliche Auswahl bleibt auch bei erlaubten Standard-Zugangsdaten möglich; Campaign speichert niemals lokale Passwörter.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox",
@@ -282,6 +352,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
"i18n:govoplan-mail.live_provider_response.39c46538": "Aktuelle Provider-Antwort",
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
"i18n:govoplan-mail.local.dc99d54d": "Local",
@@ -292,6 +363,19 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.compose": "Verfassen",
"i18n:govoplan-mail.recipients": "Empfänger",
"i18n:govoplan-mail.address_suggestions_unavailable": "Adressbuchvorschläge sind nicht verfügbar. Eine E-Mail-Adresse kann weiterhin manuell eingegeben werden.",
"i18n:govoplan-mail.open_mail_application": "Mail-Anwendung öffnen",
"i18n:govoplan-mail.address_book_actions": "Adressbuchaktionen",
"i18n:govoplan-mail.save_contacts_to": "Kontakte speichern in",
"i18n:govoplan-mail.no_writable_address_book": "Für dieses Konto ist kein beschreibbares Adressbuch verfügbar.",
"i18n:govoplan-mail.contact_added": "{value0} wurde zu den Kontakten hinzugefügt.",
"i18n:govoplan-mail.contact_added_short": "Hinzugefügt",
"i18n:govoplan-mail.add_value_to_contacts": "{value0} zu Kontakten hinzufügen",
"i18n:govoplan-mail.unavailable_address_books": "Nicht verfügbare Adressbücher und Richtliniengründe",
"i18n:govoplan-mail.open_mail": "Mail öffnen",
"i18n:govoplan-mail.quick_access_description": "Aktuelle Posteingangsnachrichten und Mail-Aktionen.",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
@@ -336,6 +420,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
"i18n:govoplan-mail.read.80ca1564": "Gelesen",
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
@@ -377,6 +462,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.to.ae79ea1e": "To",
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
"i18n:govoplan-mail.unread.66c78634": "Ungelesen",
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
"i18n:govoplan-mail.user.9f8a2389": "User",
"i18n:govoplan-mail.users": "Benutzer",
+24 -7
View File
@@ -1,18 +1,29 @@
import { createElement, lazy } from "react";
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { messagesProductSurfaceTranslations, type MailDevMailboxUiCapability, type MailProfilesUiCapability, type PlatformWebModule, type QuickAccessToolsUiCapability } from "@govoplan/core-webui";
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
import { generatedTranslations } from "./i18n/generatedTranslations";
import MailQuickAccess from "./features/mail/MailQuickAccess";
import "./styles/mail-profiles.css";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const MailBouncePage = lazy(() => import("./features/mail/MailBouncePage"));
const MailLegacyImportPage = lazy(() => import("./features/mail/MailLegacyImportPage"));
const mailboxRead = ["mail:mailbox:read"];
const bounceRead = ["mail:bounce:read", "mail:bounce:manage"];
const legacyImportAccess = ["mail:pop3:import", "mail:pop3:manage"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
en: { ...generatedTranslations.en, ...messagesProductSurfaceTranslations.en },
de: { ...generatedTranslations.de, ...messagesProductSurfaceTranslations.de }
};
const mailQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [
{
id: "mail.messages",
render: (context) => createElement(MailQuickAccess, context)
}
]
};
export const mailModule: PlatformWebModule = {
@@ -27,16 +38,22 @@ export const mailModule: PlatformWebModule = {
{ 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.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 },
{ to: "/mail/legacy-import", label: "Legacy POP3 import", iconName: "mail", anyOf: legacyImportAccess, order: 52 }
],
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings, auth }) => createElement(MailboxPage, { settings, auth }) },
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) }],
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) },
{ path: "/mail/legacy-import", anyOf: legacyImportAccess, order: 52, render: ({ settings, auth }) => createElement(MailLegacyImportPage, { settings, auth }) }],
uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors,
"quickAccess.tools": mailQuickAccessTools
},
runtimeUiCapabilities: {
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
+104 -86
View File
@@ -181,26 +181,6 @@
flex-direction: column;
}
}
.mailbox-page {
display: grid;
gap: 18px;
}
.mailbox-toolbar,
.mailbox-message-toolbar {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
}
.mailbox-toolbar label {
display: grid;
gap: 5px;
min-width: min(340px, 100%);
}
.mailbox-toolbar label span,
.mailbox-preview-header span {
color: var(--muted);
font-size: 12px;
@@ -208,15 +188,6 @@
text-transform: uppercase;
}
.mailbox-toolbar-meta {
flex: 1;
min-width: 0;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-layout {
display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1.25fr) minmax(320px, .75fr);
@@ -385,7 +356,7 @@
overflow-wrap: anywhere;
}
@media (max-width: 1200px) {
@media (max-width: 1280px) {
.mailbox-layout {
grid-template-columns: 220px minmax(0, 1fr);
}
@@ -396,12 +367,6 @@
}
@media (max-width: 760px) {
.mailbox-toolbar,
.mailbox-message-toolbar {
align-items: stretch;
flex-direction: column;
}
.mailbox-layout {
grid-template-columns: 1fr;
}
@@ -411,7 +376,10 @@
/* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */
.mailbox-shell.file-manager-shell {
position: relative;
flex: 1 1 auto;
min-height: 0;
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr);
grid-template-rows: minmax(0, 1fr);
}
.mailbox-shell.is-loading .mailbox-preview-panel {
@@ -458,54 +426,13 @@
.mailbox-folder-count {
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 {
display: grid;
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
gap: 12px;
}
.mailbox-profile-field {
display: grid;
gap: 4px;
flex: 0 1 20rem;
min-width: 0;
}
.mailbox-profile-field > span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.mailbox-toolbar-meta {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-toolbar-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.mailbox-toolbar-actions .btn {
white-space: nowrap;
}
.mailbox-profile-blocker {
margin: 12px;
}
@@ -576,6 +503,14 @@
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 {
max-width: 100%;
}
@@ -599,6 +534,27 @@
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 {
display: flex;
flex-direction: column;
@@ -614,28 +570,90 @@
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 {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
grid-template-rows: minmax(0, 1.4fr) minmax(0, 1fr);
}
.mailbox-preview-panel {
grid-column: 1 / -1;
min-height: 320px;
min-height: 0;
border-top: var(--border-line);
}
}
@media (max-width: 760px) {
.mailbox-toolbar.file-manager-toolbar {
grid-template-columns: 1fr;
.mailbox-shell.file-manager-shell {
grid-template-columns: minmax(0, 1fr);
/* The three domain panes no longer compete for a fraction of one short
mobile viewport. Scroll this bounded workspace, not the action header;
the index and preview retain their own usable, bounded scroll regions. */
grid-template-rows: 8rem max-content minmax(14rem, 35dvh);
align-content: start;
overflow-y: auto;
}
.mailbox-toolbar-actions {
justify-content: flex-start;
.mailbox-page .mailbox-message-list-panel {
display: grid;
/* Header and pagination take their intrinsic space. The message viewport
has its own bound, so translated wrapping cannot squeeze it to zero. */
grid-template-rows: auto clamp(8rem, 24dvh, 14rem) auto;
border-right: 0;
border-top: var(--border-line);
}
.mailbox-message-head {
.mailbox-page .mailbox-message-head {
display: none;
}
@@ -643,7 +661,7 @@
min-width: 0;
}
.mailbox-message-row {
.mailbox-page .mailbox-message-row {
grid-template-columns: 1fr;
gap: 8px;
}
@@ -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");
@@ -21,11 +21,13 @@ assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp12.example.org"), f
const policy = {
whitelist: {
smtp_hosts: ["smtp.allowed.test"],
jmap_hosts: ["jmap.allowed.test"],
from_headers: ["*@allowed.test"],
recipient_domains: ["allowed.test"]
},
blacklist: {
smtp_hosts: ["smtp.blocked.test"],
jmap_hosts: ["jmap.blocked.test"],
envelope_senders: ["blocked@*"]
}
};
@@ -33,14 +35,17 @@ const policy = {
assertDeepEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.allowed.test"), { allowed: true, value: "smtp.allowed.test" });
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.blocked.test").allowed, false, "blacklist wins for SMTP host");
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.other.test").allowed, false, "whitelist blocks unknown SMTP host");
assertEqual(mailPolicyValueAllowed(policy, "jmap_hosts", "jmap.blocked.test").allowed, false, "JMAP hostname deny policy is independent");
const messages = validateMailPolicy(policy, {
smtpHost: "smtp.other.test",
jmapHost: "jmap.blocked.test",
envelopeSender: "blocked@allowed.test",
fromHeader: "sender@other.test",
recipientDomains: ["allowed.test", "denied.test", "user@denied.test"]
});
assert(messages.some((item) => item.key === "smtp_hosts" && item.value === "smtp.other.test"));
assert(messages.some((item) => item.key === "jmap_hosts" && item.value === "jmap.blocked.test"));
assert(messages.some((item) => item.key === "envelope_senders" && item.value === "blocked@allowed.test"));
assert(messages.some((item) => item.key === "from_headers" && item.value === "sender@other.test"));
assertEqual(messages.filter((item) => item.key === "recipient_domains" && item.value === "denied.test").length, 1, "recipient domains are normalized and de-duplicated");
+16 -1
View File
@@ -47,6 +47,9 @@ assertEqual(mailProfileEditTargetPanelMode({ kind: "credentials", protocol: "ima
assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "create" }), ["smtp", "imap"]);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["imap"]);
assertEqual(mailProfileEditTargetInitialSection({ kind: "server", protocol: "jmap" }), "imap");
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "server", protocol: "jmap" }), []);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "credentials", protocol: "jmap" }), false);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
@@ -118,9 +121,11 @@ const updateParts = {
profile: { name: "Renamed" },
smtp: { host: "smtp.example.org" },
imap: { host: "imap.example.org" },
jmap: { session_url: "https://jmap.example.org/.well-known/jmap" },
credentials: {
smtp: { username: "smtp-user", password: "smtp-secret" },
imap: { username: "imap-user", password: "imap-secret" }
imap: { username: "imap-user", password: "imap-secret" },
jmap: { password: "jmap-token" }
},
clearImap: false
};
@@ -139,6 +144,16 @@ assertDeepEqual(
{ credentials: { imap: { username: "imap-user", password: "imap-secret" } } },
"credential edits send only the selected protocol"
);
assertDeepEqual(
mailProfileTargetedUpdatePayload({ kind: "server", protocol: "jmap" }, updateParts),
{ jmap: { session_url: "https://jmap.example.org/.well-known/jmap" } },
"JMAP server edits remain isolated from legacy profile transports"
);
assertDeepEqual(
mailProfileTargetedUpdatePayload({ kind: "credentials", protocol: "jmap" }, updateParts),
{ credentials: { jmap: { password: "jmap-token" } } },
"JMAP credential edits retain only the selected credential"
);
assertEqual(
mailProfileCreateCredentialsPayload({ username: null }, { password: "" }),
undefined,
+19
View File
@@ -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);
+46
View File
@@ -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");
+7 -1
View File
@@ -17,11 +17,17 @@
"rootDir": "."
},
"include": [
"tests/mailbox-display.test.ts",
"tests/mailbox-folders.test.ts",
"tests/mailbox-launch.test.ts",
"tests/mail-profile-editor-model.test.ts",
"tests/mail-policy-validation.test.ts",
"tests/mail-address-integration.test.ts",
"src/features/mail/mailboxDisplay.ts",
"src/features/mail/mailboxFolders.ts",
"src/features/mail/mailboxLaunch.ts",
"src/features/mail/mailProfileEditorModel.ts",
"src/features/mail/mailPolicyValidation.ts"
"src/features/mail/mailPolicyValidation.ts",
"src/features/mail/mailAddressIntegration.ts"
]
}