Compare commits

..
9 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
55 changed files with 9248 additions and 1188 deletions
+18 -11
View File
@@ -4,7 +4,7 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- 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 ## Ownership
@@ -12,17 +12,18 @@ This repository owns:
- backend module manifest `mail` - 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` - 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 - SMTP send and IMAP append adapters, including mock transports for development
- development mock mailbox endpoints used by test-send flows - 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. 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 Mail publishes `privacy.dsar.mail` for Core's governed data-subject-request
workflow. It isolates matching mailbox header parties and returns bounded index, workflow. It isolates matching mailbox header parties and returns bounded index,
personal-profile, delivery, reconciliation, and bounce metadata. SMTP/IMAP personal-profile, delivery, reconciliation, bounce, and imported-message metadata. SMTP/IMAP/JMAP/POP3
configuration and credentials, encrypted messages and envelopes, folder/UID configuration and credentials, encrypted messages and envelopes, source UIDL
and folder/UID
locators, worker and idempotency state, diagnostics, and opaque evidence are locators, worker and idempotency state, diagnostics, and opaque evidence are
excluded. Delivery and bounce outcomes remain retained evidence; mailbox and excluded. Delivery and bounce outcomes remain retained evidence; mailbox and
profile changes require coordinated Mail and external-provider review, so the profile changes require coordinated Mail and external-provider review, so the
@@ -30,7 +31,7 @@ provider does not perform direct erasure.
## Profile and credential ownership ## Profile and credential ownership
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP/JMAP
endpoints, encrypted credentials, tests, scope, and policy. Consumers such as endpoints, encrypted credentials, tests, scope, and policy. Consumers such as
Campaign store only a stable profile identifier and resolve the authorized, Campaign store only a stable profile identifier and resolve the authorized,
active profile through `mail.campaign_delivery`; they never copy or override active profile through `mail.campaign_delivery`; they never copy or override
@@ -86,7 +87,7 @@ closed with guidance to store those credentials on a Mail profile and enable
inheritance. inheritance.
Deleting a profile deactivates its non-secret tombstone metadata and scrubs 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 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 to every remaining profile before any Mail table is dropped; a scrub or audit
failure blocks retirement. failure blocks retirement.
@@ -143,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 core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
does not contribute these routes directly. does not contribute these routes directly.
POP3 and JMAP are deferred. The protocol decision is documented in JMAP is available as an opt-in, read-only mailbox transport after the stable
[docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md): stabilize IMAP baseline. It discovers RFC 8620/8621 capabilities, lists folders,
SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3 performs server-side message search and pagination, reads bounded message
only for explicit legacy-download requirements. The same roadmap records the 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 approved S/MIME-first, OpenPGP-additional message-protection profile and its
no-silent-downgrade requirement. no-silent-downgrade requirement.
+17 -2
View File
@@ -9,7 +9,7 @@ mailbox, policy, and delivery-evidence consequences described here.
| Surface | Primary task | Archetype | Consequence | Pattern evidence | | 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` 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 | | System/tenant/group/user/campaign profile surfaces | Compare profiles, protocol servers, reusable credentials, status, and scope | Administration/configuration | High because endpoints, credentials, and inheritance control external communication | Shared `ConnectionTree`, stable row actions, textual status, permission/target blockers, and contextual admin help |
| Profile creation and focused profile/server/credential editors | Create a governed transport identity or edit one hierarchy object | Guided setup plus adaptive create/edit | High because saving may enable provider access or replace encrypted credentials | Shared `Dialog` and `StageRail` for multi-object setup; focused edit modes show only the selected hierarchy object; field help, connection tests, unsaved-draft guard, and disabled-save reasons | | Profile creation and focused profile/server/credential editors | Create a governed transport identity or edit one hierarchy object | Guided setup plus adaptive create/edit | High because saving may enable provider access or replace encrypted credentials | Shared `Dialog` and `StageRail` for multi-object setup; focused edit modes show only the selected hierarchy object; field help, connection tests, unsaved-draft guard, and disabled-save reasons |
| Mail profile policy card | Narrow visible profiles, lower-scope definitions, hosts, senders, and recipients | Effective-policy editor | High because inherited allow/deny rules govern delivery and lower scopes | Shared policy rows, typed selectors, source path, locked/read-only blocker, dirty-save state, and contextual policy help | | Mail profile policy card | Narrow visible profiles, lower-scope definitions, hosts, senders, and recipients | Effective-policy editor | High because inherited allow/deny rules govern delivery and lower scopes | Shared policy rows, typed selectors, source path, locked/read-only blocker, dirty-save state, and contextual policy help |
@@ -42,6 +42,15 @@ mailbox, policy, and delivery-evidence consequences described here.
copy distinguishes retained reusable credentials from scrubbed owned secrets. copy distinguishes retained reusable credentials from scrubbed owned secrets.
- Mailbox browsing is read-only. Listing or previewing must not mark messages - Mailbox browsing is read-only. Listing or previewing must not mark messages
read, move, delete, reply, or expose unbounded content. 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 ## Accessibility, responsive, and privacy evidence
@@ -52,8 +61,14 @@ reasons are keyboard-focusable. Status always has text in addition to color.
Contextual links identify their destination to assistive technology. Contextual links identify their destination to assistive technology.
Profile/policy grids collapse to one column below 900 px. The mailbox changes 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. 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. Long identities and transport summaries wrap or ellipsize inside stable bounds.
Profile and mailbox APIs return non-secret transport metadata and bounded Profile and mailbox APIs return non-secret transport metadata and bounded
+288 -38
View File
@@ -23,12 +23,13 @@ See also [Mail protocol roadmap](MAIL_PROTOCOL_ROADMAP.md) and the Campaign
Mail owns: Mail owns:
- reusable SMTP/IMAP profile definitions and scope; - reusable SMTP/IMAP/JMAP profile definitions, dedicated legacy POP3 sources, and scope;
- encrypted SMTP/IMAP credentials and safe credential replacement; - encrypted SMTP/IMAP/JMAP/POP3 credentials and safe credential replacement;
- effective profile policy and visibility/authorization decisions; - effective profile policy and visibility/authorization decisions;
- connection tests and protocol adapters; - connection tests and protocol adapters;
- SMTP send and IMAP append operations exposed to consumers; - 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 - the transport sanitization boundary and throttling behavior. A general
Mail-owned provider-attempt/diagnostic ledger remains planned. Mail-owned provider-attempt/diagnostic ledger remains planned.
@@ -60,11 +61,23 @@ conflicting profile is preserved unless the reviewed fragment explicitly sets
`on_conflict` to `update`. Credential bindings are added idempotently and are `on_conflict` to `update`. Credential bindings are added idempotently and are
never removed merely because a package omits a credential reference. 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 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, 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 is invalid, or is not mounted, import is blocked with an operator-facing
resolution instead of creating a partial profile. 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 ## Interface patterns and unavailable actions
Mail uses the platform's shared explorer, connection tree, adaptive form, Mail uses the platform's shared explorer, connection tree, adaptive form,
@@ -89,9 +102,10 @@ deactivating a profile may scrub Mail-owned credentials as described below.
### Profile ### Profile
A profile is a reusable, named delivery identity with optional SMTP and IMAP 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 configuration and one or more opt-in JMAP mailbox endpoints. It can additionally own a dedicated POP3 endpoint for an
non-secret connection metadata. Passwords are write-only encrypted values and explicit legacy-import workflow. It has a stable id, lifecycle state, scope,
are never returned through list/read/capability responses. 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 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 Junk roles to exact provider folder names. These mappings belong to the reusable
@@ -100,6 +114,16 @@ profile/server. Empty roles retain automatic behavior. The historical
for compatibility; a Campaign-specific Sent override still wins for that for compatibility; a Campaign-specific Sent override still wins for that
Campaign. 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. Profiles may be scoped to system, tenant, user, group, or campaign context.
Scope controls where a profile can be discovered; effective policy can narrow Scope controls where a profile can be discovered; effective policy can narrow
that further. A visible profile is not automatically authorized for every that further. A visible profile is not automatically authorized for every
@@ -108,14 +132,14 @@ operation: using, testing, managing, and managing secrets are separate rights.
### Policy ### Policy
Mail policy controls approved profile ids, which lower scopes may define 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 and lower-level limits. The effective result is assembled from applicable
system, tenant, user/group, and campaign context. Denials and locked parent system, tenant, user/group, and campaign context. Denials and locked parent
limits cannot be relaxed by a lower scope. limits cannot be relaxed by a lower scope.
### Transport identity and revision ### 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 configuration. They are concurrency tokens, not deterministic hashes that a
consumer could use to guess a host or account. Credentials are excluded. 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 consumer can freeze these revisions at build time and ask Mail to require the
@@ -128,6 +152,11 @@ authorization. Mail re-evaluates profile activity, visibility, policy, and
revision immediately before it resolves credentials and performs the revision immediately before it resolves credentials and performs the
effect. 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 ### Provider outcomes
SMTP acceptance, partial or complete recipient refusal, temporary/permanent SMTP acceptance, partial or complete recipient refusal, temporary/permanent
@@ -153,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, 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 owner, group, and task context. Never enter or copy a profile id manually
when the UI can present a picker. 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 and policy-relevant sender identity. Credentials and raw provider internals
are not visible. are not visible.
4. Save the reference in the consuming module. If ownership or policy changes, 4. Save the reference in the consuming module. If ownership or policy changes,
@@ -166,12 +195,25 @@ permission.
### Test a profile ### Test a profile
An authorized profile test verifies connection and authentication for the 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 consumer-use path evaluates effective Mail policy separately. Use a
non-production provider and mailbox first. A successful connection test does non-production provider and mailbox first. A successful connection test does
not prove policy authorization for a later Campaign context, deliverability, not prove policy authorization for a later Campaign context, deliverability,
recipient acceptance, SPF/DKIM/DMARC alignment, or future availability. 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 Testing a saved profile requires both `mail:profile:test` and
`mail:profile:use`, and the profile must be active. Profile creation or test `mail:profile:use`, and the profile must be active. Profile creation or test
authority alone is not enough. authority alone is not enough.
@@ -182,15 +224,58 @@ ordinary consumers to bypass reusable profiles.
### Read a mailbox ### 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 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 or JMAP folders, page through messages, and inspect a bounded full message.
names are parsed and quoted defensively; Sent-folder discovery uses provider IMAP folder names are decoded for display and encoded and quoted defensively
flags and common names. The list exposes the provider's `Seen` flag as a 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 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 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 an index while a refresh is in progress, including the index timestamp when
available. 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. Message HTML is displayed only in the shared sandboxed message component.
Remote URLs and active markup are removed, embedded `data:`/`cid:` image Remote URLs and active markup are removed, embedded `data:`/`cid:` image
references remain isolated, and plain text is always available when supplied. references remain isolated, and plain text is always available when supplied.
@@ -199,9 +284,62 @@ silently interpreted as an empty message.
Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must
not mutate read/unread, delete, move, or reply state. Message responses are not mutate read/unread, delete, move, or reply state. Message responses are
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. 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 ## Profile administration
### Roles ### Roles
@@ -214,11 +352,16 @@ The supplied templates are:
and manage credentials only for the current account's own user-scoped and manage credentials only for the current account's own user-scoped
profiles, subject to the effective Mail policy. profiles, subject to the effective Mail policy.
- **Mail profile administrator:** additionally create/update profiles and - **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`, The specific permissions are `mail:profile:read`, `mail:profile:use`,
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write_own`, `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 The `_own` permissions are enforced against the authenticated membership id and
never authorize a tenant, group, campaign, system, or another user's profile. never authorize a tenant, group, campaign, system, or another user's profile.
They also do not authorize profile-policy changes. System-scoped definitions They also do not authorize profile-policy changes. System-scoped definitions
@@ -237,21 +380,21 @@ the deletion in the audit log.
The configured Help Center exposes **Create a custom Mail profile** only when 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 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, allow-list groups and deny rules, plus the actor's separate credential, test,
use, and approval requirements. The Settings task creates in the current use, and approval requirements. The Settings task creates in the current
account's user scope. Grant `mail:profile:write_own` for self-service; account's user scope. Grant `mail:profile:write_own` for self-service;
`mail:profile:write` remains broad profile administration authority. `mail:profile:write` remains broad profile administration authority.
1. Choose the narrowest suitable scope and a stable, descriptive name/slug. 1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
2. Configure SMTP, optional IMAP, TLS mode, account identity, standard folder 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, mappings, and timeouts. Folder discovery proposes provider-visible Inbox,
Sent, Drafts, Trash, Archive, and Junk names without mutating the mailbox. Sent, Drafts, Trash, Archive, and Junk names without mutating the mailbox.
Sender/envelope/recipient constraints belong to Sender/envelope/recipient constraints belong to
effective Mail policy; Campaign rate limits remain delivery configuration. effective Mail policy; Campaign rate limits remain delivery configuration.
3. Enter credentials only in the dedicated credential fields. Returned profile 3. Enter credentials only in the dedicated credential fields. Returned profile
data indicates whether credentials are configured without returning them. data indicates whether credentials are configured without returning them.
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. 5. Verify the effective policy for every intended consumer context.
6. Communicate changes that alter the non-secret transport identity; prepared 6. Communicate changes that alter the non-secret transport identity; prepared
consumer snapshots will deliberately stop until rebuilt. consumer snapshots will deliberately stop until rebuilt.
@@ -259,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 An update that omits a password preserves the current encrypted password. A
credential replacement never depends on reading the old cleartext value back. 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 ### Delete a profile
Profile deletion is immediate for Mail-owned secrets and audit evidence: 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 2. It deactivates the remaining non-secret tombstone state so historical stable
references can fail safely rather than resolve to another profile. references can fail safely rather than resolve to another profile.
3. When owned encrypted secrets existed, it emits 3. When owned encrypted secrets existed, it emits
@@ -284,16 +440,35 @@ Effective policy is contextual. Administrators should document:
- which profile ids are approved globally or for a tenant; - which profile ids are approved globally or for a tenant;
- whether tenant, user, group, or campaign-scoped profiles may be created; - 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 - permitted From, envelope sender (including bounce address), and envelope
recipient-domain patterns; 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. - which lower-level settings are locked by a parent policy.
Campaign delivery requires reusable profile credentials. A legacy policy that In **Mail profile policy → Credential selection**, SMTP and IMAP have separate
requires campaign-local credentials fails closed with guidance to store them on controls. **Allow profile default credential** (`inherit: true`) permits either
the Mail profile and enable effective inheritance. This preserves compatibility the selected server's default credential or an explicit authorized Mail-owned
of the policy model without reopening a consumer-owned secret store. 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 Policy reads are available through system/tenant/context routes to suitably
authorized actors. Adaptive Docs exposes a safe explanation of the effective authorized actors. Adaptive Docs exposes a safe explanation of the effective
@@ -305,10 +480,10 @@ tenant posture; it does not expose credential material.
Private-network connector access is controlled deployment-wide by Private-network connector access is controlled deployment-wide by
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Whether private or public targets `GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Whether private or public targets
are allowed, SMTP and IMAP resolve, validate, and connect to the exact approved are allowed, SMTP, IMAP, and legacy POP3 resolve, validate, and connect to the
address records at connection time while retaining the original hostname for exact approved address records at connection time while retaining the original
TLS SNI and certificate verification. A DNS change cannot redirect the socket hostname for TLS SNI and certificate verification. A DNS change cannot redirect
after validation. the socket after validation.
Transports that cannot pin every connection peer or revalidate protocol-managed Transports that cannot pin every connection peer or revalidate protocol-managed
redirects/referrals must fail before client construction. Do not weaken this redirects/referrals must fail before client construction. Do not weaken this
@@ -370,6 +545,78 @@ treated as an unknown provider mutation.
confirmed absence records verified recovery and permits only a new, confirmed absence records verified recovery and permits only a new,
deliberate attempt identifier. 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 ### Delivery-status and calendar-reply sources
An authorized Mail bounce source scans a bounded IMAP UID range without An authorized Mail bounce source scans a bounded IMAP UID range without
@@ -383,10 +630,11 @@ state transition or outbound synchronization effect.
### Backup, restore, and retirement ### Backup, restore, and retirement
Backups contain encrypted credentials and therefore need the same protection as Backups contain encrypted credentials and encrypted raw POP3 import records and
the live database and key material. Restoring a Mail database without the therefore need the same protection as the live database and key material.
matching encryption key makes credentials unusable; restoring it with keys can Restoring a Mail database without the matching encryption key makes those
reactivate sensitive historical state and must be controlled. records unusable; restoring it with keys can reactivate sensitive historical
state and must be controlled.
Destructive module retirement first applies the same immediate credential Destructive module retirement first applies the same immediate credential
scrub/audit rule to every remaining profile, then drops Mail-owned tables after scrub/audit rule to every remaining profile, then drops Mail-owned tables after
@@ -436,7 +684,7 @@ mandatory release gate.
Security invariants: 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 - Password fields are write-only and encrypted at rest; safe responses expose
configuration state, not values. configuration state, not values.
- Consumers persist stable profile references, not transport copies. - Consumers persist stable profile references, not transport copies.
@@ -493,9 +741,11 @@ Before claiming a Mail composition is production-ready:
semantics (`govoplan-mail#16`). semantics (`govoplan-mail#16`).
- Final Campaign **test / single send / single resend** semantics; those are a - Final Campaign **test / single send / single resend** semantics; those are a
Campaign business-action contract built on Mail transport operations. Campaign business-action contract built on Mail transport operations.
- JMAP mailbox synchronization/search; it is preferred only after the IMAP MVP - JMAP provider-side mutation, submission, push, thread, calendar, contact,
is stable. attachment-binary, and automatic background-sync support; read-only mailbox
- POP3 except for a future explicit legacy download/import requirement. 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. - A full mail client with compose/reply/move/delete/read-state mutation.
- Quick Access may launch the operating environment's configured composer via - Quick Access may launch the operating environment's configured composer via
`mailto:`. That explicit handoff is not a GovOPlaN Mail delivery: it selects `mailto:`. That explicit handoff is not a GovOPlaN Mail delivery: it selects
+55 -21
View File
@@ -1,14 +1,20 @@
# Mail Protocol Roadmap # Mail Protocol Roadmap
GovOPlaN Mail currently focuses on SMTP sending and IMAP mailbox access. POP3 GovOPlaN Mail uses SMTP for sending and supports read-only mailbox access over
and JMAP are deferred until the IMAP mailbox MVP is stable. 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 ## Current Baseline
- SMTP is the send protocol. - SMTP is the send protocol.
- IMAP is the read/append protocol. - IMAP is the established read/append protocol and remains unchanged.
- Mail profile policy, encrypted credentials, mailbox folder parsing, test - JMAP is an opt-in read-only sync/search protocol; it is not used for sending
buttons, and read-only mailbox UI are built around SMTP and IMAP. 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 This baseline matches the first production use case: send campaign mail, append
sent copies when configured, and inspect mailboxes read-only. sent copies when configured, and inspect mailboxes read-only.
@@ -37,8 +43,8 @@ the S/MIME profile is stable.
## JMAP ## JMAP
JMAP is the preferred future sync/search protocol where target mail servers JMAP is the preferred sync/search protocol where target mail servers support
support it. RFC 8620 Core and RFC 8621 Mail capabilities.
Reasons: Reasons:
@@ -47,26 +53,54 @@ Reasons:
- modern search and thread models - modern search and thread models
- better fit for browser-facing mailbox UX through a server proxy - 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 - authenticated Session discovery with Bearer or Basic credentials;
- mail profile policy can express protocol-specific availability - explicit account selection or primary Mail-account selection;
- mailbox UI can handle protocol-neutral folder/message DTOs - folder hierarchy projection through `Mailbox/get`;
- test infrastructure includes at least one reliable JMAP server target - 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
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 It is disabled until an administrator creates a dedicated POP3 endpoint and
that cannot offer IMAP or JMAP. POP3 is a poor fit for the normal GovOPlaN sets `legacy_import_enabled`. The endpoint has its own encrypted credential,
mailbox UX because it has limited folder, sync, and server-side state semantics. 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 Preview and ordinary import are non-destructive. Selected messages become
general mailbox browsing. 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 ## Decision
Do not add POP3 or JMAP now. Stabilize SMTP/IMAP first, design protocol-neutral Keep the implemented POP3 surface limited to governed legacy import. Do not
mailbox DTOs, then prefer JMAP for modern servers and reserve POP3 for explicit expand it into mailbox browsing. Keep the protocol-neutral mailbox DTOs and
legacy download requirements. 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", "name": "@govoplan/mail-webui",
"version": "0.1.18", "version": "0.1.27",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.18", "version": "0.1.27",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.18", "version": "0.1.27",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-mail" name = "govoplan-mail"
version = "0.1.19" version = "0.1.27"
description = "GovOPlaN mail module with backend and WebUI integration." description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.45",
"pydantic>=2,<3", "pydantic>=2,<3",
"redis>=5,<6", "redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
+106 -2
View File
@@ -5,12 +5,14 @@ from contextvars import ContextVar
from dataclasses import dataclass from dataclasses import dataclass
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formatdate, make_msgid from email.utils import formatdate, make_msgid
from threading import get_ident
from typing import Any, Iterator from typing import Any, Iterator
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.mail import NotificationMailDeliveryRequest from govoplan_core.core.mail import NotificationMailDeliveryRequest
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.mail_profiles import ( from govoplan_mail.backend.mail_profiles import (
MailProfileError, MailProfileError,
_assert_campaign_inherits_profile_credentials, _assert_campaign_inherits_profile_credentials,
@@ -38,6 +40,7 @@ from govoplan_mail.backend.recovery import (
) )
from govoplan_mail.backend.sending.imap import ( from govoplan_mail.backend.sending.imap import (
ImapAppendError, ImapAppendError,
ImapBatchSession,
ImapConfigurationError, ImapConfigurationError,
append_message_to_sent, append_message_to_sent,
) )
@@ -72,6 +75,78 @@ class CampaignSmtpDeliveryResult:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class CampaignImapAppendResult: class CampaignImapAppendResult:
folder: str 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) @dataclass(frozen=True, slots=True)
@@ -155,6 +230,7 @@ def _authorized_campaign_profile(
campaign_id: str, campaign_id: str,
profile_id: str, profile_id: str,
selection: dict[str, str | None] | None = None, selection: dict[str, str | None] | None = None,
credential_protocol: str | None = None,
): ):
profile = ensure_mail_profile_allowed_for_campaign( profile = ensure_mail_profile_allowed_for_campaign(
session, session,
@@ -164,7 +240,7 @@ def _authorized_campaign_profile(
require_active=True, require_active=True,
) )
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) 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 return profile
@@ -343,6 +419,7 @@ def campaign_smtp_batch(
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection, selection=selection,
credential_protocol="smtp",
) )
except MailProfileError: except MailProfileError:
raise raise
@@ -444,6 +521,7 @@ def send_campaign_email_bytes(
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection, selection=selection,
credential_protocol="smtp",
) )
except MailProfileError: except MailProfileError:
raise raise
@@ -593,6 +671,9 @@ def append_campaign_message_to_sent(
recovery_resource_type: str | None = None, recovery_resource_type: str | None = None,
recovery_resource_id: str | None = None, recovery_resource_id: str | None = None,
) -> CampaignImapAppendResult: ) -> 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( selection = _selection_payload(
profile_id=profile_id, profile_id=profile_id,
smtp_server_id=smtp_server_id, smtp_server_id=smtp_server_id,
@@ -607,6 +688,7 @@ def append_campaign_message_to_sent(
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection, selection=selection,
credential_protocol="imap",
) )
except MailProfileError: except MailProfileError:
raise raise
@@ -681,6 +763,15 @@ def append_campaign_message_to_sent(
) )
except MailProfileError: except MailProfileError:
raise MailProfileError("Appending to Sent is blocked by the effective Mail policy.") from None 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: try:
recovery = begin_provider_effect_recovery( recovery = begin_provider_effect_recovery(
kind="imap-append", kind="imap-append",
@@ -701,7 +792,12 @@ def append_campaign_message_to_sent(
outcome_unknown=True, outcome_unknown=True,
) )
try: try:
if batch_session is None:
result = append_message_to_sent(message_bytes, imap_config=imap, folder=folder) 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: except ImapAppendError as exc:
sanitized = _sanitized_imap_error(exc) sanitized = _sanitized_imap_error(exc)
if recovery is not None: if recovery is not None:
@@ -728,11 +824,18 @@ def append_campaign_message_to_sent(
try: try:
recovery.succeed_imap(folder=result.folder) recovery.succeed_imap(folder=result.folder)
except Exception: except Exception:
if batch is not None:
batch.close()
raise ImapAppendError( raise ImapAppendError(
"IMAP APPEND returned success, but durable recovery evidence could not be finalized.", "IMAP APPEND returned success, but durable recovery evidence could not be finalized.",
outcome_unknown=True, outcome_unknown=True,
) from None ) 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: class MailCampaignCapability:
@@ -745,6 +848,7 @@ class MailCampaignCapability:
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json) mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary) campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
campaign_smtp_batch = staticmethod(campaign_smtp_batch) campaign_smtp_batch = staticmethod(campaign_smtp_batch)
campaign_imap_batch = staticmethod(campaign_imap_batch)
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes) send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent) append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
wait_for_rate_limit = staticmethod(wait_for_rate_limit) wait_for_rate_limit = staticmethod(wait_for_rate_limit)
+114
View File
@@ -1,5 +1,10 @@
from __future__ import annotations from __future__ import annotations
import urllib.parse
from typing import Literal
from pydantic import Field, model_validator
from govoplan_core.mail.config import ( from govoplan_core.mail.config import (
ImapConfig, ImapConfig,
ImapFolderMappings, ImapFolderMappings,
@@ -12,10 +17,119 @@ from govoplan_core.mail.config import (
normalize_split_transport_credentials, 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__ = [ __all__ = [
"ImapConfig", "ImapConfig",
"ImapFolderMappings", "ImapFolderMappings",
"ImapServerConfig", "ImapServerConfig",
"JmapConfig",
"JmapServerConfig",
"Pop3Config",
"Pop3ServerConfig",
"SmtpConfig", "SmtpConfig",
"SmtpServerConfig", "SmtpServerConfig",
"StrictModel", "StrictModel",
@@ -5,7 +5,7 @@ from dataclasses import dataclass, replace
import re import re
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.configuration_packages import ( from govoplan_core.core.configuration_packages import (
@@ -24,6 +24,8 @@ from govoplan_core.core.configuration_packages import (
from govoplan_core.core.infrastructure_capabilities import ( from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapability, InfrastructureCapability,
InfrastructureCapabilityReceipt, InfrastructureCapabilityReceipt,
InfrastructureDependency,
InfrastructureDependencyProvider,
) )
from govoplan_core.security.credential_envelopes import ( from govoplan_core.security.credential_envelopes import (
CredentialAccessContext, CredentialAccessContext,
@@ -51,6 +53,9 @@ from govoplan_mail.backend.server_hierarchy import (
MAIL_CONFIGURATION_CAPABILITY = "mail.configuration" MAIL_CONFIGURATION_CAPABILITY = "mail.configuration"
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = (
"infrastructure.dependency_inventory.mail"
)
SMTP_PROFILE_FRAGMENT = "smtp_profile" SMTP_PROFILE_FRAGMENT = "smtp_profile"
_SYSTEM_CONFIGURATION_SCOPES = frozenset( _SYSTEM_CONFIGURATION_SCOPES = frozenset(
{"system:settings:write", "system:governance:write"} {"system:settings:write", "system:governance:write"}
@@ -132,8 +137,12 @@ class _ProfileTarget:
on_conflict: str on_conflict: str
class SqlMailConfigurationProvider(ConfigurationProvider): class SqlMailConfigurationProvider(
ConfigurationProvider,
InfrastructureDependencyProvider,
):
module_id = "mail" module_id = "mail"
capability_ids = ("mail.smtp",)
def describe(self) -> ConfigurationProviderDescription: def describe(self) -> ConfigurationProviderDescription:
return ConfigurationProviderDescription( return ConfigurationProviderDescription(
@@ -204,6 +213,84 @@ class SqlMailConfigurationProvider(ConfigurationProvider):
item for item in import_result.diagnostics if item.severity == "blocker" item for item in import_result.diagnostics if item.severity == "blocker"
) )
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
with get_database().session() as session:
return _smtp_infrastructure_dependencies(session)
def _smtp_infrastructure_dependencies(
session: Session,
) -> tuple[InfrastructureDependency, ...]:
endpoints = tuple(
session.execute(
select(MailServerEndpoint)
.where(MailServerEndpoint.protocol == "smtp")
.order_by(MailServerEndpoint.id)
).scalars()
)
binding_counts = {
str(server_id): int(count)
for server_id, count in session.execute(
select(
MailServerCredentialBinding.server_id,
func.count(MailServerCredentialBinding.id),
).group_by(MailServerCredentialBinding.server_id)
)
}
dependencies: list[InfrastructureDependency] = []
endpoint_profile_ids: set[str] = set()
for endpoint in endpoints:
endpoint_profile_ids.add(endpoint.profile_id)
dependencies.append(
InfrastructureDependency(
capability_id="mail.smtp",
module_id="mail",
dependency_type="smtp_endpoint",
dependency_ref=mail_server_ref(endpoint.id) or f"mail:{endpoint.id}",
state="active" if endpoint.is_active else "inactive",
scope=str(endpoint.scope_type or "tenant"),
summary=(
"A persisted Mail SMTP endpoint is bound to the deployment relay."
),
metrics={
"credential_binding_count": binding_counts.get(endpoint.id, 0),
"default_endpoint": int(bool(endpoint.is_default)),
},
required_action=(
"Rebind, migrate, or explicitly retire this SMTP endpoint and its credential-envelope references before changing the relay capability."
),
)
)
legacy_profiles = tuple(
session.execute(
select(MailServerProfile)
.where(MailServerProfile.smtp_config.is_not(None))
.order_by(MailServerProfile.id)
).scalars()
)
for profile in legacy_profiles:
if profile.id in endpoint_profile_ids or not dict(profile.smtp_config or {}):
continue
dependencies.append(
InfrastructureDependency(
capability_id="mail.smtp",
module_id="mail",
dependency_type="legacy_smtp_profile",
dependency_ref=f"mail-profile:{profile.id}",
state="active" if profile.is_active else "inactive",
scope=str(profile.scope_type or "tenant"),
summary=(
"A persisted legacy Mail profile still contains SMTP transport configuration."
),
metrics={"credential_binding_count": 0},
required_action=(
"Migrate or explicitly retire this legacy profile before changing the relay capability."
),
)
)
return tuple(dependencies)
def _preflight_smtp_profile( def _preflight_smtp_profile(
session: Session, session: Session,
+69
View File
@@ -387,3 +387,72 @@ class MailBounceObservation(Base, TimestampMixin):
) )
matched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) matched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=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_SECRET_MANAGE_OWN_SCOPE = "mail:secret:manage_own" # noqa: S105 # nosec B105
MAIL_PROFILE_TEST_SCOPE = "mail:profile:test" MAIL_PROFILE_TEST_SCOPE = "mail:profile:test"
MAIL_PROFILE_USE_SCOPE = "mail:profile:use" 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, ...]: 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", title="Create a custom Mail profile",
summary=( summary=(
"Create a reusable profile in the current account's user-scoped Settings view, " "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), body="\n".join(authority_lines),
layer="configured", layer="configured",
@@ -345,12 +345,12 @@ def _custom_profile_authority_lines(
approval_required: bool, approval_required: bool,
) -> tuple[str, ...]: ) -> tuple[str, ...]:
credentials = ( 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 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." 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 = ( 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 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." 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, ...]: ) -> tuple[str, ...]:
steps = [ steps = [
"Open Settings, choose Mail profiles, and select Add profile in the current account's user-scoped view.", "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( 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 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." 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; Mail validates the effective user-scope host policy again on the server.")
steps.append( 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 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: if approval_required:
steps.append("Ask a Mail administrator to add the new profile to the active approved-profile list.") 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: 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 = ["Reopen My Mail profiles and confirm the saved profile remains in the current account's user-scoped view."]
checks.append( checks.append(
"Confirm the authorized SMTP/IMAP tests succeed." "Confirm the authorized SMTP/IMAP/JMAP tests succeed."
if can_test_profile 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: if approval_required:
checks.append("Confirm an administrator approved the generated profile reference before expecting it in a picker.") 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) locked_limit_count = sum(1 for value in _lower_limit_values(policy) if value is False)
if approved_profile_limit and not lower_scopes: 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: 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." summary = "This tenant limits mail sending to approved profile ids, while selected lower scopes can still define profiles within policy limits."
elif lower_scopes: 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." 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: elif approved_profile_limit:
summary = "You can use approved Mail profiles. Some scopes may also define additional reusable profiles." 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: elif lower_scopes:
summary = "You may be able to define reusable Mail profiles in selected scopes, subject to tenant rules." 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: else:
summary = "Mail servers are managed centrally for this tenant." 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." 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)) smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True))
imap_inherit = bool((policy.get("imap_credentials") or {}).get("inherit", True)) imap_inherit = bool((policy.get("imap_credentials") or {}).get("inherit", True))
return ( return (
f"Credential inheritance: SMTP {'inherits' if smtp_inherit else 'requires local credentials'}; " f"Credential selection: SMTP {'allows a profile default or explicit Mail credential' if smtp_inherit else 'requires an explicit Mail credential'}; "
f"IMAP {'inherits' if imap_inherit else 'requires local credentials'}. " f"IMAP {'allows a profile default or explicit Mail credential' if imap_inherit else 'requires an explicit Mail credential'}. "
"Campaign delivery is available only for protocols that inherit credentials from the selected Mail profile." "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."
) )
@@ -20,6 +20,7 @@ from govoplan_mail.backend.db.models import (
MailDeliveryCommand, MailDeliveryCommand,
MailDeliveryReconciliation, MailDeliveryReconciliation,
MailMailboxMessageIndex, MailMailboxMessageIndex,
MailPop3Import,
MailServerProfile, MailServerProfile,
) )
@@ -116,6 +117,38 @@ class MailDsarProvider:
) )
) )
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( bounces = _matching_bounces(
db, db,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -404,6 +437,57 @@ def _matching_bounces(
) )
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( def _matching_commands(
session: Session, session: Session,
*, *,
@@ -458,6 +542,7 @@ def _mail_references(subject: DsarSubjectRef) -> dict[str, str]:
"mail.message_index": "message_index", "mail.message_index": "message_index",
"mail.delivery_command": "command", "mail.delivery_command": "command",
"mail.bounce_observation": "bounce", "mail.bounce_observation": "bounce",
"mail.pop3_import": "pop3_import",
} }
return { return {
target: value target: value
@@ -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.'}}
+27 -8
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import fnmatch import fnmatch
import json import json
import re import re
import urllib.parse
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Iterable, Mapping from typing import Any, Iterable, Mapping
@@ -33,7 +34,7 @@ class MailProfileError(RuntimeError):
MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy" MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy"
PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"} 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} PROFILE_SCOPE_ORDER = {"system": 0, "tenant": 1, "user": 2, "group": 2, "campaign": 3}
MAIL_POLICY_LIMIT_KEYS = ( MAIL_POLICY_LIMIT_KEYS = (
"allowed_profile_ids", "allowed_profile_ids",
@@ -44,11 +45,13 @@ MAIL_POLICY_LIMIT_KEYS = (
"imap_credentials.inherit", "imap_credentials.inherit",
"whitelist.smtp_hosts", "whitelist.smtp_hosts",
"whitelist.imap_hosts", "whitelist.imap_hosts",
"whitelist.jmap_hosts",
"whitelist.envelope_senders", "whitelist.envelope_senders",
"whitelist.from_headers", "whitelist.from_headers",
"whitelist.recipient_domains", "whitelist.recipient_domains",
"blacklist.smtp_hosts", "blacklist.smtp_hosts",
"blacklist.imap_hosts", "blacklist.imap_hosts",
"blacklist.jmap_hosts",
"blacklist.envelope_senders", "blacklist.envelope_senders",
"blacklist.from_headers", "blacklist.from_headers",
"blacklist.recipient_domains", "blacklist.recipient_domains",
@@ -711,11 +714,19 @@ def _domain_from_email(value: str | None) -> str | None:
return text.rsplit("@", 1)[1] or 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 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 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, "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, "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( def assert_mail_policy_allows_transport(
@@ -724,6 +735,7 @@ def assert_mail_policy_allows_transport(
tenant_id: str, tenant_id: str,
smtp: SmtpConfig | dict[str, Any] | None, smtp: SmtpConfig | dict[str, Any] | None,
imap: ImapConfig | dict[str, Any] | None = None, imap: ImapConfig | dict[str, Any] | None = None,
jmap: dict[str, Any] | None = None,
campaign_id: str | None = None, campaign_id: str | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_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_user_id=owner_user_id,
owner_group_id=owner_group_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( def assert_mail_policy_allows_send(
@@ -1418,19 +1430,26 @@ def _assert_campaign_inherits_profile_credentials(
profile: MailServerProfile, profile: MailServerProfile,
policy: EffectiveMailProfilePolicy, policy: EffectiveMailProfilePolicy,
selection: Mapping[str, str | None] | None = None, selection: Mapping[str, str | None] | None = None,
*,
protocol: str | None = None,
) -> None: ) -> None:
for protocol in ("smtp", "imap"): # Authoring and complete transport summaries validate both protocols.
if not _profile_has_transport(profile, protocol): # 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 continue
explicit_credential = ( explicit_credential = (
selection or {} selection or {}
).get(f"{protocol}_credential_id") ).get(f"{selected_protocol}_credential_id")
if ( if (
not _credential_policy_for_protocol(policy, protocol).inherit not _credential_policy_for_protocol(policy, selected_protocol).inherit
and not explicit_credential and not explicit_credential
): ):
raise MailProfileError( 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." "credential policy requires an explicit credential selection for this campaign."
) )
+557 -38
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_mail.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
@@ -29,7 +32,9 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAvailabilityExplanation,
ProductAreaContribution, ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessTool, QuickAccessTool,
RoleTemplate, RoleTemplate,
) )
@@ -43,15 +48,22 @@ from govoplan_core.core.provider_governance import (
from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_mail.backend.configuration_provider import MAIL_CONFIGURATION_CAPABILITY from govoplan_mail.backend.configuration_provider import (
MAIL_CONFIGURATION_CAPABILITY,
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
)
from govoplan_mail.backend.documentation import ( from govoplan_mail.backend.documentation import (
documentation_configuration_states, documentation_configuration_states,
documentation_topics, documentation_topics,
) )
from govoplan_mail.backend.provider_state import ( from govoplan_mail.backend.provider_state import (
IMAP_PROVIDER_ID, IMAP_PROVIDER_ID,
JMAP_PROVIDER_ID,
POP3_PROVIDER_ID,
SMTP_PROVIDER_ID, SMTP_PROVIDER_ID,
imap_provider_states, imap_provider_states,
jmap_provider_states,
pop3_provider_states,
smtp_provider_states, smtp_provider_states,
) )
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
@@ -66,6 +78,7 @@ _mail_table_retirement_provider = drop_table_retirement_provider(
mail_models.MailProfilePolicy, mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex, mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex, mail_models.MailMailboxMessageIndex,
mail_models.MailPop3Import,
mail_models.MailDeliveryReconciliation, mail_models.MailDeliveryReconciliation,
mail_models.MailDeliveryAttempt, mail_models.MailDeliveryAttempt,
mail_models.MailDeliveryCommand, mail_models.MailDeliveryCommand,
@@ -94,7 +107,7 @@ def _mail_retirement_provider(session: object | None, module_id: str):
plan, plan,
destroy_data_warnings=( destroy_data_warnings=(
*plan.destroy_data_warnings, *plan.destroy_data_warnings,
"Mail-owned encrypted SMTP/IMAP passwords are scrubbed with non-secret audit records immediately before tables are dropped; any scrub or audit failure blocks retirement.", "Mail-owned encrypted SMTP/IMAP/JMAP/POP3 credentials are scrubbed with non-secret audit records immediately before tables are dropped; encrypted POP3 import records are then removed with the Mail tables. Any scrub or audit failure blocks retirement.",
), ),
destroy_data_executor=executor, destroy_data_executor=executor,
) )
@@ -129,17 +142,32 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = ( PERMISSIONS = (
_permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP profile metadata."), _permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP/JMAP and governed legacy POP3 profile metadata."),
_permission("mail:profile:use", "Use mail profiles", "Select an approved mail profile for delivery."), _permission("mail:profile:use", "Use mail profiles", "Select an approved mail profile for delivery."),
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."), _permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP/JMAP connection tests and explicitly enabled POP3 diagnostics."),
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."), _permission("mail:mailbox:read", "Read mailboxes", "List IMAP or JMAP folders and inspect messages without mutating mailbox state."),
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."), _permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
_permission( _permission(
"mail:profile:write_own", "mail:profile:write_own",
"Manage own mail profiles", "Manage own mail profiles",
"Create, edit, and deactivate only the current account's user-scoped mail profiles within effective policy.", "Create, edit, and deactivate only the current account's user-scoped mail profiles within effective policy.",
), ),
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."), _permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP/JMAP/POP3 credentials."),
_permission(
"mail:pop3:manage",
"Manage legacy POP3 imports",
"Configure explicitly enabled legacy POP3 sources and their non-destructive or delete-after-import policy.",
),
_permission(
"mail:pop3:import",
"Import legacy POP3 messages",
"Preview and import bounded messages from an explicitly enabled legacy POP3 source.",
),
_permission(
"mail:pop3:delete",
"Delete imported POP3 messages at source",
"Request source deletion after a governed local import when the server policy also allows it.",
),
_permission( _permission(
"mail:delivery:diagnostic", "mail:delivery:diagnostic",
"Inspect mail delivery diagnostics", "Inspect mail delivery diagnostics",
@@ -183,6 +211,20 @@ ROLE_TEMPLATES = (
"mail:delivery:reconcile", "mail:delivery:reconcile",
"mail:bounce:read", "mail:bounce:read",
"mail:bounce:manage", "mail:bounce:manage",
"mail:pop3:manage",
"mail:pop3:import",
"mail:pop3:delete",
),
),
RoleTemplate(
slug="mail_legacy_import_operator",
name="Mail legacy import operator",
description="Preview and import from an approved POP3 legacy source without deleting provider messages.",
permissions=(
"mail:profile:read",
"mail:profile:use",
"mail:profile:test",
"mail:pop3:import",
), ),
), ),
RoleTemplate( RoleTemplate(
@@ -317,10 +359,98 @@ IMAP_PROVIDER = ExternalProviderDeclaration(
) )
JMAP_PROVIDER = ExternalProviderDeclaration(
id=JMAP_PROVIDER_ID,
module_id="mail",
label="JMAP mailbox projection",
maturity="read",
operations=("discover", "search", "read", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="mailbox_folder",
field_groups=("identity", "role", "counts", "revision"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
ProviderObjectDeclaration(
object_type="mailbox_message",
field_groups=("identity", "headers", "body_preview", "flags", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="JMAP Mailbox and Email state values plus the transport revision identify mailbox observations.",
concurrency="Bounded Email/query, Email/get, and Email/changes calls pin one discovered Session and never mutate provider state.",
freshness="Mailbox and message index timestamps state when the external JMAP account was last observed.",
health="Capability discovery and connection diagnostics remain separate from secret credential fields.",
max_read_items=500,
evidence="Mailbox index rows retain bounded provider identifiers and observation times.",
audit_event_types=("mail.mailbox.read",),
correction="A later JMAP refresh replaces the derived projection while source-owned history remains external.",
reconciliation="An expired Email state requires a bounded full refresh instead of guessing missed changes.",
outage="The last derived index remains readable with stale or unknown freshness where policy permits.",
classifications=("confidential", "personal", "special_category"),
purposes=("mailbox access", "mailbox search"),
retention="Derived mailbox indexes follow Mail retention policy.",
secret_handling="JMAP bearer tokens or Basic credentials remain encrypted and are never returned through mailbox or provider-state APIs.",
),
documentation_topic_ids=("mail.workflow.read-mailbox",),
)
POP3_PROVIDER = ExternalProviderDeclaration(
id=POP3_PROVIDER_ID,
module_id="mail",
label="Legacy POP3 governed import",
maturity="publish",
operations=("discover", "read", "preview", "write", "delete"),
objects=(
ProviderObjectDeclaration(
object_type="legacy_mailbox_message",
field_groups=("provider_identity", "safe_headers", "body_preview", "size"),
authority_modes=("external_authoritative", "governance_overlay"),
default_authority_mode="external_authoritative",
),
ProviderObjectDeclaration(
object_type="mail_pop3_import",
field_groups=("source_identity", "content_digest", "encrypted_content", "review_state", "deletion_evidence"),
authority_modes=("governance_overlay",),
default_authority_mode="governance_overlay",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Each import pins the explicit POP3 endpoint and credential transport revision and identifies source messages by UIDL.",
concurrency="The expected transport revision is checked before credentials are decrypted; missing or changed UIDLs require a fresh preview.",
freshness="POP3 previews are live bounded observations; imported records retain their source UIDL and import time.",
health="Disabled, enabled, imported, and failed or unknown source-deletion states are projected without exposing secrets.",
max_read_items=100,
idempotency="Tenant, profile, server, and provider UIDL uniquely identify one governed local import.",
retry="Preview and pre-effect download failures are safe to retry; source deletion is never retried blindly after QUIT begins.",
timeout_seconds=60,
conflicts="A repeated UIDL is reported as an existing import and cannot create duplicate local content.",
outcome_unknown="A connection failure while committing POP3 DELE operations is retained as outcome_unknown for operator reconciliation.",
outcome_unknown_supported=True,
evidence="Encrypted raw message content, bounded safe headers, content digest, UIDL, pinned revision, import audit, and separate deletion outcome are retained.",
audit_event_types=("mail.pop3.imported", "mail.pop3.source_deletion"),
correction="Operators review the local import; later source or classification corrections do not rewrite import evidence.",
rollback="A committed local import is preserved even if optional provider deletion fails or is uncertain.",
compensation="Operators reconcile the source mailbox before any further destructive action when deletion is uncertain.",
reconciliation="Compare the retained UIDL and digest with the provider mailbox and record an operational decision outside blind automation.",
outage="No provider effect occurs while unavailable; already imported encrypted records remain available for governed review.",
classifications=("confidential", "personal", "special_category"),
purposes=("legacy mailbox migration", "governed message intake"),
retention="Imported content and source-deletion evidence follow configured Mail and records-retention policy.",
secret_handling="POP3 credentials and imported raw messages are encrypted; neither is returned by preview, import-list, provider-state, or DSAR APIs.",
),
documentation_topic_ids=("mail.workflow.legacy-pop3-import",),
)
manifest = ModuleManifest( manifest = ModuleManifest(
id="mail", id="mail",
name="Mail", name="Mail",
version="0.1.19", version="0.1.27",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"), optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
provides_interfaces=( provides_interfaces=(
@@ -385,7 +515,10 @@ manifest = ModuleManifest(
factory=create_mail_search_source, factory=create_mail_search_source,
), ),
), ),
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read", "mail:bounce:read", "mail:bounce:manage"), order=50),), nav_items=(
NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read", "mail:bounce:read", "mail:bounce:manage"), order=50),
NavItem(path="/mail/legacy-import", label="Legacy POP3 import", icon="mail", required_any=("mail:pop3:import", "mail:pop3:manage"), order=52),
),
frontend=FrontendModule( frontend=FrontendModule(
module_id="mail", module_id="mail",
package_name="@govoplan/mail-webui", package_name="@govoplan/mail-webui",
@@ -403,8 +536,18 @@ manifest = ModuleManifest(
order=51, order=51,
surface_id="mail.bounce-processing", surface_id="mail.bounce-processing",
), ),
FrontendRoute(
path="/mail/legacy-import",
component="MailLegacyImportPage",
required_any=("mail:pop3:import", "mail:pop3:manage"),
order=52,
surface_id="mail.legacy-pop3-import",
),
),
nav_items=(
NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read", "mail:bounce:read", "mail:bounce:manage"), order=50),
NavItem(path="/mail/legacy-import", label="Legacy POP3 import", icon="mail", required_any=("mail:pop3:import", "mail:pop3:manage"), order=52),
), ),
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read", "mail:bounce:read", "mail:bounce:manage"), order=50),),
view_surfaces=( view_surfaces=(
ViewSurface(id="mail.admin.system-servers", module_id="mail", kind="section", label="System mail servers", order=70), ViewSurface(id="mail.admin.system-servers", module_id="mail", kind="section", label="System mail servers", order=70),
ViewSurface(id="mail.admin.tenant-servers", module_id="mail", kind="section", label="Tenant mail servers", order=60), ViewSurface(id="mail.admin.tenant-servers", module_id="mail", kind="section", label="Tenant mail servers", order=60),
@@ -424,6 +567,39 @@ manifest = ModuleManifest(
order=40, order=40,
), ),
), ),
product_surfaces=(
ProductSurfaceContribution(
id="communication.messages",
module_id="mail",
label="i18n:govoplan-core.product_surface.messages",
description="i18n:govoplan-core.product_surface.messages_description",
icon="mail",
entry_path="/messages",
route_path="/mail",
surface_ids=("mail.nav.mail", "mail.route.mail"),
presentations=("task", "reader"),
search_source_ids=("mail.mailbox_messages",),
help_context_ids=("mail.quick_access.messages",),
documentation_topic_ids=("mail.quick-access-and-product-area",),
required_any=("mail:mailbox:read",),
aliases=("/inbox",),
order=10,
unavailable=ProductAvailabilityExplanation(
reason="authorization",
title="i18n:govoplan-core.product_surface.messages_unavailable",
description="i18n:govoplan-core.product_surface.messages_unavailable_description",
resolution="i18n:govoplan-core.product_surface.messages_unavailable_resolution",
responsible_role="i18n:govoplan-core.access_administrator",
),
degraded=ProductAvailabilityExplanation(
reason="provider_degraded",
title="i18n:govoplan-core.product_surface.messages_degraded",
description="i18n:govoplan-core.product_surface.messages_degraded_description",
resolution="i18n:govoplan-core.product_surface.messages_degraded_resolution",
responsible_role="i18n:govoplan-core.integration_operator",
),
),
),
quick_access_tools=( quick_access_tools=(
QuickAccessTool( QuickAccessTool(
id="mail.messages", id="mail.messages",
@@ -458,6 +634,7 @@ manifest = ModuleManifest(
mail_models.MailProfilePolicy, mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex, mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex, mail_models.MailMailboxMessageIndex,
mail_models.MailPop3Import,
mail_models.MailDeliveryReconciliation, mail_models.MailDeliveryReconciliation,
mail_models.MailDeliveryAttempt, mail_models.MailDeliveryAttempt,
mail_models.MailDeliveryCommand, mail_models.MailDeliveryCommand,
@@ -468,6 +645,7 @@ manifest = ModuleManifest(
), ),
capability_factories={ capability_factories={
MAIL_CONFIGURATION_CAPABILITY: _configuration_provider, MAIL_CONFIGURATION_CAPABILITY: _configuration_provider,
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY: _configuration_provider,
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__( CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
"govoplan_mail.backend.capabilities", "govoplan_mail.backend.capabilities",
@@ -490,20 +668,132 @@ manifest = ModuleManifest(
capability_documentation={ capability_documentation={
MAIL_DSAR_CAPABILITY: CapabilityDocumentation( MAIL_DSAR_CAPABILITY: CapabilityDocumentation(
label="Mail data-subject request provider", label="Mail data-subject request provider",
summary="Finds isolated mailbox-index, personal-profile, delivery, reconciliation, and bounce metadata without exposing transport secrets.", summary="Finds isolated mailbox-index, imported-message, personal-profile, delivery, reconciliation, and bounce metadata without exposing transport secrets or encrypted raw content.",
contract_version="0.1.0", contract_version="0.1.0",
documentation_types=("admin",), documentation_types=("admin",),
audience=("privacy_officer", "mail_admin", "records_manager"), audience=("privacy_officer", "mail_admin", "records_manager"),
), ),
}, },
documentation=( documentation=(
DocumentationTopic(
id="mail.workflow.legacy-pop3-import",
title="Import messages from a legacy POP3 mailbox",
summary="Preview and import bounded POP3 messages into encrypted local review records, without deleting source messages by default.",
body=(
"POP3 is a low-priority migration path and is never enabled implicitly. A Mail administrator creates a dedicated POP3 endpoint, enables legacy import, stores an encrypted credential, tests TLS and authentication, and separately decides whether source deletion may ever be requested. An import operator refreshes the live bounded preview, selects messages by stable UIDL, and imports them into encrypted pending-review records. Repeating an import reports duplicates instead of creating more copies. Source messages remain untouched unless the endpoint policy allows delete-after-import, the operator also has mail:pop3:delete, and deletion is explicitly selected for that batch. Mail commits and audits the local import before attempting deletion; failed or uncertain deletion therefore never removes the governed local record and must be reconciled before another destructive attempt. Prefer IMAP or JMAP for ongoing mailbox access."
),
layer="available",
documentation_types=("user", "admin"),
audience=("mail_admin", "mail_import_operator", "records_manager", "security_reviewer"),
order=34,
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:pop3:import", "mail:pop3:manage"),
),
),
links=(
DocumentationLink(label="Legacy POP3 import", href="/mail/legacy-import", kind="runtime"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Protocol roadmap", href="govoplan-mail/docs/MAIL_PROTOCOL_ROADMAP.md", kind="repository"),
),
related_modules=("audit", "records"),
unlocks=("Governed retirement of legacy POP3 mailboxes without making destructive retrieval the default.",),
translations={
"de": {
"title": "Nachrichten aus einem alten POP3-Postfach importieren",
"summary": "Begrenzt viele POP3-Nachrichten in verschlüsselte lokale Prüfdatensätze übernehmen, ohne die Quelldaten standardmäßig zu löschen.",
"body": (
"POP3 ist ausschließlich ein nachrangiger Migrationsweg und wird niemals stillschweigend aktiviert. "
"Eine Mail-Administration legt eine eigene POP3-Quelle an, aktiviert den Altimport ausdrücklich, "
"hinterlegt Zugangsdaten verschlüsselt, prüft TLS und Anmeldung und entscheidet getrennt, ob das Löschen "
"an der Quelle überhaupt angefordert werden darf. Importberechtigte laden eine begrenzte Live-Vorschau, "
"wählen Nachrichten anhand stabiler UIDL-Kennungen und übernehmen sie als verschlüsselte Datensätze mit "
"ausstehender Prüfung. Wiederholte Importe werden als Duplikate ausgewiesen und nicht erneut gespeichert. "
"Quelldaten bleiben unverändert, solange nicht die Endpunktrichtlinie Löschen nach Import erlaubt, die "
"ausführende Person zusätzlich mail:pop3:delete besitzt und das Löschen für den konkreten Stapel ausdrücklich "
"ausgewählt wird. Mail schreibt und protokolliert zuerst den lokalen Import und versucht erst danach die "
"Löschung beim Anbieter. Eine fehlgeschlagene oder unklare Löschung entfernt deshalb niemals den verwalteten "
"lokalen Datensatz und muss vor einem weiteren destruktiven Versuch abgestimmt werden. Für den laufenden "
"Postfachzugriff sind IMAP oder JMAP vorzuziehen."
),
}
},
metadata={
"kind": "workflow",
"route": "/mail/legacy-import",
"screen": "Legacy POP3 import",
"help_contexts": [
"mail.pop3",
"mail.pop3.source-editor",
"mail.pop3.action.reload",
"mail.pop3.action.create-source",
"mail.pop3.action.configure-source",
"mail.pop3.action.save-source",
"mail.pop3.action.test",
"mail.pop3.action.preview",
"mail.pop3.action.import",
"mail.pop3.field.profile",
"mail.pop3.field.name",
"mail.pop3.field.host",
"mail.pop3.field.port",
"mail.pop3.field.transport-security",
"mail.pop3.field.timeout",
"mail.pop3.field.max-message-size",
"mail.pop3.field.max-batch-size",
"mail.pop3.field.preview-body-lines",
"mail.pop3.field.username",
"mail.pop3.field.password",
"mail.pop3.field.enabled",
"mail.pop3.field.allow-delete-after-import",
"mail.pop3.field.message-selection",
"mail.pop3.field.delete-after-import",
"mail.pop3.confirm-delete-source",
],
"fields": [
{
"label": "Transport security",
"user_description": "Use TLS or STARTTLS; unencrypted transport remains subject to deployment egress policy and is not the safe default.",
},
{
"label": "Message and batch limits",
"user_description": "Bound both each downloaded message and the total imported batch before provider content enters local encrypted storage.",
},
{
"label": "Explicitly enable legacy import",
"user_description": "Keeps the source inert until endpoint metadata and its encrypted credential have both been stored.",
},
{
"label": "Permit delete-after-import requests",
"user_description": "Allows, but never selects, the separately authorized destructive request; each batch still needs explicit confirmation.",
},
],
"steps": [
"Ask a Mail administrator to configure and explicitly enable a dedicated POP3 legacy source.",
"Test the source and refresh a bounded preview; no message flags or deletion state are changed.",
"Select messages and import them into encrypted pending-review records.",
"Use delete-after-import only where policy and a separate destructive permission allow it, then reconcile failed or unknown outcomes.",
],
"limitations": [
"A POP3 server must provide stable UIDL identifiers; otherwise safe duplicate prevention is unavailable and import is refused.",
"POP3 has no folder or flag semantics and is not the recommended protocol for ongoing mailbox access.",
"Source deletion cannot be rolled back and may have an unknown outcome if the connection fails while QUIT commits deletions.",
],
"operational_consequences": [
"Changing host, port, security, account identity, or import limits rotates the transport revision and invalidates stale previews.",
"Enabling source deletion creates an irreversible external effect boundary; unknown QUIT outcomes require reconciliation rather than blind retry.",
"Disabling legacy import leaves governed local imports and their evidence available for review while preventing new provider access.",
],
"verification": "Prove disabled policy, TLS and authentication diagnostics, non-destructive preview/import, UIDL duplicate prevention, encrypted raw retention, separate delete authorization, import-before-delete audit ordering, and failed or outcome-unknown deletion evidence.",
},
),
DocumentationTopic( DocumentationTopic(
id="mail.privacy.data-subject-requests", id="mail.privacy.data-subject-requests",
title="Review Mail data in a data-subject request", title="Review Mail data in a data-subject request",
summary="Collect tenant-scoped Mail metadata while preserving transport evidence and external-mailbox authority.", summary="Collect tenant-scoped Mail metadata while preserving transport evidence and external-mailbox authority.",
body=( body=(
"Mail's DSAR provider searches the effective tenant by normalized header or bounce-recipient email, direct membership references, and namespaced Mail profile, message-index, delivery-command, or bounce references. It isolates only matching From, To, and Cc parties and returns bounded message-index content, safe personal-profile metadata, and delivery, attempt, reconciliation, and bounce outcomes. It excludes SMTP/IMAP configuration, usernames and credentials, encrypted messages and envelopes, refusal detail, mailbox folders and UIDs, endpoint and credential identifiers, idempotency and worker claims, diagnostics, error text, and opaque evidence. " "Mail's DSAR provider searches the effective tenant by normalized header or bounce-recipient email, direct membership references, and namespaced Mail profile, message-index, POP3-import, delivery-command, or bounce references. It isolates only matching From, To, and Cc parties and returns bounded message-index or imported-message content, safe personal-profile metadata, and delivery, attempt, reconciliation, and bounce outcomes. It excludes SMTP/IMAP/JMAP/POP3 configuration, usernames and credentials, encrypted messages and envelopes, POP3 source UIDLs and revisions, refusal detail, mailbox folders and UIDs, endpoint and credential identifiers, idempotency and worker claims, diagnostics, error text, and opaque evidence. "
"Delivery attempts, reconciliation decisions, and bounce observations remain retained as immutable transport and recovery evidence. Mailbox indexes and personal profiles require manual review through Mail and the authoritative external mailbox. The provider performs no direct erasure because deleting a derived index alone would not delete its external source, while changing a profile or delivery command can affect credentials, other users, and preserved evidence." "Delivery attempts, reconciliation decisions, and bounce observations remain retained as immutable transport and recovery evidence. Mailbox indexes, encrypted POP3 imports, and personal profiles require manual review through Mail and the authoritative external mailbox. The provider performs no direct erasure because deleting a derived or imported record alone would not establish the state of its external source, while changing a profile or delivery command can affect credentials, other users, and preserved evidence."
), ),
layer="configured", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
@@ -520,12 +810,33 @@ manifest = ModuleManifest(
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"), DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
), ),
related_modules=("access", "audit", "campaigns", "postbox"), related_modules=("access", "audit", "campaigns", "postbox"),
translations={
"de": {
"title": "Mail-Daten in einer Auskunftsanfrage prüfen",
"summary": "Mandantenbezogene Mail-Metadaten zusammentragen und dabei Transportnachweise sowie die Zuständigkeit des externen Postfachs bewahren.",
"body": (
"Der Mail-Provider für Betroffenenanfragen durchsucht den wirksamen Mandanten anhand normalisierter "
"Absender-, Empfänger- oder Unzustellbarkeitsadressen, direkter Mitgliedschaftsverweise und namensraumgebundener "
"Verweise auf Mail-Profile, Nachrichtenindizes, POP3-Importe, Zustellbefehle oder Rückläufer. Er grenzt nur "
"passende Absender-, An- und Cc-Beteiligte ab und liefert begrenzte Inhalte aus Nachrichtenindex oder Import, "
"sichere Metadaten persönlicher Profile sowie Zustell-, Versuch-, Abstimmungs- und Rückläufergebnisse. "
"Ausgeschlossen bleiben SMTP-, IMAP-, JMAP- und POP3-Konfiguration, Benutzernamen und Zugangsdaten, verschlüsselte "
"Nachrichten und Umschläge, POP3-UIDLs und Quellrevisionen, Ablehnungsdetails, Postfachordner und UIDs, Endpunkt- "
"und Zugangsdatenkennungen, Idempotenz- und Worker-Ansprüche, Diagnosen, Fehlertexte und undurchsichtige Nachweise. "
"Zustellversuche, Abstimmungsentscheidungen und Rückläuferbeobachtungen bleiben als unveränderliche Transport- "
"und Wiederherstellungsnachweise erhalten. Nachrichtenindizes, verschlüsselte POP3-Importe und persönliche Profile "
"müssen in Mail und im maßgeblichen externen Postfach manuell geprüft werden. Der Provider löscht nicht unmittelbar, "
"weil das Entfernen eines abgeleiteten oder importierten Datensatzes den Zustand seiner externen Quelle nicht belegt "
"und Änderungen an Profilen oder Zustellbefehlen Zugangsdaten, andere Personen und aufbewahrte Nachweise betreffen können."
),
}
},
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/admin?section=tenant-data-subject-requests", "route": "/admin?section=tenant-data-subject-requests",
"help_contexts": ["admin.privacy.data-subject-requests"], "help_contexts": ["admin.privacy.data-subject-requests"],
"steps": [ "steps": [
"Run the Mail provider search and review mailbox-index, profile, delivery, reconciliation, and bounce dispositions.", "Run the Mail provider search and review mailbox-index, POP3-import, profile, delivery, reconciliation, and bounce dispositions.",
"Retain immutable transport evidence with its reason.", "Retain immutable transport evidence with its reason.",
"Coordinate approved mailbox content deletion with the authoritative external mailbox and then refresh the derived index.", "Coordinate approved mailbox content deletion with the authoritative external mailbox and then refresh the derived index.",
"Use Mail profile lifecycle controls for approved personal-profile changes; do not edit encrypted payload or evidence rows directly.", "Use Mail profile lifecycle controls for approved personal-profile changes; do not edit encrypted payload or evidence rows directly.",
@@ -571,7 +882,9 @@ manifest = ModuleManifest(
"The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. " "The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. "
"Missing non-secret transport fields are collected by preflight; authentication is represented only by an existing credential-envelope id. " "Missing non-secret transport fields are collected by preflight; authentication is represented only by an existing credential-envelope id. "
"Tenant scope is the default, system scope requires system authority, conflicting profiles are preserved unless update is explicitly reviewed, " "Tenant scope is the default, system scope requires system authority, conflicting profiles are preserved unless update is explicitly reviewed, "
"and an unchanged second apply is a no-op. SMTP reachability remains a separate Mail profile test." "and an unchanged second apply is a no-op. Before the host deployer changes or removes mail.smtp, Mail inventories every persisted SMTP endpoint, "
"legacy SMTP profile, and credential-binding count through the non-secret Ops dependency report. Missing, stale, or incomplete inventory blocks apply; "
"SMTP reachability remains a separate Mail profile test."
), ),
layer="configured", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
@@ -603,7 +916,9 @@ manifest = ModuleManifest(
"Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. " "Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. "
"Fehlende nicht geheime Transportangaben werden im Preflight abgefragt; Authentifizierung wird ausschließlich durch die ID eines vorhandenen Zugangsdaten-Umschlags referenziert. " "Fehlende nicht geheime Transportangaben werden im Preflight abgefragt; Authentifizierung wird ausschließlich durch die ID eines vorhandenen Zugangsdaten-Umschlags referenziert. "
"Mandantenbezug ist der Standard, Systembezug erfordert Systemberechtigung, abweichende vorhandene Profile bleiben ohne ausdrücklich geprüfte Aktualisierung unverändert, " "Mandantenbezug ist der Standard, Systembezug erfordert Systemberechtigung, abweichende vorhandene Profile bleiben ohne ausdrücklich geprüfte Aktualisierung unverändert, "
"und eine unveränderte zweite Anwendung bleibt wirkungslos. Die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet." "und eine unveränderte zweite Anwendung bleibt wirkungslos. Bevor der Host-Deployer mail.smtp ändert oder entfernt, inventarisiert Mail alle gespeicherten SMTP-Endpunkte, "
"älteren SMTP-Profile und die Anzahl ihrer Zugangsdatenbindungen im nicht geheimen Ops-Abhängigkeitsbericht. Ein fehlendes, veraltetes oder unvollständiges Inventar blockiert die Anwendung; "
"die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet."
), ),
} }
}, },
@@ -621,6 +936,9 @@ manifest = ModuleManifest(
body=( body=(
"Mail contributes its full mailbox to Communication. When Quick Access is enabled, its compact provider surface " "Mail contributes its full mailbox to Communication. When Quick Access is enabled, its compact provider surface "
"appears inside the shared Messages drawer alongside independent Postbox and future chat contributions. " "appears inside the shared Messages drawer alongside independent Postbox and future chat contributions. "
"Mail also contributes to the stable Messages product entry: `/messages` and its migration alias `/inbox` open "
"Mail only when its mailbox route is currently authorized and visible in the effective View; otherwise another "
"available Messages owner is selected or the shell explains how access can be restored. "
"Recent entries open the exact authorized profile, folder, and message; Drafts resolves the configured or " "Recent entries open the exact authorized profile, folder, and message; Drafts resolves the configured or "
"provider-detected Drafts folder, and the full Mail page keeps the versioned Quick Access return context. " "provider-detected Drafts folder, and the full Mail page keeps the versioned Quick Access return context. "
"When an active Case requests a selection, choosing a recent message returns only its tenant-bound profile, " "When an active Case requests a selection, choosing a recent message returns only its tenant-bound profile, "
@@ -642,6 +960,9 @@ manifest = ModuleManifest(
"body": ( "body": (
"Mail ordnet das vollständige Postfach Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die kompakte " "Mail ordnet das vollständige Postfach Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die kompakte "
"Mail-Oberfläche gemeinsam mit unabhängigen Beiträgen aus Postbox und künftig Chat unter Nachrichten. " "Mail-Oberfläche gemeinsam mit unabhängigen Beiträgen aus Postbox und künftig Chat unter Nachrichten. "
"Mail trägt außerdem zum stabilen Produkteinstieg Nachrichten bei: `/messages` und der Migrationsalias `/inbox` "
"öffnen Mail nur, wenn die Postfachroute aktuell berechtigt und in der wirksamen View sichtbar ist. Andernfalls "
"wählt die Shell einen anderen verfügbaren Nachrichten-Eigentümer oder erklärt die Wiederherstellung des Zugriffs. "
"Aktuelle Einträge öffnen das genaue berechtigte Profil, den Ordner und die Nachricht; Entwürfe verwendet " "Aktuelle Einträge öffnen das genaue berechtigte Profil, den Ordner und die Nachricht; Entwürfe verwendet "
"den konfigurierten oder vom Anbieter erkannten Entwurfsordner. Die vollständige Mail-Seite erhält den " "den konfigurierten oder vom Anbieter erkannten Entwurfsordner. Die vollständige Mail-Seite erhält den "
"versionierten Rücksprungkontext. Verfassen öffnet bewusst die konfigurierte Mail-Anwendung des Benutzers, " "versionierten Rücksprungkontext. Verfassen öffnet bewusst die konfigurierte Mail-Anwendung des Benutzers, "
@@ -719,13 +1040,26 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("mail_user", "mail_admin", "administrator"), audience=("mail_user", "mail_admin", "administrator"),
related_modules=("search",), related_modules=("search",),
translations={
"de": {
"title": "Berechtigte Postfachnachrichten durchsuchen",
"summary": "Begrenzte Metadaten und Vorschauen aus dem zwischengespeicherten Postfach für die berechtigungsbewusste Plattformsuche bereitstellen.",
"body": (
"Ist Search installiert, stellt Mail seinen nur lesbaren Postfachzwischenspeicher bereit, niemals Zugangsdaten "
"oder unbegrenzte Rohnachrichten. Ergebnisse erfordern sowohl Leseberechtigung für das Postfach als auch "
"Nutzungsberechtigung für das Profil. Vor jeder Ausgabe werden aktueller Profilbereich, Richtlinie, Aktivität "
"sowie Sichtbarkeit für Mandant, Person, Gruppe oder Campaign erneut geprüft. Postfachaktualisierungen veröffentlichen "
"dauerhafte Indexänderungen; für die Abstimmung bleibt ein vollständiger Neuaufbau verfügbar."
),
}
},
order=39, order=39,
), ),
DocumentationTopic( DocumentationTopic(
id="mail.profiles-and-policy", id="mail.profiles-and-policy",
title="Mail profiles and policy hierarchy", title="Mail profiles and policy hierarchy",
summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.", summary="Mail sending and mailbox access use reusable SMTP/IMAP/JMAP profiles governed by an effective system, tenant, owner, and campaign policy.",
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.", body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. SMTP, IMAP, and JMAP host allow/deny rules are inherited independently. JMAP Session discovery fails closed when its advertised API origin differs unless an administrator explicitly allows that origin. Runtime documentation adds the current tenant posture when the actor may read mail profile policy. In the Mail profile policy editor, Credential selection separately controls SMTP and IMAP: allow a profile default or require an explicit authorized Mail-owned server and credential reference in Campaign Mail settings. Both choices keep secrets in Mail. Inherit policy leaves the local choice unset and follows the parent; it is not the same as allowing default credentials. System values are concrete, while lower scopes show local, effective, and source-path values. Allow override sets the matching allow_lower_level_limits protocol key; an ancestor lock cannot be changed or re-enabled below that scope. Campaign policy has no lower-level override controls. Scope-write permission and an unlocked workflow remain required. A failed write preserves the draft for explicit retry; a successful write followed by a dependent refresh failure remains saved and asks only to reload the display.",
layer="configured", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"), audience=("tenant_admin", "mail_admin", "campaign_admin"),
@@ -747,6 +1081,29 @@ manifest = ModuleManifest(
related_modules=("campaigns",), related_modules=("campaigns",),
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",), unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
configuration_keys=("mail_profile_policy",), configuration_keys=("mail_profile_policy",),
translations={
"de": {
"title": "Mail-Profile und Richtlinienhierarchie",
"summary": "Mail-Versand und Postfachzugriff über wiederverwendbare SMTP-/IMAP-/JMAP-Profile und eine wirksame Richtlinie aus System-, Mandanten-, Eigentümer- und gegebenenfalls Campaign-Ebene steuern.",
"body": (
"Die wirksame Richtlinie entscheidet, ob Personen ausschließlich freigegebene Profile auswählen dürfen "
"oder ob auf Personen-, Gruppen- und Campaign-Ebene zusätzliche wiederverwendbare Profile angelegt werden können. "
"Host-Freigaben und -Sperren für SMTP, IMAP und JMAP werden unabhängig vererbt. Ein von der JMAP-Session "
"angekündigter API-Ursprung muss bei abweichendem Ursprung ausdrücklich freigegeben sein. "
"Die Laufzeitdokumentation ergänzt die aktuelle Lage des Mandanten, wenn die handelnde Person die Mail-Profilrichtlinie lesen darf. "
"Im Mail-Profilrichtlinieneditor steuert Auswahl der Zugangsdaten SMTP und IMAP getrennt: Standard-Zugangsdaten "
"des Profils zulassen oder ausdrückliche berechtigte Mail-Server- und Zugangsdatenverweise in den Mail-Einstellungen "
"der Kampagne verlangen. Geheimnisse bleiben bei beiden Optionen in Mail. Richtlinie erben lässt den lokalen Wert "
"offen und übernimmt die übergeordnete Entscheidung; dies ist nicht dasselbe wie Standard-Zugangsdaten zu erlauben. "
"Systemwerte sind konkret; darunter werden lokale und wirksame Werte samt Richtlinienpfad angezeigt. Überschreiben "
"zulassen setzt den passenden Protokollschlüssel in allow_lower_level_limits. Eine übergeordnete Sperre kann darunter "
"weder geändert noch aufgehoben werden. Kampagnenrichtlinien haben keine Freigabe für weitere untere Bereiche. "
"Schreibberechtigung für den jeweiligen Bereich und ein entsperrter Arbeitsablauf bleiben erforderlich. Ein fehlgeschlagener "
"Schreibvorgang erhält den Entwurf für einen ausdrücklichen Wiederholungsversuch. Scheitert nach erfolgreichem Speichern "
"nur die Aktualisierung abhängiger Daten, bleibt die Richtlinie gespeichert; lediglich die Anzeige muss neu geladen werden."
),
}
},
metadata={ metadata={
"kind": "reference", "kind": "reference",
"route": "/settings?section=mail-profiles", "route": "/settings?section=mail-profiles",
@@ -765,6 +1122,7 @@ manifest = ModuleManifest(
summary="Store Inbox, Sent, Drafts, Trash, Archive, and Junk mappings on the reusable Mail profile and populate them from bounded IMAP discovery.", summary="Store Inbox, Sent, Drafts, Trash, Archive, and Junk mappings on the reusable Mail profile and populate them from bounded IMAP discovery.",
body=( body=(
"Folder names are profile/server metadata, not Campaign settings. An authorized profile administrator may enter names directly or use folder discovery; empty mappings retain automatic behavior. Existing imap.sent_folder values are presented and persisted as the Sent mapping, while the compatibility field remains synchronized for consumers that still read it. A Campaign-specific Sent-folder override remains authoritative for that Campaign and is not rewritten by profile discovery. Folder discovery lists provider-visible names without creating, renaming, moving, or deleting remote folders. " "Folder names are profile/server metadata, not Campaign settings. An authorized profile administrator may enter names directly or use folder discovery; empty mappings retain automatic behavior. Existing imap.sent_folder values are presented and persisted as the Sent mapping, while the compatibility field remains synchronized for consumers that still read it. A Campaign-specific Sent-folder override remains authoritative for that Campaign and is not rewritten by profile discovery. Folder discovery lists provider-visible names without creating, renaming, moving, or deleting remote folders. "
"Enter readable Unicode names such as Entwürfe, not modified UTF-7 wire names such as Entw&APw-rfe. Discovery decodes names before detecting folder roles; mailbox reads, status checks, and Sent APPEND encode and quote them for the active connection. Previously saved wire names are resolved against the same account's live folder list, with an exact readable-name match taking precedence over a legacy alias. Rediscover and save to replace old encoded configuration values explicitly; reads do not rewrite profiles. Invalid provider encodings fail explicitly instead of silently addressing a replacement folder."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -782,6 +1140,27 @@ manifest = ModuleManifest(
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"), DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
), ),
related_modules=("campaigns",), related_modules=("campaigns",),
translations={
"de": {
"title": "Standardordner eines IMAP-Profils zuordnen",
"summary": "Zuordnungen für Posteingang, Gesendet, Entwürfe, Papierkorb, Archiv und Junk am wiederverwendbaren Mail-Profil speichern und durch begrenzte IMAP-Erkennung vorbelegen.",
"body": (
"Ordnernamen sind Metadaten des Profils beziehungsweise Servers und keine Campaign-Einstellungen. Eine berechtigte "
"Profiladministration kann Namen direkt eintragen oder die Ordnererkennung verwenden; leere Zuordnungen behalten "
"das automatische Verhalten bei. Vorhandene Werte aus imap.sent_folder werden als Zuordnung für Gesendet angezeigt "
"und gespeichert, während das Kompatibilitätsfeld für ältere Verbraucher synchron bleibt. Eine Campaign-spezifische "
"Abweichung für den Gesendet-Ordner bleibt für diese Campaign maßgeblich und wird von der Profilerkennung nicht verändert. "
"Die Erkennung listet nur die beim Anbieter sichtbaren Namen auf und legt keine externen Ordner an, benennt sie nicht um, "
"verschiebt sie nicht und löscht sie nicht. Lesbare Unicode-Namen wie Entwürfe eingeben, nicht die "
"Modified-UTF-7-Übertragungsform Entw&APw-rfe. Die Erkennung dekodiert Namen vor der Rollenzuordnung; "
"Postfachzugriffe, Statusabfragen und Gesendet-APPEND kodieren und maskieren sie für die aktive Verbindung. "
"Früher gespeicherte Übertragungsformen werden anhand der aktuellen Ordnerliste desselben Kontos aufgelöst; "
"ein exakt passender lesbarer Name hat Vorrang vor einem alten Alias. Erneute Erkennung und ausdrückliches "
"Speichern ersetzen alte kodierte Konfigurationswerte; Lesezugriffe schreiben Profile nicht um. Fehlerhafte "
"Anbieterkodierungen führen zu einer klaren Fehlermeldung statt unbemerkt einen Ersatzordner anzusprechen."
),
}
},
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/settings?section=mail-profiles", "route": "/settings?section=mail-profiles",
@@ -822,6 +1201,20 @@ manifest = ModuleManifest(
), ),
), ),
related_modules=("campaigns", "calendar", "audit"), related_modules=("campaigns", "calendar", "audit"),
translations={
"de": {
"title": "Zustellstatus und Rückläufer verarbeiten",
"summary": "Einen berechtigten IMAP-Ordner beobachten und DSN-Ergebnisse mit dauerhaften Mail-Befehlen abgleichen.",
"body": (
"Mail speichert die ausgehende RFC-Message-ID zusammen mit dem dauerhaften Befehl, verarbeitet begrenzte "
"message/delivery-status-Berichte ohne Postfachkennzeichen zu ändern und zeichnet je Empfänger idempotente "
"Beobachtungen auf. Die SMTP-Annahme bleibt von einem späteren Rückläufer getrennt. Nicht zuordenbare Berichte "
"bleiben zur Prüfung sichtbar; Mail speichert nur begrenzte Diagnosen und einen Digest des Rohinhalts, nicht den "
"Rohtext des Rückläufers. Ist Calendar aktiv, leitet derselbe begrenzte Quellscan iCalendar-Teile mit METHOD:REPLY "
"zur idempotenten Abstimmung der Teilnehmenden an Calendar weiter, ohne die Nachricht als Rückläufer einzuordnen."
),
}
},
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/mail/bounces", "route": "/mail/bounces",
@@ -837,7 +1230,7 @@ manifest = ModuleManifest(
id="mail.profile-ownership-and-consumers", id="mail.profile-ownership-and-consumers",
title="Mail owns transport profiles and credentials", title="Mail owns transport profiles and credentials",
summary="Other modules select authorized Mail profiles by stable identifier; they do not copy SMTP/IMAP settings or secrets.", summary="Other modules select authorized Mail profiles by stable identifier; they do not copy SMTP/IMAP settings or secrets.",
body="Mail encrypts credentials, tests connections, evaluates profile scope and policy, and performs revision-gated transport effects without returning resolved configuration. Random persisted revisions rotate when normalized transport or account identity changes, while password-only rotation remains transparent to built business intent. Campaign stores only server.mail_profile_id plus sanitized delivery evidence. Campaign-local transport fields are rejected, and legacy campaign records fail closed until an explicit profile migration preserves the source audit record and updates an editable version.", body="Mail encrypts credentials, tests connections, evaluates profile scope and policy, and performs revision-gated transport effects without returning resolved configuration. Random persisted revisions rotate when normalized transport or account identity changes, while password-only rotation remains transparent to built business intent. Campaign stores only server.mail_profile_id plus sanitized delivery evidence. Campaign-local transport fields are rejected, and legacy campaign records fail closed until an explicit profile migration preserves the source audit record and updates an editable version. The shared credential editor resolves server names from authorized Mail metadata on opening, without a page refresh or reading secrets. Typing does not reload this catalogue; reopening refreshes it and retries temporary metadata failures. Inactive servers retain their status, and deleted or unauthorized selected references stay visible as unavailable rather than being removed. A displayed label never grants server-use permission.",
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("mail_user", "mail_admin", "campaign_manager", "campaign_sender"), audience=("mail_user", "mail_admin", "campaign_manager", "campaign_sender"),
@@ -860,6 +1253,26 @@ manifest = ModuleManifest(
), ),
related_modules=("campaigns", "access"), related_modules=("campaigns", "access"),
unlocks=("Reusable, governed delivery identities without cross-module secret duplication.",), unlocks=("Reusable, governed delivery identities without cross-module secret duplication.",),
translations={
"de": {
"title": "Mail besitzt Transportprofile und Zugangsdaten",
"summary": "Andere Module wählen berechtigte Mail-Profile über stabile Kennungen aus; sie kopieren weder SMTP-/IMAP-Einstellungen noch Geheimnisse.",
"body": (
"Mail verschlüsselt Zugangsdaten, prüft Verbindungen, bewertet Profilbereich und Richtlinie und führt "
"revisionsgebundene Transportwirkungen aus, ohne die aufgelöste Konfiguration zurückzugeben. Zufällige gespeicherte "
"Revisionen wechseln, wenn sich normalisierte Transportdaten oder die Kontoidentität ändern; eine reine "
"Passwortrotation bleibt für bereits aufgebauten fachlichen Willen transparent. Campaign speichert nur "
"server.mail_profile_id und bereinigte Zustellnachweise. Campaign-lokale Transportfelder werden abgelehnt; "
"ältere Campaign-Datensätze bleiben gesperrt, bis eine ausdrückliche Profilmigration den ursprünglichen "
"Auditdatensatz bewahrt und eine bearbeitbare Version aktualisiert. Der gemeinsame Zugangsdateneditor "
"löst Servernamen beim Öffnen aus berechtigten Mail-Metadaten auf, ohne die Seite neu zu laden oder "
"Geheimnisse auszulesen. Beim Tippen wird dieser Katalog nicht erneut geladen; erneutes Öffnen aktualisiert "
"ihn und wiederholt vorübergehend fehlgeschlagene Metadatenabfragen. Inaktive Server behalten ihre "
"Kennzeichnung. Gelöschte oder nicht berechtigte ausgewählte Verweise bleiben als nicht verfügbar sichtbar "
"und werden nicht entfernt. Eine angezeigte Bezeichnung erteilt niemals die Berechtigung zur Servernutzung."
),
}
},
metadata={ metadata={
"kind": "reference", "kind": "reference",
"route": "/settings?section=mail-profiles", "route": "/settings?section=mail-profiles",
@@ -876,7 +1289,7 @@ manifest = ModuleManifest(
DocumentationTopic( DocumentationTopic(
id="mail.workflow.choose-and-test-profile", id="mail.workflow.choose-and-test-profile",
title="Choose and test a reusable Mail profile", title="Choose and test a reusable Mail profile",
summary="Select a profile visible in the current context, test its SMTP or IMAP connection, and let the consuming task store only its stable reference.", summary="Select a profile visible in the current context, test its SMTP, IMAP, or JMAP connection, and let the consuming task store only its stable reference.",
body="Profile tests verify current connection and authentication using Mail-owned credentials. They do not prove later Campaign policy authorization, deliverability, recipient acceptance, or future availability. Use the picker rather than entering identifiers or copying transport settings into another module.", body="Profile tests verify current connection and authentication using Mail-owned credentials. They do not prove later Campaign policy authorization, deliverability, recipient acceptance, or future availability. Use the picker rather than entering identifiers or copying transport settings into another module.",
layer="configured", layer="configured",
documentation_types=("user",), documentation_types=("user",),
@@ -893,7 +1306,18 @@ manifest = ModuleManifest(
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"), DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
), ),
related_modules=("campaigns",), related_modules=("campaigns",),
unlocks=("Reusable SMTP/IMAP identities that consumers can reference without handling credentials.",), unlocks=("Reusable SMTP/IMAP/JMAP identities that consumers can reference without handling credentials.",),
translations={
"de": {
"title": "Ein wiederverwendbares Mail-Profil auswählen und prüfen",
"summary": "Ein im aktuellen Kontext sichtbares Profil auswählen, seine SMTP-, IMAP- oder JMAP-Verbindung prüfen und im verbrauchenden Vorgang nur den stabilen Verweis speichern.",
"body": (
"Profiltests prüfen die aktuelle Verbindung und Anmeldung mit den von Mail verwalteten Zugangsdaten. Sie belegen "
"weder eine spätere Richtlinienfreigabe in Campaign noch Zustellbarkeit, Empfängerannahme oder künftige Verfügbarkeit. "
"Verwenden Sie die Auswahlkomponente, statt Kennungen einzugeben oder Transporteinstellungen in ein anderes Modul zu kopieren."
),
}
},
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/settings?section=mail-profiles", "route": "/settings?section=mail-profiles",
@@ -905,8 +1329,8 @@ manifest = ModuleManifest(
], ],
"steps": [ "steps": [
"Open Mail profiles and choose a visible active profile.", "Open Mail profiles and choose a visible active profile.",
"Review its safe scope, SMTP/IMAP availability, and sender identity without expecting credential values.", "Review its safe scope, SMTP/IMAP/JMAP availability, and sender identity without expecting credential values.",
"Run the relevant SMTP or IMAP connectivity/authentication test against a non-production target first.", "Run the relevant SMTP, IMAP, or JMAP connectivity/authentication test against a non-production target first.",
"Return to the consuming task and select the same profile through its picker.", "Return to the consuming task and select the same profile through its picker.",
], ],
"outcome": "The consuming task references an available Mail-owned profile and contains no copied transport configuration or credentials.", "outcome": "The consuming task references an available Mail-owned profile and contains no copied transport configuration or credentials.",
@@ -920,11 +1344,15 @@ manifest = ModuleManifest(
DocumentationTopic( DocumentationTopic(
id="mail.workflow.read-mailbox", id="mail.workflow.read-mailbox",
title="Read a permitted mailbox without changing it", title="Read a permitted mailbox without changing it",
summary="Choose an IMAP-enabled profile, browse synchronized folders, and inspect bounded message content through the read-only mailbox surface.", summary="Choose an IMAP- or JMAP-enabled profile, browse folders, search or page messages, and inspect bounded content through the read-only mailbox surface.",
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Lists expose provider read/unread flags and live/cache/refresh provenance without mutating them. Message HTML is isolated and sanitized, while attachments, inline references, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.", body=(
"Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. IMAP retains its existing bounded list behavior. IMAP folder names appear as readable Unicode, including umlauts and literal ampersands, and are encoded for the active connection when opened. Refresh an already-loaded folder list after an upgrade; folder discovery does not rename remote folders. Folder icons expand or collapse the tree; labels select without changing expansion. Selecting a synthetic parent grouping highlights that group and clears message selection without opening an invented provider folder. JMAP adds capability discovery, server-side text search, query-state cursors, and bounded Email/changes synchronization; an expired state explicitly requires a full refresh. Lists expose provider read/unread flags and provenance without mutating them. Message HTML is isolated and sanitized, while attachment metadata, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content. "
"The persistent workspace header keeps the profile selector, Mailbox tools, Help, and one right-aligned Reload available even without a profile or message selection. Reload first rechecks authorized profiles, then refreshes the current folder catalogue and bounded message page together; a selected message is reread only if still present. IMAP retains the current page; JMAP starts a fresh cursor chain on page one while retaining the search. A selected synthetic grouping refreshes only the folder catalogue, not an invented mailbox. Folder expansion is retained. Failed refreshes preserve usable loaded data with an explicit error and retry action, never present failure as an empty mailbox, and ignore late reads from a previous profile or tenant. Mailbox tools groups the optional profile-only, folder-only, and message-only refreshes separately from Bounce status. The latter remains visible with a permission explanation when bounce-read/manage authority is absent. These controls do not grant profile administration rights, run SMTP delivery, append mail, or change read/unread flags. "
"Mailbox context reads bypass browser response reuse; bounded server-side indexes and their provenance remain governed by Mail. A failed pagination request restores the page and size that belong to the retained rows. Dismissing or changing the preview while Reload is pending takes precedence over its remembered selection. On narrow screens, scroll vertically through folders, messages, and preview inside the mailbox workspace; its action header remains visible."
),
layer="configured", layer="configured",
documentation_types=("user",), documentation_types=("user", "admin"),
audience=("mail_user",), audience=("mail_user", "mail_admin"),
order=42, order=42,
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
@@ -937,18 +1365,51 @@ manifest = ModuleManifest(
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"), DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
), ),
unlocks=("Read-only access to an authorized operational mailbox without broadening profile administration rights.",), unlocks=("Read-only access to an authorized operational mailbox without broadening profile administration rights.",),
translations={
"de": {
"title": "Ein berechtigtes Postfach ohne Änderungen lesen",
"summary": "Ein IMAP- oder JMAP-fähiges Profil auswählen, Ordner durchsuchen und begrenzte Nachrichteninhalte in der nur lesbaren Postfachoberfläche prüfen.",
"body": (
"Der Postfachzugriff erfordert sowohl Leseberechtigung für das Postfach als auch Nutzungsberechtigung für ein "
"im Bereich der handelnden Person sichtbares Profil. IMAP zeigt lesbare Unicode-Ordnernamen mit Umlauten "
"und kaufmännischen Und-Zeichen; beim Öffnen werden sie für die aktive Verbindung kodiert. Eine bereits "
"geladene Ordnerliste nach einem Update neu laden; die Erkennung benennt keine externen Ordner um. "
"Ordnersymbole klappen den Baum auf oder zu; Beschriftungen wählen aus, ohne die Aufklappstellung zu ändern. "
"Eine künstliche übergeordnete Gruppe wird hervorgehoben und leert die Nachrichtenauswahl, ohne einen erfundenen Anbieterordner zu öffnen. "
"IMAP behält sein bisheriges Verhalten; JMAP ergänzt "
"serverseitige Suche, zustandsgebundene Seitennavigation und begrenzte inkrementelle Änderungen. Listen zeigen die vom Anbieter gelieferten Gelesen-/Ungelesen-Kennzeichen "
"und die Herkunft aus Livezugriff, Zwischenspeicher oder Aktualisierung, ohne diese Zustände zu verändern. HTML-Inhalte "
"werden isoliert und bereinigt; Anlagen, Inline-Verweise, nicht verfügbare Inhalte und Providerfehler bleiben ausdrücklich sichtbar. "
"Das Auflisten von Ordnern oder Nachrichten darf keine Nachricht als gelesen markieren, verschieben oder löschen und keine "
"unbegrenzten Inhalte offenlegen. "
"Die dauerhafte Arbeitsbereichsleiste enthält Profilauswahl, Postfachwerkzeuge, Hilfe und genau einmal Neuladen rechts, auch ohne Profil- oder Nachrichtenauswahl. "
"Neuladen prüft zuerst die berechtigten Profile und aktualisiert anschließend den aktuellen Ordnerkatalog und die begrenzte Nachrichtenseite gemeinsam. "
"Eine noch vorhandene ausgewählte Nachricht wird erneut gelesen. IMAP behält die Seite; JMAP beginnt mit der bestehenden Suche eine neue Cursorfolge auf Seite eins. "
"Bei einer ausgewählten künstlichen Gruppe wird nur der Ordnerkatalog gelesen, kein erfundenes Postfach. Aufklappstellungen bleiben erhalten. "
"Fehlgeschlagene Aktualisierungen erhalten nutzbare geladene Daten mit ausdrücklicher Fehlermeldung und Wiederholungsmöglichkeit; sie erscheinen nicht als leeres Postfach. "
"Verspätete Antworten eines vorherigen Profils oder Mandanten werden verworfen. Postfachwerkzeuge trennt gezieltes Aktualisieren von Profilen, Ordnern und Nachrichten vom Rückläuferstatus. "
"Bei fehlender Rückläufer-Lese- oder Verwaltungsberechtigung bleibt dieser Einstieg mit Erklärung sichtbar und deaktiviert. "
"Diese Aktionen vergeben keine Profilverwaltungsrechte, versenden oder hängen keine Nachrichten an und ändern keine Gelesen-/Ungelesen-Kennzeichen. "
"Postfachkontext-Lesezugriffe umgehen die Wiederverwendung von Browserantworten; begrenzte serverseitige Indizes und deren Herkunft bleiben unter Kontrolle von Mail. "
"Fehlgeschlagene Seitenwechsel stellen die zu den erhaltenen Zeilen gehörige Seite und Seitengröße wieder her. "
"Das Schließen oder Wechseln der Vorschau während Neuladen hat Vorrang vor der zuvor gemerkten Auswahl. "
"Auf schmalen Bildschirmen werden Ordner, Nachrichten und Vorschau innerhalb des Postfacharbeitsbereichs vertikal gescrollt; seine Aktionsleiste bleibt sichtbar."
),
}
},
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/mail", "route": "/mail",
"screen": "Mail", "screen": "Mail",
"help_contexts": ["mail.list", "mail.mailbox"], "help_contexts": ["mail.list", "mail.mailbox", "mail.mailbox.reload", "mail.mailbox.tools"],
"prerequisites": [ "prerequisites": [
"An active visible profile has IMAP configured.", "An active visible profile has IMAP or JMAP configured.",
"You may both use that profile and read its mailbox.", "You may both use that profile and read its mailbox.",
], ],
"steps": [ "steps": [
"Open Mail and choose an authorized IMAP-enabled profile.", "Open Mail and choose an authorized IMAP- or JMAP-enabled profile.",
"Select a folder, review the live/cached synchronization label, and page or filter its bounded message index.", "Use the right-aligned Reload for the complete current context; Mailbox tools contains optional targeted refreshes and permission-aware bounce diagnostics.",
"Select a folder, review the live/cached synchronization label, and page its bounded message index; JMAP searches run at the provider.",
"Use the provider-derived read/unread indicator, then open only the message needed for the task.", "Use the provider-derived read/unread indicator, then open only the message needed for the task.",
"Switch between safe plain-text and isolated HTML views as needed, and review attachment or unavailable-content details before closing the preview.", "Switch between safe plain-text and isolated HTML views as needed, and review attachment or unavailable-content details before closing the preview.",
], ],
@@ -963,8 +1424,8 @@ manifest = ModuleManifest(
DocumentationTopic( DocumentationTopic(
id="mail.reference.credentials-egress-retirement", id="mail.reference.credentials-egress-retirement",
title="Protect Mail credentials, network egress, and retirement", title="Protect Mail credentials, network egress, and retirement",
summary="Keep secrets Mail-owned, pin every SMTP/IMAP peer, bound responses, and delete owned credentials immediately with non-secret audit evidence.", summary="Keep secrets Mail-owned, govern every SMTP/IMAP/JMAP/POP3 peer, bound responses, and delete owned credentials immediately with non-secret audit evidence.",
body="Private-network connector access is deployment-wide, but every allowed hostname still resolves to an approved peer that is pinned at socket creation. Unsupported transports fail before connection. Deleting a profile immediately scrubs its owned encrypted SMTP/IMAP passwords and records non-secret audit when secrets existed; a scrub or audit failure rolls the action back. Destructive module retirement applies the same rule before table drop.", body="Private-network connector access is deployment-wide, but every allowed hostname still resolves to an approved peer that is pinned at socket creation. JMAP applies the same egress controls to its Session and advertised API URL, permits cross-origin APIs only through an explicit endpoint allow-list, and bounds complete responses and body values. Unsupported capabilities fail before mailbox data is returned. Deleting a profile immediately scrubs its owned encrypted SMTP/IMAP/JMAP/POP3 secrets and records non-secret audit when secrets existed; a scrub or audit failure rolls the action back. Encrypted POP3 import content follows Mail/records retention. Destructive module retirement applies the same secret-scrub rule before all Mail tables, including imports, are dropped.",
layer="evidence", layer="evidence",
documentation_types=("admin",), documentation_types=("admin",),
audience=("mail_admin", "platform_operator", "security_reviewer", "release_reviewer"), audience=("mail_admin", "platform_operator", "security_reviewer", "release_reviewer"),
@@ -981,6 +1442,22 @@ manifest = ModuleManifest(
), ),
related_modules=("audit",), related_modules=("audit",),
unlocks=("A testable Mail trust boundary with fail-closed egress and auditable secret disposal.",), unlocks=("A testable Mail trust boundary with fail-closed egress and auditable secret disposal.",),
translations={
"de": {
"title": "Mail-Zugangsdaten, Netzausgänge und Außerbetriebnahme schützen",
"summary": "Geheimnisse in Mail belassen, jeden SMTP-/IMAP-/JMAP-/POP3-Gegenpunkt steuern, Antworten begrenzen und eigene Zugangsdaten sofort mit nicht geheimem Auditnachweis löschen.",
"body": (
"Der Zugriff von Konnektoren auf private Netze wird bereitstellungsweit gesteuert; dennoch wird jeder erlaubte "
"Hostname in einen freigegebenen Gegenpunkt aufgelöst und beim Aufbau des Sockets fest gebunden. Nicht unterstützte "
"Transporte scheitern vor dem Verbindungsaufbau. Für JMAP gelten dieselben Netzausgangsregeln; abweichende API-Ursprünge "
"müssen ausdrücklich freigegeben sein und Antworten sowie Inhalte sind begrenzt. Beim Löschen eines Profils werden dessen verschlüsselte SMTP-, IMAP-, JMAP- "
"und POP3-Geheimnisse sofort entfernt und, sofern Geheimnisse vorhanden waren, mit nicht geheimen Angaben protokolliert. "
"Scheitert das Entfernen oder das Audit, wird die Aktion zurückgerollt. Verschlüsselte POP3-Importinhalte folgen der "
"Aufbewahrung von Mail beziehungsweise Records. Bei einer destruktiven Modulaußerbetriebnahme gilt dieselbe Regel: "
"Zuerst werden Geheimnisse entfernt, erst danach alle Mail-Tabellen einschließlich der Importdaten gelöscht."
),
}
},
metadata={ metadata={
"kind": "reference", "kind": "reference",
"route": "/settings?section=mail-profiles", "route": "/settings?section=mail-profiles",
@@ -998,7 +1475,11 @@ manifest = ModuleManifest(
id="mail.reference.campaign-delivery-contract", id="mail.reference.campaign-delivery-contract",
title="Integrate Campaign through the Mail delivery contract", title="Integrate Campaign through the Mail delivery contract",
summary="Campaign freezes a Mail profile reference and opaque revision; Mail re-authorizes, revision-checks, resolves credentials, and performs the effect in one call.", summary="Campaign freezes a Mail profile reference and opaque revision; Mail re-authorizes, revision-checks, resolves credentials, and performs the effect in one call.",
body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Synchronous batches authorize the complete recipient set and preflight DNS, egress, connectivity, TLS, and authentication before the first effect. Mail reuses the bounded connection when deployment policy permits, health-checks it before reuse, and reconnects before the next message when a stale connection is detected. A connection loss after DATA begins remains outcome-unknown and is never replayed. Systemic authentication, sender, or connectivity failures carry stable reason codes so Campaign pauses remaining queued work and shows connection, reconnect, failure, and pause progress. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Every current SMTP and Sent-folder attempt passes a stable effect identifier into a Mail-owned Core recovery operation before provider contact. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority. Mail outbox dispatch and retention scans are partitioned by tenant entitlement, so disabling Mail leaves accepted commands and evidence untouched for operator resolution.", body=(
"The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Synchronous batches authorize the complete recipient set and preflight DNS, egress, connectivity, TLS, and authentication before the first effect. Mail reuses the bounded connection when deployment policy permits, health-checks it before reuse, and reconnects before the next message when a stale connection is detected. A connection loss after DATA begins remains outcome-unknown and is never replayed. Systemic authentication, sender, or connectivity failures carry stable reason codes so Campaign pauses remaining queued work and shows connection, reconnect, failure, and pause progress. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Every current SMTP and Sent-folder attempt passes a stable effect identifier into a Mail-owned Core recovery operation before provider contact. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority. Mail outbox dispatch and retention scans are partitioned by tenant entitlement, so disabling Mail leaves accepted commands and evidence untouched for operator resolution."
" Runtime credential-selection checks are protocol-scoped: SMTP batch and single-message delivery enforce SMTP policy; Sent-folder append enforces IMAP policy. A valid explicit SMTP selection must not fail merely because the SMTP call does not carry an unrelated explicit IMAP credential, and vice versa. Full campaign authoring validation and complete profile summaries still check both configured protocols. Missing explicit credentials for the selected protocol, inactive or unauthorized bindings and stale transport revisions remain blocked before credential decryption or provider contact. A successful Mail connection test proves only that selected connection/login, not later campaign authorization or recipient acceptance. Correcting this runtime check changes no saved policy, credentials, build/review evidence, or delivery status and does not send messages automatically."
" The optional campaign_imap_batch context reuses a bounded authenticated IMAP connection for sequential APPENDs, never MULTIAPPEND or parallel effects. It opens lazily after each message's current authorization, both frozen revisions, IMAP-only credential resolution and recovery checks; permissions and recovery evidence are never cached. Connection-local Sent-folder discovery preserves the provider's original Unicode wire names. Different campaign/tenant scopes are rejected, and changed profile, references, folder or resolved credentials cannot reuse the prior connection. Defaults are 100 messages or 300 seconds per connection, an idle NOOP after 30 seconds, and one extra connection attempt before APPEND. GOVOPLAN_IMAP_BATCH_REUSE can disable reuse; MAX_MESSAGES, MAX_AGE_SECONDS, IDLE_HEALTH_CHECK_SECONDS and RECONNECT_ATTEMPTS with the same prefix bound deployment behavior as documented in the handbook. There is no automatic APPEND replay after transmission starts; unknown outcomes require mailbox reconciliation without SMTP resend. Accepted recovery-evidence finalization failure closes the batch; logout failure does not reverse acceptance. Per-message connection/reuse/reconnect counters expose no secrets. Older optional Mail providers retain single-message behavior."
),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"), audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
@@ -1016,12 +1497,35 @@ manifest = ModuleManifest(
), ),
related_modules=("campaigns", "audit"), related_modules=("campaigns", "audit"),
unlocks=("Profile-backed campaign delivery without cross-module credential or configuration copies.",), unlocks=("Profile-backed campaign delivery without cross-module credential or configuration copies.",),
translations={
"de": {
"title": "Campaign über den Mail-Zustellvertrag integrieren",
"summary": "Campaign friert Profilverweis und undurchsichtige Revision ein; Mail berechtigt erneut, prüft die Revision, löst Zugangsdaten auf und führt die Wirkung in einem Aufruf aus.",
"body": (
"Der Vertrag mail.campaign_delivery 0.2 gibt niemals entschlüsselte Zugangsdaten oder aufgelöste SMTP-/IMAP-Konfiguration zurück. "
"Mail vergleicht die erwartete zufällige Transportrevision vor dem Entschlüsseln protokollspezifischer Zugangsdaten und liefert "
"nur begrenzte, bereinigte Ergebnisse. Synchrone Stapel berechtigen die vollständige Empfängermenge und prüfen DNS, Netzausgang, "
"Verbindung, TLS und Anmeldung vor der ersten Wirkung. Wenn die Bereitstellungsrichtlinie es erlaubt, verwendet Mail die begrenzte "
"Verbindung erneut, prüft sie zuvor und verbindet vor der nächsten Nachricht neu, sobald sie veraltet ist. Ein Verbindungsverlust nach "
"Beginn von DATA bleibt mit unbekanntem Ergebnis bestehen und wird niemals automatisch wiederholt. Systemische Anmelde-, Absender- oder "
"Verbindungsfehler tragen stabile Ursachencodes, damit Campaign verbleibende Aufträge anhält und Verbindung, Neuverbindung, Fehler und "
"Pausenfortschritt anzeigt. Campaign besitzt gewöhnliche Empfängeraufträge; Berichtsnachrichten nutzen das verschlüsselte, idempotente "
"Zustellbefehls- und Versuchsledger von Mail. Jeder aktuelle SMTP- und Gesendet-Ordner-Versuch übergibt vor dem Providerkontakt eine "
"stabile Wirkungskennung an einen von Mail besessenen Core-Wiederherstellungsvorgang. Der Nachweis des Wirkungsbeginns verhindert blinde "
"Wiederzustellung; unbekannte Ergebnisse erfordern ausdrückliche Abstimmung, und rohe Empfängerablehnungen erfordern Mail-Diagnoseberechtigung. "
"Mail-Ausgangsverarbeitung und Aufbewahrungsläufe werden nach Mandantenberechtigung partitioniert. Das Deaktivieren von Mail lässt bereits "
"angenommene Befehle und Nachweise daher zur Klärung durch den Betrieb unverändert bestehen."
" Zur Laufzeit gelten Zugangsdaten-Auswahlregeln protokollbezogen: SMTP-Stapel und einzelne SMTP-Zustellungen prüfen die SMTP-Richtlinie; die Ablage im Gesendet-Ordner prüft die IMAP-Richtlinie. Eine gültige ausdrückliche SMTP-Auswahl darf nicht daran scheitern, dass der reine SMTP-Aufruf keine getrennte IMAP-Zugangsdatenkennung übergibt; umgekehrt gilt dasselbe. Vollständige Kampagnenvalidierung und Profilzusammenfassungen prüfen weiterhin beide konfigurierten Protokolle. Fehlende ausdrückliche Zugangsdaten des genutzten Protokolls, inaktive oder unberechtigte Bindungen und veraltete Transportrevisionen bleiben vor Entschlüsselung oder Providerkontakt gesperrt. Ein erfolgreicher Mail-Verbindungstest belegt nur diese Verbindung und Anmeldung, nicht spätere Kampagnenberechtigung oder Empfängerannahme. Die Korrektur verändert keine gespeicherte Richtlinie, Zugangsdaten, Build-/Prüfnachweise oder Zustellzustände und versendet nicht automatisch."
" Der optionale Kontext campaign_imap_batch verwendet eine begrenzte authentifizierte IMAP-Verbindung für sequenzielle APPENDs erneut, niemals MULTIAPPEND oder parallele Wirkungen. Er öffnet erst nach der aktuellen Berechtigungsprüfung, Prüfung beider eingefrorenen Revisionen, alleiniger Auflösung der IMAP-Zugangsdaten und Wiederherstellungsprüfung jeder Nachricht; Berechtigungen und Nachweise werden nicht zwischengespeichert. Die verbindungslokale Gesendet-Ordner-Erkennung erhält die ursprünglichen Unicode-Übertragungsnamen des Providers. Andere Mandanten oder Kampagnen werden abgelehnt; geänderte Profile, Referenzen, Ordner oder aufgelöste Zugangsdaten dürfen die bisherige Verbindung nicht weiterverwenden. Standardwerte sind 100 Nachrichten oder 300 Sekunden je Verbindung, NOOP nach 30 Sekunden Leerlauf und ein zusätzlicher Verbindungsversuch vor APPEND. GOVOPLAN_IMAP_BATCH_REUSE kann die Wiederverwendung deaktivieren; MAX_MESSAGES, MAX_AGE_SECONDS, IDLE_HEALTH_CHECK_SECONDS und RECONNECT_ATTEMPTS mit demselben Präfix begrenzen das Betriebsverhalten gemäß Handbuch. Nach Übertragungsbeginn wird APPEND niemals automatisch wiederholt; unbekannte Ergebnisse erfordern Postfachabgleich ohne erneuten SMTP-Versand. Ein Fehler beim Abschluss des Annahmenachweises schließt den Stapel; ein Abmeldefehler macht die Annahme nicht rückgängig. Verbindungs-, Wiederverwendungs- und Neuverbindungszähler enthalten keine Geheimnisse. Ältere optionale Mail-Anbieter behalten das Einzelaufrufverhalten."
),
}
},
metadata={ metadata={
"kind": "reference", "kind": "reference",
"route": "/campaigns/{campaign_id}/mail-settings", "route": "/campaigns/{campaign_id}/mail-settings",
"screen": "Campaign Mail settings", "screen": "Campaign Mail settings",
"section": "Mail-owned profile and transport boundary", "section": "Mail-owned profile and transport boundary",
"verification": "Prove stale revisions fail before credential decryption, batch preflight fails before DATA, two messages reuse one healthy connection, a stale connection reconnects before the next message, post-DATA disconnect is never replayed, systemic failures pause remaining jobs, provider details are sanitized, and the interface/version gate passes.", "verification": "Prove stale revisions fail before credential decryption, batch preflight fails before DATA, two messages reuse one healthy connection, a stale connection reconnects before the next message, post-DATA disconnect is never replayed, systemic failures pause remaining jobs, provider details are sanitized, and the interface/version gate passes. For IMAP, prove one login and folder discovery for sequential APPENDs, count/age rotation, changed authorization or revisions block the next message before decryption, per-message recovery remains independent, unknown APPEND is never replayed, and nested/scoped contexts clean up safely.",
"related_topic_ids": [ "related_topic_ids": [
"mail.profile-ownership-and-consumers", "mail.profile-ownership-and-consumers",
"campaigns.mail-profile-user-journey", "campaigns.mail-profile-user-journey",
@@ -1037,7 +1541,7 @@ manifest = ModuleManifest(
resolve=documentation_configuration_states, resolve=documentation_configuration_states,
), ),
), ),
external_providers=(SMTP_PROVIDER, IMAP_PROVIDER), external_providers=(SMTP_PROVIDER, IMAP_PROVIDER, JMAP_PROVIDER, POP3_PROVIDER),
external_provider_state_providers=( external_provider_state_providers=(
ExternalProviderStateProviderRegistration( ExternalProviderStateProviderRegistration(
module_id="mail", module_id="mail",
@@ -1049,6 +1553,16 @@ manifest = ModuleManifest(
provider_id=IMAP_PROVIDER_ID, provider_id=IMAP_PROVIDER_ID,
provider=imap_provider_states, provider=imap_provider_states,
), ),
ExternalProviderStateProviderRegistration(
module_id="mail",
provider_id=JMAP_PROVIDER_ID,
provider=jmap_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="mail",
provider_id=POP3_PROVIDER_ID,
provider=pop3_provider_states,
),
), ),
architecture=declared_module_architecture( architecture=declared_module_architecture(
layer="communication_participation", layer="communication_participation",
@@ -1056,15 +1570,15 @@ manifest = ModuleManifest(
maturity="vertical_slice", maturity="vertical_slice",
documentation_ref="docs/MAIL_HANDBOOK.md", documentation_ref="docs/MAIL_HANDBOOK.md",
test_ref="tests/test_delivery_outbox.py", test_ref="tests/test_delivery_outbox.py",
known_limits=("A complete webmail profile and recovery adoption for future provider-side mailbox mutations are not reference-ready.",), known_limits=("Mailbox access is deliberately read-only. JMAP currently covers Mailbox/get, Email/query, Email/get, and Email/changes only; provider-side mutations, threads, calendars, contacts, and JMAP submission are not implemented. POP3 is supported only as an explicitly enabled, bounded legacy-import path and has no folder or flag semantics.",),
supported_authority_modes=( supported_authority_modes=(
"external_authoritative", "external_authoritative",
"external_mirror", "external_mirror",
"governance_overlay", "governance_overlay",
), ),
owned_concepts=("mail profile", "mail delivery command", "delivery attempt", "mailbox projection"), owned_concepts=("mail profile", "mail delivery command", "delivery attempt", "mailbox projection", "governed POP3 import"),
non_owned_concepts=("campaign", "notification", "recipient address directory", "external mailbox"), non_owned_concepts=("campaign", "notification", "recipient address directory", "external mailbox"),
target_tested_providers=(SMTP_PROVIDER_ID, IMAP_PROVIDER_ID), target_tested_providers=(SMTP_PROVIDER_ID, IMAP_PROVIDER_ID, JMAP_PROVIDER_ID, POP3_PROVIDER_ID),
recovery_docs=("docs/MAIL_HANDBOOK.md",), recovery_docs=("docs/MAIL_HANDBOOK.md",),
security_docs=("docs/MAIL_HANDBOOK.md",), security_docs=("docs/MAIL_HANDBOOK.md",),
operations_docs=("docs/MAIL_HANDBOOK.md",), operations_docs=("docs/MAIL_HANDBOOK.md",),
@@ -1072,5 +1586,10 @@ manifest = ModuleManifest(
) )
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest: def get_manifest() -> ModuleManifest:
return manifest return manifest
@@ -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",
]
+168 -8
View File
@@ -16,6 +16,7 @@ from govoplan_mail.backend.db.models import (
MailDeliveryCommand, MailDeliveryCommand,
MailMailboxFolderIndex, MailMailboxFolderIndex,
MailMailboxMessageIndex, MailMailboxMessageIndex,
MailPop3Import,
MailServerEndpoint, MailServerEndpoint,
MailServerProfile, MailServerProfile,
) )
@@ -23,6 +24,8 @@ from govoplan_mail.backend.db.models import (
SMTP_PROVIDER_ID = "mail.smtp_delivery" SMTP_PROVIDER_ID = "mail.smtp_delivery"
IMAP_PROVIDER_ID = "mail.imap_mailbox" 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) _CURRENT_INDEX_WINDOW = timedelta(minutes=30)
@@ -38,6 +41,47 @@ def imap_provider_states(
return _mail_provider_states(context, protocol="imap") 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( def _mail_provider_states(
context: ExternalProviderStateContext, context: ExternalProviderStateContext,
*, *,
@@ -69,15 +113,28 @@ def _mail_provider_states(
) )
metrics = _imap_metrics(context.session, profile_ids=profile_ids) 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( return tuple(
_imap_state( _imap_state(
profile, profile,
endpoints=endpoints_by_profile.get(profile.id, []), endpoints=endpoints_by_profile.get(profile.id, []),
metrics=metrics.get(profile.id, {}), metrics=metrics.get(profile.id, {}),
observed_at=observed_at, observed_at=observed_at,
protocol=protocol,
provider_id=provider_id,
) )
for profile in profiles 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 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( def _smtp_state(
profile: MailServerProfile, profile: MailServerProfile,
*, *,
@@ -259,10 +347,13 @@ def _imap_state(
endpoints: list[MailServerEndpoint], endpoints: list[MailServerEndpoint],
metrics: dict[str, Any], metrics: dict[str, Any],
observed_at: datetime, observed_at: datetime,
protocol: str = "imap",
provider_id: str = IMAP_PROVIDER_ID,
) -> ExternalProviderRuntimeState: ) -> ExternalProviderRuntimeState:
protocol_label = protocol.upper()
active = bool(profile.is_active) and ( active = bool(profile.is_active) and (
any(item.is_active for item in endpoints) 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")) indexed_at = _aware(metrics.get("last_indexed_at"))
errors = int(metrics.get("bounce_source_errors", 0)) errors = int(metrics.get("bounce_source_errors", 0))
@@ -285,8 +376,8 @@ def _imap_state(
else "unknown" else "unknown"
) )
return ExternalProviderRuntimeState( return ExternalProviderRuntimeState(
provider_id=IMAP_PROVIDER_ID, provider_id=provider_id,
binding_ref=f"mail:profile:{profile.id}:imap", binding_ref=f"mail:profile:{profile.id}:{protocol}",
authority_mode="external_mirror", authority_mode="external_mirror",
observed_at=observed_at, observed_at=observed_at,
configured=True, configured=True,
@@ -303,13 +394,13 @@ def _imap_state(
), ),
last_success_at=indexed_at or _aware(metrics.get("last_bounce_success_at")), last_success_at=indexed_at or _aware(metrics.get("last_bounce_success_at")),
detail=( detail=(
"IMAP mailbox access is disabled." f"{protocol_label} mailbox access is disabled."
if not active 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 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 if indexed_at is None
else "IMAP mailbox index state is available." else f"{protocol_label} mailbox index state is available."
), ),
metrics={ metrics={
"indexed_folders": int(metrics.get("indexed_folders", 0)), "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: def _legacy_configured(profile: MailServerProfile, protocol: str) -> bool:
value = profile.smtp_config if protocol == "smtp" else profile.imap_config value = profile.smtp_config if protocol == "smtp" else profile.imap_config
return isinstance(value, dict) and bool(value) return isinstance(value, dict) and bool(value)
@@ -336,7 +492,11 @@ def _aware(value: object | None) -> datetime | None:
__all__ = [ __all__ = [
"IMAP_PROVIDER_ID", "IMAP_PROVIDER_ID",
"JMAP_PROVIDER_ID",
"POP3_PROVIDER_ID",
"SMTP_PROVIDER_ID", "SMTP_PROVIDER_ID",
"imap_provider_states", "imap_provider_states",
"jmap_provider_states",
"pop3_provider_states",
"smtp_provider_states", "smtp_provider_states",
] ]
File diff suppressed because it is too large Load Diff
+92 -4
View File
@@ -197,7 +197,7 @@ class MailServerEndpointResponse(BaseModel):
id: str id: str
profile_id: str profile_id: str
tenant_id: str | None = None tenant_id: str | None = None
protocol: Literal["smtp", "imap"] protocol: Literal["smtp", "imap", "jmap", "pop3"]
name: str name: str
config: dict[str, Any] = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict)
scope_type: MailProfileScope scope_type: MailProfileScope
@@ -214,7 +214,7 @@ class MailServerEndpointResponse(BaseModel):
class MailServerEndpointCreateRequest(BaseModel): class MailServerEndpointCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
protocol: Literal["smtp", "imap"] protocol: Literal["smtp", "imap", "jmap", "pop3"]
name: str = Field(min_length=1, max_length=255) name: str = Field(min_length=1, max_length=255)
config: dict[str, Any] = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict)
inherit_to_lower_scopes: bool | None = None inherit_to_lower_scopes: bool | None = None
@@ -383,7 +383,7 @@ class MailContactCreateResponse(BaseModel):
class MailConnectionTestResponse(BaseModel): class MailConnectionTestResponse(BaseModel):
ok: bool ok: bool
protocol: Literal["smtp", "imap"] protocol: Literal["smtp", "imap", "jmap", "pop3"]
host: str | None = None host: str | None = None
port: int | None = None port: int | None = None
security: str | None = None security: str | None = None
@@ -391,6 +391,82 @@ class MailConnectionTestResponse(BaseModel):
details: dict[str, Any] = Field(default_factory=dict) 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): class MailImapFolderResponse(BaseModel):
name: str name: str
flags: list[str] = Field(default_factory=list) flags: list[str] = Field(default_factory=list)
@@ -400,7 +476,7 @@ class MailImapFolderResponse(BaseModel):
class MailImapFolderListResponse(BaseModel): class MailImapFolderListResponse(BaseModel):
ok: bool ok: bool
protocol: Literal["imap"] = "imap" protocol: Literal["imap", "jmap"] = "imap"
host: str | None = None host: str | None = None
port: int | None = None port: int | None = None
security: str | None = None security: str | None = None
@@ -460,6 +536,18 @@ class MailMailboxMessageListResponse(BaseModel):
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) 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): class MailMailboxBootstrapResponse(BaseModel):
profile_id: str profile_id: str
folder: str folder: str
+384 -79
View File
@@ -1,16 +1,21 @@
from __future__ import annotations from __future__ import annotations
import base64
import binascii
import imaplib import imaplib
import logging import logging
import os
import re import re
import socket import socket
import ssl import ssl
import time import time
from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
from email import policy from email import policy
from email.message import EmailMessage from email.message import EmailMessage
from email.parser import BytesParser 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 ( from govoplan_core.security.outbound_http import (
OutboundHttpError, OutboundHttpError,
@@ -212,6 +217,9 @@ class ImapAppendResult:
folder: str folder: str
bytes_appended: int bytes_appended: int
response: str | None = None 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]: def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
@@ -276,7 +284,75 @@ def _unquote_imap_token(value: str) -> str:
return value 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. r"""Best-effort parser for IMAP LIST response lines.
RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox
@@ -292,7 +368,18 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
blindly taking the last quoted value. 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( match = re.match(
r'^\((?P<flags>[^)]*)\)\s+' r'^\((?P<flags>[^)]*)\)\s+'
r'(?P<delimiter>"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+' r'(?P<delimiter>"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+'
@@ -303,6 +390,14 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
if match: if match:
flags = {part.lower() for part in match.group("flags").split()} flags = {part.lower() for part in match.group("flags").split()}
mailbox = _unquote_imap_token(match.group("mailbox")) 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: if mailbox:
return mailbox, flags return mailbox, flags
return None return None
@@ -318,6 +413,38 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
return None return None
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
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, ...]] = { _STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = {
"inbox": ("\\inbox",), "inbox": ("\\inbox",),
"sent": ("\\sent", "\\sentmail"), "sent": ("\\sent", "\\sentmail"),
@@ -366,13 +493,7 @@ def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
if typ != "OK" or not data: if typ != "OK" or not data:
return None return None
parsed: list[tuple[str, set[str]]] = [] return _detect_sent_folder(_parsed_mailbox_listing(client, data))
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
return _detect_sent_folder(parsed)
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str: def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
@@ -452,14 +573,9 @@ def _list_imap_folders_on_client(
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True) 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] = [] folders: list[ImapMailboxInfo] = []
for item in data or []: for name, flags in parsed:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
message_count, unseen_count = ( message_count, unseen_count = (
(None, None) (None, None)
if not include_status or _has_folder_flag(flags, "noselect") if not include_status or _has_folder_flag(flags, "noselect")
@@ -622,13 +738,40 @@ def _has_folder_flag(flags: set[str], flag: str) -> bool:
return any(item.casefold().lstrip("\\") == wanted for item in flags) return any(item.casefold().lstrip("\\") == wanted for item in flags)
def _quote_mailbox_name(name: str) -> str: def _quote_mailbox_name(name: str, *, client: imaplib.IMAP4 | None = None) -> str:
return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" 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]: def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]:
try: 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: except Exception:
return None, None return None, None
if typ != "OK": if typ != "OK":
@@ -787,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]: 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": if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) 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() selected_count = _decode_item(data[0] if data else None).strip()
@@ -1253,69 +1396,231 @@ def list_imap_uids_since(
_log_imap_cleanup_failure("listing watcher UIDs", cleanup_exc) _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( def append_message_to_sent(
message_bytes: bytes, message_bytes: bytes,
*, *,
imap_config: ImapConfig, imap_config: ImapConfig,
folder: str | None = None, folder: str | None = None,
batch_session: ImapBatchSession | None = None,
) -> ImapAppendResult: ) -> 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 An explicitly scoped batch may reuse its authenticated connection. Neither
and should not be used to decide whether an email was sent. mode retries an APPEND after transmission has started.
""" """
if batch_session is not None:
host, port = _require_imap_config(imap_config) if not batch_session.matches_config(imap_config):
if is_mock_imap_host(imap_config.host): raise ImapConfigurationError("The IMAP batch configuration does not match this message")
if consume_fail_next_imap(): return batch_session.append(message_bytes, folder=folder)
raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False) with ImapBatchSession(
target_folder = folder or (imap_config.sent_folder if imap_config.sent_folder and imap_config.sent_folder != "auto" else "Sent") imap_config, policy=ImapBatchPolicy(reuse_connections=False, reconnect_attempts=0),
record = record_imap_append(message_bytes, folder=target_folder, imap_host=imap_config.host) ) as single:
return ImapAppendResult( return single.append(message_bytes, folder=folder)
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)
+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",
]
+41 -15
View File
@@ -23,6 +23,10 @@ from govoplan_core.security.secrets import decrypt_secret
from govoplan_mail.backend.config import ( from govoplan_mail.backend.config import (
ImapConfig, ImapConfig,
ImapServerConfig, ImapServerConfig,
JmapConfig,
JmapServerConfig,
Pop3Config,
Pop3ServerConfig,
SmtpConfig, SmtpConfig,
SmtpServerConfig, 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): class MailServerHierarchyError(RuntimeError):
@@ -68,7 +72,7 @@ class ResolvedMailTransport:
profile: MailServerProfile profile: MailServerProfile
server: MailServerEndpoint | None server: MailServerEndpoint | None
credential: CredentialEnvelope | None credential: CredentialEnvelope | None
config: SmtpConfig | ImapConfig config: SmtpConfig | ImapConfig | JmapConfig | Pop3Config
transport_revision: str transport_revision: str
@@ -951,6 +955,10 @@ def resolve_mail_transport(
) )
) )
if server is None: 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) return _legacy_resolved_transport(profile, clean_protocol)
binding, credential = _selected_server_credential( binding, credential = _selected_server_credential(
session, session,
@@ -978,16 +986,19 @@ def resolve_mail_transport(
if clean_protocol == "smtp": if clean_protocol == "smtp":
payload["username"] = profile.smtp_username payload["username"] = profile.smtp_username
payload["password"] = decrypt_secret(profile.smtp_password_encrypted) payload["password"] = decrypt_secret(profile.smtp_password_encrypted)
else: elif clean_protocol == "imap":
payload["username"] = profile.imap_username payload["username"] = profile.imap_username
payload["password"] = decrypt_secret(profile.imap_password_encrypted) payload["password"] = decrypt_secret(profile.imap_password_encrypted)
config: SmtpConfig | ImapConfig config: SmtpConfig | ImapConfig | JmapConfig | Pop3Config
try: try:
config = ( if clean_protocol == "smtp":
SmtpConfig.model_validate(payload) config = SmtpConfig.model_validate(payload)
if clean_protocol == "smtp" elif clean_protocol == "imap":
else ImapConfig.model_validate(payload) config = ImapConfig.model_validate(payload)
) elif clean_protocol == "jmap":
config = JmapConfig.model_validate(payload)
else:
config = Pop3Config.model_validate(payload)
except Exception as exc: except Exception as exc:
raise MailServerHierarchyError( raise MailServerHierarchyError(
f"The selected {clean_protocol.upper()} server configuration is invalid" f"The selected {clean_protocol.upper()} server configuration is invalid"
@@ -1030,6 +1041,14 @@ def select_mail_transport(
) )
) )
if server is None: 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 = ( legacy_config = (
profile.smtp_config profile.smtp_config
if clean_protocol == "smtp" if clean_protocol == "smtp"
@@ -1209,6 +1228,8 @@ def _server_matches_legacy_transport(
) -> bool: ) -> bool:
if not server.is_default: if not server.is_default:
return False return False
if server.protocol not in {"smtp", "imap"}:
return False
if server.protocol == "smtp": if server.protocol == "smtp":
legacy_config = profile.smtp_config or {} legacy_config = profile.smtp_config or {}
legacy_revision = profile.smtp_transport_revision legacy_revision = profile.smtp_transport_revision
@@ -1302,11 +1323,14 @@ def _validated_server_config(
payload.pop("password", None) payload.pop("password", None)
payload.pop("enabled", None) payload.pop("enabled", None)
try: try:
model = ( if protocol == "smtp":
SmtpServerConfig.model_validate(payload) model = SmtpServerConfig.model_validate(payload)
if protocol == "smtp" elif protocol == "imap":
else ImapServerConfig.model_validate(payload) model = ImapServerConfig.model_validate(payload)
) elif protocol == "jmap":
model = JmapServerConfig.model_validate(payload)
else:
model = Pop3ServerConfig.model_validate(payload)
except Exception as exc: except Exception as exc:
raise MailServerHierarchyError( raise MailServerHierarchyError(
f"Invalid {protocol.upper()} server configuration" f"Invalid {protocol.upper()} server configuration"
@@ -1347,7 +1371,9 @@ def _server_scope(
def _normalize_protocol(value: str) -> str: def _normalize_protocol(value: str) -> str:
clean = str(value or "").strip().casefold() clean = str(value or "").strip().casefold()
if clean not in MAIL_SERVER_PROTOCOLS: 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 return clean
+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()
+22
View File
@@ -22,6 +22,7 @@ from govoplan_core.db.session import configure_database, reset_database
from govoplan_core.security.credential_envelopes import CredentialEnvelope from govoplan_core.security.credential_envelopes import CredentialEnvelope
from govoplan_mail.backend.configuration_provider import ( from govoplan_mail.backend.configuration_provider import (
MAIL_CONFIGURATION_CAPABILITY, MAIL_CONFIGURATION_CAPABILITY,
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
SqlMailConfigurationProvider, SqlMailConfigurationProvider,
) )
from govoplan_mail.backend.db.models import ( from govoplan_mail.backend.db.models import (
@@ -168,9 +169,30 @@ class MailConfigurationProviderTests(unittest.TestCase):
def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None: def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None:
self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories) self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories)
self.assertIn(
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
manifest.capability_factories,
)
description = self.provider.describe() description = self.provider.describe()
self.assertEqual(("smtp_profile",), description.fragment_types) 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: def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None:
fragment = ConfigurationPackageFragment( fragment = ConfigurationPackageFragment(
module_id="mail", module_id="mail",
+42 -1
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationContext from govoplan_core.core.modules import DocumentationContext
from govoplan_mail.backend.documentation import ( from govoplan_mail.backend.documentation import (
_credential_line,
documentation_configuration_states, documentation_configuration_states,
documentation_topics, documentation_topics,
) )
@@ -26,6 +27,46 @@ class _Principal:
class MailRuntimeDocumentationTests(unittest.TestCase): 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: def setUp(self) -> None:
self.session = Session() self.session = Session()
@@ -182,7 +223,7 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
topics = {topic.id: topic for topic in get_manifest().documentation} 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.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.admin.profiles", topics["mail.profiles-and-policy"].metadata["help_contexts"])
self.assertIn("mail.bounce-processing", topics["mail.bounce-processing"].metadata["help_contexts"]) self.assertIn("mail.bounce-processing", topics["mail.bounce-processing"].metadata["help_contexts"])
+45
View File
@@ -22,6 +22,8 @@ from govoplan_mail.backend.db.models import (
MailDeliveryCommand, MailDeliveryCommand,
MailDeliveryReconciliation, MailDeliveryReconciliation,
MailMailboxMessageIndex, MailMailboxMessageIndex,
MailPop3Import,
MailServerEndpoint,
MailServerProfile, MailServerProfile,
) )
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY, MailDsarProvider from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY, MailDsarProvider
@@ -71,7 +73,9 @@ class MailDsarProviderTests(unittest.TestCase):
ChangeSequenceEntry.__table__, ChangeSequenceEntry.__table__,
DataSubjectRequest.__table__, DataSubjectRequest.__table__,
MailServerProfile.__table__, MailServerProfile.__table__,
MailServerEndpoint.__table__,
MailMailboxMessageIndex.__table__, MailMailboxMessageIndex.__table__,
MailPop3Import.__table__,
MailDeliveryCommand.__table__, MailDeliveryCommand.__table__,
MailDeliveryAttempt.__table__, MailDeliveryAttempt.__table__,
MailDeliveryReconciliation.__table__, MailDeliveryReconciliation.__table__,
@@ -160,6 +164,39 @@ class MailDsarProviderTests(unittest.TestCase):
to_header="subject@example.test", to_header="subject@example.test",
indexed_at=now, 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( command = MailDeliveryCommand(
id="command-subject", id="command-subject",
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -230,6 +267,8 @@ class MailDsarProviderTests(unittest.TestCase):
unrelated, unrelated,
tenant_two_profile, tenant_two_profile,
tenant_two, tenant_two,
pop3_server,
pop3_import,
command, command,
attempt, attempt,
reconciliation, reconciliation,
@@ -264,6 +303,7 @@ class MailDsarProviderTests(unittest.TestCase):
"mail_delivery_attempt", "mail_delivery_attempt",
"mail_delivery_reconciliation", "mail_delivery_reconciliation",
"mail_bounce_observation", "mail_bounce_observation",
"mail_pop3_import",
}.issubset({r.resource_type for r in records}) }.issubset({r.resource_type for r in records})
) )
serialized = repr([record.to_dict() for record in records]) serialized = repr([record.to_dict() for record in records])
@@ -295,6 +335,11 @@ class MailDsarProviderTests(unittest.TestCase):
"original-id-do-not-export", "original-id-do-not-export",
"bounce-diagnostic-do-not-export", "bounce-diagnostic-do-not-export",
"bounce-evidence-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) self.assertNotIn(hidden, serialized)
+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()
+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()
+56 -1
View File
@@ -563,7 +563,7 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
"credential-1", "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) profile = SimpleNamespace(imap_config=None)
policy = EffectiveMailProfilePolicy( policy = EffectiveMailProfilePolicy(
smtp_credentials=EffectiveCredentialPolicy(inherit=False), smtp_credentials=EffectiveCredentialPolicy(inherit=False),
@@ -572,6 +572,44 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
with self.assertRaisesRegex(MailProfileError, "explicit credential selection"): with self.assertRaisesRegex(MailProfileError, "explicit credential selection"):
_assert_campaign_inherits_profile_credentials(profile, policy) _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): def test_merge_policy_respects_locked_lower_level_limits(self):
policy = EffectiveMailProfilePolicy() policy = EffectiveMailProfilePolicy()
_merge_policy( _merge_policy(
@@ -592,6 +630,23 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
self.assertEqual(policy.blacklist_patterns["smtp_hosts"], ["*.blocked.example"]) self.assertEqual(policy.blacklist_patterns["smtp_hosts"], ["*.blocked.example"])
self.assertFalse(policy.allow_lower_level_limits["blacklist.smtp_hosts"]) 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): def test_parent_lock_violations_are_reported_per_field(self):
violations = _policy_parent_lock_violations( violations = _policy_parent_lock_violations(
{ {
+62
View File
@@ -53,7 +53,22 @@ class MailManifestTests(unittest.TestCase):
permissions = {permission.scope for permission in manifest.permissions} permissions = {permission.scope for permission in manifest.permissions}
self.assertIn("mail:profile:write_own", permissions) self.assertIn("mail:profile:write_own", permissions)
self.assertIn("mail:secret:manage_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} 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( self.assertEqual(
set(roles["mail_profile_self_service"].permissions), set(roles["mail_profile_self_service"].permissions),
{ {
@@ -73,8 +88,55 @@ class MailManifestTests(unittest.TestCase):
"mail.reference.credentials-egress-retirement", "mail.reference.credentials-egress-retirement",
"mail.reference.campaign-delivery-contract", "mail.reference.campaign-delivery-contract",
"mail.address-book-integration", "mail.address-book-integration",
"mail.workflow.legacy-pop3-import",
}.issubset(topics) }.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"] ownership = topics["mail.profile-ownership-and-consumers"]
self.assertEqual(ownership.metadata["kind"], "reference") self.assertEqual(ownership.metadata["kind"], "reference")
self.assertEqual(ownership.metadata["route"], "/settings?section=mail-profiles") 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()
+79
View File
@@ -821,6 +821,85 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
self.assertFalse(response.refreshing) self.assertFalse(response.refreshing)
self.assertEqual(session.commits, 1) 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: def test_unauthorized_profile_test_fails_before_credentials_or_provider_effect(self) -> None:
principal = _Principal({"mail:profile:test", "mail:profile:use"}) principal = _Principal({"mail:profile:test", "mail:profile:use"})
with ( with (
+106 -2
View File
@@ -14,14 +14,19 @@ from govoplan_mail.backend.db.models import (
MailDeliveryCommand, MailDeliveryCommand,
MailMailboxFolderIndex, MailMailboxFolderIndex,
MailMailboxMessageIndex, MailMailboxMessageIndex,
MailPop3Import,
MailServerEndpoint, MailServerEndpoint,
MailServerProfile, MailServerProfile,
) )
from govoplan_mail.backend.manifest import manifest from govoplan_mail.backend.manifest import manifest
from govoplan_mail.backend.provider_state import ( from govoplan_mail.backend.provider_state import (
IMAP_PROVIDER_ID, IMAP_PROVIDER_ID,
JMAP_PROVIDER_ID,
POP3_PROVIDER_ID,
SMTP_PROVIDER_ID, SMTP_PROVIDER_ID,
imap_provider_states, imap_provider_states,
jmap_provider_states,
pop3_provider_states,
smtp_provider_states, smtp_provider_states,
) )
@@ -38,6 +43,7 @@ class MailProviderStateTests(unittest.TestCase):
MailMailboxFolderIndex.__table__, MailMailboxFolderIndex.__table__,
MailMailboxMessageIndex.__table__, MailMailboxMessageIndex.__table__,
MailBounceSource.__table__, MailBounceSource.__table__,
MailPop3Import.__table__,
), ),
) )
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)() self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
@@ -120,17 +126,115 @@ class MailProviderStateTests(unittest.TestCase):
), ),
) )
self.assertEqual( 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}, {item.id for item in manifest.external_providers},
) )
self.assertEqual( self.assertEqual(
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID}, {SMTP_PROVIDER_ID, IMAP_PROVIDER_ID, JMAP_PROVIDER_ID, POP3_PROVIDER_ID},
{ {
item.provider_id item.provider_id
for item in manifest.external_provider_state_providers 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+3 -3
View File
@@ -1,17 +1,17 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.18", "version": "0.1.27",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.18", "version": "0.1.27",
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.19", "version": "0.1.27",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css" "./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
@@ -6,9 +6,11 @@ function read(relativePath) {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); 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 mailbox = read("../src/features/mail/MailboxPage.tsx");
const mailApi = read("../src/api/mail.ts");
const bounces = read("../src/features/mail/MailBouncePage.tsx"); const bounces = read("../src/features/mail/MailBouncePage.tsx");
const legacyImport = read("../src/features/mail/MailLegacyImportPage.tsx");
const moduleSource = read("../src/module.ts"); const moduleSource = read("../src/module.ts");
const styles = read("../src/styles/mail-profiles.css"); const styles = read("../src/styles/mail-profiles.css");
const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md"); const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md");
@@ -33,6 +35,32 @@ assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/); assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
assert.match(mailbox, /folder_mappings\?\.inbox/); assert.match(mailbox, /folder_mappings\?\.inbox/);
assert.match(mailbox, /detected_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(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
assert.match(bounces, /DocumentationHelpLink/); assert.match(bounces, /DocumentationHelpLink/);
@@ -40,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, /<ConfirmDialog[\s\S]*confirmLabel="Remove watcher"[\s\S]*tone="danger"/);
assert.match(bounces, /disabledReason=\{saveWatcherBlocker\}/); assert.match(bounces, /disabledReason=\{saveWatcherBlocker\}/);
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}`, /window\.(?:alert|confirm)\(/); for (const sharedComponent of ["PageLayout", "PageActionBar", "SelectionList", "DataGrid", "Dialog", "ConfirmDialog", "ToggleSwitch", "StatusBadge"]) {
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/); 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(moduleSource, /"mail\.profiles"/);
assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/); assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/);
assert.match(styles, /@media \(max-width: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/); assert.match(styles, /@media \(max-width: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-toolbar\.file-manager-toolbar[\s\S]*grid-template-columns: 1fr/); assert.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"]) { for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) {
assert.match(migration, new RegExp(archetype.replace("/", "\\/"))); assert.match(migration, new RegExp(archetype.replace("/", "\\/")));
+200 -15
View File
@@ -164,6 +164,20 @@ export type MailMailboxMessageResponse = {
message: MailMailboxMessageDetail; 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 = { export type MailSettingsDeltaResponse = {
profiles: MailServerProfile[]; profiles: MailServerProfile[];
policy?: MailProfilePolicyResponse | null; policy?: MailProfilePolicyResponse | null;
@@ -258,11 +272,14 @@ export async function createMailAddressContact(
}); });
} }
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> { type MailboxReadOptions = Pick<RequestInit, "cache" | "signal">;
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
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, include_inactive: includeInactive ? true : undefined,
campaign_id: campaignId campaign_id: campaignId
}); }), options);
return response.profiles ?? [];
} }
export async function fetchMailSettingsDelta( export async function fetchMailSettingsDelta(
@@ -296,7 +313,7 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean }; export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
export type MailServerEndpointPayload = { export type MailServerEndpointPayload = {
protocol: "smtp" | "imap"; protocol: "smtp" | "imap" | "jmap" | "pop3";
name: string; name: string;
config: Record<string, unknown>; config: Record<string, unknown>;
inherit_to_lower_scopes?: boolean | null; inherit_to_lower_scopes?: boolean | null;
@@ -304,6 +321,77 @@ export type MailServerEndpointPayload = {
is_active?: boolean; 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 = { export type MailCredentialCreatePayload = {
name: string; name: string;
description?: string | null; description?: string | null;
@@ -503,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( export async function listMailProfileImapFolders(
settings: ApiSettings, settings: ApiSettings,
profileId: string, profileId: string,
@@ -520,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`, { return apiFetch<MailImapFolderListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, {
include_status: includeStatus ? true : undefined, include_status: includeStatus ? true : undefined,
refresh: refresh ? true : undefined refresh: refresh ? true : undefined,
})); protocol
}), options);
} }
export async function bootstrapMailbox( export async function bootstrapMailbox(
@@ -533,14 +696,17 @@ export async function bootstrapMailbox(
folder = "INBOX", folder = "INBOX",
limit = 50, limit = 50,
offset = 0, offset = 0,
refresh = false refresh = false,
protocol: MailMailboxProtocol = "imap",
options?: MailboxReadOptions
): Promise<MailMailboxBootstrapResponse> { ): Promise<MailMailboxBootstrapResponse> {
return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, { return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, {
folder, folder,
limit, limit,
offset, offset,
refresh: refresh ? true : undefined refresh: refresh ? true : undefined,
})); protocol
}), options);
} }
export async function listMailboxMessages( export async function listMailboxMessages(
@@ -550,24 +716,43 @@ export async function listMailboxMessages(
limit = 50, limit = 50,
offset = 0, offset = 0,
cursor?: string | null, cursor?: string | null,
refresh = false refresh = false,
protocol: MailMailboxProtocol = "imap",
query?: string | null,
options?: MailboxReadOptions
): Promise<MailMailboxMessageListResponse> { ): Promise<MailMailboxMessageListResponse> {
return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, { return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
folder, folder,
limit, limit,
offset, offset,
cursor, cursor,
refresh: refresh ? true : undefined refresh: refresh ? true : undefined,
})); protocol,
q: query || undefined
}), options);
} }
export async function getMailboxMessage( export async function getMailboxMessage(
settings: ApiSettings, settings: ApiSettings,
profileId: string, profileId: string,
folder: string, folder: string,
uid: string uid: string,
protocol: MailMailboxProtocol = "imap",
options?: MailboxReadOptions
): Promise<MailMailboxMessageResponse> { ): 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( export async function testSmtpSettings(
@@ -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);
}
+10 -2
View File
@@ -16,6 +16,7 @@ import {
bootstrapMailbox, bootstrapMailbox,
listMailServerProfiles, listMailServerProfiles,
lookupMailAddresses, lookupMailAddresses,
type MailMailboxProtocol,
type MailMailboxMessageSummary type MailMailboxMessageSummary
} from "../../api/mail"; } from "../../api/mail";
import { mailLookupSuggestions, mailtoHref } from "./mailAddressIntegration"; import { mailLookupSuggestions, mailtoHref } from "./mailAddressIntegration";
@@ -53,9 +54,10 @@ export default function MailQuickAccess({
const lookupRequestRef = useRef(0); const lookupRequestRef = useRef(0);
const load = useCallback(async (): Promise<MailQuickAccessData> => { const load = useCallback(async (): Promise<MailQuickAccessData> => {
const profiles = await listMailServerProfiles(settings); const profiles = await listMailServerProfiles(settings);
const profile = profiles.find((item) => item.is_active && item.imap); const profile = profiles.find((item) => item.is_active && quickAccessMailboxProtocol(item));
if (!profile) return { messages: [], available: false }; if (!profile) return { messages: [], available: false };
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false); const protocol = quickAccessMailboxProtocol(profile) ?? "imap";
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false, protocol);
return { return {
profileName: profile.name, profileName: profile.name,
profileId: profile.id, profileId: profile.id,
@@ -188,6 +190,12 @@ export default function MailQuickAccess({
); );
} }
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 { function formatMessageDate(value?: string | null): string | undefined {
if (!value) return undefined; if (!value) return undefined;
+327 -116
View File
@@ -1,17 +1,22 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react"; import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { ToolbarGroup, ActionToolbar, import { ActionToolbar,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
CountBadge, CountBadge,
DataGridPaginationBar, DataGridPaginationBar,
DocumentationHelpLink, DocumentationHelpLink,
Dialog,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
FormField,
FormSection,
IconButton, IconButton,
LoadingIndicator, LoadingIndicator,
MessageDisplayPanel, MessageDisplayPanel,
WorkspaceActionBar,
WorkspaceFrame,
hasAnyScope, hasAnyScope,
formatDateTime, formatDateTime,
i18nMessage, i18nMessage,
@@ -25,11 +30,13 @@ import {
getMailboxMessage, getMailboxMessage,
listMailAddressWriteTargets, listMailAddressWriteTargets,
listMailboxMessages, listMailboxMessages,
listMailboxFolders,
listMailServerProfiles, listMailServerProfiles,
type MailAddressWriteTarget, type MailAddressWriteTarget,
type MailImapFolderResponse, type MailImapFolderResponse,
type MailMailboxMessageDetail, type MailMailboxMessageDetail,
type MailMailboxMessageSummary, type MailMailboxMessageSummary,
type MailMailboxProtocol,
type MailServerProfile } from type MailServerProfile } from
"../../api/mail"; "../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders"; import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
@@ -42,6 +49,11 @@ const MAILBOX_DOCUMENTATION = {
documentationType: "user" documentationType: "user"
} as const; } 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 }) { export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const location = useLocation(); const location = useLocation();
@@ -49,6 +61,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const [selectedProfileId, setSelectedProfileId] = useState(""); const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]); const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
const [selectedFolder, setSelectedFolder] = useState("INBOX"); const [selectedFolder, setSelectedFolder] = useState("INBOX");
const [selectedFolderGroup, setSelectedFolderGroup] = useState<Pick<MailFolderNode, "id" | "label"> | null>(null);
const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState(""); const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]); const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
@@ -68,50 +81,74 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const [folderError, setFolderError] = useState(""); const [folderError, setFolderError] = useState("");
const [messageError, setMessageError] = useState(""); const [messageError, setMessageError] = useState("");
const [detailError, setDetailError] = 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 selectedMessageKeyRef = useRef("");
const folderRequestRef = useRef(0); const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0); const messageListRequestRef = useRef(0);
const messageDetailRequestRef = 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 mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
const skipNextMessageLoadRef = useRef(false); const skipNextMessageLoadRef = useRef(false);
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search)); const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null; const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]); const mailboxProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile)), [profiles]);
const selectedMailboxProtocol = mailboxProtocolForProfile(selectedProfile) ?? "imap";
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]); 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 filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]);
const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize)); const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize));
const shellBusy = loadingProfiles || loadingFolders || loadingMessages; const shellBusy = loadingProfiles || loadingFolders || loadingMessages || reloadingMailbox;
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0; const noMailboxProfiles = !loadingProfiles && mailboxProfiles.length === 0;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId; const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : ""); const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady); const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const syncState = mailboxSyncState(messageProvenance); const syncState = mailboxSyncState(messageProvenance);
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26"); const folderEmptyText = folderError || (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 || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d"); const messageEmptyText = messageError || (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 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 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 const folderReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing folders." ? "Select an IMAP- or JMAP-enabled Mail profile before refreshing folders."
: loadingFolders || loadingMessages : shellBusy
? "Wait for the current mailbox refresh to finish." ? "Wait for the current mailbox refresh to finish."
: ""; : "";
const messageReloadBlocker = !selectedProfileId 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 : !selectedFolder || !foldersReady
? "Select a loaded mailbox folder before refreshing messages." ? "Select a loaded mailbox folder before refreshing messages."
: loadingMessages : shellBusy
? "Messages are already loading." ? "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(() => { useEffect(() => {
const request = parseMailboxLaunch(location.search); const request = parseMailboxLaunch(location.search);
launchRequestRef.current = request; launchRequestRef.current = request;
if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return; if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return;
const usable = profiles.filter((profile) => profile.is_active && profile.imap); const usable = profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile));
const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId) const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId)
? request.profileId ? request.profileId
: selectedProfileId || usable[0]?.id || ""; : selectedProfileId || usable[0]?.id || "";
@@ -133,6 +170,19 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
} }
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize); void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]); }, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => {
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(() => { useEffect(() => {
const request = launchRequestRef.current; const request = launchRequestRef.current;
@@ -149,10 +199,12 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
useEffect(() => { useEffect(() => {
const handlePreviewShortcut = (event: KeyboardEvent) => { const handlePreviewShortcut = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return; if (mailToolsOpen || isEditableTarget(event.target)) return;
if (event.key === "Escape") { if (event.key === "Escape") {
if (selectedMessage || selectedMessageKey || pendingMessageKey || detailError) { if (selectedMessage || selectedMessageKey || pendingMessageKey || detailError) {
event.preventDefault(); event.preventDefault();
messageDetailRequestRef.current += 1;
setLoadingMessage(false);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
selectedMessageKeyRef.current = ""; selectedMessageKeyRef.current = "";
@@ -176,55 +228,88 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
window.addEventListener("keydown", handlePreviewShortcut); window.addEventListener("keydown", handlePreviewShortcut);
return () => window.removeEventListener("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(() => { useEffect(() => {
const revealKey = `${selectedProfileId}:${selectedFolder}`;
if (revealedFolderRef.current === revealKey) return;
const ancestorIds = folderAncestorIds(folderTree, selectedFolder); const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
if (ancestorIds.length === 0) return; if (ancestorIds.length === 0) return;
revealedFolderRef.current = revealKey;
setExpandedFolders((current) => { setExpandedFolders((current) => {
const next = new Set(current); const next = new Set(current);
ancestorIds.forEach((id) => next.add(id)); ancestorIds.forEach((id) => next.add(id));
return next; return next;
}); });
}, [folderTree, selectedFolder]); }, [folderTree, selectedFolder, selectedProfileId]);
async function loadProfiles() { async function loadProfiles() {
const requestId = ++profileRequestRef.current;
const requestAuthority = authorityRef.current;
setLoadingProfiles(true); setLoadingProfiles(true);
setError(""); setError("");
try { try {
const loaded = await listMailServerProfiles(settings); const loaded = await listMailServerProfiles(settings, false, undefined, MAILBOX_READ_OPTIONS);
const usable = loaded.filter((profile) => profile.is_active && profile.imap); 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; const requestedProfileId = parseMailboxLaunch(location.search).profileId;
setProfiles(loaded); setProfiles(loaded);
setSelectedProfileId((current) => const currentId = selectedProfileIdRef.current;
requestedProfileId && usable.some((profile) => profile.id === requestedProfileId) const next = usable.find((profile) => profile.id === currentId)
? requestedProfileId ?? usable.find((profile) => profile.id === requestedProfileId)
: current && usable.some((profile) => profile.id === current) ?? usable[0] ?? null;
? current const changed = (next?.id ?? "") !== currentId;
: usable[0]?.id ?? "" if (changed) selectProfile(next?.id ?? "");
); return { selected: next, changed };
if (usable.length === 0) {
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
}
} catch (err) { } catch (err) {
if (requestId !== profileRequestRef.current || requestAuthority !== authorityRef.current) return null;
setError(errorText(err)); setError(errorText(err));
return null;
} finally { } 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; if (!profileId) return;
const profileInbox = profiles.find((profile) => profile.id === profileId)?.imap?.folder_mappings?.inbox || ""; 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 requestedLaunch = launchRequestRef.current;
const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId) const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId)
? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null) ? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null)
@@ -234,9 +319,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
: profileInbox || "INBOX"); : profileInbox || "INBOX");
const folderRequestId = ++folderRequestRef.current; const folderRequestId = ++folderRequestRef.current;
const messageRequestId = ++messageListRequestRef.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); setLoadingFolders(true);
setLoadingMessages(true); setLoadingMessages(true);
if (!preserve) {
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessageTotalCount(null); setMessageTotalCount(null);
setMessageProvenance(null); setMessageProvenance(null);
@@ -244,18 +341,16 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
selectedMessageKeyRef.current = ""; selectedMessageKeyRef.current = "";
setPendingMessageKey(""); }
setFolderError(""); setFolderError("");
setMessageError(""); setMessageError("");
setDetailError(""); setDetailError("");
setError(""); setError("");
try { try {
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh); const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, (requestedPage - 1) * messagePageSize, refresh, mailboxProtocol, MAILBOX_READ_OPTIONS);
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return; if (!isCurrent()) return;
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e"); if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }]; const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
const loadedMessages = response.messages.messages ?? [];
const total = response.messages.total_count ?? loadedMessages.length;
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX"; let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
if (!loadedFolders.some((folder) => folder.name === nextFolder)) { if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
const detectedInbox = response.folders.detected_folder_mappings?.inbox; const detectedInbox = response.folders.detected_folder_mappings?.inbox;
@@ -263,27 +358,54 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
? detectedInbox ? detectedInbox
: loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX"; : 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 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 = { mailboxPageCursorsRef.current = {
[`${cursorKey}:1`]: null, [`${cursorKey}:${nextPage}`]: null,
[`${cursorKey}:2`]: response.messages.next_cursor ?? 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); setFolders(foldersWithCounts);
setExpandedFolders(new Set()); if (!preserve) setExpandedFolders(new Set());
setSelectedFolderGroup(null);
setSelectedFolder(nextFolder); setSelectedFolder(nextFolder);
setMessagePage(nextPage);
setFoldersLoadedForProfile(profileId); setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages); setMessages(loadedMessages);
setMessageTotalCount(total); setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(response.messages)); 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) { } catch (err) {
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return; if (!isCurrent()) return;
const message = errorText(err); const message = errorText(err);
skipNextMessageLoadRef.current = false; skipNextMessageLoadRef.current = false;
setFolderError(message); setFolderError(message);
setMessageError(message); setMessageError(message);
setError(message); setError(message);
if (!preserve) {
loadedMessagePageRef.current = null;
setFolders([]); setFolders([]);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
@@ -293,28 +415,44 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
selectedMessageKeyRef.current = ""; selectedMessageKeyRef.current = "";
setPendingMessageKey(""); setPendingMessageKey("");
}
} finally { } finally {
if (folderRequestId === folderRequestRef.current) setLoadingFolders(false); if (folderRequestId === folderRequestRef.current && requestAuthority === authorityRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current) setLoadingMessages(false); if (messageRequestId === messageListRequestRef.current && requestAuthority === authorityRef.current) setLoadingMessages(false);
} }
} }
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) { async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) {
if (!profileId || !folder) return; if (!profileId || !folder) return;
const mailboxProtocol = mailboxProtocolForProfile(profiles.find((profile) => profile.id === profileId) ?? null) ?? "imap";
const requestId = ++messageListRequestRef.current; 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 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; const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
setLoadingMessages(true); setLoadingMessages(true);
setMessageProvenance(null); if (!refresh) setMessageProvenance(null);
setMessageError(""); setMessageError("");
setDetailError(""); setDetailError("");
setError(""); setError("");
try { try {
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor, refresh); const response = await listMailboxMessages(
if (requestId !== messageListRequestRef.current) return; settings,
profileId,
folder,
pageSize,
offset,
cursor,
refresh,
mailboxProtocol,
mailboxProtocol === "jmap" ? messageQuery : null,
MAILBOX_READ_OPTIONS
);
if (!isCurrent()) return;
const loaded = response.messages ?? []; const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length; const total = response.total_count ?? loaded.length;
loadedMessagePageRef.current = { profileId, folder, page, pageSize };
if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null; if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null;
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null; mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
setMessages(loaded); setMessages(loaded);
@@ -323,66 +461,87 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFolderMessageCount(folder, total); setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current; const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) { if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
messageDetailRequestRef.current += 1;
setLoadingMessage(false);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
selectedMessageKeyRef.current = "";
} }
} catch (err) { } catch (err) {
if (requestId !== messageListRequestRef.current) return; if (!isCurrent()) return;
const message = errorText(err); const message = errorText(err);
setMessageError(message); setMessageError(message);
setError(message); setError(message);
setMessages([]); const committed = loadedMessagePageRef.current;
setMessageTotalCount(null); if (committed?.profileId === profileId && committed.folder === folder && (committed.page !== page || committed.pageSize !== pageSize)) {
setMessageProvenance(null); // Retained rows must keep their actual page/size labels after a failed
setSelectedMessage(null); // pagination read; restoring controls is not a second provider request.
setSelectedMessageKeyState(""); skipNextMessageLoadRef.current = true;
setPendingMessageKey(""); 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 { } finally {
if (requestId === messageListRequestRef.current) setLoadingMessages(false); if (isCurrent()) setLoadingMessages(false);
} }
} }
async function openMessage(message: MailMailboxMessageSummary) { async function openMessage(message: MailMailboxMessageSummary, profileId = selectedProfileId, protocol = selectedMailboxProtocol, preservePreview = false) {
if (!selectedProfileId) return; if (!profileId) return;
const folderName = message.folder || selectedFolder; const folderName = message.folder || selectedFolder;
const requestId = ++messageDetailRequestRef.current; 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); const nextKey = mailboxMessageKey(folderName, message.uid);
setSelectedMessageKeyState(nextKey); setSelectedMessageKeyState(nextKey);
selectedMessageKeyRef.current = nextKey; selectedMessageKeyRef.current = nextKey;
setPendingMessageKey(nextKey); setPendingMessageKey(nextKey);
setSelectedMessage(null); if (!preservePreview) setSelectedMessage(null);
setDetailError(""); setDetailError("");
setLoadingMessage(true); setLoadingMessage(true);
setError(""); setError("");
try { try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid); const response = await getMailboxMessage(settings, profileId, folderName, message.uid, protocol, MAILBOX_READ_OPTIONS);
if (requestId !== messageDetailRequestRef.current) return; if (!isCurrent()) return;
setSelectedMessage(response.message); setSelectedMessage(response.message);
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid)); setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
setPendingMessageKey(""); setPendingMessageKey("");
} catch (err) { } catch (err) {
if (requestId !== messageDetailRequestRef.current) return; if (!isCurrent()) return;
const messageText = errorText(err); const messageText = errorText(err);
setDetailError(messageText); setDetailError(messageText);
setError(messageText); setError(messageText);
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } finally {
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false); if (isCurrent()) setLoadingMessage(false);
} }
} }
function selectProfile(profileId: string) { function selectProfile(profileId: string) {
loadedMessagePageRef.current = null;
mailboxReloadRequestRef.current += 1;
folderRequestRef.current += 1; folderRequestRef.current += 1;
messageListRequestRef.current += 1; messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1; messageDetailRequestRef.current += 1;
setSelectedProfileId(profileId); setSelectedProfileId(profileId);
selectedProfileIdRef.current = profileId;
setReloadingMailbox(false);
setSelectedFolderGroup(null);
setFolders([]); setFolders([]);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setMessageProvenance(null); setMessageProvenance(null);
setMessagePage(1); setMessagePage(1);
setSelectedFolder("INBOX");
setMessageQuery("");
setError("");
setFolderError("");
setMessageError("");
setDetailError("");
revealedFolderRef.current = "";
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState(""); setSelectedMessageKeyState("");
selectedMessageKeyRef.current = ""; selectedMessageKeyRef.current = "";
@@ -397,8 +556,16 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
function openFolderNode(node: MailFolderNode) { function openFolderNode(node: MailFolderNode) {
if (node.folderName) { if (node.folderName) {
setSelectedFolderGroup(null);
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else 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); setSelectedFolder(node.folderName);
setMessagePage(1); setMessagePage(1);
setSelectedMessage(null); setSelectedMessage(null);
@@ -409,7 +576,26 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
} }
return; 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) { function toggleFolderNode(node: MailFolderNode) {
@@ -436,9 +622,34 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
} }
return ( 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>} {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" : ""}`}> <div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4"> <aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
<div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div> <div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
@@ -473,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"> <section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
<div className="file-list-sticky"> <div className="file-list-sticky">
<ActionToolbar justify="between" className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08"> {noMailboxProfiles &&
<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>
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</ToolbarGroup>
<ToolbarGroup align="end" 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>
</ToolbarGroup>
</ActionToolbar>
{noImapProfiles &&
<ActionBlockerHint <ActionBlockerHint
className="mailbox-profile-blocker" className="mailbox-profile-blocker"
reason={{ reason={{
summary: "No IMAP-enabled Mail profile is available.", summary: "No mailbox-enabled Mail profile is available.",
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP server and credential.", 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-enabled profile.", requiredAction: "Ask a Mail administrator to configure and authorize an IMAP- or JMAP-enabled profile.",
actor: "Mail profile administrator", actor: "Mail profile administrator",
target: "Settings or Administration > Mail profiles" target: "Settings or Administration > Mail profiles"
}} }}
@@ -519,7 +699,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<nav className="file-breadcrumbs" aria-label="i18n:govoplan-mail.current_mailbox_folder.55e2aea5"> <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 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> </nav>
<div className="mailbox-filter-row"> <div className="mailbox-filter-row">
@@ -643,7 +823,20 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</div> </div>
} }
</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>);
} }
@@ -792,8 +985,8 @@ function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`; return `${folder || "INBOX"}::${uid}`;
} }
function mailboxCursorKey(profileId: string, folder: string, pageSize: number): string { function mailboxCursorKey(profileId: string, folder: string, pageSize: number, query: string): string {
return `${profileId}::${folder || "INBOX"}::${pageSize}`; return `${profileId}::${folder || "INBOX"}::${pageSize}::${query.trim()}`;
} }
function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance { function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance {
@@ -855,11 +1048,29 @@ function displayFolderFlag(flag: string): string | null {
} }
function transportLabel(profile: MailServerProfile): string { 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; const imap = profile.imap;
if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3"; if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3";
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim(); 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 { function formatBytes(value?: number | null): string {
if (!value) return "-"; if (!value) return "-";
if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value }); if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });
@@ -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 = { export type MailProfilePolicy = {
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null; whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
@@ -8,6 +8,7 @@ export type MailProfilePolicy = {
export type MailPolicyValidationInput = { export type MailPolicyValidationInput = {
smtpHost?: string | null; smtpHost?: string | null;
imapHost?: string | null; imapHost?: string | null;
jmapHost?: string | null;
envelopeSender?: string | null; envelopeSender?: string | null;
fromHeader?: string | null; fromHeader?: string | null;
recipientDomains?: Array<string | null | undefined> | null; recipientDomains?: Array<string | null | undefined> | null;
@@ -24,6 +25,7 @@ export type MailPolicyValidationMessage = {
const patternLabels: Record<MailProfilePatternKey, string> = { const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "i18n:govoplan-mail.smtp_host.2d4a434b", smtp_hosts: "i18n:govoplan-mail.smtp_host.2d4a434b",
imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751", imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751",
jmap_hosts: "JMAP host",
envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0", envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0",
from_headers: "i18n:govoplan-mail.from_header.bb78e85d", from_headers: "i18n:govoplan-mail.from_header.bb78e85d",
recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf" recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf"
@@ -43,6 +45,7 @@ input: MailPolicyValidationInput)
const checks: ValueCheck[] = [ const checks: ValueCheck[] = [
{ key: "smtp_hosts", value: input.smtpHost ?? "" }, { key: "smtp_hosts", value: input.smtpHost ?? "" },
{ key: "imap_hosts", value: input.imapHost ?? "" }, { key: "imap_hosts", value: input.imapHost ?? "" },
{ key: "jmap_hosts", value: input.jmapHost ?? "" },
{ key: "envelope_senders", value: input.envelopeSender ?? "" }, { key: "envelope_senders", value: input.envelopeSender ?? "" },
{ key: "from_headers", value: input.fromHeader ?? "" }, { key: "from_headers", value: input.fromHeader ?? "" },
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))). ...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))).
@@ -1,5 +1,5 @@
export type MailProfileProtocol = "smtp" | "imap"; export type MailProfileProtocol = "smtp" | "imap" | "jmap";
export type MailProfileEditSection = MailProfileProtocol; export type MailProfileEditSection = "smtp" | "imap";
export type MailProfilePanelMode = "all" | "server" | "credentials"; export type MailProfilePanelMode = "all" | "server" | "credentials";
export type MailProfileCreateStage = export type MailProfileCreateStage =
| "profile" | "profile"
@@ -54,9 +54,11 @@ export type MailProfileTargetedUpdateParts = {
profile: Record<string, unknown>; profile: Record<string, unknown>;
smtp: Record<string, unknown>; smtp: Record<string, unknown>;
imap: Record<string, unknown> | null; imap: Record<string, unknown> | null;
jmap?: Record<string, unknown> | null;
credentials: { credentials: {
smtp: MailProfileTransportCredentialsLike; smtp: MailProfileTransportCredentialsLike;
imap: MailProfileTransportCredentialsLike; imap: MailProfileTransportCredentialsLike;
jmap?: MailProfileTransportCredentialsLike;
}; };
clearImap: boolean; clearImap: boolean;
}; };
@@ -72,7 +74,7 @@ export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike)
} }
export function mailProfileEditTargetInitialSection(target: MailProfileEditTarget): MailProfileEditSection { 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"; return "smtp";
} }
@@ -84,7 +86,7 @@ export function mailProfileEditTargetPanelMode(target: MailProfileEditTarget): M
} }
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] { 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"]; if (target.kind === "create") return ["smtp", "imap"];
return []; return [];
} }
@@ -94,7 +96,7 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT
} }
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean { 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( export function mailProfileCreateStagePanel(
@@ -152,12 +154,16 @@ export function mailProfileTargetedUpdatePayload(
if (target.kind === "profile") return parts.profile; if (target.kind === "profile") return parts.profile;
if (target.kind === "server") { if (target.kind === "server") {
if (target.protocol === "smtp") return { smtp: parts.smtp }; if (target.protocol === "smtp") return { smtp: parts.smtp };
if (target.protocol === "jmap") return { jmap: parts.jmap ?? null };
return parts.imap === null return parts.imap === null
? { imap: null, clear_imap: parts.clearImap } ? { imap: null, clear_imap: parts.clearImap }
: { imap: parts.imap }; : { imap: parts.imap };
} }
if (target.kind === "credentials") { 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"); throw new Error("Create is not an update target");
} }
@@ -4,6 +4,7 @@ import {
type ApiSettings, type ApiSettings,
type CredentialReferenceSelectorContext, type CredentialReferenceSelectorContext,
type CredentialReferenceSelectorsUiCapability, type CredentialReferenceSelectorsUiCapability,
type DeltaDeletedItem,
type ReferenceOption, type ReferenceOption,
type ReferenceOptionProvider type ReferenceOptionProvider
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
@@ -24,7 +25,11 @@ export function createMailServerReferenceProvider(
let cataloguePromise: Promise<ReferenceOption[]> | null = null; let cataloguePromise: Promise<ReferenceOption[]> | null = null;
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> { 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) .then(mailServerOptions)
.catch((error: unknown) => { .catch((error: unknown) => {
cataloguePromise = null; cataloguePromise = null;
@@ -95,8 +100,7 @@ function mailServerOptions(
async function loadProfiles( async function loadProfiles(
settings: ApiSettings, settings: ApiSettings,
context: CredentialReferenceSelectorContext, context: CredentialReferenceSelectorContext
signal: AbortSignal
): Promise<MailServerProfile[]> { ): Promise<MailServerProfile[]> {
let watermark: string | null = null; let watermark: string | null = null;
let profiles: MailServerProfile[] = []; let profiles: MailServerProfile[] = [];
@@ -109,7 +113,6 @@ async function loadProfiles(
since: first ? null : watermark, since: first ? null : watermark,
limit: 200 limit: 200
}); });
if (signal.aborted) throw abortError();
profiles = response.full profiles = response.full
? response.profiles ? response.profiles
: mergeProfiles(profiles, response.profiles, response.deleted); : mergeProfiles(profiles, response.profiles, response.deleted);
@@ -123,12 +126,12 @@ async function loadProfiles(
function mergeProfiles( function mergeProfiles(
current: readonly MailServerProfile[], current: readonly MailServerProfile[],
changed: readonly MailServerProfile[], changed: readonly MailServerProfile[],
deleted: readonly { resource_type: string; resource_id: string }[] deleted: readonly DeltaDeletedItem[]
): MailServerProfile[] { ): MailServerProfile[] {
const removed = new Set( const removed = new Set(
deleted deleted
.filter((item) => item.resource_type === "mail_profile") .filter((item) => item.resource_type === "mail_profile")
.map((item) => item.resource_id) .map((item) => item.id)
); );
const merged = new Map( const merged = new Map(
current current
+62 -2
View File
@@ -2,6 +2,31 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "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.active.a733b809": "Active",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override", "i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow", "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_b": "{value0} B",
"i18n:govoplan-mail.bytes_kb": "{value0} KB", "i18n:govoplan-mail.bytes_kb": "{value0} KB",
"i18n:govoplan-mail.bytes_mb": "{value0} MB", "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.920ecb62": "profiles scoped to campaigns",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles", "i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles",
"i18n:govoplan-mail.campaigns": "Campaigns", "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_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate", "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_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.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Description", "i18n:govoplan-mail.description.55f8ebc8": "Description",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Development mock mailbox", "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_preview.ebd86225": "Loading preview...",
"i18n:govoplan-mail.loading.33ce4174": "Loading…", "i18n:govoplan-mail.loading.33ce4174": "Loading…",
"i18n:govoplan-mail.loading.b04ba49f": "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_required.1f5f4aba": "Local required",
"i18n:govoplan-mail.local_setting.967607a9": "Local setting", "i18n:govoplan-mail.local_setting.967607a9": "Local setting",
"i18n:govoplan-mail.local.dc99d54d": "Local", "i18n:govoplan-mail.local.dc99d54d": "Local",
@@ -152,6 +180,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.", "i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.", "i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles", "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_patterns.68466f5b": "Recipient domain patterns.",
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain", "i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains", "i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
@@ -193,6 +222,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.to.ae79ea1e": "To", "i18n:govoplan-mail.to.ae79ea1e": "To",
"i18n:govoplan-mail.transport.c10d76c9": "Transport", "i18n:govoplan-mail.transport.c10d76c9": "Transport",
"i18n:govoplan-mail.trash.e3bf62bb": "Trash", "i18n:govoplan-mail.trash.e3bf62bb": "Trash",
"i18n:govoplan-mail.unread.66c78634": "Unread",
"i18n:govoplan-mail.user_profiles.57730285": "User profiles", "i18n:govoplan-mail.user_profiles.57730285": "User profiles",
"i18n:govoplan-mail.user.9f8a2389": "User", "i18n:govoplan-mail.user.9f8a2389": "User",
"i18n:govoplan-mail.users": "Users", "i18n:govoplan-mail.users": "Users",
@@ -212,6 +242,31 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.working.049ac820": "Working..." "i18n:govoplan-mail.working.049ac820": "Working..."
}, },
"de": { "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.active.a733b809": "Aktiv",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override", "i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow", "i18n:govoplan-mail.allow.3ad0e369": "Allow",
@@ -227,6 +282,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.bytes_b": "{value0} B", "i18n:govoplan-mail.bytes_b": "{value0} B",
"i18n:govoplan-mail.bytes_kb": "{value0} KB", "i18n:govoplan-mail.bytes_kb": "{value0} KB",
"i18n:govoplan-mail.bytes_mb": "{value0} MB", "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.920ecb62": "kampagnenbezogene Profile",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Kampagnenbezogene Profile", "i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Kampagnenbezogene Profile",
"i18n:govoplan-mail.campaigns": "Kampagnen", "i18n:govoplan-mail.campaigns": "Kampagnen",
@@ -248,7 +305,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}", "i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate", "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_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.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Beschreibung", "i18n:govoplan-mail.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox", "i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox",
@@ -295,6 +352,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...", "i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
"i18n:govoplan-mail.loading.33ce4174": "Loading…", "i18n:govoplan-mail.loading.33ce4174": "Loading…",
"i18n:govoplan-mail.loading.b04ba49f": "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_required.1f5f4aba": "Local required",
"i18n:govoplan-mail.local_setting.967607a9": "Local setting", "i18n:govoplan-mail.local_setting.967607a9": "Local setting",
"i18n:govoplan-mail.local.dc99d54d": "Local", "i18n:govoplan-mail.local.dc99d54d": "Local",
@@ -362,6 +420,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.", "i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.", "i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles", "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_patterns.68466f5b": "Recipient domain patterns.",
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain", "i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains", "i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
@@ -403,6 +462,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.to.ae79ea1e": "To", "i18n:govoplan-mail.to.ae79ea1e": "To",
"i18n:govoplan-mail.transport.c10d76c9": "Transport", "i18n:govoplan-mail.transport.c10d76c9": "Transport",
"i18n:govoplan-mail.trash.e3bf62bb": "Trash", "i18n:govoplan-mail.trash.e3bf62bb": "Trash",
"i18n:govoplan-mail.unread.66c78634": "Ungelesen",
"i18n:govoplan-mail.user_profiles.57730285": "User profiles", "i18n:govoplan-mail.user_profiles.57730285": "User profiles",
"i18n:govoplan-mail.user.9f8a2389": "User", "i18n:govoplan-mail.user.9f8a2389": "User",
"i18n:govoplan-mail.users": "Benutzer", "i18n:govoplan-mail.users": "Benutzer",
+11 -5
View File
@@ -1,5 +1,5 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule, QuickAccessToolsUiCapability } 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 { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
import { validateMailPolicy } from "./features/mail/mailPolicyValidation"; import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders"; import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
@@ -9,11 +9,13 @@ import "./styles/mail-profiles.css";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage")); const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const MailBouncePage = lazy(() => import("./features/mail/MailBouncePage")); const MailBouncePage = lazy(() => import("./features/mail/MailBouncePage"));
const MailLegacyImportPage = lazy(() => import("./features/mail/MailLegacyImportPage"));
const mailboxRead = ["mail:mailbox:read"]; const mailboxRead = ["mail:mailbox:read"];
const bounceRead = ["mail:bounce:read", "mail:bounce:manage"]; const bounceRead = ["mail:bounce:read", "mail:bounce:manage"];
const legacyImportAccess = ["mail:pop3:import", "mail:pop3:manage"];
const translations = { const translations = {
en: generatedTranslations.en, en: { ...generatedTranslations.en, ...messagesProductSurfaceTranslations.en },
de: generatedTranslations.de de: { ...generatedTranslations.de, ...messagesProductSurfaceTranslations.de }
}; };
const mailQuickAccessTools: QuickAccessToolsUiCapability = { const mailQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [ tools: [
@@ -39,10 +41,14 @@ export const mailModule: PlatformWebModule = {
{ 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 } { id: "mail.quick_access.messages", moduleId: "mail", kind: "quick_access", label: "Mail Quick Access", order: 80 }
], ],
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }], navItems: [
{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 },
{ to: "/mail/legacy-import", label: "Legacy POP3 import", iconName: "mail", anyOf: legacyImportAccess, order: 52 }
],
routes: [ routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings, auth }) => createElement(MailboxPage, { settings, auth }) }, { 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: { uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability, "mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
+23 -68
View File
@@ -181,18 +181,6 @@
flex-direction: column; flex-direction: column;
} }
} }
.mailbox-page {
display: grid;
gap: 18px;
}
.mailbox-toolbar label {
display: grid;
gap: 5px;
min-width: min(340px, 100%);
}
.mailbox-toolbar label span,
.mailbox-preview-header span { .mailbox-preview-header span {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
@@ -200,15 +188,6 @@
text-transform: uppercase; text-transform: uppercase;
} }
.mailbox-toolbar-meta {
flex: 1;
min-width: 0;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-layout { .mailbox-layout {
display: grid; display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1.25fr) minmax(320px, .75fr); grid-template-columns: minmax(190px, 240px) minmax(0, 1.25fr) minmax(320px, .75fr);
@@ -388,11 +367,6 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.mailbox-toolbar {
align-items: stretch;
flex-direction: column;
}
.mailbox-layout { .mailbox-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -402,7 +376,10 @@
/* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */ /* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */
.mailbox-shell.file-manager-shell { .mailbox-shell.file-manager-shell {
position: relative; position: relative;
flex: 1 1 auto;
min-height: 0;
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr); 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 { .mailbox-shell.is-loading .mailbox-preview-panel {
@@ -451,45 +428,11 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.mailbox-toolbar.file-manager-toolbar {
display: grid;
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
gap: 12px;
}
.mailbox-profile-field { .mailbox-profile-field {
display: grid; flex: 0 1 20rem;
gap: 4px;
min-width: 0; 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 { .mailbox-profile-blocker {
margin: 12px; margin: 12px;
} }
@@ -680,25 +623,37 @@
@media (max-width: 1280px) { @media (max-width: 1280px) {
.mailbox-shell.file-manager-shell { .mailbox-shell.file-manager-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
grid-template-rows: minmax(0, 1.4fr) minmax(0, 1fr);
} }
.mailbox-preview-panel { .mailbox-preview-panel {
grid-column: 1 / -1; grid-column: 1 / -1;
min-height: 320px; min-height: 0;
border-top: var(--border-line); border-top: var(--border-line);
} }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.mailbox-toolbar.file-manager-toolbar { .mailbox-shell.file-manager-shell {
grid-template-columns: 1fr; 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 { .mailbox-page .mailbox-message-list-panel {
justify-content: flex-start; 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; display: none;
} }
@@ -706,7 +661,7 @@
min-width: 0; min-width: 0;
} }
.mailbox-message-row { .mailbox-page .mailbox-message-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px; gap: 8px;
} }
@@ -21,11 +21,13 @@ assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp12.example.org"), f
const policy = { const policy = {
whitelist: { whitelist: {
smtp_hosts: ["smtp.allowed.test"], smtp_hosts: ["smtp.allowed.test"],
jmap_hosts: ["jmap.allowed.test"],
from_headers: ["*@allowed.test"], from_headers: ["*@allowed.test"],
recipient_domains: ["allowed.test"] recipient_domains: ["allowed.test"]
}, },
blacklist: { blacklist: {
smtp_hosts: ["smtp.blocked.test"], smtp_hosts: ["smtp.blocked.test"],
jmap_hosts: ["jmap.blocked.test"],
envelope_senders: ["blocked@*"] envelope_senders: ["blocked@*"]
} }
}; };
@@ -33,14 +35,17 @@ const policy = {
assertDeepEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.allowed.test"), { allowed: true, value: "smtp.allowed.test" }); 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.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, "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, { const messages = validateMailPolicy(policy, {
smtpHost: "smtp.other.test", smtpHost: "smtp.other.test",
jmapHost: "jmap.blocked.test",
envelopeSender: "blocked@allowed.test", envelopeSender: "blocked@allowed.test",
fromHeader: "sender@other.test", fromHeader: "sender@other.test",
recipientDomains: ["allowed.test", "denied.test", "user@denied.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 === "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 === "envelope_senders" && item.value === "blocked@allowed.test"));
assert(messages.some((item) => item.key === "from_headers" && item.value === "sender@other.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"); 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); assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "create" }), ["smtp", "imap"]); assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "create" }), ["smtp", "imap"]);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["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: "profile" }), true);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false); assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false); assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
@@ -118,9 +121,11 @@ const updateParts = {
profile: { name: "Renamed" }, profile: { name: "Renamed" },
smtp: { host: "smtp.example.org" }, smtp: { host: "smtp.example.org" },
imap: { host: "imap.example.org" }, imap: { host: "imap.example.org" },
jmap: { session_url: "https://jmap.example.org/.well-known/jmap" },
credentials: { credentials: {
smtp: { username: "smtp-user", password: "smtp-secret" }, 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 clearImap: false
}; };
@@ -139,6 +144,16 @@ assertDeepEqual(
{ credentials: { imap: { username: "imap-user", password: "imap-secret" } } }, { credentials: { imap: { username: "imap-user", password: "imap-secret" } } },
"credential edits send only the selected protocol" "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( assertEqual(
mailProfileCreateCredentialsPayload({ username: null }, { password: "" }), mailProfileCreateCredentialsPayload({ username: null }, { password: "" }),
undefined, undefined,