Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48205e6e46 | ||
|
|
c31f9b69cb | ||
|
|
1cbf4acaf4 | ||
|
|
fd808336bc | ||
|
|
218fef11f1 | ||
|
|
93ecedf607 | ||
|
|
cfd3847524 | ||
|
|
2fe56fca00 | ||
|
|
34bd5be8d4 | ||
|
|
a044fee379 | ||
|
|
169b81d9db | ||
|
|
2ad122056e | ||
|
|
9ac8847559 | ||
|
|
e0e00d7000 | ||
|
|
393331574a | ||
|
|
bb8c60abb9 | ||
|
|
c59c67bd18 | ||
|
|
761dd76b9e | ||
|
|
28f357e151 | ||
|
|
cff428b475 | ||
|
|
77f44dfab5 | ||
|
|
2c5585ba99 | ||
|
|
62721d204d | ||
|
|
e41d05914a |
@@ -4,7 +4,7 @@
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile management, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
|
||||
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP/JMAP profile management, an explicitly enabled legacy POP3 import path, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -12,16 +12,26 @@ This repository owns:
|
||||
|
||||
- backend module manifest `mail`
|
||||
- mail permissions such as `mail:profile:read`, `mail:profile:write_own`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
|
||||
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
|
||||
- SMTP/IMAP/JMAP profile models, dedicated legacy POP3 sources, policy checks, encrypted credential storage, and profile resolution
|
||||
- SMTP send and IMAP append adapters, including mock transports for development
|
||||
- development mock mailbox endpoints used by test-send flows
|
||||
- WebUI package `@govoplan/mail-webui` with profile management, policy management, and read-only mailbox components
|
||||
- WebUI package `@govoplan/mail-webui` with profile management, policy management, read-only mailbox components, and governed POP3 import
|
||||
|
||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
||||
|
||||
Mail publishes `privacy.dsar.mail` for Core's governed data-subject-request
|
||||
workflow. It isolates matching mailbox header parties and returns bounded index,
|
||||
personal-profile, delivery, reconciliation, bounce, and imported-message metadata. SMTP/IMAP/JMAP/POP3
|
||||
configuration and credentials, encrypted messages and envelopes, source UIDL
|
||||
and folder/UID
|
||||
locators, worker and idempotency state, diagnostics, and opaque evidence are
|
||||
excluded. Delivery and bounce outcomes remain retained evidence; mailbox and
|
||||
profile changes require coordinated Mail and external-provider review, so the
|
||||
provider does not perform direct erasure.
|
||||
|
||||
## Profile and credential ownership
|
||||
|
||||
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
|
||||
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP/JMAP
|
||||
endpoints, encrypted credentials, tests, scope, and policy. Consumers such as
|
||||
Campaign store only a stable profile identifier and resolve the authorized,
|
||||
active profile through `mail.campaign_delivery`; they never copy or override
|
||||
@@ -41,6 +51,18 @@ and requires explicit evidence-backed reconciliation before any deliberate
|
||||
resend. Business readers receive only counts and sanitized state; recipient
|
||||
refusal details require `mail:delivery:diagnostic`.
|
||||
|
||||
Synchronous Campaign batches now preflight DNS, egress, connectivity, TLS, and
|
||||
authentication before the first message, then reuse the authorized SMTP
|
||||
connection for the bounded batch. A health check precedes reuse; a stale
|
||||
connection is reopened before the next message, while a connection loss after
|
||||
DATA starts remains outcome-unknown and is never replayed automatically.
|
||||
Systemic authentication, sender, and connectivity failures pause remaining
|
||||
Campaign jobs instead of producing one failure per recipient. Deployment
|
||||
operators can disable reuse or bound connection lifetime and reconnects with
|
||||
`GOVOPLAN_SMTP_BATCH_REUSE`, `GOVOPLAN_SMTP_BATCH_MAX_MESSAGES`,
|
||||
`GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS`, and
|
||||
`GOVOPLAN_SMTP_BATCH_HEALTH_CHECK`.
|
||||
|
||||
SMTP effects decrypt only SMTP credentials; Sent-folder effects decrypt only
|
||||
IMAP credentials. A connection loss after an effect starts is surfaced as an
|
||||
unknown outcome. Campaign does not automatically retry an unknown IMAP append,
|
||||
@@ -65,7 +87,7 @@ closed with guidance to store those credentials on a Mail profile and enable
|
||||
inheritance.
|
||||
|
||||
Deleting a profile deactivates its non-secret tombstone metadata and scrubs
|
||||
both encrypted SMTP and IMAP passwords immediately in the same transaction as
|
||||
its encrypted SMTP, IMAP, JMAP, and POP3 credentials immediately in the same transaction as
|
||||
a non-secret audit event. Destructive module retirement applies the same rule
|
||||
to every remaining profile before any Mail table is dropped; a scrub or audit
|
||||
failure blocks retirement.
|
||||
@@ -122,10 +144,16 @@ Development mailbox routes are registered by the mail module only when the
|
||||
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
|
||||
does not contribute these routes directly.
|
||||
|
||||
POP3 and JMAP are deferred. The protocol decision is documented in
|
||||
[docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md): stabilize
|
||||
SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3
|
||||
only for explicit legacy-download requirements. The same roadmap records the
|
||||
JMAP is available as an opt-in, read-only mailbox transport after the stable
|
||||
IMAP baseline. It discovers RFC 8620/8621 capabilities, lists folders,
|
||||
performs server-side message search and pagination, reads bounded message
|
||||
bodies and attachment metadata, and exposes bounded incremental Email changes.
|
||||
IMAP behavior is unchanged, and SMTP remains the send transport. JMAP Session
|
||||
and advertised API origins are governed independently and credentials stay in
|
||||
Mail's encrypted envelopes. The explicitly enabled POP3 slice remains limited
|
||||
to bounded, encrypted, duplicate-safe legacy import. The protocol boundary is
|
||||
documented in [docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md).
|
||||
The same roadmap records the
|
||||
approved S/MIME-first, OpenPGP-additional message-protection profile and its
|
||||
no-silent-downgrade requirement.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ mailbox, policy, and delivery-evidence consequences described here.
|
||||
| System/tenant/group/user/campaign profile surfaces | Compare profiles, protocol servers, reusable credentials, status, and scope | Administration/configuration | High because endpoints, credentials, and inheritance control external communication | Shared `ConnectionTree`, stable row actions, textual status, permission/target blockers, and contextual admin help |
|
||||
| Profile creation and focused profile/server/credential editors | Create a governed transport identity or edit one hierarchy object | Guided setup plus adaptive create/edit | High because saving may enable provider access or replace encrypted credentials | Shared `Dialog` and `StageRail` for multi-object setup; focused edit modes show only the selected hierarchy object; field help, connection tests, unsaved-draft guard, and disabled-save reasons |
|
||||
| Mail profile policy card | Narrow visible profiles, lower-scope definitions, hosts, senders, and recipients | Effective-policy editor | High because inherited allow/deny rules govern delivery and lower scopes | Shared policy rows, typed selectors, source path, locked/read-only blocker, dirty-save state, and contextual policy help |
|
||||
| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions |
|
||||
| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `PageLayout`, `ContentGrid`, `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions |
|
||||
| `/mail/bounces` observation table | Inspect correlated or unmatched delivery-status evidence | Evidence/reporting | Medium because recipient and diagnostic data may be sensitive | Bounded sanitized rows, textual status, filters, correlation state, and no raw bounce body |
|
||||
| Bounce watcher removal | Stop future scans while retaining evidence | Destructive confirmation | Medium and reversible by recreating the watcher; observations are retained | Shared `ConfirmDialog` states the immediate consequence and retained evidence |
|
||||
| `mail.profiles` and credential-reference capabilities | Let another module select or validate Mail-owned transport without receiving secrets | Governed capability composition | High because the selected identity can perform external effects | Stable references and Core capability boundaries; no sibling-private WebUI import; authorization and credential resolution remain Mail-owned |
|
||||
@@ -22,6 +22,9 @@ mailbox, policy, and delivery-evidence consequences described here.
|
||||
|
||||
- Loading, success, error, empty results, permission blockers, and destructive
|
||||
confirmation use Core components. Mail does not reproduce the shell.
|
||||
- Bounce processing now delegates its page inset, sticky responsive heading,
|
||||
route actions, alert regions, loading boundary, scrolling, and help audience
|
||||
to Core `PageLayout`; Mail retains only watcher and evidence semantics.
|
||||
- A target-dependent profile surface cannot load until a concrete user, group,
|
||||
or campaign is selected. The blocker identifies the responsible actor and
|
||||
destination instead of silently hiding the editor.
|
||||
|
||||
+157
-34
@@ -23,12 +23,13 @@ See also [Mail protocol roadmap](MAIL_PROTOCOL_ROADMAP.md) and the Campaign
|
||||
|
||||
Mail owns:
|
||||
|
||||
- reusable SMTP/IMAP profile definitions and scope;
|
||||
- encrypted SMTP/IMAP credentials and safe credential replacement;
|
||||
- reusable SMTP/IMAP/JMAP profile definitions, dedicated legacy POP3 sources, and scope;
|
||||
- encrypted SMTP/IMAP/JMAP/POP3 credentials and safe credential replacement;
|
||||
- effective profile policy and visibility/authorization decisions;
|
||||
- connection tests and protocol adapters;
|
||||
- SMTP send and IMAP append operations exposed to consumers;
|
||||
- read-only mailbox folder/message access and its bounded indexes; and
|
||||
- read-only IMAP/JMAP mailbox folder/message access, bounded indexes, and encrypted
|
||||
pending-review records imported from legacy POP3 sources; and
|
||||
- the transport sanitization boundary and throttling behavior. A general
|
||||
Mail-owned provider-attempt/diagnostic ledger remains planned.
|
||||
|
||||
@@ -43,6 +44,32 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the
|
||||
module registry. Optional consumers provide narrow context through capabilities;
|
||||
Mail does not import their ORM or service implementations.
|
||||
|
||||
## Deployment configuration packages
|
||||
|
||||
Mail registers the `mail.configuration` capability for `smtp_profile`
|
||||
fragments. The provider reads the validated `mail.smtp` entry from the
|
||||
installer-generated infrastructure capability receipt. Receipt host and port
|
||||
are authoritative; the generic package workflow asks for missing non-secret
|
||||
transport fields such as security mode. An external relay may operate without
|
||||
authentication, or the operator may select an existing credential-envelope id.
|
||||
Inline usernames, passwords, tokens, and secret values are rejected.
|
||||
|
||||
Tenant scope is the default. A system-scoped profile requires system settings
|
||||
or governance write authority. The fragment's stable slug is its idempotency
|
||||
identity: an absent profile is created, an exact profile is skipped, and a
|
||||
conflicting profile is preserved unless the reviewed fragment explicitly sets
|
||||
`on_conflict` to `update`. Credential bindings are added idempotently and are
|
||||
never removed merely because a package omits a credential reference.
|
||||
|
||||
Preflight does not prove SMTP reachability. After apply, use the normal Mail
|
||||
profile test and Ops health surfaces. If the receipt says SMTP is unavailable,
|
||||
is invalid, or is not mounted, import is blocked with an operator-facing
|
||||
resolution instead of creating a partial profile.
|
||||
|
||||
Configuration packages remain SMTP-focused. A POP3 legacy source is a deliberate
|
||||
operational migration action and is not silently exported, cloned, or enabled by
|
||||
an SMTP profile package.
|
||||
|
||||
## Interface patterns and unavailable actions
|
||||
|
||||
Mail uses the platform's shared explorer, connection tree, adaptive form,
|
||||
@@ -67,9 +94,17 @@ deactivating a profile may scrub Mail-owned credentials as described below.
|
||||
### Profile
|
||||
|
||||
A profile is a reusable, named delivery identity with optional SMTP and IMAP
|
||||
configuration. It has a stable id, lifecycle state, scope, owner context, and
|
||||
non-secret connection metadata. Passwords are write-only encrypted values and
|
||||
are never returned through list/read/capability responses.
|
||||
configuration and one or more opt-in JMAP mailbox endpoints. It can additionally own a dedicated POP3 endpoint for an
|
||||
explicit legacy-import workflow. It has a stable id, lifecycle state, scope,
|
||||
owner context, and non-secret connection metadata. Passwords are write-only
|
||||
encrypted values and are never returned through list/read/capability responses.
|
||||
|
||||
An IMAP server may map the standard Inbox, Sent, Drafts, Trash, Archive, and
|
||||
Junk roles to exact provider folder names. These mappings belong to the reusable
|
||||
profile/server. Empty roles retain automatic behavior. The historical
|
||||
`imap.sent_folder` value is read as the Sent mapping and remains synchronized
|
||||
for compatibility; a Campaign-specific Sent override still wins for that
|
||||
Campaign.
|
||||
|
||||
Profiles may be scoped to system, tenant, user, group, or campaign context.
|
||||
Scope controls where a profile can be discovered; effective policy can narrow
|
||||
@@ -79,14 +114,14 @@ operation: using, testing, managing, and managing secrets are separate rights.
|
||||
### Policy
|
||||
|
||||
Mail policy controls approved profile ids, which lower scopes may define
|
||||
profiles, allowed/denied hosts and addressing patterns, credential inheritance,
|
||||
profiles, allowed/denied SMTP/IMAP/JMAP hosts and addressing patterns, credential inheritance,
|
||||
and lower-level limits. The effective result is assembled from applicable
|
||||
system, tenant, user/group, and campaign context. Denials and locked parent
|
||||
limits cannot be relaxed by a lower scope.
|
||||
|
||||
### Transport identity and revision
|
||||
|
||||
Mail owns opaque random revisions for relevant SMTP/IMAP identity and
|
||||
Mail owns opaque random revisions for relevant SMTP/IMAP/JMAP identity and
|
||||
configuration. They are concurrency tokens, not deterministic hashes that a
|
||||
consumer could use to guess a host or account. Credentials are excluded. A
|
||||
consumer can freeze these revisions at build time and ask Mail to require the
|
||||
@@ -99,6 +134,11 @@ authorization. Mail re-evaluates profile activity, visibility, policy, and
|
||||
revision immediately before it resolves credentials and performs the
|
||||
effect.
|
||||
|
||||
POP3 imports pin the endpoint/credential transport revision at preview time. A
|
||||
changed revision or a missing provider UIDL stops import and requires a fresh
|
||||
preview. POP3 does not participate in the ordinary mailbox folder/message
|
||||
projection.
|
||||
|
||||
### Provider outcomes
|
||||
|
||||
SMTP acceptance, partial or complete recipient refusal, temporary/permanent
|
||||
@@ -124,7 +164,7 @@ new idempotency key and links it to the prior command.
|
||||
2. Choose from the profiles visible and authorized for the current tenant,
|
||||
owner, group, and task context. Never enter or copy a profile id manually
|
||||
when the UI can present a picker.
|
||||
3. Review the safe summary: name, scope, active state, SMTP/IMAP availability,
|
||||
3. Review the safe summary: name, scope, active state, SMTP/IMAP/JMAP availability,
|
||||
and policy-relevant sender identity. Credentials and raw provider internals
|
||||
are not visible.
|
||||
4. Save the reference in the consuming module. If ownership or policy changes,
|
||||
@@ -137,7 +177,7 @@ permission.
|
||||
### Test a profile
|
||||
|
||||
An authorized profile test verifies connection and authentication for the
|
||||
selected active/visible SMTP or IMAP profile using Mail-owned credentials. The
|
||||
selected active/visible SMTP, IMAP, or JMAP profile using Mail-owned credentials. The
|
||||
consumer-use path evaluates effective Mail policy separately. Use a
|
||||
non-production provider and mailbox first. A successful connection test does
|
||||
not prove policy authorization for a later Campaign context, deliverability,
|
||||
@@ -154,15 +194,82 @@ ordinary consumers to bypass reusable profiles.
|
||||
### Read a mailbox
|
||||
|
||||
The current mailbox UI and API are read-only. An authorized user can list IMAP
|
||||
folders, page through messages, and inspect a bounded full message. Folder
|
||||
names are parsed and quoted defensively; Sent-folder discovery uses provider
|
||||
flags and common names.
|
||||
or JMAP folders, page through messages, and inspect a bounded full message.
|
||||
IMAP folder names are parsed and quoted defensively; Sent-folder discovery uses
|
||||
provider flags and common names. JMAP discovers the Session and Mail account,
|
||||
uses `Mailbox/get` hierarchy and roles, runs text search with `Email/query`, and
|
||||
uses `Email/get` for bounded summaries/details. `Email/changes` exposes a
|
||||
bounded incremental cursor; an expired state tells the caller to perform a full
|
||||
refresh. The list exposes the provider's `Seen`/`$seen` state as a
|
||||
read/unread indicator without changing it. It also labels whether the current
|
||||
page came directly from the provider, from the bounded mailbox index, or from
|
||||
an index while a refresh is in progress, including the index timestamp when
|
||||
available.
|
||||
|
||||
Message HTML is displayed only in the shared sandboxed message component.
|
||||
Remote URLs and active markup are removed, embedded `data:`/`cid:` image
|
||||
references remain isolated, and plain text is always available when supplied.
|
||||
Attachments and provider/content failures remain explicit rather than being
|
||||
silently interpreted as an empty message.
|
||||
|
||||
Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must
|
||||
not mutate read/unread, delete, move, or reply state. Message responses are
|
||||
bounded by the deployment response policy; ordinary UI should avoid loading a
|
||||
bounded by the endpoint response/body policy; ordinary UI should avoid loading a
|
||||
whole large mailbox or attachment merely to show a list.
|
||||
|
||||
JMAP is opt-in per server endpoint. Its authenticated Session URL is governed
|
||||
by `jmap_hosts`; an advertised API URL on another origin fails closed unless
|
||||
that origin is explicitly listed for the endpoint. Bearer tokens or Basic
|
||||
credentials are stored only in Mail credential envelopes. This slice does not
|
||||
send through JMAP, mutate provider state, download attachment binaries, or add
|
||||
calendar/contact/thread features.
|
||||
|
||||
### Import a legacy POP3 mailbox
|
||||
|
||||
POP3 is available only for bounded migration from a legacy server that cannot
|
||||
provide IMAP or JMAP. It is disabled by default and is not a replacement for
|
||||
the read-only mailbox UI.
|
||||
|
||||
1. An actor with profile-write, secret-management, and `mail:pop3:manage`
|
||||
authority opens **Legacy POP3 import**, selects a Mail profile, and creates a
|
||||
dedicated source. The UI stages the endpoint disabled, stores its encrypted
|
||||
username/password credential, and enables it only after both operations
|
||||
succeed.
|
||||
2. The administrator explicitly enables legacy import, sets TLS mode, timeout,
|
||||
maximum message and batch sizes, and preview body lines, and tests connection,
|
||||
authentication, TLS, and provider message count. Plain transport remains
|
||||
subject to deployment egress/security policy and should not be used across
|
||||
an untrusted network.
|
||||
3. An operator with `mail:profile:use` and `mail:pop3:import` refreshes a live
|
||||
preview of at most 100 messages. Preview sends no `DELE`, changes no flags,
|
||||
and exposes only bounded headers/body text. A server without stable UIDL
|
||||
identifiers is rejected. If `TOP` is unavailable, Mail uses `RETR` only
|
||||
inside the configured size bound and suppresses an oversized preview.
|
||||
4. The operator selects messages. Mail downloads within the size gate and
|
||||
creates encrypted local `pending_review` records. Tenant, profile, endpoint,
|
||||
and UIDL form the duplicate boundary. Raw content never appears in list,
|
||||
provider-state, audit, or DSAR output.
|
||||
5. Source messages remain untouched by default. Delete-after-import requires
|
||||
the endpoint's separate `allow_delete_after_import` policy, the operator's
|
||||
`mail:pop3:delete` permission, an explicit per-batch choice, and destructive
|
||||
confirmation. Mail commits the local import plus `mail.pop3.imported` audit
|
||||
evidence before sending `DELE`. It separately records
|
||||
`mail.pop3.source_deletion`; disconnect during `QUIT` is outcome-unknown and
|
||||
must be reconciled before another destructive attempt.
|
||||
|
||||
The supplied **Mail legacy import operator** role can test, preview, and import
|
||||
without deleting. The **Mail profile administrator** role also contains source
|
||||
management and destructive-delete permissions; deployments should remove or
|
||||
split `mail:pop3:delete` when operators must never delete provider messages.
|
||||
Imported records follow configured Mail/records retention and require manual
|
||||
review for a data-subject request or deletion decision.
|
||||
|
||||
Contextual help is available from the page and from each policy-sensitive
|
||||
source setting, credential field, size limit, import action, and destructive
|
||||
confirmation. Press F1 while a control has focus to open the Mail-owned German
|
||||
reference for the exact POP3 context; the same topic remains available through
|
||||
the page help action.
|
||||
|
||||
## Profile administration
|
||||
|
||||
### Roles
|
||||
@@ -175,11 +282,16 @@ The supplied templates are:
|
||||
and manage credentials only for the current account's own user-scoped
|
||||
profiles, subject to the effective Mail policy.
|
||||
- **Mail profile administrator:** additionally create/update profiles and
|
||||
create/replace encrypted credentials across tenant-owned scopes.
|
||||
create/replace encrypted credentials across tenant-owned scopes, configure
|
||||
legacy POP3 imports, and—unless the template is narrowed—request source
|
||||
deletion after import.
|
||||
- **Mail legacy import operator:** test approved POP3 sources and preview/import
|
||||
messages without permission to delete them at the provider.
|
||||
|
||||
The specific permissions are `mail:profile:read`, `mail:profile:use`,
|
||||
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write_own`,
|
||||
`mail:secret:manage_own`, `mail:profile:write`, and `mail:secret:manage`.
|
||||
`mail:secret:manage_own`, `mail:profile:write`, `mail:secret:manage`,
|
||||
`mail:pop3:manage`, `mail:pop3:import`, and `mail:pop3:delete`.
|
||||
The `_own` permissions are enforced against the authenticated membership id and
|
||||
never authorize a tenant, group, campaign, system, or another user's profile.
|
||||
They also do not authorize profile-policy changes. System-scoped definitions
|
||||
@@ -198,19 +310,21 @@ the deletion in the audit log.
|
||||
|
||||
The configured Help Center exposes **Create a custom Mail profile** only when
|
||||
the current actor has broad or self-service profile-write authority and the
|
||||
effective user-scope policy permits user profiles. It states the active SMTP/IMAP hostname
|
||||
effective user-scope policy permits user profiles. It states the active SMTP/IMAP/JMAP hostname
|
||||
allow-list groups and deny rules, plus the actor's separate credential, test,
|
||||
use, and approval requirements. The Settings task creates in the current
|
||||
account's user scope. Grant `mail:profile:write_own` for self-service;
|
||||
`mail:profile:write` remains broad profile administration authority.
|
||||
|
||||
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
||||
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
|
||||
behavior, and timeouts. Sender/envelope/recipient constraints belong to
|
||||
2. Configure SMTP and optional IMAP, or add an optional JMAP Session endpoint after creating the profile. Set TLS mode, account identity, standard folder
|
||||
mappings, and timeouts. Folder discovery proposes provider-visible Inbox,
|
||||
Sent, Drafts, Trash, Archive, and Junk names without mutating the mailbox.
|
||||
Sender/envelope/recipient constraints belong to
|
||||
effective Mail policy; Campaign rate limits remain delivery configuration.
|
||||
3. Enter credentials only in the dedicated credential fields. Returned profile
|
||||
data indicates whether credentials are configured without returning them.
|
||||
4. Save and run SMTP/IMAP tests against a non-production target.
|
||||
4. Save and run SMTP/IMAP/JMAP tests against a non-production target.
|
||||
5. Verify the effective policy for every intended consumer context.
|
||||
6. Communicate changes that alter the non-secret transport identity; prepared
|
||||
consumer snapshots will deliberately stop until rebuilt.
|
||||
@@ -222,7 +336,7 @@ credential replacement never depends on reading the old cleartext value back.
|
||||
|
||||
Profile deletion is immediate for Mail-owned secrets and audit evidence:
|
||||
|
||||
1. Mail clears both encrypted SMTP and IMAP passwords in the same transaction.
|
||||
1. Mail clears encrypted SMTP, IMAP, JMAP, and POP3 credential envelopes in the same transaction.
|
||||
2. It deactivates the remaining non-secret tombstone state so historical stable
|
||||
references can fail safely rather than resolve to another profile.
|
||||
3. When owned encrypted secrets existed, it emits
|
||||
@@ -243,7 +357,7 @@ Effective policy is contextual. Administrators should document:
|
||||
|
||||
- which profile ids are approved globally or for a tenant;
|
||||
- whether tenant, user, group, or campaign-scoped profiles may be created;
|
||||
- allowed and denied SMTP/IMAP hosts;
|
||||
- allowed and denied SMTP/IMAP/JMAP hosts;
|
||||
- permitted From, envelope sender (including bounce address), and envelope
|
||||
recipient-domain patterns;
|
||||
- whether SMTP/IMAP credentials inherit from the reusable profile; and
|
||||
@@ -264,10 +378,10 @@ tenant posture; it does not expose credential material.
|
||||
|
||||
Private-network connector access is controlled deployment-wide by
|
||||
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Whether private or public targets
|
||||
are allowed, SMTP and IMAP resolve, validate, and connect to the exact approved
|
||||
address records at connection time while retaining the original hostname for
|
||||
TLS SNI and certificate verification. A DNS change cannot redirect the socket
|
||||
after validation.
|
||||
are allowed, SMTP, IMAP, and legacy POP3 resolve, validate, and connect to the
|
||||
exact approved address records at connection time while retaining the original
|
||||
hostname for TLS SNI and certificate verification. A DNS change cannot redirect
|
||||
the socket after validation.
|
||||
|
||||
Transports that cannot pin every connection peer or revalidate protocol-managed
|
||||
redirects/referrals must fail before client construction. Do not weaken this
|
||||
@@ -342,10 +456,11 @@ state transition or outbound synchronization effect.
|
||||
|
||||
### Backup, restore, and retirement
|
||||
|
||||
Backups contain encrypted credentials and therefore need the same protection as
|
||||
the live database and key material. Restoring a Mail database without the
|
||||
matching encryption key makes credentials unusable; restoring it with keys can
|
||||
reactivate sensitive historical state and must be controlled.
|
||||
Backups contain encrypted credentials and encrypted raw POP3 import records and
|
||||
therefore need the same protection as the live database and key material.
|
||||
Restoring a Mail database without the matching encryption key makes those
|
||||
records unusable; restoring it with keys can reactivate sensitive historical
|
||||
state and must be controlled.
|
||||
|
||||
Destructive module retirement first applies the same immediate credential
|
||||
scrub/audit rule to every remaining profile, then drops Mail-owned tables after
|
||||
@@ -395,7 +510,7 @@ mandatory release gate.
|
||||
|
||||
Security invariants:
|
||||
|
||||
- Decrypted SMTP/IMAP passwords never cross the Mail capability/API boundary.
|
||||
- Decrypted SMTP/IMAP passwords and JMAP/POP3 credentials never cross the Mail capability/API boundary.
|
||||
- Password fields are write-only and encrypted at rest; safe responses expose
|
||||
configuration state, not values.
|
||||
- Consumers persist stable profile references, not transport copies.
|
||||
@@ -452,10 +567,18 @@ Before claiming a Mail composition is production-ready:
|
||||
semantics (`govoplan-mail#16`).
|
||||
- Final Campaign **test / single send / single resend** semantics; those are a
|
||||
Campaign business-action contract built on Mail transport operations.
|
||||
- JMAP mailbox synchronization/search; it is preferred only after the IMAP MVP
|
||||
is stable.
|
||||
- POP3 except for a future explicit legacy download/import requirement.
|
||||
- JMAP provider-side mutation, submission, push, thread, calendar, contact,
|
||||
attachment-binary, and automatic background-sync support; read-only mailbox
|
||||
synchronization/search is implemented on the stable IMAP mailbox contract.
|
||||
- Expanding POP3 beyond the implemented explicit legacy download/import
|
||||
workflow; it has no folder, flag, search, or synchronization contract.
|
||||
- A full mail client with compose/reply/move/delete/read-state mutation.
|
||||
- Quick Access may launch the operating environment's configured composer via
|
||||
`mailto:`. That explicit handoff is not a GovOPlaN Mail delivery: it selects
|
||||
no Mail profile or credential, bypasses no Mail policy, and reports no
|
||||
GovOPlaN delivery result. Recent-message and Drafts links remain read-only
|
||||
deep links into the authorized Mail profile and preserve their Quick Access
|
||||
return context.
|
||||
- Recovery-ledger adoption for future provider-side move, delete, and flag
|
||||
mutations; no such production path exists in the current read-only mailbox.
|
||||
- Proof that process-local throttling coordinates multiple workers when Redis
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
# Mail Protocol Roadmap
|
||||
|
||||
GovOPlaN Mail currently focuses on SMTP sending and IMAP mailbox access. POP3
|
||||
and JMAP are deferred until the IMAP mailbox MVP is stable.
|
||||
GovOPlaN Mail uses SMTP for sending and supports read-only mailbox access over
|
||||
IMAP or JMAP. It also provides an explicitly enabled, bounded POP3
|
||||
legacy-import path. The first JMAP slice is implemented on the stable,
|
||||
protocol-neutral mailbox contract.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- SMTP is the send protocol.
|
||||
- IMAP is the read/append protocol.
|
||||
- Mail profile policy, encrypted credentials, mailbox folder parsing, test
|
||||
buttons, and read-only mailbox UI are built around SMTP and IMAP.
|
||||
- IMAP is the established read/append protocol and remains unchanged.
|
||||
- JMAP is an opt-in read-only sync/search protocol; it is not used for sending
|
||||
or append-to-Sent.
|
||||
- POP3 is an optional legacy migration source, never a general mailbox
|
||||
protocol or default profile endpoint.
|
||||
- Mail profile policy, encrypted credentials, connection diagnostics, and the
|
||||
read-only mailbox UI cover SMTP/IMAP/JMAP endpoints as applicable.
|
||||
|
||||
This baseline matches the first production use case: send campaign mail, append
|
||||
sent copies when configured, and inspect mailboxes read-only.
|
||||
@@ -37,8 +43,8 @@ the S/MIME profile is stable.
|
||||
|
||||
## JMAP
|
||||
|
||||
JMAP is the preferred future sync/search protocol where target mail servers
|
||||
support it.
|
||||
JMAP is the preferred sync/search protocol where target mail servers support
|
||||
RFC 8620 Core and RFC 8621 Mail capabilities.
|
||||
|
||||
Reasons:
|
||||
|
||||
@@ -47,26 +53,54 @@ Reasons:
|
||||
- modern search and thread models
|
||||
- better fit for browser-facing mailbox UX through a server proxy
|
||||
|
||||
JMAP should be added only after:
|
||||
The implemented first slice provides:
|
||||
|
||||
- the IMAP mailbox MVP has stable folder/message pagination behavior
|
||||
- mail profile policy can express protocol-specific availability
|
||||
- mailbox UI can handle protocol-neutral folder/message DTOs
|
||||
- test infrastructure includes at least one reliable JMAP server target
|
||||
- authenticated Session discovery with Bearer or Basic credentials;
|
||||
- explicit account selection or primary Mail-account selection;
|
||||
- folder hierarchy projection through `Mailbox/get`;
|
||||
- server-side text search and pagination through `Email/query` plus bounded
|
||||
summaries and details through `Email/get`;
|
||||
- incremental state through bounded `Email/changes`, with an explicit full
|
||||
refresh when the provider can no longer calculate changes;
|
||||
- per-endpoint response and body-value bounds;
|
||||
- a dedicated JMAP hostname policy and fail-closed cross-origin API discovery;
|
||||
and
|
||||
- protocol-neutral folder/message DTOs and mailbox UI selection while keeping
|
||||
the IMAP path unchanged.
|
||||
|
||||
The current boundary is read-only. JMAP submission, mailbox/message mutation,
|
||||
threads, calendars, contacts, push subscriptions, binary attachment download,
|
||||
and automatic background synchronization are deferred until a separately
|
||||
governed slice needs them.
|
||||
|
||||
## POP3
|
||||
|
||||
POP3 should remain legacy-only.
|
||||
POP3 remains legacy-only. The bounded import slice is available when a concrete
|
||||
deployment must retire a mailbox that cannot offer IMAP or JMAP.
|
||||
|
||||
Add it only when a concrete deployment requires mailbox download from a server
|
||||
that cannot offer IMAP or JMAP. POP3 is a poor fit for the normal GovOPlaN
|
||||
mailbox UX because it has limited folder, sync, and server-side state semantics.
|
||||
It is disabled until an administrator creates a dedicated POP3 endpoint and
|
||||
sets `legacy_import_enabled`. The endpoint has its own encrypted credential,
|
||||
connection/TLS/authentication diagnostics, maximum message and batch sizes, preview body
|
||||
limit, and a separate `allow_delete_after_import` policy. Stable UIDL support is
|
||||
mandatory; Mail refuses import when a provider cannot supply it.
|
||||
|
||||
If implemented, POP3 should be scoped to explicit download/import workflows, not
|
||||
general mailbox browsing.
|
||||
Preview and ordinary import are non-destructive. Selected messages become
|
||||
encrypted `pending_review` records with a content digest, pinned transport
|
||||
revision, source UIDL, and audit evidence. Repeating a UIDL reports a duplicate.
|
||||
Provider deletion requires both endpoint policy and `mail:pop3:delete`, is
|
||||
chosen separately per batch, and runs only after the local import and its audit
|
||||
event commit. A disconnect while POP3 `QUIT` commits deletions becomes
|
||||
`outcome_unknown` and is never retried blindly.
|
||||
|
||||
POP3 does not supply folder, flag, thread, search, or synchronization semantics.
|
||||
It is therefore excluded from the normal mailbox UI and from the recommended
|
||||
ongoing Mail profile. Configuration-package export/import remains SMTP-focused;
|
||||
legacy source rollout is an explicit operational action.
|
||||
|
||||
## Decision
|
||||
|
||||
Do not add POP3 or JMAP now. Stabilize SMTP/IMAP first, design protocol-neutral
|
||||
mailbox DTOs, then prefer JMAP for modern servers and reserve POP3 for explicit
|
||||
legacy download requirements.
|
||||
Keep the implemented POP3 surface limited to governed legacy import. Do not
|
||||
expand it into mailbox browsing. Keep the protocol-neutral mailbox DTOs and
|
||||
use the implemented JMAP path for modern synchronization/search support when
|
||||
an administrator explicitly configures it. Keep SMTP for sending and POP3
|
||||
limited to governed legacy import.
|
||||
|
||||
Generated
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.24",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,7 +19,7 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-mail"
|
||||
version = "0.1.16"
|
||||
version = "0.1.24"
|
||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.16",
|
||||
"govoplan-core>=0.1.18",
|
||||
"pydantic>=2,<3",
|
||||
"redis>=5,<6",
|
||||
"SQLAlchemy>=2,<3",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid
|
||||
from typing import Any
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -40,13 +42,27 @@ from govoplan_mail.backend.sending.imap import (
|
||||
append_message_to_sent,
|
||||
)
|
||||
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
|
||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
|
||||
from govoplan_mail.backend.sending.smtp import (
|
||||
SmtpBatchSession,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
send_email_bytes,
|
||||
)
|
||||
|
||||
|
||||
_ACTIVE_SMTP_BATCH: ContextVar[SmtpBatchSession | None] = ContextVar(
|
||||
"govoplan_mail_active_smtp_batch",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignSmtpDeliveryResult:
|
||||
envelope_recipients: list[str]
|
||||
refused_recipients: dict[str, dict[str, int | str]]
|
||||
connection_sequence: int = 1
|
||||
session_reused: bool = False
|
||||
reconnect_count: int = 0
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
@@ -58,6 +74,23 @@ class CampaignImapAppendResult:
|
||||
folder: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignSmtpBatchState:
|
||||
session: SmtpBatchSession
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "ready"
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
return self.session.connection_count
|
||||
|
||||
@property
|
||||
def reconnect_count(self) -> int:
|
||||
return self.session.reconnect_count
|
||||
|
||||
|
||||
def _sanitized_refusals(
|
||||
refused_recipients: dict[str, tuple[int, bytes | str]],
|
||||
) -> dict[str, dict[str, int | str]]:
|
||||
@@ -95,6 +128,9 @@ def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
|
||||
message,
|
||||
temporary=exc.temporary,
|
||||
outcome_unknown=exc.outcome_unknown,
|
||||
systemic=exc.systemic,
|
||||
reason_code=exc.reason_code,
|
||||
phase=exc.phase,
|
||||
)
|
||||
|
||||
|
||||
@@ -279,6 +315,106 @@ def campaign_profile_delivery_summary(
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def campaign_smtp_batch(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
from_header: str | None,
|
||||
expected_smtp_transport_revision: str,
|
||||
smtp_server_id: str | None = None,
|
||||
smtp_credential_id: str | None = None,
|
||||
) -> Iterator[CampaignSmtpBatchState]:
|
||||
"""Preflight and retain one authorized SMTP connection for a batch."""
|
||||
|
||||
selection = _selection_payload(
|
||||
profile_id=profile_id,
|
||||
smtp_server_id=smtp_server_id,
|
||||
smtp_credential_id=smtp_credential_id,
|
||||
)
|
||||
try:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
selection=selection,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
if _supports_hierarchy(session):
|
||||
context = _campaign_hierarchy_context(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
try:
|
||||
selected_smtp = select_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
)
|
||||
except MailServerHierarchyError as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
current_revision = selected_smtp.transport_revision
|
||||
else:
|
||||
context = None
|
||||
current_revision = campaign_profile_transport_revisions(profile)["smtp"]
|
||||
if current_revision != expected_smtp_transport_revision:
|
||||
raise MailProfileError(
|
||||
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
try:
|
||||
smtp = (
|
||||
resolve_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
).config
|
||||
if context is not None
|
||||
else smtp_config_from_profile(profile)
|
||||
)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
try:
|
||||
assert_mail_policy_allows_send(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
smtp=smtp,
|
||||
imap=None,
|
||||
envelope_sender=envelope_from,
|
||||
from_header=from_header,
|
||||
recipients=envelope_recipients,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise MailProfileError("Mail delivery is blocked by the effective Mail policy.") from None
|
||||
|
||||
smtp_session = SmtpBatchSession(smtp)
|
||||
smtp_session.preflight()
|
||||
token = _ACTIVE_SMTP_BATCH.set(smtp_session)
|
||||
try:
|
||||
yield CampaignSmtpBatchState(session=smtp_session)
|
||||
finally:
|
||||
_ACTIVE_SMTP_BATCH.reset(token)
|
||||
smtp_session.close()
|
||||
|
||||
|
||||
def send_campaign_email_bytes(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -394,6 +530,7 @@ def send_campaign_email_bytes(
|
||||
smtp_config=smtp,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
batch_session=_ACTIVE_SMTP_BATCH.get(),
|
||||
)
|
||||
except SmtpSendError as exc:
|
||||
sanitized = _sanitized_smtp_error(exc)
|
||||
@@ -420,6 +557,9 @@ def send_campaign_email_bytes(
|
||||
sanitized_result = CampaignSmtpDeliveryResult(
|
||||
envelope_recipients=list(result.envelope_recipients),
|
||||
refused_recipients=_sanitized_refusals(result.refused_recipients),
|
||||
connection_sequence=getattr(result, "connection_sequence", 0),
|
||||
session_reused=getattr(result, "session_reused", False),
|
||||
reconnect_count=getattr(result, "reconnect_count", 0),
|
||||
)
|
||||
if recovery is not None:
|
||||
try:
|
||||
@@ -604,6 +744,7 @@ class MailCampaignCapability:
|
||||
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
|
||||
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
|
||||
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
|
||||
campaign_smtp_batch = staticmethod(campaign_smtp_batch)
|
||||
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
|
||||
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
||||
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from govoplan_core.mail.config import (
|
||||
ImapConfig,
|
||||
ImapFolderMappings,
|
||||
ImapServerConfig,
|
||||
SmtpConfig,
|
||||
SmtpServerConfig,
|
||||
@@ -11,9 +17,119 @@ from govoplan_core.mail.config import (
|
||||
normalize_split_transport_credentials,
|
||||
)
|
||||
|
||||
|
||||
class Pop3ServerConfig(StrictModel):
|
||||
"""Server-only settings for an explicitly enabled legacy POP3 source."""
|
||||
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
security: TransportSecurity = TransportSecurity.TLS
|
||||
timeout_seconds: int = Field(default=30, ge=1, le=300)
|
||||
max_message_bytes: int = Field(default=25 * 1024 * 1024, ge=1_024, le=50 * 1024 * 1024)
|
||||
max_batch_bytes: int = Field(default=100 * 1024 * 1024, ge=1_048_576, le=500 * 1024 * 1024)
|
||||
preview_body_lines: int = Field(default=20, ge=0, le=100)
|
||||
legacy_import_enabled: bool = False
|
||||
allow_delete_after_import: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def apply_default_port(self) -> "Pop3ServerConfig":
|
||||
if self.port is None:
|
||||
self.port = 995 if self.security == TransportSecurity.TLS else 110
|
||||
if self.legacy_import_enabled and not str(self.host or "").strip():
|
||||
raise ValueError(
|
||||
"POP3 host is required when legacy import is enabled"
|
||||
)
|
||||
if self.max_batch_bytes < self.max_message_bytes:
|
||||
raise ValueError(
|
||||
"POP3 batch size limit cannot be lower than the per-message limit"
|
||||
)
|
||||
if self.allow_delete_after_import and not self.legacy_import_enabled:
|
||||
raise ValueError(
|
||||
"POP3 delete-after-import cannot be enabled while legacy import is disabled"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class Pop3Config(Pop3ServerConfig):
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
|
||||
class JmapServerConfig(StrictModel):
|
||||
"""Server-only settings for an RFC 8620/8621 mailbox endpoint."""
|
||||
|
||||
session_url: str
|
||||
account_id: str | None = Field(default=None, max_length=255)
|
||||
auth_scheme: Literal["bearer", "basic"] = "bearer"
|
||||
timeout_seconds: int = Field(default=20, ge=1, le=120)
|
||||
max_response_bytes: int = Field(
|
||||
default=5 * 1024 * 1024,
|
||||
ge=64 * 1024,
|
||||
le=25 * 1024 * 1024,
|
||||
)
|
||||
max_body_value_bytes: int = Field(
|
||||
default=1 * 1024 * 1024,
|
||||
ge=1_024,
|
||||
le=5 * 1024 * 1024,
|
||||
)
|
||||
allowed_api_origins: list[str] = Field(default_factory=list, max_length=10)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_urls(self) -> "JmapServerConfig":
|
||||
self.session_url = _absolute_http_url(self.session_url, label="JMAP session URL")
|
||||
session_origin = _http_origin(self.session_url)
|
||||
origins: list[str] = []
|
||||
for value in self.allowed_api_origins:
|
||||
normalized = _http_origin(
|
||||
_absolute_http_url(value, label="JMAP allowed API origin")
|
||||
)
|
||||
if normalized != session_origin and normalized not in origins:
|
||||
origins.append(normalized)
|
||||
self.allowed_api_origins = origins
|
||||
if self.account_id is not None:
|
||||
self.account_id = self.account_id.strip() or None
|
||||
return self
|
||||
|
||||
|
||||
class JmapConfig(JmapServerConfig):
|
||||
username: str | None = Field(default=None, max_length=320)
|
||||
password: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_credentials(self) -> "JmapConfig":
|
||||
if self.auth_scheme == "basic" and not (self.username and self.password):
|
||||
raise ValueError("JMAP Basic authentication requires username and password")
|
||||
if self.auth_scheme == "bearer" and not self.password:
|
||||
raise ValueError("JMAP Bearer authentication requires an access token")
|
||||
return self
|
||||
|
||||
|
||||
def _absolute_http_url(value: str, *, label: str) -> str:
|
||||
parsed = urllib.parse.urlsplit(str(value or "").strip())
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise ValueError(f"{label} must be an absolute HTTP(S) URL")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError(f"{label} must not include embedded credentials")
|
||||
if parsed.fragment:
|
||||
raise ValueError(f"{label} must not include a fragment")
|
||||
return urllib.parse.urlunsplit(parsed)
|
||||
|
||||
|
||||
def _http_origin(value: str) -> str:
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
default_port = 443 if parsed.scheme == "https" else 80
|
||||
suffix = "" if port == default_port else f":{port}"
|
||||
return f"{parsed.scheme.lower()}://{(parsed.hostname or '').lower()}{suffix}"
|
||||
|
||||
__all__ = [
|
||||
"ImapConfig",
|
||||
"ImapFolderMappings",
|
||||
"ImapServerConfig",
|
||||
"JmapConfig",
|
||||
"JmapServerConfig",
|
||||
"Pop3Config",
|
||||
"Pop3ServerConfig",
|
||||
"SmtpConfig",
|
||||
"SmtpServerConfig",
|
||||
"StrictModel",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -387,3 +387,72 @@ class MailBounceObservation(Base, TimestampMixin):
|
||||
)
|
||||
matched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class MailPop3Import(Base, TimestampMixin):
|
||||
"""Governed local review record created from a legacy POP3 mailbox."""
|
||||
|
||||
__tablename__ = "mail_pop3_imports"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"profile_id",
|
||||
"pop3_server_id",
|
||||
"provider_uidl",
|
||||
name="uq_mail_pop3_imports_source_uidl",
|
||||
),
|
||||
Index(
|
||||
"ix_mail_pop3_imports_review",
|
||||
"tenant_id",
|
||||
"status",
|
||||
"imported_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
profile_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("mail_server_profiles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
pop3_server_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("mail_server_endpoints.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
pop3_credential_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
transport_revision: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
provider_uidl: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
provider_message_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
raw_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
raw_message_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
message_id: Mapped[str | None] = mapped_column(String(998), nullable=True, index=True)
|
||||
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
from_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
to_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
date: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
body_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(40), default="pending_review", nullable=False, index=True
|
||||
)
|
||||
imported_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
imported_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
deletion_requested: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False
|
||||
)
|
||||
deletion_status: Mapped[str] = mapped_column(
|
||||
String(40), default="not_requested", nullable=False, index=True
|
||||
)
|
||||
deletion_attempted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
deletion_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
@@ -26,7 +26,7 @@ MAIL_SECRET_MANAGE_SCOPE = "mail:secret:manage" # noqa: S105 # nosec B105
|
||||
MAIL_SECRET_MANAGE_OWN_SCOPE = "mail:secret:manage_own" # noqa: S105 # nosec B105
|
||||
MAIL_PROFILE_TEST_SCOPE = "mail:profile:test"
|
||||
MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
|
||||
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"))
|
||||
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"), ("JMAP", "jmap_hosts"))
|
||||
|
||||
|
||||
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||
@@ -233,7 +233,7 @@ def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTo
|
||||
title="Create a custom Mail profile",
|
||||
summary=(
|
||||
"Create a reusable profile in the current account's user-scoped Settings view, "
|
||||
"within the active SMTP and IMAP hostname policy."
|
||||
"within the active SMTP, IMAP, and JMAP hostname policy."
|
||||
),
|
||||
body="\n".join(authority_lines),
|
||||
layer="configured",
|
||||
@@ -345,12 +345,12 @@ def _custom_profile_authority_lines(
|
||||
approval_required: bool,
|
||||
) -> tuple[str, ...]:
|
||||
credentials = (
|
||||
"Credential authority: you may save or replace Mail-owned SMTP/IMAP passwords."
|
||||
"Credential authority: you may save or replace Mail-owned SMTP/IMAP passwords and JMAP tokens or Basic credentials."
|
||||
if can_manage_credentials
|
||||
else "Credential authority: you may define the profile, but you cannot save or replace passwords; an actor with both profile-write and secret-management authority must do that when authentication requires one."
|
||||
)
|
||||
testing = (
|
||||
"Test authority: you may run the profile's SMTP/IMAP connection tests after saving it as active."
|
||||
"Test authority: you may run the profile's SMTP/IMAP/JMAP connection tests after saving it as active."
|
||||
if can_test_profile
|
||||
else "Test authority: creating the profile does not let you run connection tests; ask an actor with both profile-test and profile-use authority to verify an active profile."
|
||||
)
|
||||
@@ -376,18 +376,18 @@ def _custom_profile_steps(
|
||||
) -> tuple[str, ...]:
|
||||
steps = [
|
||||
"Open Settings, choose Mail profiles, and select Add profile in the current account's user-scoped view.",
|
||||
"Enter a stable name and configure SMTP plus optional IMAP hostnames that satisfy every host-policy statement shown above.",
|
||||
"Enter a stable name and configure SMTP plus optional IMAP settings; add an optional JMAP server after the profile exists. Every endpoint must satisfy the host-policy statements shown above.",
|
||||
]
|
||||
steps.append(
|
||||
"Enter the required SMTP/IMAP credentials in the dedicated password fields."
|
||||
"Enter the required SMTP/IMAP credentials, or link an encrypted bearer token or Basic credential to the JMAP server."
|
||||
if can_manage_credentials
|
||||
else "Save the non-secret profile definition, then ask an actor with both profile-write and secret-management authority to add credentials if the server requires authentication."
|
||||
)
|
||||
steps.append("Save the profile; Mail validates the effective user-scope host policy again on the server.")
|
||||
steps.append(
|
||||
"Save the profile as active, then run the available SMTP and IMAP connection tests."
|
||||
"Save the profile as active, then run the available SMTP, IMAP, and JMAP connection tests."
|
||||
if can_test_profile
|
||||
else "Ask an actor with both profile-test and profile-use authority to run the SMTP and IMAP connection tests after the profile is active."
|
||||
else "Ask an actor with both profile-test and profile-use authority to run the SMTP, IMAP, and JMAP connection tests after the profile is active."
|
||||
)
|
||||
if approval_required:
|
||||
steps.append("Ask a Mail administrator to add the new profile to the active approved-profile list.")
|
||||
@@ -402,9 +402,9 @@ def _custom_profile_steps(
|
||||
def _custom_profile_verification(*, can_test_profile: bool, can_use_profile: bool, approval_required: bool) -> str:
|
||||
checks = ["Reopen My Mail profiles and confirm the saved profile remains in the current account's user-scoped view."]
|
||||
checks.append(
|
||||
"Confirm the authorized SMTP/IMAP tests succeed."
|
||||
"Confirm the authorized SMTP/IMAP/JMAP tests succeed."
|
||||
if can_test_profile
|
||||
else "Have an authorized tester confirm the SMTP/IMAP tests succeed."
|
||||
else "Have an authorized tester confirm the SMTP/IMAP/JMAP tests succeed."
|
||||
)
|
||||
if approval_required:
|
||||
checks.append("Confirm an administrator approved the generated profile reference before expecting it in a picker.")
|
||||
@@ -424,7 +424,7 @@ def _mail_policy_admin_text(policy: dict[str, Any], *, source_count: int) -> tup
|
||||
locked_limit_count = sum(1 for value in _lower_limit_values(policy) if value is False)
|
||||
|
||||
if approved_profile_limit and not lower_scopes:
|
||||
summary = "This tenant is in approved-profile mode: users can choose configured mail profiles, but lower scopes cannot bring arbitrary SMTP or IMAP servers."
|
||||
summary = "This tenant is in approved-profile mode: users can choose configured mail profiles, but lower scopes cannot bring arbitrary SMTP, IMAP, or JMAP servers."
|
||||
elif approved_profile_limit:
|
||||
summary = "This tenant limits mail sending to approved profile ids, while selected lower scopes can still define profiles within policy limits."
|
||||
elif lower_scopes:
|
||||
@@ -451,10 +451,10 @@ def _mail_policy_user_text(policy: dict[str, Any]) -> tuple[str, str]:
|
||||
body = "This is set by tenant policy. If the mail server you need is not offered, ask an administrator to add it as an approved mail profile."
|
||||
elif approved_profile_limit:
|
||||
summary = "You can use approved Mail profiles. Some scopes may also define additional reusable profiles."
|
||||
body = "The available profiles are limited by tenant policy. Campaigns select one profile by reference; SMTP/IMAP settings and credentials remain managed in Mail."
|
||||
body = "The available profiles are limited by tenant policy. Campaigns select one profile by reference; SMTP/IMAP/JMAP settings and credentials remain managed in Mail."
|
||||
elif lower_scopes:
|
||||
summary = "You may be able to define reusable Mail profiles in selected scopes, subject to tenant rules."
|
||||
body = "GovOPlaN checks each profile before use. Campaigns reference an available profile and never store its SMTP/IMAP settings or credentials."
|
||||
body = "GovOPlaN checks each profile before use. Campaigns reference an available profile and never store its SMTP/IMAP/JMAP settings or credentials."
|
||||
else:
|
||||
summary = "Mail servers are managed centrally for this tenant."
|
||||
body = "You cannot add a personal, group, or campaign mail server here. Choose one of the configured options or ask an administrator to add another approved profile."
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import getaddresses
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import (
|
||||
MailBounceObservation,
|
||||
MailDeliveryAttempt,
|
||||
MailDeliveryCommand,
|
||||
MailDeliveryReconciliation,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerProfile,
|
||||
)
|
||||
|
||||
|
||||
MAIL_DSAR_CAPABILITY = dsar_capability_name("mail")
|
||||
_MAX_RECORDS = 5_000
|
||||
|
||||
|
||||
class MailDsarProvider:
|
||||
provider_id = "mail"
|
||||
module_id = "mail"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
email = _subject_email(subject)
|
||||
membership_ids = _membership_ids(subject)
|
||||
references = _mail_references(subject)
|
||||
if email is None and not membership_ids and not references:
|
||||
return ()
|
||||
|
||||
records: list[DsarRecordRef] = []
|
||||
|
||||
def append(record: DsarRecordRef) -> None:
|
||||
if len(records) >= _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Mail DSAR match limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
records.append(record)
|
||||
|
||||
profiles = _matching_profiles(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
membership_ids=membership_ids,
|
||||
profile_id=references.get("profile"),
|
||||
)
|
||||
for profile in profiles:
|
||||
append(
|
||||
_record(
|
||||
"mail_server_profile",
|
||||
profile.id,
|
||||
"mail_profile",
|
||||
profile.name,
|
||||
{
|
||||
"match_fields": _profile_matching_fields(
|
||||
profile, membership_ids
|
||||
),
|
||||
"name": profile.name,
|
||||
"slug": profile.slug,
|
||||
"description": profile.description,
|
||||
"scope_type": profile.scope_type,
|
||||
"is_active": profile.is_active,
|
||||
"inherit_to_lower_scopes": profile.inherit_to_lower_scopes,
|
||||
},
|
||||
observed_at=profile.updated_at,
|
||||
source_path="/settings?section=mail-profiles",
|
||||
)
|
||||
)
|
||||
|
||||
messages = _matching_messages(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
message_id=references.get("message_index"),
|
||||
)
|
||||
for message in messages:
|
||||
matching_headers = _matching_headers(message, email)
|
||||
append(
|
||||
_record(
|
||||
"mailbox_message_index",
|
||||
message.id,
|
||||
"mailbox_message",
|
||||
message.subject or "Mailbox message",
|
||||
{
|
||||
"match_fields": list(matching_headers),
|
||||
"subject": _bounded_text(message.subject),
|
||||
"matching_headers": matching_headers,
|
||||
"date": message.date,
|
||||
"flags": tuple(
|
||||
str(flag)[:100] for flag in (message.flags or ())[:32]
|
||||
),
|
||||
"size_bytes": message.size_bytes,
|
||||
"body_preview": _bounded_text(message.body_preview),
|
||||
"attachment_count": message.attachment_count,
|
||||
"indexed_at": _iso(message.indexed_at),
|
||||
},
|
||||
observed_at=message.updated_at,
|
||||
source_path="/mail",
|
||||
)
|
||||
)
|
||||
|
||||
pop3_imports = _matching_pop3_imports(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
import_id=references.get("pop3_import"),
|
||||
)
|
||||
for imported in pop3_imports:
|
||||
matching_headers = _matching_pop3_headers(imported, email)
|
||||
append(
|
||||
_record(
|
||||
"mail_pop3_import",
|
||||
imported.id,
|
||||
"mail_imported_message",
|
||||
imported.subject or "Imported legacy message",
|
||||
{
|
||||
"match_fields": list(matching_headers),
|
||||
"subject": _bounded_text(imported.subject),
|
||||
"matching_headers": matching_headers,
|
||||
"date": imported.date,
|
||||
"message_id": _bounded_text(imported.message_id),
|
||||
"body_preview": _bounded_text(imported.body_preview),
|
||||
"size_bytes": imported.size_bytes,
|
||||
"status": imported.status,
|
||||
"imported_at": _iso(imported.imported_at),
|
||||
"deletion_requested": imported.deletion_requested,
|
||||
"deletion_status": imported.deletion_status,
|
||||
},
|
||||
observed_at=imported.updated_at,
|
||||
source_path="/mail/legacy-import",
|
||||
)
|
||||
)
|
||||
|
||||
bounces = _matching_bounces(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
bounce_id=references.get("bounce"),
|
||||
)
|
||||
bounce_command_ids = {row.command_id for row in bounces if row.command_id}
|
||||
commands = _matching_commands(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
membership_ids=membership_ids,
|
||||
command_id=references.get("command"),
|
||||
related_command_ids=bounce_command_ids,
|
||||
)
|
||||
command_ids = {row.id for row in commands}
|
||||
for command in commands:
|
||||
append(
|
||||
_record(
|
||||
"mail_delivery_command",
|
||||
command.id,
|
||||
"mail_delivery_evidence",
|
||||
f"Mail {command.command_type} command",
|
||||
{
|
||||
"match_fields": (
|
||||
["created_by_user_id"]
|
||||
if command.created_by_user_id in membership_ids
|
||||
else (
|
||||
["reference"]
|
||||
if command.id == references.get("command")
|
||||
else ["bounce"]
|
||||
)
|
||||
),
|
||||
"command_type": command.command_type,
|
||||
"source_module": command.source_module,
|
||||
"source_resource_type": command.source_resource_type,
|
||||
"message_sha256": command.message_sha256,
|
||||
"rfc_message_id": command.rfc_message_id,
|
||||
"message_size_bytes": command.message_size_bytes,
|
||||
"recipient_count": command.recipient_count,
|
||||
"status": command.status,
|
||||
"attempt_count": command.attempt_count,
|
||||
"effect_started_at": _iso(command.effect_started_at),
|
||||
"completed_at": _iso(command.completed_at),
|
||||
"accepted_count": command.accepted_count,
|
||||
"refused_count": command.refused_count,
|
||||
"failure_code": command.failure_code,
|
||||
"payload_purged_at": _iso(command.payload_purged_at),
|
||||
},
|
||||
observed_at=command.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Mail delivery commands are immutable transport, retry, and outcome evidence; encrypted payload retention is governed separately.",
|
||||
)
|
||||
)
|
||||
|
||||
for attempt in _command_attempts(db, command_ids):
|
||||
append(
|
||||
_record(
|
||||
"mail_delivery_attempt",
|
||||
attempt.id,
|
||||
"mail_delivery_evidence",
|
||||
f"Mail delivery attempt {attempt.attempt_number}",
|
||||
{
|
||||
"command_id": attempt.command_id,
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"started_at": _iso(attempt.started_at),
|
||||
"effect_started_at": _iso(attempt.effect_started_at),
|
||||
"completed_at": _iso(attempt.completed_at),
|
||||
"accepted_count": attempt.accepted_count,
|
||||
"refused_count": attempt.refused_count,
|
||||
"outcome_code": attempt.outcome_code,
|
||||
},
|
||||
observed_at=attempt.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Per-attempt Mail outcome state is immutable delivery and recovery evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
for reconciliation in _command_reconciliations(db, command_ids):
|
||||
append(
|
||||
_record(
|
||||
"mail_delivery_reconciliation",
|
||||
reconciliation.id,
|
||||
"mail_delivery_evidence",
|
||||
"Mail delivery reconciliation",
|
||||
{
|
||||
"command_id": reconciliation.command_id,
|
||||
"decision": reconciliation.decision,
|
||||
},
|
||||
observed_at=reconciliation.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Mail reconciliation decisions are immutable authorization and recovery evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
for bounce in bounces:
|
||||
append(
|
||||
_record(
|
||||
"mail_bounce_observation",
|
||||
bounce.id,
|
||||
"mail_bounce_evidence",
|
||||
"Mail bounce observation",
|
||||
{
|
||||
"match_fields": (
|
||||
["recipient"]
|
||||
if email and _normalized_email(bounce.recipient) == email
|
||||
else ["reference"]
|
||||
),
|
||||
"command_id": bounce.command_id
|
||||
if bounce.command_id in command_ids
|
||||
else None,
|
||||
"recipient": email
|
||||
if email and _normalized_email(bounce.recipient) == email
|
||||
else None,
|
||||
"action": bounce.action,
|
||||
"status_code": bounce.status_code,
|
||||
"permanent": bounce.permanent,
|
||||
"observed_at": _iso(bounce.observed_at),
|
||||
"matched": bounce.matched,
|
||||
},
|
||||
observed_at=bounce.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Bounce observations are immutable delivery-status and suppression evidence.",
|
||||
source_path="/mail/bounces",
|
||||
)
|
||||
)
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del session, tenant_id, subject
|
||||
actions = []
|
||||
for record in records:
|
||||
if (
|
||||
record.provider_id != self.provider_id
|
||||
or record.module_id != self.module_id
|
||||
):
|
||||
raise ValueError("Mail DSAR received a foreign provider record.")
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"mail:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}",
|
||||
provider_id="mail",
|
||||
module_id="mail",
|
||||
kind="retain" if record.immutable_evidence else "manual_review",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}",
|
||||
rationale=record.retention_reason
|
||||
or "Mailbox indexes and personal profiles must be reviewed through Mail and the authoritative external mailbox lifecycle.",
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del session, tenant_id, subject, request_id
|
||||
return tuple(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary="Mail erasure requires an authorized Mail/external-mailbox lifecycle action; the DSAR provider does not mutate it directly.",
|
||||
)
|
||||
for action in actions
|
||||
)
|
||||
|
||||
|
||||
def _matching_profiles(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
membership_ids: set[str],
|
||||
profile_id: str | None,
|
||||
) -> list[MailServerProfile]:
|
||||
conditions = []
|
||||
if profile_id:
|
||||
conditions.append(MailServerProfile.id == profile_id)
|
||||
if membership_ids:
|
||||
conditions.extend(
|
||||
(
|
||||
MailServerProfile.created_by_user_id.in_(membership_ids),
|
||||
MailServerProfile.updated_by_user_id.in_(membership_ids),
|
||||
(MailServerProfile.scope_type == "user")
|
||||
& MailServerProfile.scope_id.in_(membership_ids),
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailServerProfile)
|
||||
.filter(MailServerProfile.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailServerProfile.id)
|
||||
)
|
||||
|
||||
|
||||
def _profile_matching_fields(
|
||||
row: MailServerProfile, membership_ids: set[str]
|
||||
) -> list[str]:
|
||||
fields = []
|
||||
if row.scope_type == "user" and row.scope_id in membership_ids:
|
||||
fields.append("scope_id")
|
||||
for field in ("created_by_user_id", "updated_by_user_id"):
|
||||
if getattr(row, field) in membership_ids:
|
||||
fields.append(field)
|
||||
return fields
|
||||
|
||||
|
||||
def _matching_messages(
|
||||
session: Session, *, tenant_id: str, email: str | None, message_id: str | None
|
||||
) -> list[MailMailboxMessageIndex]:
|
||||
conditions = []
|
||||
if message_id:
|
||||
conditions.append(MailMailboxMessageIndex.id == message_id)
|
||||
if email:
|
||||
pattern = f"%{_escape_like(email)}%"
|
||||
conditions.extend(
|
||||
func.lower(field).like(pattern, escape="\\")
|
||||
for field in (
|
||||
MailMailboxMessageIndex.from_header,
|
||||
MailMailboxMessageIndex.to_header,
|
||||
MailMailboxMessageIndex.cc_header,
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
candidates = _bounded_rows(
|
||||
session.query(MailMailboxMessageIndex)
|
||||
.filter(MailMailboxMessageIndex.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailMailboxMessageIndex.id)
|
||||
)
|
||||
return [
|
||||
row
|
||||
for row in candidates
|
||||
if row.id == message_id or _matching_headers(row, email)
|
||||
]
|
||||
|
||||
|
||||
def _matching_headers(
|
||||
row: MailMailboxMessageIndex, email: str | None
|
||||
) -> dict[str, list[dict[str, str | None]]]:
|
||||
if email is None:
|
||||
return {}
|
||||
result = {}
|
||||
for role, value in (
|
||||
("from", row.from_header),
|
||||
("to", row.to_header),
|
||||
("cc", row.cc_header),
|
||||
):
|
||||
matches = [
|
||||
{"email": address.casefold(), "name": name or None}
|
||||
for name, address in getaddresses([value or ""])
|
||||
if address.casefold() == email
|
||||
]
|
||||
if matches:
|
||||
result[role] = matches[:64]
|
||||
return result
|
||||
|
||||
|
||||
def _matching_bounces(
|
||||
session: Session, *, tenant_id: str, email: str | None, bounce_id: str | None
|
||||
) -> list[MailBounceObservation]:
|
||||
conditions = []
|
||||
if bounce_id:
|
||||
conditions.append(MailBounceObservation.id == bounce_id)
|
||||
if email:
|
||||
conditions.append(func.lower(MailBounceObservation.recipient) == email)
|
||||
if not conditions:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailBounceObservation)
|
||||
.filter(MailBounceObservation.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailBounceObservation.id)
|
||||
)
|
||||
|
||||
|
||||
def _matching_pop3_imports(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
email: str | None,
|
||||
import_id: str | None,
|
||||
) -> list[MailPop3Import]:
|
||||
conditions = []
|
||||
if import_id:
|
||||
conditions.append(MailPop3Import.id == import_id)
|
||||
if email:
|
||||
pattern = f"%{_escape_like(email)}%"
|
||||
conditions.extend(
|
||||
func.lower(field).like(pattern, escape="\\")
|
||||
for field in (
|
||||
MailPop3Import.from_header,
|
||||
MailPop3Import.to_header,
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
candidates = _bounded_rows(
|
||||
session.query(MailPop3Import)
|
||||
.filter(MailPop3Import.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailPop3Import.id)
|
||||
)
|
||||
return [
|
||||
row
|
||||
for row in candidates
|
||||
if row.id == import_id or _matching_pop3_headers(row, email)
|
||||
]
|
||||
|
||||
|
||||
def _matching_pop3_headers(
|
||||
row: MailPop3Import,
|
||||
email: str | None,
|
||||
) -> dict[str, list[dict[str, str | None]]]:
|
||||
if email is None:
|
||||
return {}
|
||||
result = {}
|
||||
for role, value in (("from", row.from_header), ("to", row.to_header)):
|
||||
matches = [
|
||||
{"email": address.casefold(), "name": name or None}
|
||||
for name, address in getaddresses([value or ""])
|
||||
if address.casefold() == email
|
||||
]
|
||||
if matches:
|
||||
result[role] = matches[:64]
|
||||
return result
|
||||
|
||||
|
||||
def _matching_commands(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
membership_ids: set[str],
|
||||
command_id: str | None,
|
||||
related_command_ids: set[str],
|
||||
) -> list[MailDeliveryCommand]:
|
||||
conditions = []
|
||||
if command_id:
|
||||
conditions.append(MailDeliveryCommand.id == command_id)
|
||||
if related_command_ids:
|
||||
conditions.append(MailDeliveryCommand.id.in_(related_command_ids))
|
||||
if membership_ids:
|
||||
conditions.append(MailDeliveryCommand.created_by_user_id.in_(membership_ids))
|
||||
if not conditions:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailDeliveryCommand)
|
||||
.filter(MailDeliveryCommand.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailDeliveryCommand.id)
|
||||
)
|
||||
|
||||
|
||||
def _command_attempts(
|
||||
session: Session, command_ids: set[str]
|
||||
) -> list[MailDeliveryAttempt]:
|
||||
if not command_ids:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailDeliveryAttempt)
|
||||
.filter(MailDeliveryAttempt.command_id.in_(command_ids))
|
||||
.order_by(MailDeliveryAttempt.id)
|
||||
)
|
||||
|
||||
|
||||
def _command_reconciliations(
|
||||
session: Session, command_ids: set[str]
|
||||
) -> list[MailDeliveryReconciliation]:
|
||||
if not command_ids:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailDeliveryReconciliation)
|
||||
.filter(MailDeliveryReconciliation.command_id.in_(command_ids))
|
||||
.order_by(MailDeliveryReconciliation.id)
|
||||
)
|
||||
|
||||
|
||||
def _mail_references(subject: DsarSubjectRef) -> dict[str, str]:
|
||||
aliases = {
|
||||
"mail.profile": "profile",
|
||||
"mail.message_index": "message_index",
|
||||
"mail.delivery_command": "command",
|
||||
"mail.bounce_observation": "bounce",
|
||||
"mail.pop3_import": "pop3_import",
|
||||
}
|
||||
return {
|
||||
target: value
|
||||
for key, target in aliases.items()
|
||||
if (value := str(subject.external_references.get(key) or "").strip())
|
||||
}
|
||||
|
||||
|
||||
def _membership_ids(subject: DsarSubjectRef) -> set[str]:
|
||||
values = [subject.membership_id]
|
||||
values.extend(
|
||||
subject.external_references.get(key)
|
||||
for key in (
|
||||
"mail.user",
|
||||
"mail.membership",
|
||||
"access.membership",
|
||||
"membership_id",
|
||||
)
|
||||
)
|
||||
return {value for item in values if (value := str(item or "").strip())}
|
||||
|
||||
|
||||
def _subject_email(subject: DsarSubjectRef) -> str | None:
|
||||
values = [subject.email, subject.external_references.get("mail.email")]
|
||||
normalized = {email for value in values if (email := _normalized_email(value))}
|
||||
return normalized.pop() if len(normalized) == 1 else None
|
||||
|
||||
|
||||
def _normalized_email(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
value = value.strip().casefold()
|
||||
return value or None
|
||||
|
||||
|
||||
def _record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
category: str,
|
||||
title: str,
|
||||
data: Mapping[str, object],
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
immutable: bool = False,
|
||||
retention_reason: str | None = None,
|
||||
source_path: str | None = None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="mail",
|
||||
module_id="mail",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category=category,
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=observed_at,
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=retention_reason,
|
||||
source_path=source_path,
|
||||
)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Mail DSAR provider requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_rows(query: object) -> list[object]:
|
||||
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Mail DSAR match limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _bounded_text(value: str | None) -> str | None:
|
||||
return value[:2_000] if value else None
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = ["MAIL_DSAR_CAPABILITY", "MailDsarProvider"]
|
||||
@@ -0,0 +1,214 @@
|
||||
"""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.',
|
||||
'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.'}}
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import fnmatch
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
@@ -33,7 +34,7 @@ class MailProfileError(RuntimeError):
|
||||
|
||||
MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy"
|
||||
PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"}
|
||||
PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "envelope_senders", "from_headers", "recipient_domains")
|
||||
PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "jmap_hosts", "envelope_senders", "from_headers", "recipient_domains")
|
||||
PROFILE_SCOPE_ORDER = {"system": 0, "tenant": 1, "user": 2, "group": 2, "campaign": 3}
|
||||
MAIL_POLICY_LIMIT_KEYS = (
|
||||
"allowed_profile_ids",
|
||||
@@ -44,11 +45,13 @@ MAIL_POLICY_LIMIT_KEYS = (
|
||||
"imap_credentials.inherit",
|
||||
"whitelist.smtp_hosts",
|
||||
"whitelist.imap_hosts",
|
||||
"whitelist.jmap_hosts",
|
||||
"whitelist.envelope_senders",
|
||||
"whitelist.from_headers",
|
||||
"whitelist.recipient_domains",
|
||||
"blacklist.smtp_hosts",
|
||||
"blacklist.imap_hosts",
|
||||
"blacklist.jmap_hosts",
|
||||
"blacklist.envelope_senders",
|
||||
"blacklist.from_headers",
|
||||
"blacklist.recipient_domains",
|
||||
@@ -126,7 +129,7 @@ def slugify_profile_name(value: str) -> str:
|
||||
|
||||
|
||||
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]:
|
||||
payload = config.model_dump(mode="json")
|
||||
payload = config.model_dump(mode="json", exclude_none=True)
|
||||
username_was_supplied = "username" in config.model_fields_set
|
||||
password_was_supplied = "password" in config.model_fields_set
|
||||
username = payload.pop("username", None)
|
||||
@@ -711,11 +714,19 @@ def _domain_from_email(value: str | None) -> str | None:
|
||||
return text.rsplit("@", 1)[1] or None
|
||||
|
||||
|
||||
def _assert_transport_values_allowed(policy: EffectiveMailProfilePolicy, smtp: SmtpConfig | dict[str, Any] | None, imap: ImapConfig | dict[str, Any] | None) -> None:
|
||||
def _assert_transport_values_allowed(
|
||||
policy: EffectiveMailProfilePolicy,
|
||||
smtp: SmtpConfig | dict[str, Any] | None,
|
||||
imap: ImapConfig | dict[str, Any] | None,
|
||||
jmap: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
smtp_host = smtp.host if isinstance(smtp, SmtpConfig) else smtp.get("host") if isinstance(smtp, dict) else None
|
||||
imap_host = imap.host if isinstance(imap, ImapConfig) else imap.get("host") if isinstance(imap, dict) else None
|
||||
jmap_url = jmap.get("session_url") if isinstance(jmap, dict) else None
|
||||
jmap_host = urllib.parse.urlsplit(str(jmap_url)).hostname if jmap_url else None
|
||||
_assert_policy_value(policy, "smtp_hosts", str(smtp_host) if smtp_host else None)
|
||||
_assert_policy_value(policy, "imap_hosts", str(imap_host) if imap_host else None)
|
||||
_assert_policy_value(policy, "jmap_hosts", str(jmap_host) if jmap_host else None)
|
||||
|
||||
|
||||
def assert_mail_policy_allows_transport(
|
||||
@@ -724,6 +735,7 @@ def assert_mail_policy_allows_transport(
|
||||
tenant_id: str,
|
||||
smtp: SmtpConfig | dict[str, Any] | None,
|
||||
imap: ImapConfig | dict[str, Any] | None = None,
|
||||
jmap: dict[str, Any] | None = None,
|
||||
campaign_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
@@ -735,7 +747,7 @@ def assert_mail_policy_allows_transport(
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
_assert_transport_values_allowed(policy, smtp, imap)
|
||||
_assert_transport_values_allowed(policy, smtp, imap, jmap)
|
||||
|
||||
|
||||
def assert_mail_policy_allows_send(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
"""add governed POP3 legacy imports
|
||||
|
||||
Revision ID: a4c5d6e7f809
|
||||
Revises: 93b4c5d6e7f8
|
||||
Create Date: 2026-08-22 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a4c5d6e7f809"
|
||||
down_revision = "93b4c5d6e7f8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"mail_pop3_imports",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("profile_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("pop3_server_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("pop3_credential_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("transport_revision", sa.String(length=120), nullable=False),
|
||||
sa.Column("provider_uidl", sa.String(length=500), nullable=False),
|
||||
sa.Column("provider_message_number", sa.Integer(), nullable=True),
|
||||
sa.Column("fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_message_encrypted", sa.Text(), nullable=False),
|
||||
sa.Column("message_id", sa.String(length=998), nullable=True),
|
||||
sa.Column("subject", sa.Text(), nullable=True),
|
||||
sa.Column("from_header", sa.Text(), nullable=True),
|
||||
sa.Column("to_header", sa.Text(), nullable=True),
|
||||
sa.Column("date", sa.String(length=255), nullable=True),
|
||||
sa.Column("body_preview", sa.Text(), nullable=True),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("imported_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("imported_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deletion_requested", sa.Boolean(), nullable=False),
|
||||
sa.Column("deletion_status", sa.String(length=40), nullable=False),
|
||||
sa.Column("deletion_attempted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deletion_error", sa.String(length=500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["profile_id"], ["mail_server_profiles.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["pop3_server_id"], ["mail_server_endpoints.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["imported_by_user_id"], ["access_users.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"profile_id",
|
||||
"pop3_server_id",
|
||||
"provider_uidl",
|
||||
name="uq_mail_pop3_imports_source_uidl",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"profile_id",
|
||||
"pop3_server_id",
|
||||
"fingerprint",
|
||||
"message_id",
|
||||
"status",
|
||||
"imported_at",
|
||||
"imported_by_user_id",
|
||||
"deletion_status",
|
||||
):
|
||||
op.create_index(
|
||||
f"ix_mail_pop3_imports_{column}",
|
||||
"mail_pop3_imports",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_mail_pop3_imports_review",
|
||||
"mail_pop3_imports",
|
||||
["tenant_id", "status", "imported_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("mail_pop3_imports")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from email import policy
|
||||
from email.message import Message
|
||||
from email.parser import BytesParser
|
||||
from email.utils import getaddresses
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.mail import (
|
||||
MailPostboxBridgeProvider,
|
||||
MailPostboxBridgeRequest,
|
||||
MailPostboxBridgeResult,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxAttachmentRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxParticipantRef,
|
||||
PostboxTargetRef,
|
||||
postbox_delivery_provider,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
from govoplan_mail.backend.runtime import configure_runtime, get_registry
|
||||
|
||||
|
||||
MAX_BRIDGE_MESSAGE_BYTES = 50 * 1024 * 1024
|
||||
MAX_POSTBOX_BODY_CHARS = 500_000
|
||||
|
||||
|
||||
class MailPostboxBridgeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MailPostboxBridge(MailPostboxBridgeProvider):
|
||||
"""Translate immutable IMAP observations into native Postbox delivery."""
|
||||
|
||||
def bridge_message(
|
||||
self,
|
||||
session: object,
|
||||
request: MailPostboxBridgeRequest,
|
||||
) -> MailPostboxBridgeResult:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Mail Postbox bridging requires a SQLAlchemy session.")
|
||||
if not isinstance(request.target, PostboxTargetRef):
|
||||
raise MailPostboxBridgeError("A typed Postbox target is required.")
|
||||
if len(request.raw_message) > MAX_BRIDGE_MESSAGE_BYTES:
|
||||
raise MailPostboxBridgeError("Mail message exceeds the Postbox bridge limit.")
|
||||
profile = session.get(MailServerProfile, request.profile_id)
|
||||
if profile is None or profile.tenant_id != request.tenant_id:
|
||||
raise MailPostboxBridgeError("Mail profile not found.")
|
||||
folder = request.folder.strip()
|
||||
uid = request.uid.strip()
|
||||
uidvalidity = request.uidvalidity.strip()
|
||||
if not folder or not uid or not uidvalidity:
|
||||
raise MailPostboxBridgeError(
|
||||
"Mail folder, UIDVALIDITY, and immutable UID are required."
|
||||
)
|
||||
try:
|
||||
message = BytesParser(policy=policy.default).parsebytes(request.raw_message)
|
||||
except Exception as exc:
|
||||
raise MailPostboxBridgeError("Mail message could not be parsed.") from exc
|
||||
|
||||
provider = postbox_delivery_provider(get_registry())
|
||||
if provider is None:
|
||||
raise MailPostboxBridgeError("Postbox delivery is not available.")
|
||||
source_digest = hashlib.sha256(request.raw_message).hexdigest()
|
||||
source_key = hashlib.sha256(
|
||||
f"{request.tenant_id}\0{request.profile_id}\0{folder}\0{uidvalidity}\0{uid}".encode("utf-8")
|
||||
).hexdigest()
|
||||
result = provider.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id=request.tenant_id,
|
||||
target=request.target,
|
||||
producer_module="mail",
|
||||
producer_resource_type="imap_message",
|
||||
producer_resource_id=source_key,
|
||||
idempotency_key=f"mail-postbox:{source_key}:{source_digest}",
|
||||
subject=_header(message, "Subject") or "(No subject)",
|
||||
body_text=_plain_text_body(message),
|
||||
sender_label=_header(message, "From"),
|
||||
classification=request.classification,
|
||||
participants=_participants(message),
|
||||
attachments=_attachments(
|
||||
message,
|
||||
profile_id=request.profile_id,
|
||||
folder=folder,
|
||||
uidvalidity=uidvalidity,
|
||||
uid=uid,
|
||||
),
|
||||
metadata={
|
||||
**dict(request.metadata),
|
||||
"transport": "mail-imap",
|
||||
"mail_profile_id": request.profile_id,
|
||||
"mailbox_folder": folder,
|
||||
"mailbox_uidvalidity": uidvalidity,
|
||||
"mailbox_uid": uid,
|
||||
"rfc_message_id": _header(message, "Message-ID"),
|
||||
"raw_sha256": source_digest,
|
||||
},
|
||||
),
|
||||
)
|
||||
return MailPostboxBridgeResult(
|
||||
postbox_id=result.postbox_id,
|
||||
message_id=result.message_id,
|
||||
delivery_id=result.delivery_id,
|
||||
duplicate=result.duplicate,
|
||||
source_digest=source_digest,
|
||||
)
|
||||
|
||||
|
||||
def _header(message: Message, name: str) -> str | None:
|
||||
value = " ".join(str(message.get(name) or "").split())
|
||||
return value[:1000] or None
|
||||
|
||||
|
||||
def _plain_text_body(message: Message) -> str | None:
|
||||
candidates = message.walk() if message.is_multipart() else (message,)
|
||||
for part in candidates:
|
||||
if part.get_content_type() != "text/plain":
|
||||
continue
|
||||
if part.get_content_disposition() == "attachment":
|
||||
continue
|
||||
try:
|
||||
value = part.get_content()
|
||||
except Exception:
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
value = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
normalized = str(value).strip()
|
||||
if normalized:
|
||||
return normalized[:MAX_POSTBOX_BODY_CHARS]
|
||||
return None
|
||||
|
||||
|
||||
def _participants(message: Message) -> tuple[PostboxParticipantRef, ...]:
|
||||
result: list[PostboxParticipantRef] = []
|
||||
for kind, headers in (
|
||||
("sender", ("From",)),
|
||||
("to", ("To",)),
|
||||
("cc", ("Cc",)),
|
||||
("bcc", ("Bcc",)),
|
||||
):
|
||||
for name, address in getaddresses(
|
||||
[str(value) for header in headers for value in message.get_all(header, [])]
|
||||
):
|
||||
clean_address = address.strip()[:500]
|
||||
if not clean_address:
|
||||
continue
|
||||
result.append(
|
||||
PostboxParticipantRef(
|
||||
kind=kind,
|
||||
reference_type="external_email",
|
||||
label=name.strip()[:500] or None,
|
||||
address=clean_address,
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _attachments(
|
||||
message: Message,
|
||||
*,
|
||||
profile_id: str,
|
||||
folder: str,
|
||||
uidvalidity: str,
|
||||
uid: str,
|
||||
) -> tuple[PostboxAttachmentRef, ...]:
|
||||
result: list[PostboxAttachmentRef] = []
|
||||
for index, part in enumerate(message.walk()):
|
||||
filename = part.get_filename()
|
||||
if part.get_content_disposition() != "attachment" and not filename:
|
||||
continue
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
reference_id = hashlib.sha256(
|
||||
f"{profile_id}\0{folder}\0{uidvalidity}\0{uid}\0{index}\0{digest}".encode("utf-8")
|
||||
).hexdigest()
|
||||
result.append(
|
||||
PostboxAttachmentRef(
|
||||
reference_type="mail_attachment",
|
||||
reference_id=reference_id,
|
||||
name=str(filename or f"attachment-{index + 1}")[:1000],
|
||||
media_type=part.get_content_type(),
|
||||
size_bytes=len(payload),
|
||||
digest=digest,
|
||||
metadata={
|
||||
"mail_profile_id": profile_id,
|
||||
"mailbox_folder": folder,
|
||||
"mailbox_uidvalidity": uidvalidity,
|
||||
"mailbox_uid": uid,
|
||||
"mime_part_index": index,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def create_postbox_bridge(context: ModuleContext) -> MailPostboxBridge:
|
||||
configure_runtime(registry=context.registry, settings=context.settings)
|
||||
return MailPostboxBridge()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MailPostboxBridge",
|
||||
"MailPostboxBridgeError",
|
||||
"create_postbox_bridge",
|
||||
]
|
||||
@@ -16,6 +16,7 @@ from govoplan_mail.backend.db.models import (
|
||||
MailDeliveryCommand,
|
||||
MailMailboxFolderIndex,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
@@ -23,6 +24,8 @@ from govoplan_mail.backend.db.models import (
|
||||
|
||||
SMTP_PROVIDER_ID = "mail.smtp_delivery"
|
||||
IMAP_PROVIDER_ID = "mail.imap_mailbox"
|
||||
JMAP_PROVIDER_ID = "mail.jmap_mailbox"
|
||||
POP3_PROVIDER_ID = "mail.pop3_legacy_import"
|
||||
_CURRENT_INDEX_WINDOW = timedelta(minutes=30)
|
||||
|
||||
|
||||
@@ -38,6 +41,47 @@ def imap_provider_states(
|
||||
return _mail_provider_states(context, protocol="imap")
|
||||
|
||||
|
||||
def jmap_provider_states(
|
||||
context: ExternalProviderStateContext,
|
||||
) -> tuple[ExternalProviderRuntimeState, ...]:
|
||||
return _mail_provider_states(context, protocol="jmap")
|
||||
|
||||
|
||||
def pop3_provider_states(
|
||||
context: ExternalProviderStateContext,
|
||||
) -> tuple[ExternalProviderRuntimeState, ...]:
|
||||
if not isinstance(context.session, Session):
|
||||
raise RuntimeError("Mail provider state requires a database session.")
|
||||
profiles = _profiles(context)
|
||||
if not profiles:
|
||||
return ()
|
||||
profile_ids = tuple(item.id for item in profiles)
|
||||
endpoints = _endpoints(
|
||||
context.session,
|
||||
profile_ids=profile_ids,
|
||||
protocol="pop3",
|
||||
)
|
||||
endpoints_by_profile: dict[str, list[MailServerEndpoint]] = defaultdict(list)
|
||||
for endpoint in endpoints:
|
||||
endpoints_by_profile[endpoint.profile_id].append(endpoint)
|
||||
metrics = _pop3_metrics(
|
||||
context.session,
|
||||
profile_ids=profile_ids,
|
||||
tenant_id=context.tenant_id,
|
||||
)
|
||||
observed_at = datetime.now(UTC)
|
||||
return tuple(
|
||||
_pop3_state(
|
||||
profile,
|
||||
endpoints=endpoints_by_profile.get(profile.id, []),
|
||||
metrics=metrics.get(profile.id, {}),
|
||||
observed_at=observed_at,
|
||||
)
|
||||
for profile in profiles
|
||||
if endpoints_by_profile.get(profile.id)
|
||||
)
|
||||
|
||||
|
||||
def _mail_provider_states(
|
||||
context: ExternalProviderStateContext,
|
||||
*,
|
||||
@@ -69,15 +113,28 @@ def _mail_provider_states(
|
||||
)
|
||||
|
||||
metrics = _imap_metrics(context.session, profile_ids=profile_ids)
|
||||
if protocol == "jmap":
|
||||
metrics = {
|
||||
profile_id: {
|
||||
key: value
|
||||
for key, value in values.items()
|
||||
if key in {"indexed_folders", "indexed_messages", "last_indexed_at"}
|
||||
}
|
||||
for profile_id, values in metrics.items()
|
||||
}
|
||||
provider_id = JMAP_PROVIDER_ID if protocol == "jmap" else IMAP_PROVIDER_ID
|
||||
return tuple(
|
||||
_imap_state(
|
||||
profile,
|
||||
endpoints=endpoints_by_profile.get(profile.id, []),
|
||||
metrics=metrics.get(profile.id, {}),
|
||||
observed_at=observed_at,
|
||||
protocol=protocol,
|
||||
provider_id=provider_id,
|
||||
)
|
||||
for profile in profiles
|
||||
if endpoints_by_profile.get(profile.id) or _legacy_configured(profile, "imap")
|
||||
if endpoints_by_profile.get(profile.id)
|
||||
or (protocol == "imap" and _legacy_configured(profile, "imap"))
|
||||
)
|
||||
|
||||
|
||||
@@ -194,6 +251,37 @@ def _imap_metrics(
|
||||
return result
|
||||
|
||||
|
||||
def _pop3_metrics(
|
||||
session: Session,
|
||||
*,
|
||||
profile_ids: tuple[str, ...],
|
||||
tenant_id: str | None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
result: dict[str, dict[str, Any]] = defaultdict(dict)
|
||||
statement = select(
|
||||
MailPop3Import.profile_id,
|
||||
MailPop3Import.deletion_status,
|
||||
func.count(MailPop3Import.id),
|
||||
func.max(MailPop3Import.imported_at),
|
||||
).where(MailPop3Import.profile_id.in_(profile_ids))
|
||||
if tenant_id is not None:
|
||||
statement = statement.where(MailPop3Import.tenant_id == tenant_id)
|
||||
rows = session.execute(
|
||||
statement.group_by(
|
||||
MailPop3Import.profile_id,
|
||||
MailPop3Import.deletion_status,
|
||||
)
|
||||
)
|
||||
for profile_id, deletion_status, count, last_imported_at in rows:
|
||||
item = result[str(profile_id)]
|
||||
item[f"deletion_{deletion_status}"] = int(count)
|
||||
current = _aware(item.get("last_imported_at"))
|
||||
candidate = _aware(last_imported_at)
|
||||
if candidate is not None and (current is None or candidate > current):
|
||||
item["last_imported_at"] = candidate
|
||||
return result
|
||||
|
||||
|
||||
def _smtp_state(
|
||||
profile: MailServerProfile,
|
||||
*,
|
||||
@@ -259,10 +347,13 @@ def _imap_state(
|
||||
endpoints: list[MailServerEndpoint],
|
||||
metrics: dict[str, Any],
|
||||
observed_at: datetime,
|
||||
protocol: str = "imap",
|
||||
provider_id: str = IMAP_PROVIDER_ID,
|
||||
) -> ExternalProviderRuntimeState:
|
||||
protocol_label = protocol.upper()
|
||||
active = bool(profile.is_active) and (
|
||||
any(item.is_active for item in endpoints)
|
||||
or (not endpoints and _legacy_configured(profile, "imap"))
|
||||
or (protocol == "imap" and not endpoints and _legacy_configured(profile, "imap"))
|
||||
)
|
||||
indexed_at = _aware(metrics.get("last_indexed_at"))
|
||||
errors = int(metrics.get("bounce_source_errors", 0))
|
||||
@@ -285,8 +376,8 @@ def _imap_state(
|
||||
else "unknown"
|
||||
)
|
||||
return ExternalProviderRuntimeState(
|
||||
provider_id=IMAP_PROVIDER_ID,
|
||||
binding_ref=f"mail:profile:{profile.id}:imap",
|
||||
provider_id=provider_id,
|
||||
binding_ref=f"mail:profile:{profile.id}:{protocol}",
|
||||
authority_mode="external_mirror",
|
||||
observed_at=observed_at,
|
||||
configured=True,
|
||||
@@ -303,13 +394,13 @@ def _imap_state(
|
||||
),
|
||||
last_success_at=indexed_at or _aware(metrics.get("last_bounce_success_at")),
|
||||
detail=(
|
||||
"IMAP mailbox access is disabled."
|
||||
f"{protocol_label} mailbox access is disabled."
|
||||
if not active
|
||||
else "IMAP mailbox or bounce-source errors require attention."
|
||||
else f"{protocol_label} mailbox or bounce-source errors require attention."
|
||||
if errors
|
||||
else "IMAP mailbox state has not been indexed yet."
|
||||
else f"{protocol_label} mailbox state has not been indexed yet."
|
||||
if indexed_at is None
|
||||
else "IMAP mailbox index state is available."
|
||||
else f"{protocol_label} mailbox index state is available."
|
||||
),
|
||||
metrics={
|
||||
"indexed_folders": int(metrics.get("indexed_folders", 0)),
|
||||
@@ -321,6 +412,71 @@ def _imap_state(
|
||||
)
|
||||
|
||||
|
||||
def _pop3_state(
|
||||
profile: MailServerProfile,
|
||||
*,
|
||||
endpoints: list[MailServerEndpoint],
|
||||
metrics: dict[str, Any],
|
||||
observed_at: datetime,
|
||||
) -> ExternalProviderRuntimeState:
|
||||
enabled_endpoints = [
|
||||
item
|
||||
for item in endpoints
|
||||
if item.is_active and bool((item.config or {}).get("legacy_import_enabled"))
|
||||
]
|
||||
active = bool(profile.is_active) and bool(enabled_endpoints)
|
||||
failed_deletions = int(metrics.get("deletion_failed", 0))
|
||||
unknown_deletions = int(metrics.get("deletion_outcome_unknown", 0))
|
||||
last_imported_at = _aware(metrics.get("last_imported_at"))
|
||||
health = (
|
||||
"inactive"
|
||||
if not active
|
||||
else "warning"
|
||||
if failed_deletions or unknown_deletions
|
||||
else "healthy"
|
||||
if last_imported_at is not None
|
||||
else "unknown"
|
||||
)
|
||||
return ExternalProviderRuntimeState(
|
||||
provider_id=POP3_PROVIDER_ID,
|
||||
binding_ref=f"mail:profile:{profile.id}:pop3",
|
||||
authority_mode="governance_overlay",
|
||||
observed_at=observed_at,
|
||||
configured=True,
|
||||
active=active,
|
||||
health=health,
|
||||
freshness="not_applicable",
|
||||
conflict="pending" if unknown_deletions else "clear",
|
||||
recovery=(
|
||||
"not_applicable"
|
||||
if not active
|
||||
else "attention"
|
||||
if failed_deletions or unknown_deletions
|
||||
else "ready"
|
||||
),
|
||||
last_success_at=last_imported_at,
|
||||
detail=(
|
||||
"POP3 legacy import is disabled."
|
||||
if not active
|
||||
else "POP3 source deletion evidence requires attention."
|
||||
if failed_deletions or unknown_deletions
|
||||
else "POP3 legacy import is enabled but has no retained import yet."
|
||||
if last_imported_at is None
|
||||
else "POP3 governed import evidence is available."
|
||||
),
|
||||
metrics={
|
||||
"active_endpoints": len(enabled_endpoints),
|
||||
"imports": sum(
|
||||
int(value)
|
||||
for key, value in metrics.items()
|
||||
if key.startswith("deletion_")
|
||||
),
|
||||
"failed_deletions": failed_deletions,
|
||||
"outcome_unknown_deletions": unknown_deletions,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _legacy_configured(profile: MailServerProfile, protocol: str) -> bool:
|
||||
value = profile.smtp_config if protocol == "smtp" else profile.imap_config
|
||||
return isinstance(value, dict) and bool(value)
|
||||
@@ -336,7 +492,11 @@ def _aware(value: object | None) -> datetime | None:
|
||||
|
||||
__all__ = [
|
||||
"IMAP_PROVIDER_ID",
|
||||
"JMAP_PROVIDER_ID",
|
||||
"POP3_PROVIDER_ID",
|
||||
"SMTP_PROVIDER_ID",
|
||||
"imap_provider_states",
|
||||
"jmap_provider_states",
|
||||
"pop3_provider_states",
|
||||
"smtp_provider_states",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -197,7 +197,7 @@ class MailServerEndpointResponse(BaseModel):
|
||||
id: str
|
||||
profile_id: str
|
||||
tenant_id: str | None = None
|
||||
protocol: Literal["smtp", "imap"]
|
||||
protocol: Literal["smtp", "imap", "jmap", "pop3"]
|
||||
name: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
scope_type: MailProfileScope
|
||||
@@ -214,7 +214,7 @@ class MailServerEndpointResponse(BaseModel):
|
||||
class MailServerEndpointCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
protocol: Literal["smtp", "imap"]
|
||||
protocol: Literal["smtp", "imap", "jmap", "pop3"]
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
@@ -344,9 +344,46 @@ class MailAddressLookupResponse(BaseModel):
|
||||
candidates: list[MailAddressLookupCandidate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailAddressWriteTarget(BaseModel):
|
||||
address_book_id: str
|
||||
address_book_label: str | None = None
|
||||
operation: str = "create_contact"
|
||||
allowed: bool = False
|
||||
reason: str
|
||||
message: str
|
||||
scope_type: str | None = None
|
||||
scope_id: str | None = None
|
||||
source_kind: str | None = None
|
||||
read_only: bool = False
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailAddressWriteTargetResponse(BaseModel):
|
||||
available: bool = False
|
||||
targets: list[MailAddressWriteTarget] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailContactCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
address_book_id: str = Field(min_length=1, max_length=36)
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
email: str = Field(min_length=3, max_length=320, pattern=r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||||
|
||||
|
||||
class MailContactCreateResponse(BaseModel):
|
||||
contact_id: str
|
||||
address_book_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
source_kind: str = "local"
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailConnectionTestResponse(BaseModel):
|
||||
ok: bool
|
||||
protocol: Literal["smtp", "imap"]
|
||||
protocol: Literal["smtp", "imap", "jmap", "pop3"]
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
@@ -354,6 +391,82 @@ class MailConnectionTestResponse(BaseModel):
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailPop3PreviewRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
server_id: str = Field(min_length=1, max_length=36)
|
||||
credential_id: str | None = Field(default=None, max_length=36)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class MailPop3MessagePreviewResponse(BaseModel):
|
||||
message_number: int
|
||||
uidl: str
|
||||
subject: str | None = None
|
||||
from_header: str | None = None
|
||||
to_header: str | None = None
|
||||
date: str | None = None
|
||||
message_id: str | None = None
|
||||
size_bytes: int = 0
|
||||
body_preview: str | None = None
|
||||
already_imported: bool = False
|
||||
|
||||
|
||||
class MailPop3PreviewResponse(BaseModel):
|
||||
profile_id: str
|
||||
server_id: str
|
||||
transport_revision: str
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
message_count: int
|
||||
mailbox_size_bytes: int
|
||||
delete_after_import_allowed: bool = False
|
||||
messages: list[MailPop3MessagePreviewResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailPop3ImportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
server_id: str = Field(min_length=1, max_length=36)
|
||||
credential_id: str | None = Field(default=None, max_length=36)
|
||||
expected_transport_revision: str = Field(min_length=1, max_length=120)
|
||||
uidls: list[str] = Field(min_length=1, max_length=100)
|
||||
delete_after_import: bool = False
|
||||
|
||||
|
||||
class MailPop3ImportRecordResponse(BaseModel):
|
||||
id: str
|
||||
profile_id: str
|
||||
pop3_server_id: str
|
||||
transport_revision: str
|
||||
provider_uidl: str
|
||||
message_id: str | None = None
|
||||
subject: str | None = None
|
||||
from_header: str | None = None
|
||||
to_header: str | None = None
|
||||
date: str | None = None
|
||||
body_preview: str | None = None
|
||||
size_bytes: int
|
||||
raw_sha256: str
|
||||
status: str
|
||||
imported_at: datetime
|
||||
deletion_requested: bool = False
|
||||
deletion_status: str
|
||||
deletion_attempted_at: datetime | None = None
|
||||
deletion_error: str | None = None
|
||||
|
||||
|
||||
class MailPop3ImportResponse(BaseModel):
|
||||
imports: list[MailPop3ImportRecordResponse] = Field(default_factory=list)
|
||||
duplicate_uidls: list[str] = Field(default_factory=list)
|
||||
deletion_status: str = "not_requested"
|
||||
|
||||
|
||||
class MailPop3ImportListResponse(BaseModel):
|
||||
imports: list[MailPop3ImportRecordResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailImapFolderResponse(BaseModel):
|
||||
name: str
|
||||
flags: list[str] = Field(default_factory=list)
|
||||
@@ -363,13 +476,14 @@ class MailImapFolderResponse(BaseModel):
|
||||
|
||||
class MailImapFolderListResponse(BaseModel):
|
||||
ok: bool
|
||||
protocol: Literal["imap"] = "imap"
|
||||
protocol: Literal["imap", "jmap"] = "imap"
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
message: str
|
||||
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
||||
detected_sent_folder: str | None = None
|
||||
detected_folder_mappings: dict[str, str] = Field(default_factory=dict)
|
||||
from_cache: bool = False
|
||||
refreshing: bool = False
|
||||
indexed_at: datetime | None = None
|
||||
@@ -422,6 +536,18 @@ class MailMailboxMessageListResponse(BaseModel):
|
||||
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailMailboxChangesResponse(BaseModel):
|
||||
profile_id: str
|
||||
protocol: Literal["jmap"] = "jmap"
|
||||
account_id: str
|
||||
old_state: str
|
||||
new_state: str
|
||||
has_more_changes: bool = False
|
||||
created: list[str] = Field(default_factory=list)
|
||||
updated: list[str] = Field(default_factory=list)
|
||||
destroyed: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailMailboxBootstrapResponse(BaseModel):
|
||||
profile_id: str
|
||||
folder: str
|
||||
|
||||
@@ -103,6 +103,7 @@ class ImapFolderListResult:
|
||||
security: str
|
||||
folders: list[ImapMailboxInfo]
|
||||
detected_sent_folder: str | None = None
|
||||
detected_folder_mappings: dict[str, str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -317,25 +318,47 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
|
||||
return None
|
||||
|
||||
|
||||
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
||||
for name, flags in parsed:
|
||||
if "\\sent" in flags or "\\sentmail" in flags:
|
||||
return name
|
||||
_STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = {
|
||||
"inbox": ("\\inbox",),
|
||||
"sent": ("\\sent", "\\sentmail"),
|
||||
"drafts": ("\\drafts",),
|
||||
"trash": ("\\trash",),
|
||||
"archive": ("\\archive", "\\all"),
|
||||
"junk": ("\\junk", "\\spam"),
|
||||
}
|
||||
|
||||
common_names = [
|
||||
"Sent",
|
||||
"Sent Items",
|
||||
"Sent Messages",
|
||||
"Gesendet",
|
||||
"Gesendete Elemente",
|
||||
"INBOX.Sent",
|
||||
"INBOX/Sent",
|
||||
]
|
||||
names = {name.lower(): name for name, _ in parsed}
|
||||
for candidate in common_names:
|
||||
if candidate.lower() in names:
|
||||
return names[candidate.lower()]
|
||||
return None
|
||||
_STANDARD_FOLDER_NAMES: dict[str, tuple[str, ...]] = {
|
||||
"inbox": ("INBOX", "Posteingang"),
|
||||
"sent": ("Sent", "Sent Items", "Sent Messages", "Gesendet", "Gesendete Elemente", "INBOX.Sent", "INBOX/Sent"),
|
||||
"drafts": ("Drafts", "Entwürfe", "Entwuerfe"),
|
||||
"trash": ("Trash", "Deleted Items", "Gelöscht", "Geloescht", "Papierkorb"),
|
||||
"archive": ("Archive", "Archives", "Archiv"),
|
||||
"junk": ("Junk", "Spam", "Junk Email", "Unerwünscht", "Unerwuenscht"),
|
||||
}
|
||||
|
||||
|
||||
def _detect_standard_folder_mappings(parsed: list[tuple[str, set[str]]]) -> dict[str, str]:
|
||||
detected: dict[str, str] = {}
|
||||
for role, accepted_flags in _STANDARD_FOLDER_FLAGS.items():
|
||||
for name, flags in parsed:
|
||||
if any(flag in flags for flag in accepted_flags):
|
||||
detected[role] = name
|
||||
break
|
||||
|
||||
names = {name.casefold(): name for name, _ in parsed}
|
||||
for role, candidates in _STANDARD_FOLDER_NAMES.items():
|
||||
if role in detected:
|
||||
continue
|
||||
for candidate in candidates:
|
||||
match = names.get(candidate.casefold())
|
||||
if match:
|
||||
detected[role] = match
|
||||
break
|
||||
return detected
|
||||
|
||||
|
||||
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
||||
return _detect_standard_folder_mappings(parsed).get("sent")
|
||||
|
||||
|
||||
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
|
||||
@@ -402,12 +425,18 @@ def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||
name = str(item["name"])
|
||||
count = sum(1 for record in records if _mock_folder_matches(record, name))
|
||||
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
|
||||
parsed = [
|
||||
(str(item["name"]), {str(flag).lower() for flag in item.get("flags") or []})
|
||||
for item in MOCK_IMAP_FOLDERS
|
||||
]
|
||||
detected = _detect_standard_folder_mappings(parsed)
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folders=folders,
|
||||
detected_sent_folder="Sent",
|
||||
detected_sent_folder=detected.get("sent"),
|
||||
detected_folder_mappings=detected,
|
||||
)
|
||||
|
||||
|
||||
@@ -438,12 +467,14 @@ def _list_imap_folders_on_client(
|
||||
)
|
||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
|
||||
|
||||
detected = _detect_standard_folder_mappings(parsed)
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=security,
|
||||
folders=folders,
|
||||
detected_sent_folder=_detect_sent_folder(parsed),
|
||||
detected_sent_folder=detected.get("sent"),
|
||||
detected_folder_mappings=detected,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
@@ -64,10 +65,22 @@ class SmtpSendError(RuntimeError):
|
||||
started, so automatic retry is intentionally forbidden.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
temporary: bool = False,
|
||||
outcome_unknown: bool = False,
|
||||
systemic: bool = False,
|
||||
reason_code: str | None = None,
|
||||
phase: str = "send",
|
||||
):
|
||||
super().__init__(message)
|
||||
self.temporary = temporary
|
||||
self.outcome_unknown = outcome_unknown
|
||||
self.systemic = systemic
|
||||
self.reason_code = reason_code
|
||||
self.phase = phase
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -86,12 +99,315 @@ class SmtpSendResult:
|
||||
envelope_from: str
|
||||
envelope_recipients: list[str]
|
||||
refused_recipients: dict[str, tuple[int, bytes | str]]
|
||||
connection_sequence: int = 1
|
||||
session_reused: bool = False
|
||||
reconnect_count: int = 0
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
return len(self.envelope_recipients) - len(self.refused_recipients)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmtpBatchPolicy:
|
||||
reuse_connections: bool = True
|
||||
max_messages_per_connection: int = 100
|
||||
reconnect_attempts: int = 1
|
||||
health_check_before_reuse: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "SmtpBatchPolicy":
|
||||
return cls(
|
||||
reuse_connections=_environment_bool("GOVOPLAN_SMTP_BATCH_REUSE", True),
|
||||
max_messages_per_connection=_environment_int(
|
||||
"GOVOPLAN_SMTP_BATCH_MAX_MESSAGES",
|
||||
default=100,
|
||||
minimum=1,
|
||||
maximum=10_000,
|
||||
),
|
||||
reconnect_attempts=_environment_int(
|
||||
"GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS",
|
||||
default=1,
|
||||
minimum=0,
|
||||
maximum=5,
|
||||
),
|
||||
health_check_before_reuse=_environment_bool(
|
||||
"GOVOPLAN_SMTP_BATCH_HEALTH_CHECK",
|
||||
True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmtpBatchPreflightResult:
|
||||
ready: bool
|
||||
authenticated: bool
|
||||
connection_sequence: int
|
||||
reconnect_count: int
|
||||
|
||||
|
||||
class SmtpBatchSession:
|
||||
"""Bounded reusable SMTP connection for one already-authorized batch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
smtp_config: SmtpConfig,
|
||||
*,
|
||||
policy: SmtpBatchPolicy | None = None,
|
||||
) -> None:
|
||||
self.smtp_config = smtp_config
|
||||
self.policy = policy or SmtpBatchPolicy.from_environment()
|
||||
self._smtp: smtplib.SMTP | None = None
|
||||
self._connection_sequence = 0
|
||||
self._reconnect_count = 0
|
||||
self._messages_on_connection = 0
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
return self._connection_sequence
|
||||
|
||||
@property
|
||||
def reconnect_count(self) -> int:
|
||||
return self._reconnect_count
|
||||
|
||||
def preflight(self) -> SmtpBatchPreflightResult:
|
||||
"""Validate DNS/egress/connectivity/TLS/auth before a provider effect."""
|
||||
|
||||
if self._closed:
|
||||
raise SmtpSendError(
|
||||
"SMTP batch session is closed.",
|
||||
systemic=True,
|
||||
reason_code="batch_session_closed",
|
||||
phase="preflight",
|
||||
)
|
||||
_require_smtp_config(self.smtp_config)
|
||||
if is_mock_smtp_host(self.smtp_config.host):
|
||||
if self._connection_sequence == 0:
|
||||
self._connection_sequence = 1
|
||||
return self._preflight_result()
|
||||
if self._smtp is None:
|
||||
self._connect_with_retries()
|
||||
return self._preflight_result()
|
||||
|
||||
def send(
|
||||
self,
|
||||
message: EmailMessage | bytes,
|
||||
*,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
) -> SmtpSendResult:
|
||||
host, port, recipients = _prepare_smtp_send(
|
||||
smtp_config=self.smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
if is_mock_smtp_host(self.smtp_config.host):
|
||||
preflight = self.preflight()
|
||||
_accepted, refused = _send_mock_smtp_payload(
|
||||
message,
|
||||
smtp_config=self.smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
)
|
||||
self._messages_on_connection += 1
|
||||
return _smtp_send_result(
|
||||
smtp_config=self.smtp_config,
|
||||
host=host,
|
||||
port=port,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
refused=refused,
|
||||
connection_sequence=preflight.connection_sequence,
|
||||
session_reused=self._messages_on_connection > 1,
|
||||
reconnect_count=preflight.reconnect_count,
|
||||
)
|
||||
|
||||
reused = self._prepare_connection_for_send()
|
||||
smtp = self._smtp
|
||||
if smtp is None: # Defensive: preflight either opens or raises.
|
||||
raise SmtpSendError(
|
||||
"SMTP preflight did not establish a connection.",
|
||||
temporary=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connectivity_unavailable",
|
||||
phase="preflight",
|
||||
)
|
||||
try:
|
||||
if isinstance(message, bytes):
|
||||
refused = smtp.sendmail(envelope_from, recipients, message)
|
||||
else:
|
||||
refused = smtp.send_message(
|
||||
message,
|
||||
from_addr=envelope_from,
|
||||
to_addrs=recipients,
|
||||
)
|
||||
except smtplib.SMTPRecipientsRefused as exc:
|
||||
raise SmtpSendError(
|
||||
f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}",
|
||||
temporary=False,
|
||||
reason_code="smtp_recipients_refused",
|
||||
) from exc
|
||||
except smtplib.SMTPSenderRefused as exc:
|
||||
self._discard_connection()
|
||||
raise SmtpSendError(
|
||||
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=True,
|
||||
reason_code="smtp_sender_refused",
|
||||
) from exc
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
disconnected = int(exc.smtp_code) == 421
|
||||
if disconnected:
|
||||
self._discard_connection()
|
||||
raise SmtpSendError(
|
||||
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=disconnected,
|
||||
reason_code="smtp_connection_closed" if disconnected else "smtp_message_rejected",
|
||||
) from exc
|
||||
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
|
||||
self._discard_connection()
|
||||
raise SmtpSendError(
|
||||
f"SMTP outcome is unknown after transmission started: {exc}",
|
||||
outcome_unknown=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connection_lost_after_transmission",
|
||||
) from exc
|
||||
|
||||
self._messages_on_connection += 1
|
||||
result = _smtp_send_result(
|
||||
smtp_config=self.smtp_config,
|
||||
host=host,
|
||||
port=port,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
refused=refused,
|
||||
connection_sequence=self._connection_sequence,
|
||||
session_reused=reused,
|
||||
reconnect_count=self._reconnect_count,
|
||||
)
|
||||
if not self.policy.reuse_connections:
|
||||
self._discard_connection()
|
||||
return result
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
self._discard_connection()
|
||||
|
||||
def __enter__(self) -> "SmtpBatchSession":
|
||||
self.preflight()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _traceback) -> None:
|
||||
self.close()
|
||||
|
||||
def _preflight_result(self) -> SmtpBatchPreflightResult:
|
||||
return SmtpBatchPreflightResult(
|
||||
ready=True,
|
||||
authenticated=bool(self.smtp_config.username and self.smtp_config.password),
|
||||
connection_sequence=self._connection_sequence,
|
||||
reconnect_count=self._reconnect_count,
|
||||
)
|
||||
|
||||
def _prepare_connection_for_send(self) -> bool:
|
||||
reused = self._smtp is not None and self._messages_on_connection > 0
|
||||
if self._smtp is not None and self._messages_on_connection >= self.policy.max_messages_per_connection:
|
||||
self._discard_connection()
|
||||
reused = False
|
||||
elif reused and self.policy.health_check_before_reuse:
|
||||
try:
|
||||
code, _message = self._smtp.noop()
|
||||
if int(code) >= 400:
|
||||
raise smtplib.SMTPServerDisconnected(f"SMTP NOOP returned {code}")
|
||||
except (OSError, smtplib.SMTPException):
|
||||
self._discard_connection()
|
||||
reused = False
|
||||
self.preflight()
|
||||
return reused and self._smtp is not None
|
||||
|
||||
def _connect_with_retries(self) -> None:
|
||||
last_error: BaseException | None = None
|
||||
for attempt in range(self.policy.reconnect_attempts + 1):
|
||||
try:
|
||||
smtp = _open_smtp(self.smtp_config)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise SmtpSendError(
|
||||
"SMTP authentication failed during batch preflight.",
|
||||
systemic=True,
|
||||
reason_code="smtp_authentication_failed",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
except SmtpConfigurationError:
|
||||
raise
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
temporary = 400 <= int(exc.smtp_code) < 500
|
||||
last_error = exc
|
||||
if not temporary or attempt >= self.policy.reconnect_attempts:
|
||||
raise SmtpSendError(
|
||||
"SMTP server rejected batch preflight.",
|
||||
temporary=temporary,
|
||||
systemic=True,
|
||||
reason_code="smtp_preflight_rejected",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
continue
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
last_error = exc
|
||||
if attempt >= self.policy.reconnect_attempts:
|
||||
raise SmtpSendError(
|
||||
"SMTP connectivity is unavailable during batch preflight.",
|
||||
temporary=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connectivity_unavailable",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
continue
|
||||
self._smtp = smtp
|
||||
if attempt > 0 or self._connection_sequence > 0:
|
||||
self._reconnect_count += 1
|
||||
self._connection_sequence += 1
|
||||
self._messages_on_connection = 0
|
||||
return
|
||||
raise SmtpSendError(
|
||||
f"SMTP batch preflight failed: {last_error}",
|
||||
temporary=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connectivity_unavailable",
|
||||
phase="preflight",
|
||||
)
|
||||
|
||||
def _discard_connection(self) -> None:
|
||||
smtp, self._smtp = self._smtp, None
|
||||
self._messages_on_connection = 0
|
||||
if smtp is None:
|
||||
return
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception as quit_exc:
|
||||
_log_smtp_cleanup_failure("closing batch connection", quit_exc)
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception as close_exc:
|
||||
_log_smtp_cleanup_failure("closing batch socket", close_exc)
|
||||
|
||||
|
||||
def _environment_bool(name: str, default: bool) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _environment_int(name: str, *, default: int, minimum: int, maximum: int) -> int:
|
||||
value = os.getenv(name)
|
||||
try:
|
||||
parsed = int(value) if value is not None else default
|
||||
except ValueError:
|
||||
parsed = default
|
||||
return max(minimum, min(maximum, parsed))
|
||||
|
||||
|
||||
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
|
||||
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
|
||||
|
||||
@@ -324,15 +640,27 @@ def _send_network_smtp_payload(
|
||||
raise SmtpSendError(
|
||||
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=False,
|
||||
systemic=True,
|
||||
reason_code="smtp_authentication_failed",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
raise SmtpSendError(
|
||||
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=True,
|
||||
reason_code="smtp_preflight_rejected",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
# No message transmission has begun yet; a later explicit retry is safe.
|
||||
raise SmtpSendError(f"SMTP connection failed: {exc}", temporary=True) from exc
|
||||
raise SmtpSendError(
|
||||
f"SMTP connection failed: {exc}",
|
||||
temporary=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connectivity_unavailable",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
if isinstance(message, bytes):
|
||||
@@ -352,6 +680,8 @@ def _send_network_smtp_payload(
|
||||
raise SmtpSendError(
|
||||
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=True,
|
||||
reason_code="smtp_sender_refused",
|
||||
) from exc
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
# An explicit SMTP response means the server rejected the transaction;
|
||||
@@ -359,6 +689,8 @@ def _send_network_smtp_payload(
|
||||
raise SmtpSendError(
|
||||
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=int(exc.smtp_code) == 421,
|
||||
reason_code="smtp_connection_closed" if int(exc.smtp_code) == 421 else "smtp_message_rejected",
|
||||
) from exc
|
||||
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
|
||||
# A connection loss after DATA began can happen after the server accepted
|
||||
@@ -366,6 +698,8 @@ def _send_network_smtp_payload(
|
||||
raise SmtpSendError(
|
||||
f"SMTP outcome is unknown after transmission started: {exc}",
|
||||
outcome_unknown=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connection_lost_after_transmission",
|
||||
) from exc
|
||||
finally:
|
||||
try:
|
||||
@@ -387,6 +721,9 @@ def _smtp_send_result(
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
refused: dict[str, tuple[int, bytes]],
|
||||
connection_sequence: int = 1,
|
||||
session_reused: bool = False,
|
||||
reconnect_count: int = 0,
|
||||
) -> SmtpSendResult:
|
||||
return SmtpSendResult(
|
||||
host=host,
|
||||
@@ -395,6 +732,9 @@ def _smtp_send_result(
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=list(envelope_recipients),
|
||||
refused_recipients=_decode_refused(refused),
|
||||
connection_sequence=connection_sequence,
|
||||
session_reused=session_reused,
|
||||
reconnect_count=reconnect_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -404,15 +744,19 @@ def send_email_bytes(
|
||||
smtp_config: SmtpConfig,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
batch_session: SmtpBatchSession | None = None,
|
||||
) -> SmtpSendResult:
|
||||
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
|
||||
|
||||
return _send_smtp_payload(
|
||||
if batch_session is not None:
|
||||
if batch_session.smtp_config != smtp_config:
|
||||
raise SmtpConfigurationError("SMTP batch session does not match the resolved transport.")
|
||||
return batch_session.send(
|
||||
message_bytes,
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
return _send_smtp_payload(message_bytes, smtp_config=smtp_config, envelope_from=envelope_from, envelope_recipients=envelope_recipients)
|
||||
|
||||
|
||||
def send_email_message(
|
||||
|
||||
@@ -23,6 +23,10 @@ from govoplan_core.security.secrets import decrypt_secret
|
||||
from govoplan_mail.backend.config import (
|
||||
ImapConfig,
|
||||
ImapServerConfig,
|
||||
JmapConfig,
|
||||
JmapServerConfig,
|
||||
Pop3Config,
|
||||
Pop3ServerConfig,
|
||||
SmtpConfig,
|
||||
SmtpServerConfig,
|
||||
)
|
||||
@@ -34,7 +38,7 @@ from govoplan_mail.backend.db.models import (
|
||||
)
|
||||
|
||||
|
||||
MAIL_SERVER_PROTOCOLS = frozenset({"smtp", "imap"})
|
||||
MAIL_SERVER_PROTOCOLS = frozenset({"smtp", "imap", "jmap", "pop3"})
|
||||
|
||||
|
||||
class MailServerHierarchyError(RuntimeError):
|
||||
@@ -68,7 +72,7 @@ class ResolvedMailTransport:
|
||||
profile: MailServerProfile
|
||||
server: MailServerEndpoint | None
|
||||
credential: CredentialEnvelope | None
|
||||
config: SmtpConfig | ImapConfig
|
||||
config: SmtpConfig | ImapConfig | JmapConfig | Pop3Config
|
||||
transport_revision: str
|
||||
|
||||
|
||||
@@ -358,7 +362,7 @@ def initialize_profile_hierarchy(
|
||||
for protocol, value in (("smtp", smtp), ("imap", imap)):
|
||||
if value is None or protocol in existing:
|
||||
continue
|
||||
raw = value.model_dump(mode="json") if hasattr(value, "model_dump") else dict(value)
|
||||
raw = value.model_dump(mode="json", exclude_none=True) if hasattr(value, "model_dump") else dict(value)
|
||||
credentials = {
|
||||
"username": raw.pop("username", None),
|
||||
"password": raw.pop("password", None),
|
||||
@@ -951,6 +955,10 @@ def resolve_mail_transport(
|
||||
)
|
||||
)
|
||||
if server is None:
|
||||
if clean_protocol in {"jmap", "pop3"}:
|
||||
raise MailServerHierarchyError(
|
||||
f"The selected Mail profile has no active {clean_protocol.upper()} server"
|
||||
)
|
||||
return _legacy_resolved_transport(profile, clean_protocol)
|
||||
binding, credential = _selected_server_credential(
|
||||
session,
|
||||
@@ -978,16 +986,19 @@ def resolve_mail_transport(
|
||||
if clean_protocol == "smtp":
|
||||
payload["username"] = profile.smtp_username
|
||||
payload["password"] = decrypt_secret(profile.smtp_password_encrypted)
|
||||
else:
|
||||
elif clean_protocol == "imap":
|
||||
payload["username"] = profile.imap_username
|
||||
payload["password"] = decrypt_secret(profile.imap_password_encrypted)
|
||||
config: SmtpConfig | ImapConfig
|
||||
config: SmtpConfig | ImapConfig | JmapConfig | Pop3Config
|
||||
try:
|
||||
config = (
|
||||
SmtpConfig.model_validate(payload)
|
||||
if clean_protocol == "smtp"
|
||||
else ImapConfig.model_validate(payload)
|
||||
)
|
||||
if clean_protocol == "smtp":
|
||||
config = SmtpConfig.model_validate(payload)
|
||||
elif clean_protocol == "imap":
|
||||
config = ImapConfig.model_validate(payload)
|
||||
elif clean_protocol == "jmap":
|
||||
config = JmapConfig.model_validate(payload)
|
||||
else:
|
||||
config = Pop3Config.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise MailServerHierarchyError(
|
||||
f"The selected {clean_protocol.upper()} server configuration is invalid"
|
||||
@@ -1030,6 +1041,14 @@ def select_mail_transport(
|
||||
)
|
||||
)
|
||||
if server is None:
|
||||
if clean_protocol in {"jmap", "pop3"}:
|
||||
return SelectedMailTransport(
|
||||
profile=profile,
|
||||
server=None,
|
||||
credential=None,
|
||||
available=False,
|
||||
transport_revision="unconfigured",
|
||||
)
|
||||
legacy_config = (
|
||||
profile.smtp_config
|
||||
if clean_protocol == "smtp"
|
||||
@@ -1209,6 +1228,8 @@ def _server_matches_legacy_transport(
|
||||
) -> bool:
|
||||
if not server.is_default:
|
||||
return False
|
||||
if server.protocol not in {"smtp", "imap"}:
|
||||
return False
|
||||
if server.protocol == "smtp":
|
||||
legacy_config = profile.smtp_config or {}
|
||||
legacy_revision = profile.smtp_transport_revision
|
||||
@@ -1302,11 +1323,14 @@ def _validated_server_config(
|
||||
payload.pop("password", None)
|
||||
payload.pop("enabled", None)
|
||||
try:
|
||||
model = (
|
||||
SmtpServerConfig.model_validate(payload)
|
||||
if protocol == "smtp"
|
||||
else ImapServerConfig.model_validate(payload)
|
||||
)
|
||||
if protocol == "smtp":
|
||||
model = SmtpServerConfig.model_validate(payload)
|
||||
elif protocol == "imap":
|
||||
model = ImapServerConfig.model_validate(payload)
|
||||
elif protocol == "jmap":
|
||||
model = JmapServerConfig.model_validate(payload)
|
||||
else:
|
||||
model = Pop3ServerConfig.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise MailServerHierarchyError(
|
||||
f"Invalid {protocol.upper()} server configuration"
|
||||
@@ -1347,7 +1371,9 @@ def _server_scope(
|
||||
def _normalize_protocol(value: str) -> str:
|
||||
clean = str(value or "").strip().casefold()
|
||||
if clean not in MAIL_SERVER_PROTOCOLS:
|
||||
raise MailServerHierarchyError("Mail server protocol must be smtp or imap")
|
||||
raise MailServerHierarchyError(
|
||||
"Mail server protocol must be smtp, imap, jmap or pop3"
|
||||
)
|
||||
return clean
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from govoplan_mail.backend.router import (
|
||||
create_mail_address_contact,
|
||||
list_mail_address_write_targets,
|
||||
lookup_mail_addresses,
|
||||
)
|
||||
from govoplan_mail.backend.schemas import MailContactCreateRequest
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rollbacks += 1
|
||||
|
||||
|
||||
class _Writer:
|
||||
def __init__(self, *, allowed: bool = True, read_only: bool = False) -> None:
|
||||
self.allowed = allowed
|
||||
self.read_only = read_only
|
||||
self.created_payload = None
|
||||
self.created_provenance = None
|
||||
|
||||
def list_write_targets(self, _session, _principal, *, operation):
|
||||
return (
|
||||
SimpleNamespace(
|
||||
address_book_id="book-1",
|
||||
address_book_label="Personal contacts",
|
||||
operation=operation,
|
||||
allowed=self.allowed,
|
||||
reason="allowed" if self.allowed else "read_only_source",
|
||||
message="Contact can be added." if self.allowed else "This source is read-only.",
|
||||
scope_type="user",
|
||||
scope_id="user-1",
|
||||
source_kind="local" if self.allowed else "ldap",
|
||||
read_only=self.read_only,
|
||||
required_scopes=("addresses:contacts:write",),
|
||||
provenance={"policy": "addresses"},
|
||||
),
|
||||
)
|
||||
|
||||
def can_write_to_address_book(self, _session, _principal, *, address_book_id, operation):
|
||||
return self.list_write_targets(_session, _principal, operation=operation)[0]
|
||||
|
||||
def create_contact(self, _session, _principal, *, address_book_id, payload, provenance):
|
||||
self.created_payload = payload
|
||||
self.created_provenance = provenance
|
||||
return SimpleNamespace(
|
||||
contact_id="contact-1",
|
||||
address_book_id=address_book_id,
|
||||
display_name=payload["display_name"],
|
||||
email=payload["emails"][0]["email"],
|
||||
source_kind="local",
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
def _principal():
|
||||
return SimpleNamespace(has=lambda scope: scope == "mail:profile:use")
|
||||
|
||||
|
||||
class MailAddressIntegrationTests(unittest.TestCase):
|
||||
def test_optional_capabilities_fail_open_for_mail(self) -> None:
|
||||
with patch("govoplan_mail.backend.router._registry_capability", return_value=None):
|
||||
lookup = lookup_mail_addresses(query="ada", limit=25, session=_Session(), principal=_principal())
|
||||
targets = list_mail_address_write_targets(session=_Session(), principal=_principal())
|
||||
|
||||
self.assertFalse(lookup.available)
|
||||
self.assertEqual(lookup.candidates, [])
|
||||
self.assertFalse(targets.available)
|
||||
self.assertEqual(targets.targets, [])
|
||||
|
||||
def test_write_target_preserves_read_only_decision(self) -> None:
|
||||
writer = _Writer(allowed=False, read_only=True)
|
||||
with patch("govoplan_mail.backend.router._registry_capability", return_value=writer):
|
||||
response = list_mail_address_write_targets(session=_Session(), principal=_principal())
|
||||
|
||||
self.assertTrue(response.available)
|
||||
self.assertFalse(response.targets[0].allowed)
|
||||
self.assertTrue(response.targets[0].read_only)
|
||||
self.assertEqual(response.targets[0].reason, "read_only_source")
|
||||
|
||||
def test_blocked_target_cannot_be_bypassed_by_create(self) -> None:
|
||||
session = _Session()
|
||||
writer = _Writer(allowed=False, read_only=True)
|
||||
with (
|
||||
patch("govoplan_mail.backend.router._registry_capability", return_value=writer),
|
||||
self.assertRaises(HTTPException) as raised,
|
||||
):
|
||||
create_mail_address_contact(
|
||||
MailContactCreateRequest(
|
||||
address_book_id="book-1",
|
||||
display_name="Ada Lovelace",
|
||||
email="ada@example.test",
|
||||
),
|
||||
session=session,
|
||||
principal=_principal(),
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 422)
|
||||
self.assertEqual(session.commits, 0)
|
||||
|
||||
def test_allowed_create_uses_writer_and_records_consumer_provenance(self) -> None:
|
||||
session = _Session()
|
||||
writer = _Writer()
|
||||
with patch("govoplan_mail.backend.router._registry_capability", return_value=writer):
|
||||
result = create_mail_address_contact(
|
||||
MailContactCreateRequest(
|
||||
address_book_id="book-1",
|
||||
display_name="Ada Lovelace",
|
||||
email="ada@example.test",
|
||||
),
|
||||
session=session,
|
||||
principal=_principal(),
|
||||
)
|
||||
|
||||
self.assertEqual(result.contact_id, "contact-1")
|
||||
self.assertEqual(writer.created_payload["emails"][0]["email"], "ada@example.test")
|
||||
self.assertEqual(writer.created_provenance["consumer_module"], "mail")
|
||||
self.assertEqual(session.commits, 1)
|
||||
|
||||
def test_proxy_rejects_invalid_email_before_calling_writer(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
MailContactCreateRequest(address_book_id="book-1", email="not-an-email")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,388 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPreflightContext,
|
||||
)
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
infrastructure_capability_receipt_from_mapping,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_core.security.credential_envelopes import CredentialEnvelope
|
||||
from govoplan_mail.backend.configuration_provider import (
|
||||
MAIL_CONFIGURATION_CAPABILITY,
|
||||
SqlMailConfigurationProvider,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import (
|
||||
MailProfilePolicy,
|
||||
MailServerCredentialBinding,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
from govoplan_mail.backend.manifest import manifest
|
||||
|
||||
|
||||
def _receipt():
|
||||
return infrastructure_capability_receipt_from_mapping(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"installation_id": "mail-provider-test",
|
||||
"profile": "evaluation",
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "mail.smtp",
|
||||
"label": "SMTP delivery",
|
||||
"state": "available_unconfigured",
|
||||
"source": "installer-managed-test",
|
||||
"detail": "GreenMail is available for profile binding.",
|
||||
"endpoint": {
|
||||
"scheme": "smtp",
|
||||
"host": "test-mail",
|
||||
"port": 3025,
|
||||
},
|
||||
"secret_refs": [],
|
||||
"dependent_modules": ["mail"],
|
||||
}
|
||||
],
|
||||
"post_install_tasks": [
|
||||
{
|
||||
"id": "mail.smtp-profile",
|
||||
"resume_key": "mail-provider-test:mail.smtp-profile:v1",
|
||||
"capability_id": "mail.smtp",
|
||||
"state": "pending",
|
||||
"owner_module": "mail",
|
||||
"summary": "Create a Mail SMTP profile.",
|
||||
"required_inputs": ["credential envelope reference when required"],
|
||||
"secret_boundary": "credential-envelope-reference-only",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _external_receipt(*, credential_required: bool = False):
|
||||
return infrastructure_capability_receipt_from_mapping(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"installation_id": "mail-provider-external",
|
||||
"profile": "production",
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "mail.smtp",
|
||||
"label": "SMTP delivery",
|
||||
"state": "available_unconfigured",
|
||||
"source": "operator-supplied",
|
||||
"detail": "An external relay needs reviewed Mail configuration.",
|
||||
"endpoint": {},
|
||||
"secret_refs": (
|
||||
["env:SMTP_CREDENTIAL_ENVELOPE_REF"]
|
||||
if credential_required
|
||||
else []
|
||||
),
|
||||
"dependent_modules": ["mail"],
|
||||
}
|
||||
],
|
||||
"post_install_tasks": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MailConfigurationProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tempdir = tempfile.TemporaryDirectory(prefix="govoplan-mail-config-")
|
||||
self.addCleanup(self.tempdir.cleanup)
|
||||
database_path = Path(self.tempdir.name) / "mail.sqlite3"
|
||||
self.engine = create_engine(f"sqlite:///{database_path}")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=(
|
||||
access_models.User.__table__,
|
||||
SystemSettings.__table__,
|
||||
CredentialEnvelope.__table__,
|
||||
MailServerProfile.__table__,
|
||||
MailServerEndpoint.__table__,
|
||||
MailServerCredentialBinding.__table__,
|
||||
MailProfilePolicy.__table__,
|
||||
),
|
||||
)
|
||||
configure_database(
|
||||
f"sqlite:///{database_path}",
|
||||
engine=self.engine,
|
||||
dispose_previous=True,
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
session.add(
|
||||
MailProfilePolicy(
|
||||
id="tenant-policy",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
policy={},
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
CredentialEnvelope(
|
||||
id="credential-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="SMTP credential",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "mailer"},
|
||||
secret_data_encrypted="encrypted-outside-package",
|
||||
secret_keys=["password"],
|
||||
allowed_modules=["mail"],
|
||||
allowed_server_refs=[],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
self.addCleanup(self._cleanup_database)
|
||||
self.provider = SqlMailConfigurationProvider()
|
||||
self.context = ConfigurationPreflightContext(
|
||||
tenant_id="tenant-1",
|
||||
operator_user_id=None,
|
||||
infrastructure_receipt=_receipt(),
|
||||
)
|
||||
|
||||
def _cleanup_database(self) -> None:
|
||||
reset_database()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None:
|
||||
self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories)
|
||||
description = self.provider.describe()
|
||||
self.assertEqual(("smtp_profile",), description.fragment_types)
|
||||
|
||||
def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={"credential_envelope_id": "credential-1"},
|
||||
)
|
||||
|
||||
first_plan = self.provider.preflight(fragment, self.context)
|
||||
self.assertEqual("create", first_plan.plan[0].action)
|
||||
self.assertFalse(
|
||||
any(item.severity == "blocker" for item in first_plan.diagnostics)
|
||||
)
|
||||
first_apply = self.provider.apply(fragment, {}, self.context)
|
||||
self.assertIn("test-smtp", first_apply.created_refs)
|
||||
|
||||
second_plan = self.provider.preflight(fragment, self.context)
|
||||
second_apply = self.provider.apply(fragment, {}, self.context)
|
||||
|
||||
self.assertEqual("skip", second_plan.plan[0].action)
|
||||
self.assertEqual({}, second_apply.created_refs)
|
||||
self.assertEqual({}, second_apply.updated_refs)
|
||||
with self.SessionLocal() as session:
|
||||
profiles = session.scalars(select(MailServerProfile)).all()
|
||||
servers = session.scalars(select(MailServerEndpoint)).all()
|
||||
bindings = session.scalars(select(MailServerCredentialBinding)).all()
|
||||
self.assertEqual(1, len(profiles))
|
||||
self.assertEqual("test-mail", profiles[0].smtp_config["host"])
|
||||
self.assertEqual("plain", profiles[0].smtp_config["security"])
|
||||
self.assertEqual(1, len(servers))
|
||||
self.assertEqual("test-mail", servers[0].config["host"])
|
||||
self.assertEqual(1, len(bindings))
|
||||
self.assertEqual("credential-1", bindings[0].credential_id)
|
||||
|
||||
def test_conflicting_existing_profile_is_preserved_by_default(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={},
|
||||
)
|
||||
self.provider.apply(fragment, {}, self.context)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
server.config = {**server.config, "host": "manually-changed.example.test"}
|
||||
session.commit()
|
||||
|
||||
plan = self.provider.preflight(fragment, self.context)
|
||||
result = self.provider.apply(fragment, {}, self.context)
|
||||
|
||||
self.assertEqual("blocked", plan.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail_configuration_conflict",
|
||||
{item.code for item in result.diagnostics},
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
self.assertEqual("manually-changed.example.test", server.config["host"])
|
||||
|
||||
def test_explicit_conflict_update_reconciles_then_becomes_noop(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={},
|
||||
)
|
||||
self.provider.apply(fragment, {}, self.context)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
server.config = {**server.config, "host": "manually-changed.example.test"}
|
||||
session.commit()
|
||||
update_fragment = replace(
|
||||
fragment,
|
||||
payload={"on_conflict": "update"},
|
||||
)
|
||||
|
||||
plan = self.provider.preflight(update_fragment, self.context)
|
||||
result = self.provider.apply(update_fragment, {}, self.context)
|
||||
settled = self.provider.preflight(update_fragment, self.context)
|
||||
|
||||
self.assertEqual("update", plan.plan[0].action)
|
||||
self.assertIn("test-smtp", result.updated_refs)
|
||||
self.assertEqual("skip", settled.plan[0].action)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
self.assertEqual("test-mail", server.config["host"])
|
||||
|
||||
def test_inline_credentials_are_rejected(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
payload={
|
||||
"smtp": {
|
||||
"host": "test-mail",
|
||||
"port": 3025,
|
||||
"security": "plain",
|
||||
"password": "must-not-cross-boundary",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
plan = self.provider.preflight(fragment, self.context)
|
||||
|
||||
self.assertEqual("blocked", plan.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail_configuration_secret_forbidden",
|
||||
{item.code for item in plan.diagnostics},
|
||||
)
|
||||
|
||||
def test_external_relay_collects_missing_non_secret_inputs(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="external-smtp",
|
||||
payload={},
|
||||
)
|
||||
missing = self.provider.preflight(
|
||||
fragment,
|
||||
replace(self.context, infrastructure_receipt=_external_receipt()),
|
||||
)
|
||||
ready = self.provider.preflight(
|
||||
fragment,
|
||||
replace(
|
||||
self.context,
|
||||
infrastructure_receipt=_external_receipt(),
|
||||
supplied_data={
|
||||
"mail.smtp.external-smtp.host": "smtp.example.test",
|
||||
"mail.smtp.external-smtp.port": 587,
|
||||
"mail.smtp.external-smtp.security": "starttls",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", missing.plan[0].action)
|
||||
self.assertEqual(
|
||||
{
|
||||
"mail.smtp.external-smtp.host",
|
||||
"mail.smtp.external-smtp.port",
|
||||
"mail.smtp.external-smtp.security",
|
||||
},
|
||||
{item.key for item in missing.required_data if item.required},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
def test_receipt_secret_reference_requires_credential_envelope_reference(
|
||||
self,
|
||||
) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="authenticated-smtp",
|
||||
payload={
|
||||
"smtp": {
|
||||
"host": "smtp.example.test",
|
||||
"port": 587,
|
||||
"security": "starttls",
|
||||
}
|
||||
},
|
||||
)
|
||||
context = replace(
|
||||
self.context,
|
||||
infrastructure_receipt=_external_receipt(credential_required=True),
|
||||
)
|
||||
|
||||
missing = self.provider.preflight(fragment, context)
|
||||
ready = self.provider.preflight(
|
||||
ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="authenticated-smtp",
|
||||
payload={
|
||||
**fragment.payload,
|
||||
"credential_envelope_id": "credential-1",
|
||||
},
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", missing.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail.smtp.authenticated-smtp.credential_envelope_id",
|
||||
{item.key for item in missing.required_data if item.required},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
def test_system_profile_requires_system_configuration_authority(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
payload={"profile": {"scope_type": "system"}},
|
||||
)
|
||||
|
||||
blocked = self.provider.preflight(fragment, self.context)
|
||||
ready = self.provider.preflight(
|
||||
fragment,
|
||||
replace(
|
||||
self.context,
|
||||
operator_scopes=frozenset({"system:settings:write"}),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", blocked.plan[0].action)
|
||||
self.assertIn(
|
||||
"system_configuration_authority_required",
|
||||
{item.code for item in blocked.diagnostics},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
DataSubjectRequest,
|
||||
create_data_subject_request,
|
||||
plan_data_subject_erasure,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import (
|
||||
MailBounceObservation,
|
||||
MailDeliveryAttempt,
|
||||
MailDeliveryCommand,
|
||||
MailDeliveryReconciliation,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY, MailDsarProvider
|
||||
from govoplan_mail.backend.manifest import manifest
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider, active=True):
|
||||
self.provider = provider
|
||||
self.active = active
|
||||
|
||||
def capability_names(self):
|
||||
return (MAIL_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
assert name == MAIL_DSAR_CAPABILITY
|
||||
return "mail"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
active = self.active
|
||||
|
||||
class Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State", (), {"effective_modules": ("mail",) if active else ()}
|
||||
)()
|
||||
|
||||
return Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
assert name == MAIL_DSAR_CAPABILITY
|
||||
return self.provider
|
||||
|
||||
|
||||
class MailDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
DataSubjectRequest.__table__,
|
||||
MailServerProfile.__table__,
|
||||
MailServerEndpoint.__table__,
|
||||
MailMailboxMessageIndex.__table__,
|
||||
MailPop3Import.__table__,
|
||||
MailDeliveryCommand.__table__,
|
||||
MailDeliveryAttempt.__table__,
|
||||
MailDeliveryReconciliation.__table__,
|
||||
MailBounceObservation.__table__,
|
||||
],
|
||||
)
|
||||
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||
now = datetime.now(timezone.utc)
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="subject@example.test",
|
||||
normalized_email="subject@example.test",
|
||||
display_name="Subject",
|
||||
)
|
||||
user = User(
|
||||
id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email="subject@example.test",
|
||||
display_name="Subject",
|
||||
)
|
||||
profile = MailServerProfile(
|
||||
id="profile-subject",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
scope_id=user.id,
|
||||
name="Personal mail",
|
||||
slug="personal",
|
||||
smtp_config={"host": "smtp-secret-do-not-export"},
|
||||
smtp_username="smtp-user-do-not-export",
|
||||
smtp_password_encrypted="smtp-cipher-do-not-export",
|
||||
imap_config={"host": "imap-secret-do-not-export"},
|
||||
imap_username="imap-user-do-not-export",
|
||||
imap_password_encrypted="imap-cipher-do-not-export",
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
message = MailMailboxMessageIndex(
|
||||
id="message-subject",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=profile.id,
|
||||
folder="INBOX-secret-do-not-export",
|
||||
uid="uid-secret-do-not-export",
|
||||
uid_int=1,
|
||||
sort_position=1,
|
||||
subject="Subject notice",
|
||||
from_header="Office <office@example.test>",
|
||||
to_header="Subject Person <Subject@Example.Test>",
|
||||
cc_header="Unrelated Person <other@example.test>",
|
||||
date="2026-08-20",
|
||||
message_id="message-locator-do-not-export",
|
||||
flags=["\\Seen"],
|
||||
size_bytes=42,
|
||||
body_preview="Message preview for the subject",
|
||||
attachment_count=1,
|
||||
indexed_at=now,
|
||||
)
|
||||
unrelated = MailMailboxMessageIndex(
|
||||
id="message-other",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=profile.id,
|
||||
folder="INBOX",
|
||||
uid="2",
|
||||
uid_int=2,
|
||||
sort_position=2,
|
||||
subject="Unrelated message do not export",
|
||||
from_header="other@example.test",
|
||||
to_header="someone@example.test",
|
||||
indexed_at=now,
|
||||
)
|
||||
tenant_two_profile = MailServerProfile(
|
||||
id="profile-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
name="Tenant two",
|
||||
slug="tenant-two",
|
||||
smtp_config={},
|
||||
)
|
||||
tenant_two = MailMailboxMessageIndex(
|
||||
id="message-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
profile_id=tenant_two_profile.id,
|
||||
folder="INBOX",
|
||||
uid="1",
|
||||
uid_int=1,
|
||||
sort_position=1,
|
||||
subject="Tenant two message do not export",
|
||||
to_header="subject@example.test",
|
||||
indexed_at=now,
|
||||
)
|
||||
pop3_server = MailServerEndpoint(
|
||||
id="pop3-server-subject",
|
||||
profile_id=profile.id,
|
||||
tenant_id="tenant-1",
|
||||
protocol="pop3",
|
||||
name="Legacy POP3",
|
||||
config={"host": "pop3-secret-do-not-export"},
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
)
|
||||
pop3_import = MailPop3Import(
|
||||
id="pop3-import-subject",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=profile.id,
|
||||
pop3_server_id=pop3_server.id,
|
||||
pop3_credential_id="credential-secret-do-not-export",
|
||||
transport_revision="pop3-revision-secret-do-not-export",
|
||||
provider_uidl="provider-uidl-secret-do-not-export",
|
||||
fingerprint="e" * 64,
|
||||
raw_sha256="f" * 64,
|
||||
raw_message_encrypted="pop3-message-cipher-do-not-export",
|
||||
message_id="pop3-message-id",
|
||||
subject="Imported subject notice",
|
||||
from_header="Legacy office <legacy@example.test>",
|
||||
to_header="Subject Person <subject@example.test>",
|
||||
date="2026-08-19",
|
||||
body_preview="Imported message preview for the subject",
|
||||
size_bytes=84,
|
||||
status="pending_review",
|
||||
imported_at=now,
|
||||
deletion_requested=False,
|
||||
deletion_status="not_requested",
|
||||
)
|
||||
command = MailDeliveryCommand(
|
||||
id="command-subject",
|
||||
tenant_id="tenant-1",
|
||||
command_type="send",
|
||||
source_module="notifications",
|
||||
source_resource_type="notification",
|
||||
idempotency_key="idempotency-do-not-export",
|
||||
canonical_request_hash="a" * 64,
|
||||
profile_id=profile.id,
|
||||
expected_smtp_transport_revision="revision-secret",
|
||||
envelope_recipients_encrypted="recipient-cipher-do-not-export",
|
||||
message_encrypted="message-cipher-do-not-export",
|
||||
message_sha256="b" * 64,
|
||||
rfc_message_id="rfc-message-id",
|
||||
message_size_bytes=100,
|
||||
recipient_count=1,
|
||||
status="succeeded",
|
||||
attempt_count=1,
|
||||
accepted_count=1,
|
||||
created_by_user_id=user.id,
|
||||
completed_at=now,
|
||||
expires_at=now + timedelta(days=30),
|
||||
)
|
||||
attempt = MailDeliveryAttempt(
|
||||
id="attempt-subject",
|
||||
command_id=command.id,
|
||||
attempt_number=1,
|
||||
worker_id="worker-do-not-export",
|
||||
status="succeeded",
|
||||
started_at=now,
|
||||
completed_at=now,
|
||||
accepted_count=1,
|
||||
diagnostic_summary="diagnostic-do-not-export",
|
||||
)
|
||||
reconciliation = MailDeliveryReconciliation(
|
||||
id="reconciliation-subject",
|
||||
command_id=command.id,
|
||||
decision="confirmed_sent",
|
||||
evidence_reference="private-reference-do-not-export",
|
||||
note_encrypted="private-note-do-not-export",
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
bounce = MailBounceObservation(
|
||||
id="bounce-subject",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=profile.id,
|
||||
folder="bounce-folder-do-not-export",
|
||||
uid="bounce-uid-do-not-export",
|
||||
fingerprint="c" * 64,
|
||||
raw_sha256="d" * 64,
|
||||
original_message_id="original-id-do-not-export",
|
||||
command_id=command.id,
|
||||
recipient="subject@example.test",
|
||||
action="failed",
|
||||
status_code="5.1.1",
|
||||
diagnostic="bounce-diagnostic-do-not-export",
|
||||
permanent=True,
|
||||
observed_at=now,
|
||||
matched=True,
|
||||
evidence={"secret": "bounce-evidence-do-not-export"},
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
account,
|
||||
user,
|
||||
profile,
|
||||
message,
|
||||
unrelated,
|
||||
tenant_two_profile,
|
||||
tenant_two,
|
||||
pop3_server,
|
||||
pop3_import,
|
||||
command,
|
||||
attempt,
|
||||
reconciliation,
|
||||
bounce,
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = MailDsarProvider()
|
||||
self.subject = DsarSubjectRef(
|
||||
membership_id=user.id, email="subject@example.test"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_manifest_and_minimized_tenant_scoped_search(self):
|
||||
self.assertIn(
|
||||
MAIL_DSAR_CAPABILITY, {item.name for item in manifest.provides_interfaces}
|
||||
)
|
||||
self.assertIsInstance(
|
||||
manifest.capability_factories[MAIL_DSAR_CAPABILITY](None), DsarProvider
|
||||
)
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self.subject
|
||||
)
|
||||
self.assertTrue(
|
||||
{
|
||||
"mail_server_profile",
|
||||
"mailbox_message_index",
|
||||
"mail_delivery_command",
|
||||
"mail_delivery_attempt",
|
||||
"mail_delivery_reconciliation",
|
||||
"mail_bounce_observation",
|
||||
"mail_pop3_import",
|
||||
}.issubset({r.resource_type for r in records})
|
||||
)
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
for hidden in (
|
||||
"other@example.test",
|
||||
"Unrelated Person",
|
||||
"message-other",
|
||||
"Unrelated message do not export",
|
||||
"message-tenant-2",
|
||||
"Tenant two message do not export",
|
||||
"smtp-secret-do-not-export",
|
||||
"smtp-user-do-not-export",
|
||||
"smtp-cipher-do-not-export",
|
||||
"imap-secret-do-not-export",
|
||||
"imap-user-do-not-export",
|
||||
"imap-cipher-do-not-export",
|
||||
"INBOX-secret-do-not-export",
|
||||
"uid-secret-do-not-export",
|
||||
"message-locator-do-not-export",
|
||||
"idempotency-do-not-export",
|
||||
"recipient-cipher-do-not-export",
|
||||
"message-cipher-do-not-export",
|
||||
"worker-do-not-export",
|
||||
"diagnostic-do-not-export",
|
||||
"private-reference-do-not-export",
|
||||
"private-note-do-not-export",
|
||||
"bounce-folder-do-not-export",
|
||||
"bounce-uid-do-not-export",
|
||||
"original-id-do-not-export",
|
||||
"bounce-diagnostic-do-not-export",
|
||||
"bounce-evidence-do-not-export",
|
||||
"pop3-secret-do-not-export",
|
||||
"credential-secret-do-not-export",
|
||||
"pop3-revision-secret-do-not-export",
|
||||
"provider-uidl-secret-do-not-export",
|
||||
"pop3-message-cipher-do-not-export",
|
||||
):
|
||||
self.assertNotIn(hidden, serialized)
|
||||
|
||||
def test_conflicting_email_fails_closed(self):
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
email="subject@example.test",
|
||||
external_references={"mail.email": "other@example.test"},
|
||||
),
|
||||
)
|
||||
self.assertEqual((), records)
|
||||
|
||||
def test_plan_preserves_evidence_and_executes_nothing(self):
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self.subject
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session, tenant_id="tenant-1", subject=self.subject, records=records
|
||||
)
|
||||
self.assertTrue({"retain", "manual_review"}.issubset({a.kind for a in actions}))
|
||||
self.assertFalse(any(action.executable for action in actions))
|
||||
|
||||
def test_core_workflow_discovers_active_and_skips_disabled_provider(self):
|
||||
request = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-MAIL-1",
|
||||
request_kind="access_and_erasure",
|
||||
subject=self.subject,
|
||||
purpose="Authorized request",
|
||||
legal_basis="GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=request,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(["mail"], request.coverage["covered_modules"])
|
||||
plan_data_subject_erasure(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=request,
|
||||
expected_revision=2,
|
||||
)
|
||||
self.assertFalse(
|
||||
any(action["executable"] for action in request.erasure_plan["actions"])
|
||||
)
|
||||
disabled = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-MAIL-OFF",
|
||||
request_kind="access",
|
||||
subject=self.subject,
|
||||
purpose="Coverage",
|
||||
legal_basis=None,
|
||||
due_at=None,
|
||||
requested_by_account_id="officer",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, active=False),
|
||||
row=disabled,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(
|
||||
[MAIL_DSAR_CAPABILITY], disabled.coverage["inactive_provider_capabilities"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ from govoplan_mail.backend.config import ImapConfig
|
||||
from govoplan_mail.backend.sending.imap import (
|
||||
ImapAppendError,
|
||||
ImapConfigurationError,
|
||||
_detect_standard_folder_mappings,
|
||||
_detect_sent_folder,
|
||||
_extract_mailbox_name,
|
||||
_fetch_message_by_uid,
|
||||
@@ -18,6 +19,7 @@ from govoplan_mail.backend.sending.imap import (
|
||||
_select_readonly,
|
||||
_sequence_set,
|
||||
append_message_to_sent,
|
||||
list_imap_folders,
|
||||
list_imap_messages,
|
||||
list_imap_uids_since,
|
||||
)
|
||||
@@ -79,6 +81,45 @@ class ImapFolderParserTests(unittest.TestCase):
|
||||
"Gesendet",
|
||||
)
|
||||
|
||||
def test_detects_all_standard_folder_roles_by_flag_then_name(self):
|
||||
self.assertEqual(
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Gesendete Elemente",
|
||||
"drafts": "Entwürfe",
|
||||
"trash": "Deleted",
|
||||
"archive": "All Mail",
|
||||
"junk": "Spam",
|
||||
},
|
||||
_detect_standard_folder_mappings(
|
||||
[
|
||||
("INBOX", set()),
|
||||
("Gesendete Elemente", set()),
|
||||
("Entwürfe", set()),
|
||||
("Deleted", {"\\trash"}),
|
||||
("All Mail", {"\\all"}),
|
||||
("Spam", {"\\junk"}),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
def test_mock_folder_listing_exposes_detected_standard_mappings(self):
|
||||
result = list_imap_folders(
|
||||
imap_config=ImapConfig(host="mock.imap.local"), include_status=False
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent",
|
||||
"drafts": "Drafts",
|
||||
"trash": "Trash",
|
||||
"archive": "Archive",
|
||||
},
|
||||
result.detected_folder_mappings,
|
||||
)
|
||||
self.assertEqual("Sent", result.detected_sent_folder)
|
||||
|
||||
|
||||
class ImapMessagePaginationTests(unittest.TestCase):
|
||||
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
|
||||
|
||||
@@ -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()
|
||||
@@ -290,6 +290,51 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
||||
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
|
||||
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
|
||||
|
||||
def test_apply_transport_update_persists_standard_folder_mappings(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-folders",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_config={"host": "smtp.example.org"},
|
||||
smtp_username=None,
|
||||
smtp_password_encrypted=None,
|
||||
smtp_transport_revision="smtp-before",
|
||||
imap_config={"host": "imap.example.org", "sent_folder": "Legacy Sent"},
|
||||
imap_username=None,
|
||||
imap_password_encrypted=None,
|
||||
imap_transport_revision="imap-before",
|
||||
)
|
||||
session = SimpleNamespace(flush=lambda: None)
|
||||
|
||||
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index"):
|
||||
_apply_profile_transport_update(
|
||||
session, # type: ignore[arg-type]
|
||||
profile,
|
||||
user_id="user-1",
|
||||
api_key_id=None,
|
||||
smtp=None,
|
||||
imap=ImapConfig(
|
||||
host="imap.example.org",
|
||||
folder_mappings={
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent Items",
|
||||
"drafts": "Drafts",
|
||||
},
|
||||
),
|
||||
clear_imap=False,
|
||||
)
|
||||
|
||||
self.assertEqual(profile.imap_config["sent_folder"], "Sent Items")
|
||||
self.assertEqual(
|
||||
profile.imap_config["folder_mappings"],
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent Items",
|
||||
"drafts": "Drafts",
|
||||
},
|
||||
)
|
||||
|
||||
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
@@ -547,6 +592,23 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
|
||||
self.assertEqual(policy.blacklist_patterns["smtp_hosts"], ["*.blocked.example"])
|
||||
self.assertFalse(policy.allow_lower_level_limits["blacklist.smtp_hosts"])
|
||||
|
||||
def test_jmap_hostname_policy_has_independent_deny_and_lower_scope_lock(self):
|
||||
policy = EffectiveMailProfilePolicy()
|
||||
_merge_policy(
|
||||
policy,
|
||||
{
|
||||
"blacklist": {"jmap_hosts": ["*.blocked.example"]},
|
||||
"allow_lower_level_limits": {"blacklist.jmap_hosts": False},
|
||||
},
|
||||
source="system",
|
||||
)
|
||||
|
||||
allowed, reason = policy.value_allowed("jmap_hosts", "mail.blocked.example")
|
||||
|
||||
self.assertFalse(allowed)
|
||||
self.assertIn("jmap_hosts", reason or "")
|
||||
self.assertFalse(policy.allow_lower_level_limits["blacklist.jmap_hosts"])
|
||||
|
||||
def test_parent_lock_violations_are_reported_per_field(self):
|
||||
violations = _policy_parent_lock_violations(
|
||||
{
|
||||
|
||||
@@ -9,6 +9,15 @@ from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manife
|
||||
|
||||
|
||||
class MailManifestTests(unittest.TestCase):
|
||||
def test_mail_quick_access_can_return_exact_message_references(self) -> None:
|
||||
frontend = get_manifest().frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
tool = next(
|
||||
item for item in frontend.quick_access_tools if item.id == "mail.messages" # type: ignore[union-attr]
|
||||
)
|
||||
self.assertIn("select", tool.modes)
|
||||
self.assertEqual(("mail.message",), tool.returned_reference_kinds)
|
||||
|
||||
def test_manifest_declares_optional_addresses_lookup(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
@@ -16,6 +25,7 @@ class MailManifestTests(unittest.TestCase):
|
||||
self.assertEqual(manifest.id, "mail")
|
||||
self.assertIn("addresses", manifest.optional_dependencies)
|
||||
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("addresses.contact_writer", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "campaigns.access",
|
||||
@@ -43,7 +53,22 @@ class MailManifestTests(unittest.TestCase):
|
||||
permissions = {permission.scope for permission in manifest.permissions}
|
||||
self.assertIn("mail:profile:write_own", permissions)
|
||||
self.assertIn("mail:secret:manage_own", permissions)
|
||||
self.assertTrue(
|
||||
{"mail:pop3:manage", "mail:pop3:import", "mail:pop3:delete"}.issubset(
|
||||
permissions
|
||||
)
|
||||
)
|
||||
roles = {template.slug: template for template in manifest.role_templates}
|
||||
self.assertIn("mail:pop3:delete", roles["mail_profile_admin"].permissions)
|
||||
self.assertEqual(
|
||||
set(roles["mail_legacy_import_operator"].permissions),
|
||||
{
|
||||
"mail:profile:read",
|
||||
"mail:profile:use",
|
||||
"mail:profile:test",
|
||||
"mail:pop3:import",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
set(roles["mail_profile_self_service"].permissions),
|
||||
{
|
||||
@@ -62,8 +87,56 @@ class MailManifestTests(unittest.TestCase):
|
||||
"mail.workflow.read-mailbox",
|
||||
"mail.reference.credentials-egress-retirement",
|
||||
"mail.reference.campaign-delivery-contract",
|
||||
"mail.address-book-integration",
|
||||
"mail.workflow.legacy-pop3-import",
|
||||
}.issubset(topics)
|
||||
)
|
||||
pop3_topic = topics["mail.workflow.legacy-pop3-import"]
|
||||
self.assertEqual(("user", "admin"), pop3_topic.documentation_types)
|
||||
self.assertIn("mail:pop3:import", pop3_topic.conditions[0].any_scopes)
|
||||
self.assertTrue(
|
||||
{
|
||||
"mail.pop3",
|
||||
"mail.pop3.source-editor",
|
||||
"mail.pop3.action.reload",
|
||||
"mail.pop3.action.save-source",
|
||||
"mail.pop3.action.import",
|
||||
"mail.pop3.field.transport-security",
|
||||
"mail.pop3.field.max-message-size",
|
||||
"mail.pop3.field.password",
|
||||
"mail.pop3.field.delete-after-import",
|
||||
"mail.pop3.confirm-delete-source",
|
||||
}.issubset(pop3_topic.metadata["help_contexts"])
|
||||
)
|
||||
self.assertGreaterEqual(len(pop3_topic.metadata["fields"]), 4)
|
||||
self.assertGreaterEqual(
|
||||
len(pop3_topic.metadata["operational_consequences"]),
|
||||
3,
|
||||
)
|
||||
|
||||
for topic in manifest.documentation:
|
||||
with self.subTest(topic_id=topic.id):
|
||||
german = topic.translations.get("de", {})
|
||||
for attribute in ("title", "summary", "body"):
|
||||
self.assertTrue(
|
||||
str(german.get(attribute, "")).strip(),
|
||||
f"{topic.id} is missing its German {attribute}",
|
||||
)
|
||||
|
||||
pop3_provider = next(
|
||||
item
|
||||
for item in manifest.external_providers
|
||||
if item.id == "mail.pop3_legacy_import"
|
||||
)
|
||||
self.assertIn("delete", pop3_provider.operations)
|
||||
self.assertTrue(pop3_provider.behavior.outcome_unknown_supported)
|
||||
self.assertIn("mail.pop3.source_deletion", pop3_provider.behavior.audit_event_types)
|
||||
self.assertTrue(
|
||||
any(
|
||||
route.path == "/mail/legacy-import"
|
||||
for route in manifest.frontend.routes # type: ignore[union-attr]
|
||||
)
|
||||
)
|
||||
ownership = topics["mail.profile-ownership-and-consumers"]
|
||||
self.assertEqual(ownership.metadata["kind"], "reference")
|
||||
self.assertEqual(ownership.metadata["route"], "/settings?section=mail-profiles")
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.mail import MailPostboxBridgeRequest
|
||||
from govoplan_core.core.postbox import PostboxTargetRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
from govoplan_mail.backend.postbox_bridge import MailPostboxBridge
|
||||
|
||||
|
||||
RAW_MESSAGE = b"""From: Ada Example <ada@example.test>
|
||||
To: Clerk <clerk@example.test>
|
||||
Cc: Archive <archive@example.test>
|
||||
Message-ID: <bridge-1@example.test>
|
||||
Subject: Submitted evidence
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary=bridge
|
||||
|
||||
--bridge
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Please process the attached evidence.
|
||||
--bridge
|
||||
Content-Type: application/pdf
|
||||
Content-Disposition: attachment; filename=evidence.pdf
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
UERG
|
||||
--bridge--
|
||||
"""
|
||||
|
||||
|
||||
class _DeliveryProvider:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def deliver(self, session, request):
|
||||
del session
|
||||
self.requests.append(request)
|
||||
return SimpleNamespace(
|
||||
postbox_id="postbox-1",
|
||||
message_id="message-1",
|
||||
delivery_id="delivery-1",
|
||||
duplicate=len(self.requests) > 1,
|
||||
)
|
||||
|
||||
|
||||
class MailPostboxBridgeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
access_users = Base.metadata.tables.get("access_users")
|
||||
if access_users is None:
|
||||
access_users = Table(
|
||||
"access_users",
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[access_users, MailServerProfile.__table__],
|
||||
)
|
||||
self.Session = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.Session() as session:
|
||||
session.add(
|
||||
MailServerProfile(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Inbound",
|
||||
slug="inbound",
|
||||
smtp_config={},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
self.addCleanup(self.engine.dispose)
|
||||
|
||||
def test_bridge_parses_bounded_content_and_delegates_idempotently(self) -> None:
|
||||
delivery = _DeliveryProvider()
|
||||
request = MailPostboxBridgeRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id="postbox-1"),
|
||||
profile_id="profile-1",
|
||||
folder="INBOX",
|
||||
uid="42",
|
||||
uidvalidity="20260807",
|
||||
raw_message=RAW_MESSAGE,
|
||||
)
|
||||
with self.Session() as session, patch(
|
||||
"govoplan_mail.backend.postbox_bridge.postbox_delivery_provider",
|
||||
return_value=delivery,
|
||||
):
|
||||
first = MailPostboxBridge().bridge_message(session, request)
|
||||
second = MailPostboxBridge().bridge_message(session, request)
|
||||
MailPostboxBridge().bridge_message(
|
||||
session,
|
||||
replace(request, uidvalidity="20260808"),
|
||||
)
|
||||
|
||||
self.assertFalse(first.duplicate)
|
||||
self.assertTrue(second.duplicate)
|
||||
self.assertEqual(first.source_digest, second.source_digest)
|
||||
self.assertEqual(
|
||||
delivery.requests[0].idempotency_key,
|
||||
delivery.requests[1].idempotency_key,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
delivery.requests[0].idempotency_key,
|
||||
delivery.requests[2].idempotency_key,
|
||||
)
|
||||
bridged = delivery.requests[0]
|
||||
self.assertEqual("Submitted evidence", bridged.subject)
|
||||
self.assertIn("Please process", bridged.body_text)
|
||||
self.assertEqual(
|
||||
["sender", "to", "cc"],
|
||||
[participant.kind for participant in bridged.participants],
|
||||
)
|
||||
self.assertEqual(1, len(bridged.attachments))
|
||||
self.assertEqual("evidence.pdf", bridged.attachments[0].name)
|
||||
self.assertEqual("mail_attachment", bridged.attachments[0].reference_type)
|
||||
self.assertEqual("<bridge-1@example.test>", bridged.metadata["rfc_message_id"])
|
||||
self.assertEqual("20260807", bridged.metadata["mailbox_uidvalidity"])
|
||||
self.assertNotIn("raw_message", bridged.metadata)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -821,6 +821,85 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
|
||||
self.assertFalse(response.refreshing)
|
||||
self.assertEqual(session.commits, 1)
|
||||
|
||||
def test_jmap_mailbox_list_uses_protocol_neutral_response_and_provider_search(self) -> None:
|
||||
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
|
||||
jmap = SimpleNamespace(session_url="https://jmap.example.test/.well-known/jmap")
|
||||
provider_result = SimpleNamespace(
|
||||
host="jmap.example.test",
|
||||
port=443,
|
||||
security="https",
|
||||
folder="INBOX",
|
||||
messages=[],
|
||||
total_count=0,
|
||||
offset=0,
|
||||
limit=25,
|
||||
uidvalidity="query-state-1",
|
||||
cursor_reset=False,
|
||||
)
|
||||
with (
|
||||
patch("govoplan_mail.backend.router._jmap_config_for_principal", return_value=jmap),
|
||||
patch("govoplan_mail.backend.router.list_jmap_messages", return_value=provider_result) as provider,
|
||||
patch("govoplan_mail.backend.router._next_jmap_mailbox_cursor", return_value=(None, True)),
|
||||
):
|
||||
response = router.list_profile_mailbox_messages(
|
||||
"profile-1",
|
||||
folder="INBOX",
|
||||
limit=25,
|
||||
offset=0,
|
||||
cursor=None,
|
||||
refresh=False,
|
||||
protocol="jmap",
|
||||
q="budget",
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
session=_Session(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
provider.assert_called_once_with(
|
||||
jmap_config=jmap,
|
||||
folder="INBOX",
|
||||
limit=25,
|
||||
offset=0,
|
||||
expected_query_state=None,
|
||||
query="budget",
|
||||
)
|
||||
self.assertEqual("profile-1", response.profile_id)
|
||||
self.assertEqual("INBOX", response.folder)
|
||||
self.assertEqual([], response.messages)
|
||||
self.assertTrue(response.cursor_stable)
|
||||
|
||||
def test_jmap_changes_maps_bounded_incremental_state(self) -> None:
|
||||
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
|
||||
jmap = SimpleNamespace(session_url="https://jmap.example.test/.well-known/jmap")
|
||||
changes = SimpleNamespace(
|
||||
account_id="account-1",
|
||||
old_state="state-1",
|
||||
new_state="state-2",
|
||||
has_more_changes=False,
|
||||
created=("email-1",),
|
||||
updated=("email-2",),
|
||||
destroyed=("email-3",),
|
||||
)
|
||||
with (
|
||||
patch("govoplan_mail.backend.router._jmap_config_for_principal", return_value=jmap),
|
||||
patch("govoplan_mail.backend.router.get_jmap_email_changes", return_value=changes) as provider,
|
||||
):
|
||||
response = router.get_profile_mailbox_changes(
|
||||
"profile-1",
|
||||
since_state="state-1",
|
||||
max_changes=50,
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
session=_Session(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
provider.assert_called_once_with(
|
||||
jmap_config=jmap,
|
||||
since_state="state-1",
|
||||
max_changes=50,
|
||||
)
|
||||
self.assertEqual("jmap", response.protocol)
|
||||
self.assertEqual(["email-1"], response.created)
|
||||
self.assertEqual("state-2", response.new_state)
|
||||
|
||||
def test_unauthorized_profile_test_fails_before_credentials_or_provider_effect(self) -> None:
|
||||
principal = _Principal({"mail:profile:test", "mail:profile:use"})
|
||||
with (
|
||||
|
||||
@@ -14,14 +14,19 @@ from govoplan_mail.backend.db.models import (
|
||||
MailDeliveryCommand,
|
||||
MailMailboxFolderIndex,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
from govoplan_mail.backend.manifest import manifest
|
||||
from govoplan_mail.backend.provider_state import (
|
||||
IMAP_PROVIDER_ID,
|
||||
JMAP_PROVIDER_ID,
|
||||
POP3_PROVIDER_ID,
|
||||
SMTP_PROVIDER_ID,
|
||||
imap_provider_states,
|
||||
jmap_provider_states,
|
||||
pop3_provider_states,
|
||||
smtp_provider_states,
|
||||
)
|
||||
|
||||
@@ -38,6 +43,7 @@ class MailProviderStateTests(unittest.TestCase):
|
||||
MailMailboxFolderIndex.__table__,
|
||||
MailMailboxMessageIndex.__table__,
|
||||
MailBounceSource.__table__,
|
||||
MailPop3Import.__table__,
|
||||
),
|
||||
)
|
||||
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
|
||||
@@ -120,17 +126,115 @@ class MailProviderStateTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID},
|
||||
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID, JMAP_PROVIDER_ID, POP3_PROVIDER_ID},
|
||||
{item.id for item in manifest.external_providers},
|
||||
)
|
||||
self.assertEqual(
|
||||
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID},
|
||||
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID, JMAP_PROVIDER_ID, POP3_PROVIDER_ID},
|
||||
{
|
||||
item.provider_id
|
||||
for item in manifest.external_provider_state_providers
|
||||
},
|
||||
)
|
||||
|
||||
def test_jmap_state_reuses_protocol_neutral_index_without_endpoint_details(self) -> None:
|
||||
self.session.add(
|
||||
MailServerEndpoint(
|
||||
id="jmap-server-1",
|
||||
profile_id=self.profile.id,
|
||||
tenant_id="tenant-1",
|
||||
protocol="jmap",
|
||||
name="JMAP",
|
||||
config={"session_url": "https://jmap.example.test/.well-known/jmap"},
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
state = jmap_provider_states(
|
||||
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
|
||||
)[0]
|
||||
|
||||
self.assertEqual(JMAP_PROVIDER_ID, state.provider_id)
|
||||
self.assertEqual("healthy", state.health)
|
||||
self.assertEqual("current", state.freshness)
|
||||
self.assertEqual("mail:profile:profile-1:jmap", state.binding_ref)
|
||||
self.assertNotIn("jmap.example.test", str(state.to_dict()))
|
||||
|
||||
def test_pop3_state_is_disabled_by_default_and_projects_deletion_evidence(self) -> None:
|
||||
endpoint = MailServerEndpoint(
|
||||
id="pop3-server-1",
|
||||
profile_id=self.profile.id,
|
||||
tenant_id="tenant-1",
|
||||
protocol="pop3",
|
||||
name="Legacy POP3",
|
||||
config={"host": "pop3.example.test", "legacy_import_enabled": False},
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
is_active=True,
|
||||
)
|
||||
self.session.add(endpoint)
|
||||
self.session.flush()
|
||||
|
||||
disabled = pop3_provider_states(
|
||||
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
|
||||
)[0]
|
||||
self.assertFalse(disabled.active)
|
||||
self.assertEqual("inactive", disabled.health)
|
||||
|
||||
endpoint.config = {
|
||||
"host": "pop3.example.test",
|
||||
"legacy_import_enabled": True,
|
||||
}
|
||||
self.session.add(
|
||||
MailPop3Import(
|
||||
id="pop3-import-1",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=self.profile.id,
|
||||
pop3_server_id=endpoint.id,
|
||||
transport_revision=endpoint.transport_revision,
|
||||
provider_uidl="uid-1",
|
||||
fingerprint="a" * 64,
|
||||
raw_sha256="b" * 64,
|
||||
raw_message_encrypted="ciphertext-do-not-project",
|
||||
size_bytes=42,
|
||||
imported_at=datetime.now(UTC),
|
||||
deletion_requested=True,
|
||||
deletion_status="outcome_unknown",
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
MailPop3Import(
|
||||
id="pop3-import-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
profile_id=self.profile.id,
|
||||
pop3_server_id=endpoint.id,
|
||||
transport_revision=endpoint.transport_revision,
|
||||
provider_uidl="uid-tenant-2",
|
||||
fingerprint="c" * 64,
|
||||
raw_sha256="d" * 64,
|
||||
raw_message_encrypted="other-tenant-ciphertext",
|
||||
size_bytes=42,
|
||||
imported_at=datetime.now(UTC),
|
||||
deletion_requested=True,
|
||||
deletion_status="failed",
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
state = pop3_provider_states(
|
||||
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
|
||||
)[0]
|
||||
self.assertTrue(state.active)
|
||||
self.assertEqual("warning", state.health)
|
||||
self.assertEqual("pending", state.conflict)
|
||||
self.assertEqual(1, state.metrics["outcome_unknown_deletions"])
|
||||
self.assertEqual(0, state.metrics["failed_deletions"])
|
||||
self.assertNotIn("pop3.example.test", str(state.to_dict()))
|
||||
self.assertNotIn("ciphertext-do-not-project", str(state.to_dict()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -7,6 +8,9 @@ from govoplan_core.security.outbound_http import OutboundHttpBlocked
|
||||
from govoplan_mail.backend.config import SmtpConfig
|
||||
from govoplan_mail.backend.sending.smtp import (
|
||||
SmtpConfigurationError,
|
||||
SmtpBatchPolicy,
|
||||
SmtpBatchSession,
|
||||
SmtpSendError,
|
||||
_open_smtp,
|
||||
_prepare_smtp_send,
|
||||
_smtp_send_result,
|
||||
@@ -70,6 +74,109 @@ class SmtpSendHelperTests(unittest.TestCase):
|
||||
self.assertEqual(result.accepted_count, 1)
|
||||
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
|
||||
|
||||
def test_batch_preflight_reuses_one_authenticated_connection(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
smtp = _FakeSmtp()
|
||||
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp) as opener:
|
||||
with SmtpBatchSession(config) as batch:
|
||||
first = batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
|
||||
second = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
|
||||
|
||||
opener.assert_called_once_with(config)
|
||||
self.assertFalse(first.session_reused)
|
||||
self.assertTrue(second.session_reused)
|
||||
self.assertEqual(1, second.connection_sequence)
|
||||
self.assertEqual([b"first", b"second"], smtp.messages)
|
||||
self.assertTrue(smtp.quit_called)
|
||||
|
||||
def test_batch_reconnects_before_next_message_when_health_check_fails(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
first_smtp = _FakeSmtp(noop_error_on_call=1)
|
||||
second_smtp = _FakeSmtp()
|
||||
policy = SmtpBatchPolicy(reconnect_attempts=1)
|
||||
with patch(
|
||||
"govoplan_mail.backend.sending.smtp._open_smtp",
|
||||
side_effect=[first_smtp, second_smtp],
|
||||
) as opener:
|
||||
with SmtpBatchSession(config, policy=policy) as batch:
|
||||
batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
|
||||
result = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
|
||||
|
||||
self.assertEqual(2, opener.call_count)
|
||||
self.assertEqual(2, result.connection_sequence)
|
||||
self.assertEqual(1, result.reconnect_count)
|
||||
self.assertEqual([b"first"], first_smtp.messages)
|
||||
self.assertEqual([b"second"], second_smtp.messages)
|
||||
|
||||
def test_preflight_retries_a_transient_connection_failure(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
smtp = _FakeSmtp()
|
||||
with patch(
|
||||
"govoplan_mail.backend.sending.smtp._open_smtp",
|
||||
side_effect=[OSError("temporary DNS failure"), smtp],
|
||||
) as opener:
|
||||
with SmtpBatchSession(config, policy=SmtpBatchPolicy(reconnect_attempts=1)) as batch:
|
||||
result = batch.send(b"message", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
|
||||
|
||||
self.assertEqual(2, opener.call_count)
|
||||
self.assertEqual(1, result.reconnect_count)
|
||||
|
||||
def test_connection_loss_after_send_starts_is_unknown_and_never_replayed(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
smtp = _FakeSmtp(send_error=smtplib.SMTPServerDisconnected("lost"))
|
||||
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp), self.assertRaises(SmtpSendError) as raised:
|
||||
with SmtpBatchSession(config) as batch:
|
||||
batch.send(b"one", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
|
||||
|
||||
self.assertTrue(raised.exception.outcome_unknown)
|
||||
self.assertTrue(raised.exception.systemic)
|
||||
self.assertEqual("smtp_connection_lost_after_transmission", raised.exception.reason_code)
|
||||
self.assertEqual(1, smtp.send_calls)
|
||||
|
||||
def test_authentication_preflight_is_systemic_and_blocks_batch(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
error = smtplib.SMTPAuthenticationError(535, b"bad credentials")
|
||||
with patch("govoplan_mail.backend.sending.smtp._open_smtp", side_effect=error), self.assertRaises(SmtpSendError) as raised:
|
||||
SmtpBatchSession(config).preflight()
|
||||
|
||||
self.assertTrue(raised.exception.systemic)
|
||||
self.assertFalse(raised.exception.temporary)
|
||||
self.assertEqual("preflight", raised.exception.phase)
|
||||
self.assertEqual("smtp_authentication_failed", raised.exception.reason_code)
|
||||
|
||||
|
||||
class _FakeSmtp:
|
||||
def __init__(self, *, noop_error_on_call: int | None = None, send_error: BaseException | None = None):
|
||||
self.noop_error_on_call = noop_error_on_call
|
||||
self.send_error = send_error
|
||||
self.noop_calls = 0
|
||||
self.send_calls = 0
|
||||
self.messages: list[bytes] = []
|
||||
self.quit_called = False
|
||||
|
||||
def noop(self):
|
||||
self.noop_calls += 1
|
||||
if self.noop_error_on_call == self.noop_calls:
|
||||
raise smtplib.SMTPServerDisconnected("stale")
|
||||
return 250, b"ok"
|
||||
|
||||
def sendmail(self, _sender, _recipients, message):
|
||||
self.send_calls += 1
|
||||
if self.send_error is not None:
|
||||
raise self.send_error
|
||||
self.messages.append(message)
|
||||
return {}
|
||||
|
||||
def send_message(self, message, **_kwargs):
|
||||
return self.sendmail(None, None, message.as_bytes())
|
||||
|
||||
def quit(self):
|
||||
self.quit_called = True
|
||||
return 221, b"bye"
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Generated
+3
-3
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.24",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.16",
|
||||
"version": "0.1.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,7 +14,7 @@
|
||||
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node .mail-test-build/tests/mail-address-integration.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -9,6 +9,7 @@ function read(relativePath) {
|
||||
const profiles = read("../src/features/mail/MailProfileManagement.tsx");
|
||||
const mailbox = read("../src/features/mail/MailboxPage.tsx");
|
||||
const bounces = read("../src/features/mail/MailBouncePage.tsx");
|
||||
const legacyImport = read("../src/features/mail/MailLegacyImportPage.tsx");
|
||||
const moduleSource = read("../src/module.ts");
|
||||
const styles = read("../src/styles/mail-profiles.css");
|
||||
const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md");
|
||||
@@ -22,11 +23,17 @@ assert.match(profiles, /disabledReason: credentialMutationBlocker/);
|
||||
assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/);
|
||||
assert.match(profiles, /disabledReason=\{policySaveBlocker\}/);
|
||||
assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/);
|
||||
assert.match(profiles, /folder_mappings: draft\.imapFolderMappings/);
|
||||
assert.match(profiles, /listMailProfileImapFolders[\s\S]*listImapFolders/);
|
||||
assert.match(profiles, /onLookupImapFolders=\{\(\) => void runImapFolderLookup\(\)\}/);
|
||||
assert.match(profiles, /imapFolderLookupResult=\{imapFolderResult\}/);
|
||||
|
||||
assert.match(mailbox, /ActionBlockerHint/);
|
||||
assert.match(mailbox, /DocumentationHelpLink/);
|
||||
assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
|
||||
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
|
||||
assert.match(mailbox, /folder_mappings\?\.inbox/);
|
||||
assert.match(mailbox, /detected_folder_mappings\?\.inbox/);
|
||||
assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
|
||||
|
||||
assert.match(bounces, /DocumentationHelpLink/);
|
||||
@@ -34,11 +41,40 @@ assert.match(bounces, /topicId: "mail\.bounce-processing"/);
|
||||
assert.match(bounces, /<ConfirmDialog[\s\S]*confirmLabel="Remove watcher"[\s\S]*tone="danger"/);
|
||||
assert.match(bounces, /disabledReason=\{saveWatcherBlocker\}/);
|
||||
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}`, /window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
|
||||
for (const sharedComponent of ["PageLayout", "PageActionBar", "SelectionList", "DataGrid", "Dialog", "ConfirmDialog", "ToggleSwitch", "StatusBadge"]) {
|
||||
assert.match(legacyImport, new RegExp(`\\b${sharedComponent}\\b`));
|
||||
}
|
||||
assert.match(legacyImport, /archetype="collection"/);
|
||||
assert.match(legacyImport, /variant="collection"[\s\S]*refreshable[\s\S]*reloadAction=/);
|
||||
assert.match(legacyImport, /topicId: "mail\.workflow\.legacy-pop3-import"/);
|
||||
assert.match(legacyImport, /helpContextId="mail\.pop3"[\s\S]*helpTopicId="mail\.workflow\.legacy-pop3-import"/);
|
||||
for (const helpContextId of [
|
||||
"mail.pop3.source-editor",
|
||||
"mail.pop3.action.reload",
|
||||
"mail.pop3.action.save-source",
|
||||
"mail.pop3.action.import",
|
||||
"mail.pop3.field.transport-security",
|
||||
"mail.pop3.field.max-message-size",
|
||||
"mail.pop3.field.max-batch-size",
|
||||
"mail.pop3.field.password",
|
||||
"mail.pop3.field.delete-after-import",
|
||||
"mail.pop3.confirm-delete-source",
|
||||
]) {
|
||||
assert.match(
|
||||
legacyImport,
|
||||
new RegExp(`helpContextId(?:=|:\\s*)"${helpContextId.replaceAll(".", "\\.")}"`)
|
||||
);
|
||||
}
|
||||
assert.match(legacyImport, /data-help-context-id="mail\.pop3\.field\.message-selection"/);
|
||||
assert.match(legacyImport, /<ConfirmDialog[\s\S]*tone="danger"[\s\S]*onConfirm=\{\(\) => void runImport\(\)\}/);
|
||||
assert.match(legacyImport, /legacy_import_enabled: false[\s\S]*is_active: false[\s\S]*createMailServerCredential[\s\S]*updateMailServerEndpoint/);
|
||||
assert.match(legacyImport, /expected_transport_revision: preview\.transport_revision/);
|
||||
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}`, /window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
|
||||
assert.match(moduleSource, /"mail\.profiles"/);
|
||||
assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/);
|
||||
assert.match(styles, /@media \(max-width: 1250px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
|
||||
assert.match(styles, /@media \(max-width: 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/);
|
||||
|
||||
for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) {
|
||||
|
||||
@@ -23,3 +23,15 @@ assert(
|
||||
!styles.includes(".mailbox-search-field button"),
|
||||
"Mail must not redefine the central icon-button appearance"
|
||||
);
|
||||
assert(
|
||||
source.includes("isMailboxMessageRead(message.flags)"),
|
||||
"mailbox rows must present provider-derived read/unread state"
|
||||
);
|
||||
assert(
|
||||
source.includes("mailboxSyncState(messageProvenance)"),
|
||||
"mailbox lists must present synchronization provenance"
|
||||
);
|
||||
assert(
|
||||
styles.includes(".mailbox-message-row.is-unread") && styles.includes(".mailbox-sync-provenance"),
|
||||
"read state and synchronization provenance must retain focused responsive styling"
|
||||
);
|
||||
|
||||
+237
-9
@@ -65,6 +65,41 @@ export type MailAddressLookupResponse = {
|
||||
candidates: MailAddressLookupCandidate[];
|
||||
};
|
||||
|
||||
export type MailAddressWriteTarget = {
|
||||
address_book_id: string;
|
||||
address_book_label?: string | null;
|
||||
operation: string;
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
message: string;
|
||||
scope_type?: string | null;
|
||||
scope_id?: string | null;
|
||||
source_kind?: string | null;
|
||||
read_only: boolean;
|
||||
required_scopes: string[];
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MailAddressWriteTargetResponse = {
|
||||
available: boolean;
|
||||
targets: MailAddressWriteTarget[];
|
||||
};
|
||||
|
||||
export type MailContactCreatePayload = {
|
||||
address_book_id: string;
|
||||
display_name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type MailContactCreateResponse = {
|
||||
contact_id: string;
|
||||
address_book_id: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
source_kind: string;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MailMailboxAttachment = {
|
||||
filename?: string | null;
|
||||
content_type: string;
|
||||
@@ -129,6 +164,20 @@ export type MailMailboxMessageResponse = {
|
||||
message: MailMailboxMessageDetail;
|
||||
};
|
||||
|
||||
export type MailMailboxProtocol = "imap" | "jmap";
|
||||
|
||||
export type MailMailboxChangesResponse = {
|
||||
profile_id: string;
|
||||
protocol: "jmap";
|
||||
account_id: string;
|
||||
old_state: string;
|
||||
new_state: string;
|
||||
has_more_changes: boolean;
|
||||
created: string[];
|
||||
updated: string[];
|
||||
destroyed: string[];
|
||||
};
|
||||
|
||||
export type MailSettingsDeltaResponse = {
|
||||
profiles: MailServerProfile[];
|
||||
policy?: MailProfilePolicyResponse | null;
|
||||
@@ -209,6 +258,20 @@ export async function lookupMailAddresses(settings: ApiSettings, query: string,
|
||||
return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
|
||||
}
|
||||
|
||||
export async function listMailAddressWriteTargets(settings: ApiSettings): Promise<MailAddressWriteTargetResponse> {
|
||||
return apiFetch<MailAddressWriteTargetResponse>(settings, "/api/v1/mail/address-write-targets");
|
||||
}
|
||||
|
||||
export async function createMailAddressContact(
|
||||
settings: ApiSettings,
|
||||
payload: MailContactCreatePayload
|
||||
): Promise<MailContactCreateResponse> {
|
||||
return apiFetch<MailContactCreateResponse>(settings, "/api/v1/mail/address-contacts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
|
||||
include_inactive: includeInactive ? true : undefined,
|
||||
@@ -247,7 +310,7 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
|
||||
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
|
||||
|
||||
export type MailServerEndpointPayload = {
|
||||
protocol: "smtp" | "imap";
|
||||
protocol: "smtp" | "imap" | "jmap" | "pop3";
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
inherit_to_lower_scopes?: boolean | null;
|
||||
@@ -255,6 +318,77 @@ export type MailServerEndpointPayload = {
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3ServerConfig = {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string;
|
||||
timeout_seconds?: number;
|
||||
max_message_bytes?: number;
|
||||
max_batch_bytes?: number;
|
||||
preview_body_lines?: number;
|
||||
legacy_import_enabled?: boolean;
|
||||
allow_delete_after_import?: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3ServerEndpoint = Omit<MailServerEndpoint, "protocol" | "config"> & {
|
||||
protocol: "pop3";
|
||||
config: MailPop3ServerConfig;
|
||||
};
|
||||
|
||||
export type MailPop3MessagePreview = {
|
||||
message_number: number;
|
||||
uidl: string;
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
date?: string | null;
|
||||
message_id?: string | null;
|
||||
size_bytes: number;
|
||||
body_preview?: string | null;
|
||||
already_imported: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3PreviewResponse = {
|
||||
profile_id: string;
|
||||
server_id: string;
|
||||
transport_revision: string;
|
||||
host: string;
|
||||
port: number;
|
||||
security: string;
|
||||
message_count: number;
|
||||
mailbox_size_bytes: number;
|
||||
delete_after_import_allowed: boolean;
|
||||
messages: MailPop3MessagePreview[];
|
||||
};
|
||||
|
||||
export type MailPop3ImportRecord = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
pop3_server_id: string;
|
||||
transport_revision: string;
|
||||
provider_uidl: string;
|
||||
message_id?: string | null;
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
date?: string | null;
|
||||
body_preview?: string | null;
|
||||
size_bytes: number;
|
||||
raw_sha256: string;
|
||||
status: string;
|
||||
imported_at: string;
|
||||
deletion_requested: boolean;
|
||||
deletion_status: string;
|
||||
deletion_attempted_at?: string | null;
|
||||
deletion_error?: string | null;
|
||||
};
|
||||
|
||||
export type MailPop3ImportResponse = {
|
||||
imports: MailPop3ImportRecord[];
|
||||
duplicate_uidls: string[];
|
||||
deletion_status: string;
|
||||
};
|
||||
|
||||
export type MailCredentialCreatePayload = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
@@ -454,6 +588,80 @@ export async function testMailProfileImap(
|
||||
);
|
||||
}
|
||||
|
||||
export async function testMailProfileJmap(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return apiPost<MailConnectionTestResponse>(
|
||||
settings,
|
||||
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-jmap`, {
|
||||
server_id: serverId,
|
||||
credential_id: credentialId,
|
||||
campaign_id: campaignId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function testMailProfilePop3(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId: string,
|
||||
credentialId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return apiPost<MailConnectionTestResponse>(
|
||||
settings,
|
||||
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-pop3`, {
|
||||
server_id: serverId,
|
||||
credential_id: credentialId
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function previewMailProfilePop3(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: { server_id: string; credential_id?: string | null; limit?: number }
|
||||
): Promise<MailPop3PreviewResponse> {
|
||||
return apiPostJson<MailPop3PreviewResponse>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/pop3/preview`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
export async function importMailProfilePop3(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
payload: {
|
||||
server_id: string;
|
||||
credential_id?: string | null;
|
||||
expected_transport_revision: string;
|
||||
uidls: string[];
|
||||
delete_after_import?: boolean;
|
||||
}
|
||||
): Promise<MailPop3ImportResponse> {
|
||||
return apiPostJson<MailPop3ImportResponse>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/pop3/import`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMailPop3Imports(
|
||||
settings: ApiSettings,
|
||||
profileId?: string | null,
|
||||
limit = 100
|
||||
): Promise<MailPop3ImportRecord[]> {
|
||||
const response = await apiFetch<{ imports: MailPop3ImportRecord[] }>(
|
||||
settings,
|
||||
apiPath("/api/v1/mail/pop3/imports", { profile_id: profileId, limit })
|
||||
);
|
||||
return response.imports;
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
@@ -471,10 +679,11 @@ 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"): Promise<MailImapFolderListResponse> {
|
||||
return apiFetch<MailImapFolderListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, {
|
||||
include_status: includeStatus ? true : undefined,
|
||||
refresh: refresh ? true : undefined
|
||||
refresh: refresh ? true : undefined,
|
||||
protocol
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -484,13 +693,15 @@ export async function bootstrapMailbox(
|
||||
folder = "INBOX",
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
refresh = false
|
||||
refresh = false,
|
||||
protocol: MailMailboxProtocol = "imap"
|
||||
): Promise<MailMailboxBootstrapResponse> {
|
||||
return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, {
|
||||
folder,
|
||||
limit,
|
||||
offset,
|
||||
refresh: refresh ? true : undefined
|
||||
refresh: refresh ? true : undefined,
|
||||
protocol
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -501,14 +712,18 @@ export async function listMailboxMessages(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
cursor?: string | null,
|
||||
refresh = false
|
||||
refresh = false,
|
||||
protocol: MailMailboxProtocol = "imap",
|
||||
query?: string | null
|
||||
): Promise<MailMailboxMessageListResponse> {
|
||||
return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
|
||||
folder,
|
||||
limit,
|
||||
offset,
|
||||
cursor,
|
||||
refresh: refresh ? true : undefined
|
||||
refresh: refresh ? true : undefined,
|
||||
protocol,
|
||||
q: query || undefined
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -516,9 +731,22 @@ export async function getMailboxMessage(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
folder: string,
|
||||
uid: string
|
||||
uid: string,
|
||||
protocol: MailMailboxProtocol = "imap"
|
||||
): 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 }));
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
import { FormGrid, ContentGrid,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
PageScrollViewport,
|
||||
PageTitle,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
@@ -194,33 +192,37 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
];
|
||||
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
|
||||
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
|
||||
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label="Loading bounce processing">
|
||||
<div className="dashboard-grid">
|
||||
<>
|
||||
<PageLayout
|
||||
archetype="collection"
|
||||
title="Bounce processing"
|
||||
description="Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands."
|
||||
actions={<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), disabled: Boolean(pageMutationBlocker), disabledReason: pageMutationBlocker }}
|
||||
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
|
||||
helpAction={<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />}
|
||||
createAction={<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>}
|
||||
/>}
|
||||
loading={loading}
|
||||
loadingLabel="Loading bounce processing"
|
||||
error={error}
|
||||
success={message}
|
||||
documentationType="admin"
|
||||
>
|
||||
<ContentGrid columns={2} collapseAt="workspace" className="">
|
||||
<Card title="Watched mailboxes">
|
||||
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
||||
</Card>
|
||||
<Card title="Delivery-status observations">
|
||||
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
||||
</Card>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
</ContentGrid>
|
||||
</PageLayout>
|
||||
|
||||
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
|
||||
<div className="form-grid">
|
||||
<FormGrid columns={1} collapseAt="standard" className="">
|
||||
{imapProfiles.length === 0 &&
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
@@ -242,7 +244,7 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
<input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
|
||||
</FormField>
|
||||
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
|
||||
</div>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -254,6 +256,6 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
busy={Boolean(busy)}
|
||||
onCancel={() => setDeleteSource(null)}
|
||||
onConfirm={() => void removeSource()} />
|
||||
</PageScrollViewport>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Pencil, Plus, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
ContentGrid,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
FormGrid,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
adminErrorMessage,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useGuardedNavigate,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createMailServerCredential,
|
||||
createMailServerEndpoint,
|
||||
importMailProfilePop3,
|
||||
listMailPop3Imports,
|
||||
listMailServerProfiles,
|
||||
previewMailProfilePop3,
|
||||
testMailProfilePop3,
|
||||
updateMailServerCredential,
|
||||
updateMailServerEndpoint,
|
||||
type MailCredentialEnvelope,
|
||||
type MailPop3ImportRecord,
|
||||
type MailPop3MessagePreview,
|
||||
type MailPop3PreviewResponse,
|
||||
type MailPop3ServerEndpoint,
|
||||
type MailServerProfile
|
||||
} from "../../api/mail";
|
||||
|
||||
const DOCUMENTATION = {
|
||||
topicId: "mail.workflow.legacy-pop3-import",
|
||||
documentationType: "admin"
|
||||
} as const;
|
||||
|
||||
type Source = {
|
||||
profile: MailServerProfile;
|
||||
server: MailPop3ServerEndpoint;
|
||||
};
|
||||
|
||||
type SourceDraft = {
|
||||
profileId: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: string;
|
||||
security: "tls" | "starttls" | "plain";
|
||||
timeoutSeconds: string;
|
||||
maxMessageMiB: string;
|
||||
maxBatchMiB: string;
|
||||
previewBodyLines: string;
|
||||
username: string;
|
||||
password: string;
|
||||
enabled: boolean;
|
||||
allowDeleteAfterImport: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: SourceDraft = {
|
||||
profileId: "",
|
||||
name: "Legacy POP3 source",
|
||||
host: "",
|
||||
port: "995",
|
||||
security: "tls",
|
||||
timeoutSeconds: "30",
|
||||
maxMessageMiB: "25",
|
||||
maxBatchMiB: "100",
|
||||
previewBodyLines: "20",
|
||||
username: "",
|
||||
password: "",
|
||||
enabled: true,
|
||||
allowDeleteAfterImport: false
|
||||
};
|
||||
|
||||
export default function MailLegacyImportPage({
|
||||
settings,
|
||||
auth
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [imports, setImports] = useState<MailPop3ImportRecord[]>([]);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState("");
|
||||
const [preview, setPreview] = useState<MailPop3PreviewResponse | null>(null);
|
||||
const [selectedUidls, setSelectedUidls] = useState<string[]>([]);
|
||||
const [deleteAfterImport, setDeleteAfterImport] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [sourceDialogOpen, setSourceDialogOpen] = useState(false);
|
||||
const [editingSourceId, setEditingSourceId] = useState<string | null>(null);
|
||||
const [sourceDraft, setSourceDraft] = useState<SourceDraft>(EMPTY_DRAFT);
|
||||
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
|
||||
|
||||
const canImport = hasScope(auth, "mail:pop3:import");
|
||||
const canDelete = hasScope(auth, "mail:pop3:delete");
|
||||
const canManage = hasScope(auth, "mail:pop3:manage");
|
||||
const canManageSecrets = hasScope(auth, "mail:secret:manage");
|
||||
const sources = useMemo(() => pop3Sources(profiles), [profiles]);
|
||||
const selectedSource = sources.find((item) => item.server.id === selectedSourceId) ?? sources[0] ?? null;
|
||||
const selectedCredential = selectedSource ? defaultCredential(selectedSource.server) : null;
|
||||
const configurableProfiles = profiles.filter((profile) => profileCanBeConfigured(auth, profile));
|
||||
const sourceCanBeConfigured = selectedSource ? profileCanBeConfigured(auth, selectedSource.profile) : false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [nextProfiles, nextImports] = await Promise.all([
|
||||
listMailServerProfiles(settings, canManage),
|
||||
canImport ? listMailPop3Imports(settings, null, 200) : Promise.resolve([])
|
||||
]);
|
||||
const nextSources = pop3Sources(nextProfiles);
|
||||
setProfiles(nextProfiles);
|
||||
setImports(nextImports);
|
||||
setSelectedSourceId((current) =>
|
||||
nextSources.some((item) => item.server.id === current)
|
||||
? current
|
||||
: nextSources[0]?.server.id ?? ""
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, canImport]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreview(null);
|
||||
setSelectedUidls([]);
|
||||
setDeleteAfterImport(false);
|
||||
}, [selectedSourceId]);
|
||||
|
||||
async function runConnectionTest() {
|
||||
if (!selectedSource) return;
|
||||
setBusy("test");
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const result = await testMailProfilePop3(
|
||||
settings,
|
||||
selectedSource.profile.id,
|
||||
selectedSource.server.id,
|
||||
selectedCredential?.id
|
||||
);
|
||||
if (!result.ok) throw new Error(result.message);
|
||||
setSuccess(
|
||||
`POP3 authentication succeeded. The mailbox currently reports ${String(result.details.message_count ?? 0)} message(s).`
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
if (!selectedSource || !canImport) return;
|
||||
setBusy("preview");
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const next = await previewMailProfilePop3(settings, selectedSource.profile.id, {
|
||||
server_id: selectedSource.server.id,
|
||||
credential_id: selectedCredential?.id,
|
||||
limit: 100
|
||||
});
|
||||
setPreview(next);
|
||||
setSelectedUidls((current) =>
|
||||
current.filter((uidl) => next.messages.some((item) => item.uidl === uidl && !item.already_imported))
|
||||
);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
if (!selectedSource || !preview || selectedUidls.length === 0) return;
|
||||
setDeleteConfirmationOpen(false);
|
||||
setBusy("import");
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const result = await importMailProfilePop3(settings, selectedSource.profile.id, {
|
||||
server_id: selectedSource.server.id,
|
||||
credential_id: selectedCredential?.id,
|
||||
expected_transport_revision: preview.transport_revision,
|
||||
uidls: selectedUidls,
|
||||
delete_after_import: deleteAfterImport
|
||||
});
|
||||
setSuccess(
|
||||
`${result.imports.length} message(s) imported; ${result.duplicate_uidls.length} duplicate(s) skipped. Source deletion: ${result.deletion_status.replaceAll("_", " ")}.`
|
||||
);
|
||||
setSelectedUidls([]);
|
||||
const [nextPreview, nextImports] = await Promise.all([
|
||||
previewMailProfilePop3(settings, selectedSource.profile.id, {
|
||||
server_id: selectedSource.server.id,
|
||||
credential_id: selectedCredential?.id,
|
||||
limit: 100
|
||||
}),
|
||||
listMailPop3Imports(settings, null, 200)
|
||||
]);
|
||||
setPreview(nextPreview);
|
||||
setImports(nextImports);
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function openSourceDialog(source: Source | null) {
|
||||
const credential = source ? defaultCredential(source.server) : null;
|
||||
setEditingSourceId(source?.server.id ?? null);
|
||||
setSourceDraft(source ? sourceDraftFromSource(source, credential) : {
|
||||
...EMPTY_DRAFT,
|
||||
profileId: selectedSource?.profile.id ?? configurableProfiles[0]?.id ?? ""
|
||||
});
|
||||
setSourceDialogOpen(true);
|
||||
}
|
||||
|
||||
async function saveSource() {
|
||||
const profile = profiles.find((item) => item.id === sourceDraft.profileId);
|
||||
if (!profile) return;
|
||||
const existing = sources.find((item) => item.server.id === editingSourceId) ?? null;
|
||||
const existingCredential = existing ? defaultCredential(existing.server) : null;
|
||||
setBusy("source");
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
if (existing) {
|
||||
if (canManageSecrets && (sourceDraft.password || !existingCredential)) {
|
||||
if (existingCredential) {
|
||||
await updateMailServerCredential(
|
||||
settings,
|
||||
existing.profile.id,
|
||||
existing.server.id,
|
||||
existingCredential.id,
|
||||
{
|
||||
name: `${sourceDraft.name.trim()} credential`,
|
||||
username: sourceDraft.username.trim(),
|
||||
...(sourceDraft.password ? { password: sourceDraft.password } : {})
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await createMailServerCredential(
|
||||
settings,
|
||||
existing.profile.id,
|
||||
existing.server.id,
|
||||
credentialPayload(sourceDraft, existing.server.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
await updateMailServerEndpoint(settings, existing.profile.id, existing.server.id, {
|
||||
name: sourceDraft.name.trim(),
|
||||
config: sourceConfig(sourceDraft),
|
||||
is_active: true
|
||||
});
|
||||
setSuccess("Legacy POP3 source updated.");
|
||||
} else {
|
||||
const disabledServer = await createMailServerEndpoint(settings, profile.id, {
|
||||
protocol: "pop3",
|
||||
name: sourceDraft.name.trim(),
|
||||
config: {
|
||||
...sourceConfig(sourceDraft),
|
||||
legacy_import_enabled: false,
|
||||
allow_delete_after_import: false
|
||||
},
|
||||
is_default: false,
|
||||
is_active: false
|
||||
});
|
||||
await createMailServerCredential(
|
||||
settings,
|
||||
profile.id,
|
||||
disabledServer.id,
|
||||
credentialPayload(sourceDraft, disabledServer.id)
|
||||
);
|
||||
await updateMailServerEndpoint(settings, profile.id, disabledServer.id, {
|
||||
config: sourceConfig(sourceDraft),
|
||||
is_active: true
|
||||
});
|
||||
setSelectedSourceId(disabledServer.id);
|
||||
setSuccess("Legacy POP3 source created. It was enabled only after its encrypted credential was stored.");
|
||||
}
|
||||
setSourceDialogOpen(false);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(adminErrorMessage(reason));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
const sourceBlocker = sourceSaveBlocker({
|
||||
draft: sourceDraft,
|
||||
existingCredential: editingSourceId ? defaultCredential(sources.find((item) => item.server.id === editingSourceId)?.server) : null,
|
||||
canManageSecrets
|
||||
});
|
||||
const importBlocker = !selectedSource
|
||||
? "Select an enabled legacy POP3 source."
|
||||
: !preview
|
||||
? "Refresh the live preview before importing."
|
||||
: selectedUidls.length === 0
|
||||
? "Select at least one message that has not already been imported."
|
||||
: deleteAfterImport && (!canDelete || !preview.delete_after_import_allowed)
|
||||
? "Source deletion needs both the destructive permission and an endpoint policy that allows it."
|
||||
: "";
|
||||
|
||||
const previewColumns: DataGridColumn<MailPop3MessagePreview>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: "Select",
|
||||
width: 82,
|
||||
render: (item) => <input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${item.subject || item.uidl}`}
|
||||
data-help-context-id="mail.pop3.field.message-selection"
|
||||
data-help-module-id="mail"
|
||||
checked={selectedUidls.includes(item.uidl)}
|
||||
disabled={Boolean(busy) || item.already_imported}
|
||||
onChange={() => setSelectedUidls((current) =>
|
||||
current.includes(item.uidl)
|
||||
? current.filter((uidl) => uidl !== item.uidl)
|
||||
: [...current, item.uidl]
|
||||
)} />
|
||||
},
|
||||
{
|
||||
id: "subject",
|
||||
header: "Message",
|
||||
width: "minmax(240px, 1.3fr)",
|
||||
filterable: true,
|
||||
value: (item) => `${item.subject || ""} ${item.from_header || ""}`,
|
||||
render: (item) => <span><strong>{item.subject || "No subject"}</strong><br /><small>{item.from_header || "Unknown sender"}</small></span>
|
||||
},
|
||||
{ id: "date", header: "Provider date", width: "minmax(170px, .8fr)", value: (item) => item.date || "", render: (item) => item.date || "Unknown" },
|
||||
{ id: "size", header: "Size", width: 105, value: (item) => item.size_bytes, render: (item) => formatBytes(item.size_bytes) },
|
||||
{
|
||||
id: "state",
|
||||
header: "State",
|
||||
width: 130,
|
||||
value: (item) => item.already_imported ? "imported" : "available",
|
||||
render: (item) => <StatusBadge status={item.already_imported ? "inactive" : "success"} label={item.already_imported ? "imported" : "available"} />
|
||||
}
|
||||
];
|
||||
|
||||
const importColumns: DataGridColumn<MailPop3ImportRecord>[] = [
|
||||
{ id: "imported", header: "Imported", width: "minmax(170px, .8fr)", value: (item) => item.imported_at, render: (item) => formatDateTime(item.imported_at) },
|
||||
{ id: "subject", header: "Message", width: "minmax(240px, 1.2fr)", filterable: true, value: (item) => `${item.subject || ""} ${item.from_header || ""}`, render: (item) => <span><strong>{item.subject || "No subject"}</strong><br /><small>{item.from_header || "Unknown sender"}</small></span> },
|
||||
{ id: "review", header: "Review state", width: 140, value: (item) => item.status, render: (item) => <StatusBadge status="warning" label={item.status.replaceAll("_", " ")} /> },
|
||||
{ id: "deletion", header: "Source deletion", width: 165, value: (item) => item.deletion_status, render: (item) => <StatusBadge status={deletionTone(item.deletion_status)} label={item.deletion_status.replaceAll("_", " ")} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
archetype="collection"
|
||||
title="Legacy POP3 import"
|
||||
description="Migrate bounded messages into encrypted local review records. POP3 is disabled by default and is not recommended for ongoing mailbox access."
|
||||
helpContextId="mail.pop3"
|
||||
helpModuleId="mail"
|
||||
helpTopicId="mail.workflow.legacy-pop3-import"
|
||||
actions={<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), helpContextId: "mail.pop3.action.reload", helpModuleId: "mail", helpTopicId: "mail.workflow.legacy-pop3-import", disabled: Boolean(busy), disabledReason: busy ? "Wait for the current POP3 action to finish." : undefined }}
|
||||
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
|
||||
helpAction={<DocumentationHelpLink reference={DOCUMENTATION} />}
|
||||
createAction={canManage ? <Button helpContextId="mail.pop3.action.create-source" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => openSourceDialog(null)} disabled={Boolean(busy) || configurableProfiles.length === 0} disabledReason={configurableProfiles.length === 0 ? "Create or gain write access to a Mail profile first." : undefined}><Plus size={16} aria-hidden="true" /> Add legacy source</Button> : undefined}
|
||||
/>}
|
||||
loading={loading}
|
||||
loadingLabel="Loading legacy POP3 sources"
|
||||
error={error}
|
||||
success={success}
|
||||
documentationType="admin"
|
||||
>
|
||||
<ContentGrid columns={2} collapseAt="workspace" align="stretch">
|
||||
<Card title="Legacy sources">
|
||||
{sources.length === 0 ? <ActionBlockerHint reason={{
|
||||
summary: "No POP3 legacy source is configured.",
|
||||
details: "Mail never derives or enables POP3 from an SMTP or IMAP profile.",
|
||||
requiredAction: canManage ? "Add a dedicated legacy source to an existing Mail profile." : "Ask a Mail profile administrator to configure and explicitly enable a source.",
|
||||
actor: "Mail profile administrator",
|
||||
target: "Legacy POP3 import"
|
||||
}} documentation={DOCUMENTATION} /> : <SelectionList label="POP3 legacy sources" variant="navigation">
|
||||
{sources.map((source) => <SelectionListItem key={source.server.id} selected={source.server.id === selectedSource?.server.id} onClick={() => setSelectedSourceId(source.server.id)}>
|
||||
<SelectionListItemContent
|
||||
title={source.server.name}
|
||||
description={`${source.profile.name} · ${source.server.config.host || "Host missing"}`}
|
||||
leading={<ShieldCheck size={18} />}
|
||||
/>
|
||||
</SelectionListItem>)}
|
||||
</SelectionList>}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={selectedSource?.server.name || "Selected source"}
|
||||
actions={selectedSource && canManage && sourceCanBeConfigured ? <TableActionGroup actions={[{
|
||||
id: "edit",
|
||||
label: "Configure source",
|
||||
icon: <Pencil aria-hidden="true" />,
|
||||
disabled: Boolean(busy),
|
||||
disabledReason: busy ? "Wait for the current POP3 action to finish." : "",
|
||||
helpContextId: "mail.pop3.action.configure-source",
|
||||
helpModuleId: "mail",
|
||||
helpTopicId: "mail.workflow.legacy-pop3-import",
|
||||
onClick: () => openSourceDialog(selectedSource)
|
||||
}]} /> : undefined}
|
||||
>
|
||||
{selectedSource ? <FormGrid columns={2} collapseAt="standard">
|
||||
<FormField label="Profile"><span>{selectedSource.profile.name}</span></FormField>
|
||||
<FormField label="Policy"><StatusBadge status={selectedSource.server.config.legacy_import_enabled ? "success" : "inactive"} label={selectedSource.server.config.legacy_import_enabled ? "explicitly enabled" : "disabled"} /></FormField>
|
||||
<FormField label="Transport"><span>{selectedSource.server.config.security || "tls"} · {String(selectedSource.server.config.port || 995)}</span></FormField>
|
||||
<FormField label="Credential"><span>{selectedCredential ? String(selectedCredential.public_data?.username || selectedCredential.name) : "No credential"}</span></FormField>
|
||||
<Button helpContextId="mail.pop3.action.test" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" onClick={() => void runConnectionTest()} disabled={Boolean(busy) || !selectedCredential || !selectedSource.server.config.legacy_import_enabled} disabledReason={!selectedCredential ? "Store an encrypted POP3 credential first." : !selectedSource.server.config.legacy_import_enabled ? "Explicitly enable legacy import first." : busy ? "Wait for the current POP3 action to finish." : undefined}>Test connection</Button>
|
||||
{canImport ? <Button helpContextId="mail.pop3.action.preview" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => void refreshPreview()} disabled={Boolean(busy) || !selectedCredential || !selectedSource.server.config.legacy_import_enabled} disabledReason={!selectedCredential ? "Store an encrypted POP3 credential first." : !selectedSource.server.config.legacy_import_enabled ? "Explicitly enable legacy import first." : busy ? "Wait for the current POP3 action to finish." : undefined}>Refresh live preview</Button> : null}
|
||||
</FormGrid> : <p className="muted">Select or configure a legacy source.</p>}
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
|
||||
<Card title="Live provider preview">
|
||||
{preview ? <>
|
||||
<p className="muted">Provider reports {preview.message_count} message(s), {formatBytes(preview.mailbox_size_bytes)} total. Preview and ordinary import do not delete source messages.</p>
|
||||
<DataGrid id="mail-pop3-preview" rows={preview.messages} columns={previewColumns} getRowKey={(item) => item.uidl} emptyText="The legacy mailbox contains no messages." />
|
||||
<FormGrid columns={2} collapseAt="standard" spacing="block">
|
||||
<ToggleSwitch helpContextId="mail.pop3.field.delete-after-import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={deleteAfterImport} disabled={!canDelete || !preview.delete_after_import_allowed || Boolean(busy)} onChange={setDeleteAfterImport} label="Delete newly imported messages at the source" help="Destructive and separately governed. The local encrypted import is committed first." />
|
||||
<Button helpContextId="mail.pop3.action.import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant={deleteAfterImport ? "danger" : "primary"} onClick={() => deleteAfterImport ? setDeleteConfirmationOpen(true) : void runImport()} disabled={Boolean(busy) || Boolean(importBlocker)} disabledReason={importBlocker || (busy ? "Wait for the current POP3 action to finish." : undefined)}>Import {selectedUidls.length || "selected"} message(s)</Button>
|
||||
</FormGrid>
|
||||
</> : <p className="muted">Refresh a source to obtain a bounded, non-destructive preview.</p>}
|
||||
</Card>
|
||||
|
||||
<Card title="Governed local imports">
|
||||
<DataGrid id="mail-pop3-imports" rows={imports} columns={importColumns} getRowKey={(item) => item.id} emptyText="No legacy messages have been imported." />
|
||||
</Card>
|
||||
</PageLayout>
|
||||
|
||||
<Dialog
|
||||
open={sourceDialogOpen}
|
||||
title={editingSourceId ? "Configure legacy POP3 source" : "Add legacy POP3 source"}
|
||||
helpContextId="mail.pop3.source-editor"
|
||||
helpModuleId="mail"
|
||||
helpTopicId="mail.workflow.legacy-pop3-import"
|
||||
onClose={() => !busy && setSourceDialogOpen(false)}
|
||||
footer={<><Button onClick={() => setSourceDialogOpen(false)} disabled={Boolean(busy)}>Cancel</Button><Button helpContextId="mail.pop3.action.save-source" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" variant="primary" onClick={() => void saveSource()} disabled={Boolean(busy) || Boolean(sourceBlocker)} disabledReason={sourceBlocker || (busy ? "Wait for the source to be saved." : undefined)}>Save source</Button></>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard">
|
||||
<FormField label="Mail profile" help="The profile supplies scope and lifecycle ownership for this dedicated source." helpContextId="mail.pop3.field.profile" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import">
|
||||
<select value={sourceDraft.profileId} disabled={Boolean(editingSourceId) || Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, profileId: event.target.value }))}>
|
||||
<option value="">Select a profile</option>
|
||||
{configurableProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profile.scope_type})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Source name" helpContextId="mail.pop3.field.name" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.name} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, name: event.target.value }))} /></FormField>
|
||||
<FormField label="POP3 host" helpContextId="mail.pop3.field.host" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.host} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, host: event.target.value }))} placeholder="pop3.example.org" /></FormField>
|
||||
<FormField label="Port" helpContextId="mail.pop3.field.port" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={65535} value={sourceDraft.port} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, port: event.target.value }))} /></FormField>
|
||||
<FormField label="Transport security" helpContextId="mail.pop3.field.transport-security" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import">
|
||||
<select value={sourceDraft.security} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, security: event.target.value as SourceDraft["security"], port: event.target.value === "tls" ? "995" : "110" }))}>
|
||||
<option value="tls">TLS</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="plain">Plain (deployment policy may deny)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Timeout (seconds)" helpContextId="mail.pop3.field.timeout" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={300} value={sourceDraft.timeoutSeconds} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, timeoutSeconds: event.target.value }))} /></FormField>
|
||||
<FormField label="Maximum message size (MiB)" helpContextId="mail.pop3.field.max-message-size" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={50} value={sourceDraft.maxMessageMiB} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, maxMessageMiB: event.target.value }))} /></FormField>
|
||||
<FormField label="Maximum batch size (MiB)" helpContextId="mail.pop3.field.max-batch-size" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={1} max={500} value={sourceDraft.maxBatchMiB} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, maxBatchMiB: event.target.value }))} /></FormField>
|
||||
<FormField label="Preview body lines" helpContextId="mail.pop3.field.preview-body-lines" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="number" min={0} max={100} value={sourceDraft.previewBodyLines} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, previewBodyLines: event.target.value }))} /></FormField>
|
||||
<FormField label="Username" helpContextId="mail.pop3.field.username" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input value={sourceDraft.username} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, username: event.target.value }))} autoComplete="username" /></FormField>
|
||||
<FormField label={editingSourceId ? "New password (optional)" : "Password"} helpContextId="mail.pop3.field.password" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import"><input type="password" value={sourceDraft.password} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, password: event.target.value }))} autoComplete="new-password" /></FormField>
|
||||
<ToggleSwitch helpContextId="mail.pop3.field.enabled" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={sourceDraft.enabled} disabled={Boolean(busy)} onChange={(enabled) => setSourceDraft((draft) => ({ ...draft, enabled, allowDeleteAfterImport: enabled ? draft.allowDeleteAfterImport : false }))} label="Explicitly enable legacy import" help="Off is the product default." />
|
||||
<ToggleSwitch helpContextId="mail.pop3.field.allow-delete-after-import" helpModuleId="mail" helpTopicId="mail.workflow.legacy-pop3-import" checked={sourceDraft.allowDeleteAfterImport} disabled={Boolean(busy) || !sourceDraft.enabled} onChange={(allowDeleteAfterImport) => setSourceDraft((draft) => ({ ...draft, allowDeleteAfterImport }))} label="Permit delete-after-import requests" help="Operators still need a separate destructive permission and must choose deletion per batch." />
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmationOpen}
|
||||
title="Import and delete source messages"
|
||||
message={`Mail will first commit and audit ${selectedUidls.length} encrypted local import(s), then ask the POP3 server to delete only those newly imported messages. Provider deletion cannot be undone and may require reconciliation if its outcome is unknown.`}
|
||||
confirmLabel="Import, then delete source"
|
||||
helpContextId="mail.pop3.confirm-delete-source"
|
||||
helpModuleId="mail"
|
||||
helpTopicId="mail.workflow.legacy-pop3-import"
|
||||
tone="danger"
|
||||
busy={Boolean(busy)}
|
||||
onCancel={() => setDeleteConfirmationOpen(false)}
|
||||
onConfirm={() => void runImport()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pop3Sources(profiles: MailServerProfile[]): Source[] {
|
||||
return profiles.flatMap((profile) =>
|
||||
((profile.servers ?? []) as unknown as MailPop3ServerEndpoint[])
|
||||
.filter((server) => server.protocol === "pop3")
|
||||
.map((server) => ({ profile, server }))
|
||||
);
|
||||
}
|
||||
|
||||
function defaultCredential(server: MailPop3ServerEndpoint | undefined): MailCredentialEnvelope | null {
|
||||
if (!server) return null;
|
||||
return server.credentials.find((credential) => credential.is_default)
|
||||
?? server.credentials.find((credential) => credential.is_active)
|
||||
?? server.credentials[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
function profileCanBeConfigured(auth: AuthInfo, profile: MailServerProfile): boolean {
|
||||
if (profile.scope_type === "system") return hasScope(auth, "system:settings:write");
|
||||
if (hasScope(auth, "mail:profile:write")) return true;
|
||||
return profile.scope_type === "user"
|
||||
&& profile.scope_id === auth.user.id
|
||||
&& hasScope(auth, "mail:profile:write_own");
|
||||
}
|
||||
|
||||
function sourceDraftFromSource(source: Source, credential: MailCredentialEnvelope | null): SourceDraft {
|
||||
const config = source.server.config;
|
||||
return {
|
||||
profileId: source.profile.id,
|
||||
name: source.server.name,
|
||||
host: String(config.host ?? ""),
|
||||
port: String(config.port ?? (config.security === "tls" ? 995 : 110)),
|
||||
security: config.security === "starttls" || config.security === "plain" ? config.security : "tls",
|
||||
timeoutSeconds: String(config.timeout_seconds ?? 30),
|
||||
maxMessageMiB: String(Math.max(1, Math.round(Number(config.max_message_bytes ?? 25 * 1024 * 1024) / 1024 / 1024))),
|
||||
maxBatchMiB: String(Math.max(1, Math.round(Number(config.max_batch_bytes ?? 100 * 1024 * 1024) / 1024 / 1024))),
|
||||
previewBodyLines: String(config.preview_body_lines ?? 20),
|
||||
username: String(credential?.public_data?.username ?? ""),
|
||||
password: "",
|
||||
enabled: Boolean(config.legacy_import_enabled),
|
||||
allowDeleteAfterImport: Boolean(config.allow_delete_after_import)
|
||||
};
|
||||
}
|
||||
|
||||
function sourceConfig(draft: SourceDraft): Record<string, unknown> {
|
||||
return {
|
||||
host: draft.host.trim(),
|
||||
port: Number(draft.port),
|
||||
security: draft.security,
|
||||
timeout_seconds: Number(draft.timeoutSeconds),
|
||||
max_message_bytes: Number(draft.maxMessageMiB) * 1024 * 1024,
|
||||
max_batch_bytes: Number(draft.maxBatchMiB) * 1024 * 1024,
|
||||
preview_body_lines: Number(draft.previewBodyLines),
|
||||
legacy_import_enabled: draft.enabled,
|
||||
allow_delete_after_import: draft.allowDeleteAfterImport
|
||||
};
|
||||
}
|
||||
|
||||
function credentialPayload(draft: SourceDraft, serverId: string) {
|
||||
return {
|
||||
name: `${draft.name.trim()} credential`,
|
||||
credential_kind: "username_password",
|
||||
username: draft.username.trim(),
|
||||
password: draft.password,
|
||||
allowed_modules: ["mail"],
|
||||
allowed_server_refs: [`mail:${serverId}`],
|
||||
is_default: true
|
||||
};
|
||||
}
|
||||
|
||||
function sourceSaveBlocker({
|
||||
draft,
|
||||
existingCredential,
|
||||
canManageSecrets
|
||||
}: {
|
||||
draft: SourceDraft;
|
||||
existingCredential: MailCredentialEnvelope | null;
|
||||
canManageSecrets: boolean;
|
||||
}): string {
|
||||
if (!draft.profileId) return "Select a Mail profile.";
|
||||
if (!draft.name.trim()) return "Enter a source name.";
|
||||
if (!draft.host.trim()) return "Enter the POP3 host.";
|
||||
if (!boundedInteger(draft.port, 1, 65535)) return "Enter a valid POP3 port.";
|
||||
if (!boundedInteger(draft.timeoutSeconds, 1, 300)) return "Enter a timeout from 1 to 300 seconds.";
|
||||
if (!boundedInteger(draft.maxMessageMiB, 1, 50)) return "Enter a message limit from 1 to 50 MiB.";
|
||||
if (!boundedInteger(draft.maxBatchMiB, 1, 500)) return "Enter a batch limit from 1 to 500 MiB.";
|
||||
if (Number(draft.maxBatchMiB) < Number(draft.maxMessageMiB)) return "The batch limit cannot be lower than the per-message limit.";
|
||||
if (!boundedInteger(draft.previewBodyLines, 0, 100)) return "Enter 0 to 100 preview body lines.";
|
||||
if (draft.allowDeleteAfterImport && !draft.enabled) return "Enable legacy import before permitting source deletion.";
|
||||
if (!existingCredential && !canManageSecrets) return "Managing the encrypted POP3 credential requires Mail secret authority.";
|
||||
if (!existingCredential && !draft.username.trim()) return "Enter the POP3 username.";
|
||||
if (!existingCredential && !draft.password) return "Enter the POP3 password.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function boundedInteger(value: string, minimum: number, maximum: number): boolean {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum;
|
||||
}
|
||||
|
||||
function deletionTone(status: string): "success" | "warning" | "error" | "inactive" {
|
||||
if (status === "succeeded") return "success";
|
||||
if (status === "failed" || status === "outcome_unknown") return "error";
|
||||
if (status === "pending") return "warning";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||
import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailJmapTransportSettings, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||
import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
deactivateMailServerProfile,
|
||||
fetchMailSettingsDelta,
|
||||
getMailProfilePolicy,
|
||||
listImapFolders,
|
||||
listMailProfileImapFolders,
|
||||
mailProfilePatternKeys,
|
||||
mailProfilePolicyLimitKeys,
|
||||
listAvailableMailCredentials,
|
||||
updateMailProfilePolicy,
|
||||
testImapSettings,
|
||||
testMailProfileImap,
|
||||
testMailProfileJmap,
|
||||
testMailProfileSmtp,
|
||||
testSmtpSettings,
|
||||
unlinkMailServerCredential,
|
||||
@@ -102,7 +105,17 @@ type ProfileDraft = {
|
||||
imapUsername: string;
|
||||
imapPassword: string;
|
||||
imapSentFolder: string;
|
||||
imapFolderMappings: MailImapFolderMappings;
|
||||
imapTimeout: string;
|
||||
jmapSessionUrl: string;
|
||||
jmapAccountId: string;
|
||||
jmapAuthScheme: "bearer" | "basic";
|
||||
jmapAllowedApiOrigins: string;
|
||||
jmapTimeout: string;
|
||||
jmapMaxResponseBytes: string;
|
||||
jmapMaxBodyValueBytes: string;
|
||||
jmapUsername: string;
|
||||
jmapToken: string;
|
||||
};
|
||||
|
||||
type MailProfileScopeManagerProps = {
|
||||
@@ -160,6 +173,7 @@ const MAIL_PROFILE_WORKFLOW_DOCUMENTATION = {
|
||||
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"
|
||||
@@ -399,7 +413,11 @@ export function MailProfileScopeManager({
|
||||
} else if (editingTarget.kind === "server") {
|
||||
const serverPayload = {
|
||||
name: draft.serverName.trim(),
|
||||
config: editingTarget.protocol === "smtp" ? smtpServerPayload(draft) : imapServerPayload(draft),
|
||||
config: editingTarget.protocol === "smtp"
|
||||
? smtpServerPayload(draft)
|
||||
: editingTarget.protocol === "imap"
|
||||
? imapServerPayload(draft)
|
||||
: jmapServerPayload(draft),
|
||||
inherit_to_lower_scopes: draft.serverInheritToLowerScopes,
|
||||
is_default: draft.serverIsDefault,
|
||||
is_active: draft.serverIsActive
|
||||
@@ -523,7 +541,7 @@ export function MailProfileScopeManager({
|
||||
header: "i18n:govoplan-mail.transport.c10d76c9",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (row) => row.kind === "profile" ?
|
||||
<span>{transportLabel(row.profile.smtp)}{row.profile.imap ? i18nMessage("i18n:govoplan-mail.value.48afe802", { value0: transportLabel(row.profile.imap) }) : ""}</span> :
|
||||
<span>{profileTransportLabels(row.profile).join(" · ")}</span> :
|
||||
row.kind === "server" ?
|
||||
<TransportCell server={row.server} /> :
|
||||
<span>{String(row.credential.public_data?.username || "No username")}</span>
|
||||
@@ -657,6 +675,7 @@ export function MailProfileScopeManager({
|
||||
{ id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openEdit(row.profile) },
|
||||
{ id: "add-smtp", label: "Add SMTP server", icon: <Plus size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "smtp") },
|
||||
{ id: "add-imap", label: "Add IMAP server", icon: <Plus size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "imap") },
|
||||
{ id: "add-jmap", label: "Add JMAP server", icon: <Plus size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "jmap") },
|
||||
{ id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: <Trash2 size={16} />, variant: "danger", disabled: Boolean(deactivationBlocker), disabledReason: deactivationBlocker, onClick: () => setDeactivating(row.profile) }
|
||||
]} />;
|
||||
|
||||
@@ -740,11 +759,11 @@ export function MailProfileScopeManager({
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={editing !== null}
|
||||
title={profileDialogTitle(editing, editingTarget)}
|
||||
onClose={() => !busy && closeProfileEditor()}
|
||||
className="admin-dialog admin-dialog-wide mail-profile-dialog"
|
||||
className="mail-profile-dialog"
|
||||
closeDisabled={busy}
|
||||
footer={editing === "new"
|
||||
? <>
|
||||
@@ -1020,13 +1039,13 @@ export function MailProfilePolicyEditor({
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<section className="mail-policy-section policy-section">
|
||||
<div className="subsection-heading split">
|
||||
<ActionToolbar surface="section-header" className="subsection-heading split">
|
||||
<h3>i18n:govoplan-mail.profile_allow_list.507dfe6c</h3>
|
||||
<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>
|
||||
</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) => ({
|
||||
@@ -1163,7 +1182,9 @@ function ProfileForm({
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null);
|
||||
const [jmapTestResult, setJmapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapFolderResult, setImapFolderResult] = useState<MailImapFolderListResponse | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "jmap" | "folders" | null>(null);
|
||||
|
||||
const disabled = busy || !canWrite;
|
||||
const credentialDisabled = disabled || !canManageCredentials;
|
||||
@@ -1201,6 +1222,13 @@ function ProfileForm({
|
||||
&& selectedServerId
|
||||
&& !draft.imapPassword
|
||||
);
|
||||
const useSavedJmapTest = Boolean(
|
||||
existingProfile
|
||||
&& (editTarget.kind === "server" || editTarget.kind === "credentials")
|
||||
&& editTarget.protocol === "jmap"
|
||||
&& selectedServerId
|
||||
&& !draft.jmapToken
|
||||
);
|
||||
const creatingCredential = editTarget.kind === "credentials" && !editTarget.credentialId;
|
||||
const creatingNewCredential = creatingCredential && !reuseCredentialId;
|
||||
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
|
||||
@@ -1245,8 +1273,9 @@ function ProfileForm({
|
||||
);
|
||||
const policyMessages = useMemo(() => validateMailPolicy(effectivePolicy, {
|
||||
smtpHost: draft.smtpHost,
|
||||
imapHost: draft.imapHost
|
||||
}), [draft.imapHost, draft.smtpHost, effectivePolicy]);
|
||||
imapHost: draft.imapHost,
|
||||
jmapHost: jmapHostname(draft.jmapSessionUrl)
|
||||
}), [draft.imapHost, draft.jmapSessionUrl, draft.smtpHost, effectivePolicy]);
|
||||
const profileFormMutationBlocker = mailProfileMutationDisabledReason(canWrite, busy);
|
||||
const smtpTestBlocker = profileFormMutationBlocker
|
||||
|| (!draft.smtpHost.trim() ? "Enter an SMTP server hostname before testing the connection." : "");
|
||||
@@ -1256,6 +1285,8 @@ function ProfileForm({
|
||||
useEffect(() => {
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
setJmapTestResult(null);
|
||||
setImapFolderResult(null);
|
||||
setMailActionState(null);
|
||||
}, [editing, editTarget]);
|
||||
|
||||
@@ -1276,6 +1307,7 @@ function ProfileForm({
|
||||
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
|
||||
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
|
||||
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
|
||||
imapFolderMappings: patch.folder_mappings !== undefined ? normalizeMailImapFolderMappings(patch.folder_mappings) : draft.imapFolderMappings,
|
||||
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
|
||||
});
|
||||
}
|
||||
@@ -1325,6 +1357,39 @@ function ProfileForm({
|
||||
}
|
||||
}
|
||||
|
||||
async function runImapFolderLookup() {
|
||||
if (!draftHasImap) return;
|
||||
setMailActionState("folders");
|
||||
setImapFolderResult(null);
|
||||
try {
|
||||
setImapFolderResult(useSavedImapTest && existingProfile ?
|
||||
await listMailProfileImapFolders(settings, existingProfile.id, selectedServerId, selectedCredentialId) :
|
||||
await listImapFolders(settings, rawImapPayload(draft, false)));
|
||||
} catch (err) {
|
||||
setImapFolderResult({ ok: false, protocol: "imap", message: errorMessage(err), folders: [] });
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function runJmapTest() {
|
||||
if (!existingProfile || !selectedServerId) return;
|
||||
setMailActionState("jmap");
|
||||
setJmapTestResult(null);
|
||||
try {
|
||||
setJmapTestResult(await testMailProfileJmap(
|
||||
settings,
|
||||
existingProfile.id,
|
||||
selectedServerId,
|
||||
selectedCredentialId
|
||||
));
|
||||
} catch (err) {
|
||||
setJmapTestResult({ ok: false, protocol: "jmap", message: errorMessage(err), details: {} });
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mail-profile-form">
|
||||
{!canManageCredentials && (
|
||||
@@ -1338,12 +1403,12 @@ function ProfileForm({
|
||||
details: "Profile metadata and server configuration are separate from encrypted credential authority.",
|
||||
requiredAction: "Ask a Mail credential administrator to create, replace, or link the required credential.",
|
||||
actor: "Mail credential administrator",
|
||||
target: "The credential row beneath the relevant SMTP or IMAP server"
|
||||
target: "The credential row beneath the relevant SMTP, IMAP, or JMAP server"
|
||||
}}
|
||||
documentation={MAIL_PROFILE_DOCUMENTATION} />
|
||||
}
|
||||
{showProfileFields &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-mail.name.709a2322" documentation={MAIL_PROFILE_DOCUMENTATION}><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField>
|
||||
<FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField>
|
||||
@@ -1355,7 +1420,7 @@ function ProfileForm({
|
||||
onChange={(checked) => setDraft({ ...draft, inheritToLowerScopes: checked })}
|
||||
label="Visible to lower scopes" />
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{editTarget.kind === "profile" && existingProfile &&
|
||||
@@ -1363,7 +1428,7 @@ function ProfileForm({
|
||||
}
|
||||
|
||||
{editTarget.kind === "server" &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="Server name" documentation={MAIL_PROFILE_DOCUMENTATION}>
|
||||
<input value={draft.serverName} disabled={disabled} onChange={(event) => setDraft({ ...draft, serverName: event.target.value })} />
|
||||
</FormField>
|
||||
@@ -1387,13 +1452,50 @@ function ProfileForm({
|
||||
onChange={(checked) => setDraft({ ...draft, serverInheritToLowerScopes: checked })}
|
||||
label="Visible to lower scopes" />
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{editTarget.kind === "server" && editTarget.protocol === "jmap" &&
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="JMAP Session URL" help="Use the authenticated RFC 8620 Session resource URL; credentials must not be embedded in the URL." documentation={MAIL_PROFILE_DOCUMENTATION}>
|
||||
<input value={draft.jmapSessionUrl} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapSessionUrl: event.target.value })} placeholder="https://mail.example.org/.well-known/jmap" />
|
||||
</FormField>
|
||||
<FormField label="Account id" help="Leave blank to use the primary JMAP Mail account. Configure it when the credential exposes multiple mail accounts.">
|
||||
<input value={draft.jmapAccountId} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapAccountId: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Authentication scheme">
|
||||
<select value={draft.jmapAuthScheme} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapAuthScheme: event.target.value as "bearer" | "basic" })}>
|
||||
<option value="bearer">Bearer access token</option>
|
||||
<option value="basic">Basic username and password</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Allowed API origins" help="Optional comma-separated origins for an apiUrl hosted on a different origin than the Session resource. Cross-origin endpoints fail closed unless listed.">
|
||||
<textarea rows={2} value={draft.jmapAllowedApiOrigins} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapAllowedApiOrigins: event.target.value })} placeholder="https://api.example.org" />
|
||||
</FormField>
|
||||
<FormField label="Timeout (seconds)">
|
||||
<input type="number" min="1" max="120" value={draft.jmapTimeout} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapTimeout: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Maximum response bytes">
|
||||
<input type="number" min="65536" max="26214400" value={draft.jmapMaxResponseBytes} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapMaxResponseBytes: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Maximum body value bytes">
|
||||
<input type="number" min="1024" max="5242880" value={draft.jmapMaxBodyValueBytes} disabled={disabled} onChange={(event) => setDraft({ ...draft, jmapMaxBodyValueBytes: event.target.value })} />
|
||||
</FormField>
|
||||
{editTarget.serverId &&
|
||||
<div className="mail-server-field-span">
|
||||
<Button onClick={() => void runJmapTest()} disabled={disabled || mailActionState !== null || (!useSavedJmapTest && !selectedCredentialId)} disabledReason={!useSavedJmapTest && !selectedCredentialId ? "Save or select a JMAP credential before testing." : undefined}>
|
||||
{mailActionState === "jmap" ? "Testing JMAP…" : "Test saved JMAP"}
|
||||
</Button>
|
||||
<MailServerActionResult result={jmapTestResult} />
|
||||
</div>
|
||||
}
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{editTarget.kind === "credentials" &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
{creatingCredential &&
|
||||
<FormField label="Reusable credential" documentation={MAIL_PROFILE_WORKFLOW_DOCUMENTATION}>
|
||||
<FormField label="Reusable credential" documentation={MAIL_PROFILE_WORKFLOW_DOCUMENTATION} helpContextId="mail.profiles" helpModuleId="mail">
|
||||
<select value={reuseCredentialId} disabled={credentialDisabled} onChange={(event) => setReuseCredentialId(event.target.value)}>
|
||||
<option value="">Create a new credential</option>
|
||||
{availableCredentials.map((credential) =>
|
||||
@@ -1407,13 +1509,13 @@ function ProfileForm({
|
||||
{creatingCredential && <div />}
|
||||
{!reuseCredentialId &&
|
||||
<>
|
||||
<FormField label="Credential name" documentation={MAIL_PROFILE_DOCUMENTATION}>
|
||||
<FormField label="Credential name" documentation={MAIL_PROFILE_DOCUMENTATION} helpContextId="mail.profiles" helpModuleId="mail">
|
||||
<input value={draft.credentialName} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, credentialName: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Description">
|
||||
<input value={draft.credentialDescription} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, credentialDescription: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Available to modules" help="Choose installed modules. Keep Mail selected to use this credential here.">
|
||||
<FormField label="Available to modules" help="Choose installed modules. Keep Mail selected to use this credential here." helpContextId="mail.profiles" helpModuleId="mail">
|
||||
<ReferenceMultiSelect
|
||||
values={commaValues(draft.credentialAllowedModules)}
|
||||
onChange={(values) => setDraft({ ...draft, credentialAllowedModules: values.join(", ") })}
|
||||
@@ -1425,7 +1527,9 @@ function ProfileForm({
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Limited to servers"
|
||||
help="Choose an announced server or enter a module-qualified reference. Leave blank for every permitted server.">
|
||||
help="Choose an announced server or enter a module-qualified reference. Leave blank for every permitted server."
|
||||
helpContextId="mail.profiles"
|
||||
helpModuleId="mail">
|
||||
<ReferenceMultiSelect
|
||||
values={commaValues(draft.credentialAllowedServerRefs)}
|
||||
onChange={(values) => setDraft({ ...draft, credentialAllowedServerRefs: values.join(", ") })}
|
||||
@@ -1438,6 +1542,8 @@ function ProfileForm({
|
||||
</FormField>
|
||||
<ToggleSwitch
|
||||
checked={draft.credentialIsDefault}
|
||||
helpContextId="mail.profiles"
|
||||
helpModuleId="mail"
|
||||
disabled={credentialDisabled}
|
||||
onChange={(checked) => setDraft({ ...draft, credentialIsDefault: checked })}
|
||||
label={`Default credential for this ${editTarget.protocol.toUpperCase()} server`} />
|
||||
@@ -1448,6 +1554,18 @@ function ProfileForm({
|
||||
onChange={(checked) => setDraft({ ...draft, credentialInheritToLowerScopes: checked })}
|
||||
label="Visible to lower scopes" />
|
||||
</div>
|
||||
{editTarget.protocol === "jmap" &&
|
||||
<>
|
||||
{draft.jmapAuthScheme === "basic" &&
|
||||
<FormField label="JMAP username">
|
||||
<input value={draft.jmapUsername} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, jmapUsername: event.target.value })} autoComplete="username" />
|
||||
</FormField>
|
||||
}
|
||||
<FormField label={draft.jmapAuthScheme === "bearer" ? "JMAP access token" : "JMAP password"} help={selectedCredential?.secret_configured ? "A secret is saved. Leave blank to retain it." : "The secret is encrypted in the governed credential envelope."}>
|
||||
<input type="password" value={draft.jmapToken} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, jmapToken: event.target.value })} autoComplete="new-password" />
|
||||
</FormField>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
}
|
||||
{reuseCredentialId &&
|
||||
@@ -1455,7 +1573,7 @@ function ProfileForm({
|
||||
The selected credential remains independently managed and will be linked to this server.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{policyMessages.length > 0 && (
|
||||
@@ -1472,7 +1590,7 @@ function ProfileForm({
|
||||
{showSettingsPanel && settingsPanelMode && (!creatingCredential || creatingNewCredential) &&
|
||||
<MailServerSettingsPanel
|
||||
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
|
||||
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
|
||||
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout }}
|
||||
smtpCredentials={{ username: draft.smtpUsername, password: draft.smtpPassword }}
|
||||
imapCredentials={{ username: draft.imapUsername, password: draft.imapPassword }}
|
||||
onSmtpChange={patchSmtpSettings}
|
||||
@@ -1501,8 +1619,10 @@ function ProfileForm({
|
||||
busyAction={mailActionState}
|
||||
onTestSmtp={() => void runSmtpTest()}
|
||||
onTestImap={() => void runImapTest()}
|
||||
onLookupImapFolders={() => void runImapFolderLookup()}
|
||||
smtpTestResult={smtpTestResult}
|
||||
imapTestResult={imapTestResult}
|
||||
imapFolderLookupResult={imapFolderResult}
|
||||
initialSection={initialSection}
|
||||
visibleSections={visibleSections}
|
||||
mode={settingsPanelMode} />
|
||||
@@ -1551,6 +1671,7 @@ function MailCredentialTreeCell({ credential }: {credential: MailCredentialEnvel
|
||||
}
|
||||
|
||||
function ProfileTransportSummary({ profile }: {profile: MailServerProfile;}) {
|
||||
const jmapServer = preferredProfileServer(profile, "jmap");
|
||||
return (
|
||||
<div className="mail-profile-transport-summary">
|
||||
<div>
|
||||
@@ -1563,6 +1684,11 @@ function ProfileTransportSummary({ profile }: {profile: MailServerProfile;}) {
|
||||
<strong>{transportLabel(profile.imap)}</strong>
|
||||
<small>{profile.imap ? mailCredentialConfigured(profile, "imap") ? "i18n:govoplan-mail.password_saved.f6fab237" : "i18n:govoplan-mail.no_saved_password.32ce2b16" : "i18n:govoplan-mail.not_configured.811931bb"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>JMAP server</span>
|
||||
<strong>{transportLabel(jmapServer?.config)}</strong>
|
||||
<small>{jmapServer ? jmapServer.credentials.some((credential) => credential.secret_configured) ? "Credential saved" : "No saved credential" : "i18n:govoplan-mail.not_configured.811931bb"}</small>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
}
|
||||
@@ -1584,6 +1710,19 @@ function mailCredentialConfigured(profile: MailServerProfile, protocol: "smtp" |
|
||||
return protocol === "smtp" ? profile.smtp_password_configured : profile.imap_password_configured;
|
||||
}
|
||||
|
||||
function preferredProfileServer(profile: MailServerProfile, protocol: MailProfileProtocol): MailServerEndpoint | null {
|
||||
const servers = (profile.servers ?? []).filter((server) => server.protocol === protocol && server.is_active);
|
||||
return servers.find((server) => server.is_default) ?? servers[0] ?? null;
|
||||
}
|
||||
|
||||
function profileTransportLabels(profile: MailServerProfile): string[] {
|
||||
const labels = [`SMTP · ${transportLabel(profile.smtp)}`];
|
||||
if (profile.imap) labels.push(`IMAP · ${transportLabel(profile.imap)}`);
|
||||
const jmap = preferredProfileServer(profile, "jmap");
|
||||
if (jmap) labels.push(`JMAP · ${transportLabel(jmap.config)}`);
|
||||
return labels;
|
||||
}
|
||||
|
||||
function credentialAvailabilityLabel(credential: MailCredentialEnvelope): string {
|
||||
const modules = credential.allowed_modules.length > 0 ? credential.allowed_modules.join(", ") : "all modules";
|
||||
const servers = credential.allowed_server_refs.length > 0
|
||||
@@ -1645,7 +1784,17 @@ function emptyProfileDraft(): ProfileDraft {
|
||||
imapUsername: "",
|
||||
imapPassword: "",
|
||||
imapSentFolder: "auto",
|
||||
imapTimeout: "30"
|
||||
imapFolderMappings: {},
|
||||
imapTimeout: "30",
|
||||
jmapSessionUrl: "",
|
||||
jmapAccountId: "",
|
||||
jmapAuthScheme: "bearer",
|
||||
jmapAllowedApiOrigins: "",
|
||||
jmapTimeout: "20",
|
||||
jmapMaxResponseBytes: String(5 * 1024 * 1024),
|
||||
jmapMaxBodyValueBytes: String(1024 * 1024),
|
||||
jmapUsername: "",
|
||||
jmapToken: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1664,6 +1813,7 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
|
||||
: undefined;
|
||||
const smtpConfig = targetServer?.protocol === "smtp" ? targetServer.config : profile.smtp;
|
||||
const imapConfig = targetServer?.protocol === "imap" ? targetServer.config : profile.imap;
|
||||
const jmapConfig = targetServer?.protocol === "jmap" ? targetServer.config as MailJmapTransportSettings : undefined;
|
||||
const targetUsername = stringValue(targetCredential?.public_data?.username);
|
||||
return {
|
||||
name: profile.name,
|
||||
@@ -1696,8 +1846,21 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
|
||||
? targetUsername
|
||||
: stringValue(profile.credentials?.imap?.username ?? profile.imap?.username),
|
||||
imapPassword: "",
|
||||
imapSentFolder: stringValue("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto"),
|
||||
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30)
|
||||
imapSentFolder: stringValue(imapConfig?.folder_mappings?.sent || ("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto")),
|
||||
imapFolderMappings: normalizeMailImapFolderMappings({
|
||||
...(imapConfig?.folder_mappings ?? {}),
|
||||
...(!imapConfig?.folder_mappings?.sent && imapConfig?.sent_folder && imapConfig.sent_folder !== "auto" ? { sent: imapConfig.sent_folder } : {})
|
||||
}),
|
||||
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30),
|
||||
jmapSessionUrl: stringValue(jmapConfig?.session_url),
|
||||
jmapAccountId: stringValue(jmapConfig?.account_id),
|
||||
jmapAuthScheme: jmapConfig?.auth_scheme === "basic" ? "basic" : "bearer",
|
||||
jmapAllowedApiOrigins: (jmapConfig?.allowed_api_origins ?? []).join(", "),
|
||||
jmapTimeout: stringValue(jmapConfig?.timeout_seconds ?? 20),
|
||||
jmapMaxResponseBytes: stringValue(jmapConfig?.max_response_bytes ?? 5 * 1024 * 1024),
|
||||
jmapMaxBodyValueBytes: stringValue(jmapConfig?.max_body_value_bytes ?? 1024 * 1024),
|
||||
jmapUsername: targetServer?.protocol === "jmap" && targetCredential ? targetUsername : "",
|
||||
jmapToken: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1730,8 +1893,8 @@ function updateProfilePayload(
|
||||
}
|
||||
|
||||
function credentialPayload(draft: ProfileDraft, protocol: MailProfileProtocol, preserveBlankPassword: boolean) {
|
||||
const username = protocol === "smtp" ? draft.smtpUsername : draft.imapUsername;
|
||||
const password = protocol === "smtp" ? draft.smtpPassword : draft.imapPassword;
|
||||
const username = protocol === "smtp" ? draft.smtpUsername : protocol === "imap" ? draft.imapUsername : draft.jmapUsername;
|
||||
const password = protocol === "smtp" ? draft.smtpPassword : protocol === "imap" ? draft.imapPassword : draft.jmapToken;
|
||||
const allowedModules = commaValues(draft.credentialAllowedModules);
|
||||
const allowedServerRefs = commaValues(draft.credentialAllowedServerRefs);
|
||||
const payload: {
|
||||
@@ -1747,7 +1910,7 @@ function credentialPayload(draft: ProfileDraft, protocol: MailProfileProtocol, p
|
||||
} = {
|
||||
name: draft.credentialName.trim(),
|
||||
description: mailTextOrNull(draft.credentialDescription),
|
||||
credential_kind: "username_password",
|
||||
credential_kind: protocol === "jmap" && draft.jmapAuthScheme === "bearer" ? "bearer_token" : "username_password",
|
||||
username: mailTextOrNull(username),
|
||||
inherit_to_lower_scopes: draft.credentialInheritToLowerScopes,
|
||||
allowed_modules: allowedModules.length > 0 ? allowedModules : ["mail"],
|
||||
@@ -1768,13 +1931,13 @@ function profileEditorCanSave(
|
||||
if (editing === "new") return Boolean(draft.name.trim() && draft.smtpHost.trim());
|
||||
if (target.kind === "profile") return Boolean(draft.name.trim());
|
||||
if (target.kind === "server") {
|
||||
const host = target.protocol === "smtp" ? draft.smtpHost : draft.imapHost;
|
||||
const host = target.protocol === "smtp" ? draft.smtpHost : target.protocol === "imap" ? draft.imapHost : draft.jmapSessionUrl;
|
||||
return Boolean(draft.serverName.trim() && host.trim());
|
||||
}
|
||||
if (target.kind === "credentials") {
|
||||
if (!target.serverId) return false;
|
||||
if (!target.credentialId && reuseCredentialId) return true;
|
||||
const password = target.protocol === "smtp" ? draft.smtpPassword : draft.imapPassword;
|
||||
const password = target.protocol === "smtp" ? draft.smtpPassword : target.protocol === "imap" ? draft.imapPassword : draft.jmapToken;
|
||||
return Boolean(draft.credentialName.trim() && (target.credentialId || password));
|
||||
}
|
||||
return false;
|
||||
@@ -1873,11 +2036,23 @@ function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
|
||||
|
||||
function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
|
||||
return mailImapSettingsPayload<MailSecurity>(
|
||||
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout },
|
||||
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout },
|
||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
||||
);
|
||||
}
|
||||
|
||||
function jmapServerPayload(draft: ProfileDraft): MailJmapTransportSettings {
|
||||
return {
|
||||
session_url: draft.jmapSessionUrl.trim(),
|
||||
account_id: mailTextOrNull(draft.jmapAccountId),
|
||||
auth_scheme: draft.jmapAuthScheme,
|
||||
timeout_seconds: boundedInteger(draft.jmapTimeout, 20, 1, 120),
|
||||
max_response_bytes: boundedInteger(draft.jmapMaxResponseBytes, 5 * 1024 * 1024, 64 * 1024, 25 * 1024 * 1024),
|
||||
max_body_value_bytes: boundedInteger(draft.jmapMaxBodyValueBytes, 1024 * 1024, 1024, 5 * 1024 * 1024),
|
||||
allowed_api_origins: commaValues(draft.jmapAllowedApiOrigins)
|
||||
};
|
||||
}
|
||||
|
||||
function profileCredentialsPayload(draft: ProfileDraft, preserveBlankPassword: boolean) {
|
||||
return {
|
||||
smtp: mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword),
|
||||
@@ -2143,8 +2318,9 @@ function mailPolicySourcePath(scopeType: MailProfileScope): string[] {
|
||||
return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.owner_policy.1e8df143", "i18n:govoplan-mail.campaign.69390e16"];
|
||||
}
|
||||
|
||||
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | null | undefined): string {
|
||||
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}`;
|
||||
@@ -2176,6 +2352,20 @@ function commaValues(value: string): string[] {
|
||||
)];
|
||||
}
|
||||
|
||||
function boundedInteger(value: string, fallback: number, minimum: number, maximum: number): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, parsed));
|
||||
}
|
||||
|
||||
function jmapHostname(value: string): string {
|
||||
try {
|
||||
return new URL(value).hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ExternalLink, FilePenLine, Mail, Pencil, X } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
EmailAddressInput,
|
||||
LoadingFrame,
|
||||
quickAccessLaunchState,
|
||||
useDashboardWidgetData,
|
||||
type MailboxAddress,
|
||||
type QuickAccessToolRenderContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
bootstrapMailbox,
|
||||
listMailServerProfiles,
|
||||
lookupMailAddresses,
|
||||
type MailMailboxProtocol,
|
||||
type MailMailboxMessageSummary
|
||||
} from "../../api/mail";
|
||||
import { mailLookupSuggestions, mailtoHref } from "./mailAddressIntegration";
|
||||
import {
|
||||
mailboxDraftsLaunchPath,
|
||||
mailboxMessageLaunchPath
|
||||
} from "./mailboxLaunch";
|
||||
|
||||
|
||||
type MailQuickAccessData = {
|
||||
profileName?: string;
|
||||
profileId?: string;
|
||||
draftsFolder?: string | null;
|
||||
messages: MailMailboxMessageSummary[];
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
type Props = Pick<
|
||||
QuickAccessToolRenderContext,
|
||||
"settings" | "launchContext" | "complete" | "cancel" | "close"
|
||||
>;
|
||||
|
||||
export default function MailQuickAccess({
|
||||
settings,
|
||||
launchContext,
|
||||
complete,
|
||||
cancel,
|
||||
close
|
||||
}: Props) {
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [recipients, setRecipients] = useState<MailboxAddress[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<MailboxAddress[]>([]);
|
||||
const [lookupAvailable, setLookupAvailable] = useState<boolean | null>(null);
|
||||
const [lookupError, setLookupError] = useState("");
|
||||
const lookupRequestRef = useRef(0);
|
||||
const load = useCallback(async (): Promise<MailQuickAccessData> => {
|
||||
const profiles = await listMailServerProfiles(settings);
|
||||
const profile = profiles.find((item) => item.is_active && quickAccessMailboxProtocol(item));
|
||||
if (!profile) return { messages: [], available: false };
|
||||
const protocol = quickAccessMailboxProtocol(profile) ?? "imap";
|
||||
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false, protocol);
|
||||
return {
|
||||
profileName: profile.name,
|
||||
profileId: profile.id,
|
||||
draftsFolder: profile.imap?.folder_mappings?.drafts
|
||||
|| response.folders.detected_folder_mappings?.drafts
|
||||
|| null,
|
||||
messages: response.messages.messages ?? [],
|
||||
available: true
|
||||
};
|
||||
}, [settings]);
|
||||
const { data, loading, error } = useDashboardWidgetData(load, 0);
|
||||
const selectingForCase = launchContext.activeObject?.ownerModule === "cases"
|
||||
&& launchContext.activeObject.kind === "case";
|
||||
|
||||
const lookupRecipients = useCallback(async (query: string) => {
|
||||
const request = ++lookupRequestRef.current;
|
||||
const normalized = query.trim();
|
||||
if (!normalized) {
|
||||
setSuggestions([]);
|
||||
setLookupError("");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await lookupMailAddresses(settings, normalized, 12);
|
||||
if (request !== lookupRequestRef.current) return;
|
||||
setLookupAvailable(response.available);
|
||||
setSuggestions(mailLookupSuggestions(response.candidates));
|
||||
setLookupError("");
|
||||
} catch (lookupFailure) {
|
||||
if (request !== lookupRequestRef.current) return;
|
||||
setSuggestions([]);
|
||||
setLookupError(lookupFailure instanceof Error ? lookupFailure.message : String(lookupFailure));
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_messages.4294022c">
|
||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{selectingForCase ? (
|
||||
<p className="muted small-note">
|
||||
Select an authorized exact message for {launchContext.activeObject?.label}.
|
||||
Mail content remains in Mail and access is checked again when opened.
|
||||
</p>
|
||||
) : null}
|
||||
<DashboardWidgetList
|
||||
emptyText={data?.available ? "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d" : "i18n:govoplan-mail.select_an_imap_profile.5445648c"}
|
||||
items={(data?.messages ?? []).map((message) => ({
|
||||
id: `${message.folder}:${message.uid}`,
|
||||
title: message.subject || "i18n:govoplan-mail.no_subject.49b20da0",
|
||||
detail: message.from_header || data?.profileName,
|
||||
meta: formatMessageDate(message.date),
|
||||
leading: <Mail size={17} aria-hidden="true" />,
|
||||
to: selectingForCase
|
||||
? undefined
|
||||
: mailboxMessageLaunchPath(data!.profileId!, message),
|
||||
state: selectingForCase ? undefined : quickAccessLaunchState(launchContext),
|
||||
onClick: selectingForCase ? () => complete({
|
||||
contractVersion: "1",
|
||||
outcome: "completed",
|
||||
action: "selected",
|
||||
reference: {
|
||||
ownerModule: "mail",
|
||||
kind: "message",
|
||||
objectId: `${data!.profileId!}:${message.folder}:${message.uid}`,
|
||||
tenantId: launchContext.tenantId,
|
||||
label: message.subject || "Mail message",
|
||||
version: message.uid,
|
||||
path: mailboxMessageLaunchPath(data!.profileId!, message)
|
||||
}
|
||||
}) : close
|
||||
}))}
|
||||
/>
|
||||
{!selectingForCase && composing ? (
|
||||
<div className="mail-quick-compose" aria-label="i18n:govoplan-mail.compose">
|
||||
<label>i18n:govoplan-mail.recipients</label>
|
||||
<EmailAddressInput
|
||||
value={recipients}
|
||||
onChange={setRecipients}
|
||||
suggestions={suggestions}
|
||||
onSuggestionQueryChange={(query) => void lookupRecipients(query)}
|
||||
compact
|
||||
interfaceId="mail.quick-access.compose.recipients"
|
||||
helpModuleId="mail"
|
||||
helpTopicId="mail.address-book-integration"
|
||||
/>
|
||||
{lookupAvailable === false ? (
|
||||
<p className="form-help">i18n:govoplan-mail.address_suggestions_unavailable</p>
|
||||
) : null}
|
||||
{lookupError ? <DismissibleAlert tone="warning" resetKey={lookupError}>{lookupError}</DismissibleAlert> : null}
|
||||
<div className="button-row compact-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setComposing(false)}>
|
||||
i18n:govoplan-mail.cancel.77dfd213
|
||||
</button>
|
||||
<a className="btn btn-primary" href={mailtoHref(recipients)} onClick={close}>
|
||||
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.open_mail_application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectingForCase ? (
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Button onClick={() => cancel("user")}>
|
||||
<X size={15} aria-hidden="true" /> Cancel selection
|
||||
</Button>
|
||||
</div>
|
||||
) : <div className="dashboard-contribution-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setComposing((current) => !current)} aria-expanded={composing}>
|
||||
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.compose
|
||||
</button>
|
||||
{data?.profileId && data.draftsFolder ? (
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={mailboxDraftsLaunchPath(data.profileId, data.draftsFolder)}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<FilePenLine size={15} aria-hidden="true" /> i18n:govoplan-mail.drafts.22a31d86
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to="/mail"
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-mail.open_mail
|
||||
</Link>
|
||||
</div>}
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function quickAccessMailboxProtocol(profile: { imap?: unknown; servers?: Array<{ protocol: string; is_active: boolean; is_default?: boolean }> }): MailMailboxProtocol | null {
|
||||
if (profile.servers?.some((server) => server.protocol === "jmap" && server.is_active)) return "jmap";
|
||||
if (profile.imap || profile.servers?.some((server) => server.protocol === "imap" && server.is_active)) return "imap";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
function formatMessageDate(value?: string | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(parsed);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import { ToolbarGroup, ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
CountBadge,
|
||||
DataGridPaginationBar,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
@@ -19,15 +21,22 @@ import {
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
bootstrapMailbox,
|
||||
createMailAddressContact,
|
||||
getMailboxMessage,
|
||||
listMailAddressWriteTargets,
|
||||
listMailboxMessages,
|
||||
listMailServerProfiles,
|
||||
type MailAddressWriteTarget,
|
||||
type MailImapFolderResponse,
|
||||
type MailMailboxMessageDetail,
|
||||
type MailMailboxMessageSummary,
|
||||
type MailMailboxProtocol,
|
||||
type MailServerProfile } from
|
||||
"../../api/mail";
|
||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
||||
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
|
||||
import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch";
|
||||
import { mailboxHeaderAddresses } from "./mailAddressIntegration";
|
||||
|
||||
const MAILBOX_DOCUMENTATION = {
|
||||
topicId: "mail.workflow.read-mailbox",
|
||||
@@ -36,6 +45,7 @@ const MAILBOX_DOCUMENTATION = {
|
||||
|
||||
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||
@@ -44,6 +54,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
||||
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
|
||||
const [messagePage, setMessagePage] = useState(1);
|
||||
const [messagePageSize, setMessagePageSize] = useState(10);
|
||||
const [messageQuery, setMessageQuery] = useState("");
|
||||
@@ -64,30 +75,33 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const messageDetailRequestRef = useRef(0);
|
||||
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
|
||||
const skipNextMessageLoadRef = useRef(false);
|
||||
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
|
||||
|
||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||
const mailboxProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile)), [profiles]);
|
||||
const selectedMailboxProtocol = mailboxProtocolForProfile(selectedProfile) ?? "imap";
|
||||
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
|
||||
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
|
||||
const filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]);
|
||||
const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize));
|
||||
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
|
||||
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
|
||||
const noMailboxProfiles = !loadingProfiles && mailboxProfiles.length === 0;
|
||||
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
|
||||
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
|
||||
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
|
||||
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
|
||||
const syncState = mailboxSyncState(messageProvenance);
|
||||
const folderEmptyText = folderError || (noMailboxProfiles ? "No IMAP- or JMAP-enabled Mail profiles are available." : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
|
||||
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
|
||||
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
|
||||
const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094";
|
||||
const profileReloadBlocker = loadingProfiles ? "Mail profiles are already loading." : "";
|
||||
const folderReloadBlocker = !selectedProfileId
|
||||
? "Select an IMAP-enabled Mail profile before refreshing folders."
|
||||
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing folders."
|
||||
: loadingFolders || loadingMessages
|
||||
? "Wait for the current mailbox refresh to finish."
|
||||
: "";
|
||||
const messageReloadBlocker = !selectedProfileId
|
||||
? "Select an IMAP-enabled Mail profile before refreshing messages."
|
||||
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing messages."
|
||||
: !selectedFolder || !foldersReady
|
||||
? "Select a loaded mailbox folder before refreshing messages."
|
||||
: loadingMessages
|
||||
@@ -95,6 +109,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
: "";
|
||||
|
||||
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
useEffect(() => {
|
||||
const request = parseMailboxLaunch(location.search);
|
||||
launchRequestRef.current = request;
|
||||
if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return;
|
||||
const usable = profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile));
|
||||
const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId)
|
||||
? request.profileId
|
||||
: selectedProfileId || usable[0]?.id || "";
|
||||
if (!targetProfileId) return;
|
||||
if (targetProfileId !== selectedProfileId) {
|
||||
selectProfile(targetProfileId);
|
||||
return;
|
||||
}
|
||||
void loadMailboxBootstrap(targetProfileId);
|
||||
}, [location.search]);
|
||||
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
|
||||
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
|
||||
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
|
||||
@@ -106,6 +135,28 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
}
|
||||
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
|
||||
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
|
||||
useEffect(() => {
|
||||
if (selectedMailboxProtocol !== "jmap" || !selectedProfileId || !selectedFolder || !foldersReady) return;
|
||||
const handle = window.setTimeout(() => {
|
||||
setMessagePage(1);
|
||||
mailboxPageCursorsRef.current = {};
|
||||
void loadMessages(selectedProfileId, selectedFolder, 1, messagePageSize);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [messageQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const request = launchRequestRef.current;
|
||||
if (!request || !foldersReady || loadingMessages) return;
|
||||
const targetProfileMatches = !request.profileId || request.profileId === selectedProfileId;
|
||||
const targetFolder = mailboxLaunchFolder(request, selectedProfile);
|
||||
if (!targetProfileMatches || (targetFolder && targetFolder !== selectedFolder)) return;
|
||||
const target = request.messageUid
|
||||
? messages.find((message) => message.uid === request.messageUid)
|
||||
: null;
|
||||
launchRequestRef.current = null;
|
||||
if (target) void openMessage(target);
|
||||
}, [foldersReady, loadingMessages, messages, selectedFolder, selectedProfile, selectedProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePreviewShortcut = (event: KeyboardEvent) => {
|
||||
@@ -153,14 +204,22 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setError("");
|
||||
try {
|
||||
const loaded = await listMailServerProfiles(settings);
|
||||
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
|
||||
const usable = loaded.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile));
|
||||
const requestedProfileId = parseMailboxLaunch(location.search).profileId;
|
||||
setProfiles(loaded);
|
||||
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
||||
setSelectedProfileId((current) =>
|
||||
requestedProfileId && usable.some((profile) => profile.id === requestedProfileId)
|
||||
? requestedProfileId
|
||||
: current && usable.some((profile) => profile.id === current)
|
||||
? current
|
||||
: usable[0]?.id ?? ""
|
||||
);
|
||||
if (usable.length === 0) {
|
||||
setFolders([]);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
@@ -176,6 +235,16 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
|
||||
if (!profileId) return;
|
||||
const targetProfile = profiles.find((profile) => profile.id === profileId) ?? null;
|
||||
const mailboxProtocol = mailboxProtocolForProfile(targetProfile) ?? "imap";
|
||||
const profileInbox = mailboxProtocol === "imap" ? targetProfile?.imap?.folder_mappings?.inbox || "" : "";
|
||||
const requestedLaunch = launchRequestRef.current;
|
||||
const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId)
|
||||
? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null)
|
||||
: null;
|
||||
const requestedFolder = requestedLaunchFolder || (foldersLoadedForProfile === profileId && selectedFolder
|
||||
? selectedFolder
|
||||
: profileInbox || "INBOX");
|
||||
const folderRequestId = ++folderRequestRef.current;
|
||||
const messageRequestId = ++messageListRequestRef.current;
|
||||
messageDetailRequestRef.current += 1;
|
||||
@@ -183,6 +252,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setLoadingMessages(true);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
@@ -193,7 +263,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setDetailError("");
|
||||
setError("");
|
||||
try {
|
||||
const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
|
||||
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh, mailboxProtocol);
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
|
||||
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
|
||||
@@ -201,10 +271,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const total = response.messages.total_count ?? loadedMessages.length;
|
||||
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
|
||||
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
|
||||
nextFolder = loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
||||
const detectedInbox = response.folders.detected_folder_mappings?.inbox;
|
||||
nextFolder = detectedInbox && loadedFolders.some((folder) => folder.name === detectedInbox)
|
||||
? detectedInbox
|
||||
: loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
||||
}
|
||||
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, "");
|
||||
mailboxPageCursorsRef.current = {
|
||||
[`${cursorKey}:1`]: null,
|
||||
[`${cursorKey}:2`]: response.messages.next_cursor ?? null
|
||||
@@ -216,6 +289,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile(profileId);
|
||||
setMessages(loadedMessages);
|
||||
setMessageTotalCount(total);
|
||||
setMessageProvenance(mailboxProvenance(response.messages));
|
||||
} catch (err) {
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
const message = errorText(err);
|
||||
@@ -227,6 +301,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
selectedMessageKeyRef.current = "";
|
||||
@@ -239,16 +314,28 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) {
|
||||
if (!profileId || !folder) return;
|
||||
const mailboxProtocol = mailboxProtocolForProfile(profiles.find((profile) => profile.id === profileId) ?? null) ?? "imap";
|
||||
const requestId = ++messageListRequestRef.current;
|
||||
const offset = (Math.max(1, page) - 1) * pageSize;
|
||||
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
|
||||
const cursorKey = mailboxCursorKey(profileId, folder, pageSize, mailboxProtocol === "jmap" ? messageQuery : "");
|
||||
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
|
||||
setLoadingMessages(true);
|
||||
setMessageProvenance(null);
|
||||
setMessageError("");
|
||||
setDetailError("");
|
||||
setError("");
|
||||
try {
|
||||
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor, refresh);
|
||||
const response = await listMailboxMessages(
|
||||
settings,
|
||||
profileId,
|
||||
folder,
|
||||
pageSize,
|
||||
offset,
|
||||
cursor,
|
||||
refresh,
|
||||
mailboxProtocol,
|
||||
mailboxProtocol === "jmap" ? messageQuery : null
|
||||
);
|
||||
if (requestId !== messageListRequestRef.current) return;
|
||||
const loaded = response.messages ?? [];
|
||||
const total = response.total_count ?? loaded.length;
|
||||
@@ -256,6 +343,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
|
||||
setMessages(loaded);
|
||||
setMessageTotalCount(total);
|
||||
setMessageProvenance(mailboxProvenance(response));
|
||||
setFolderMessageCount(folder, total);
|
||||
const rememberedKey = selectedMessageKeyRef.current;
|
||||
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
|
||||
@@ -270,6 +358,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setError(message);
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
@@ -291,7 +380,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setLoadingMessage(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
|
||||
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid, selectedMailboxProtocol);
|
||||
if (requestId !== messageDetailRequestRef.current) return;
|
||||
setSelectedMessage(response.message);
|
||||
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
|
||||
@@ -316,6 +405,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
@@ -395,7 +485,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<span className="mailbox-tree-node-label">
|
||||
<span className="mailbox-tree-node-main">
|
||||
<span>{node.label}</span>
|
||||
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
|
||||
{showCount && <CountBadge tone="neutral" size="compact" className="mailbox-folder-count">{node.messageCount}</CountBadge>}
|
||||
</span>
|
||||
{flagText && <small>{flagText}</small>}
|
||||
</span>);
|
||||
@@ -407,16 +497,16 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
||||
<div className="file-list-sticky">
|
||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
||||
<ActionToolbar justify="between" className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
||||
<label className="mailbox-profile-field">
|
||||
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
|
||||
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||
{imapProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_imap_profiles_available.d64589f8</option>}
|
||||
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||
<span>Mailbox profile</span>
|
||||
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || mailboxProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||
{mailboxProfiles.length === 0 && <option value="">No mailbox profiles available</option>}
|
||||
{mailboxProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
|
||||
<div className="mailbox-toolbar-actions">
|
||||
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile ? transportLabel(selectedProfile) : "No mailbox profile selected"}</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">
|
||||
@@ -435,16 +525,16 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
i18n:govoplan-mail.messages.f1702b46
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
</ActionToolbar>
|
||||
|
||||
{noImapProfiles &&
|
||||
{noMailboxProfiles &&
|
||||
<ActionBlockerHint
|
||||
className="mailbox-profile-blocker"
|
||||
reason={{
|
||||
summary: "No IMAP-enabled Mail profile is available.",
|
||||
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP server and credential.",
|
||||
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP-enabled profile.",
|
||||
summary: "No mailbox-enabled Mail profile is available.",
|
||||
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP or JMAP server and credential.",
|
||||
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP- or JMAP-enabled profile.",
|
||||
actor: "Mail profile administrator",
|
||||
target: "Settings or Administration > Mail profiles"
|
||||
}}
|
||||
@@ -468,6 +558,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<span>{messageCountLabel}</span>
|
||||
<span>{selectedFolder}</span>
|
||||
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
|
||||
{syncState &&
|
||||
<span className={`mailbox-sync-provenance is-${syncState}`} aria-live="polite">
|
||||
<Database size={13} aria-hidden="true" />
|
||||
{syncState === "refreshing" ? "i18n:govoplan-mail.cached_index_refreshing.75f18a6c" : syncState === "cached" ? "i18n:govoplan-mail.cached_mailbox_index.16fe75d1" : "i18n:govoplan-mail.live_provider_response.39c46538"}
|
||||
{messageProvenance?.indexedAt && <span> · {formatDateTime(messageProvenance.indexedAt, { fallback: "-" })}</span>}
|
||||
</span>
|
||||
}
|
||||
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
|
||||
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
|
||||
</div>
|
||||
@@ -486,10 +583,11 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
||||
const selected = key === selectedMessageKey;
|
||||
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
||||
const read = isMailboxMessageRead(message.flags);
|
||||
return (
|
||||
<div
|
||||
key={message.uid}
|
||||
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
||||
className={`file-list-row file-row mailbox-message-row ${read ? "is-read" : "is-unread"} ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
||||
role="row"
|
||||
tabIndex={0}
|
||||
onClick={() => void openMessage(message)}
|
||||
@@ -502,7 +600,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
<div className="file-list-name-cell">
|
||||
<div className="file-list-name">
|
||||
<Mail className="file-row-icon" size={20} aria-hidden="true" />
|
||||
{read ? <MailOpen className="file-row-icon" size={20} aria-hidden="true" /> : <Mail className="file-row-icon" size={20} aria-hidden="true" />}
|
||||
<span>
|
||||
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
|
||||
<small>{message.from_header || "-"}</small>
|
||||
@@ -511,6 +609,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
</div>
|
||||
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
|
||||
<span className="file-row-tail mailbox-message-tail">
|
||||
<span className="mailbox-read-state" title={read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}>
|
||||
<span className="visually-hidden">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
||||
<span aria-hidden="true">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
||||
</span>
|
||||
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
|
||||
<span>{formatBytes(message.size_bytes)}</span>
|
||||
</span>
|
||||
@@ -554,6 +656,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
}))}
|
||||
emptyText={previewEmptyText} />
|
||||
|
||||
<MailboxContactActions settings={settings} message={selectedMessage} />
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -567,13 +671,161 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
}
|
||||
|
||||
function MailboxContactActions({
|
||||
settings,
|
||||
message
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
message: MailMailboxMessageDetail | null;
|
||||
}) {
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [targets, setTargets] = useState<MailAddressWriteTarget[]>([]);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState("");
|
||||
const [creatingEmail, setCreatingEmail] = useState("");
|
||||
const [addedEmails, setAddedEmails] = useState<Set<string>>(() => new Set());
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoaded(false);
|
||||
void listMailAddressWriteTargets(settings)
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const writable = response.targets.filter((target) => target.allowed);
|
||||
setAvailable(response.available);
|
||||
setTargets(response.targets);
|
||||
setSelectedTargetId((current) => writable.some((target) => target.address_book_id === current)
|
||||
? current
|
||||
: writable[0]?.address_book_id || "");
|
||||
setError("");
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!active) return;
|
||||
setAvailable(false);
|
||||
setTargets([]);
|
||||
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoaded(true);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
setAddedEmails(new Set());
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}, [message?.folder, message?.uid]);
|
||||
|
||||
if (!message || !loaded || !available) return null;
|
||||
|
||||
const writableTargets = targets.filter((target) => target.allowed);
|
||||
const blockedTargets = targets.filter((target) => !target.allowed);
|
||||
const addresses = uniqueMailboxAddresses([
|
||||
...mailboxHeaderAddresses(message.from_header),
|
||||
...mailboxHeaderAddresses(message.to_header),
|
||||
...mailboxHeaderAddresses(message.cc_header)
|
||||
]);
|
||||
|
||||
async function addContact(address: { name?: string | null; email: string }) {
|
||||
if (!selectedTargetId || creatingEmail) return;
|
||||
setCreatingEmail(address.email);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const result = await createMailAddressContact(settings, {
|
||||
address_book_id: selectedTargetId,
|
||||
display_name: address.name || address.email,
|
||||
email: address.email
|
||||
});
|
||||
setAddedEmails((current) => new Set(current).add(address.email));
|
||||
setSuccess(i18nMessage("i18n:govoplan-mail.contact_added", { value0: result.display_name }));
|
||||
} catch (createError) {
|
||||
setError(createError instanceof Error ? createError.message : String(createError));
|
||||
} finally {
|
||||
setCreatingEmail("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mailbox-contact-actions" aria-label="i18n:govoplan-mail.address_book_actions">
|
||||
<h4>i18n:govoplan-mail.address_book_actions</h4>
|
||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
|
||||
{writableTargets.length > 0 ? (
|
||||
<label className="mailbox-contact-target">
|
||||
<span>i18n:govoplan-mail.save_contacts_to</span>
|
||||
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
{writableTargets.map((target) => (
|
||||
<option key={target.address_book_id} value={target.address_book_id}>
|
||||
{target.address_book_label || target.address_book_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<p className="form-help">i18n:govoplan-mail.no_writable_address_book</p>
|
||||
)}
|
||||
<div className="mailbox-contact-candidates">
|
||||
{addresses.map((address) => {
|
||||
const added = addedEmails.has(address.email);
|
||||
return (
|
||||
<Button
|
||||
key={address.email}
|
||||
className="compact"
|
||||
disabled={!selectedTargetId || Boolean(creatingEmail) || added}
|
||||
disabledReason={!selectedTargetId ? blockedTargets[0]?.message || "i18n:govoplan-mail.no_writable_address_book" : undefined}
|
||||
onClick={() => void addContact(address)}
|
||||
>
|
||||
{added ? <Check size={15} aria-hidden="true" /> : <UserPlus size={15} aria-hidden="true" />}
|
||||
{added ? "i18n:govoplan-mail.contact_added_short" : i18nMessage("i18n:govoplan-mail.add_value_to_contacts", { value0: address.name || address.email })}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{blockedTargets.length > 0 ? (
|
||||
<details className="mailbox-contact-policy">
|
||||
<summary>i18n:govoplan-mail.unavailable_address_books</summary>
|
||||
<ul>
|
||||
{blockedTargets.map((target) => (
|
||||
<li key={target.address_book_id}>
|
||||
<strong>{target.address_book_label || target.address_book_id}</strong>: {target.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueMailboxAddresses<T extends { email: string }>(addresses: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return addresses.filter((address) => {
|
||||
const email = address.email.toLocaleLowerCase();
|
||||
if (seen.has(email)) return false;
|
||||
seen.add(email);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function mailboxMessageKey(folder: string, uid: string): string {
|
||||
return `${folder || "INBOX"}::${uid}`;
|
||||
}
|
||||
|
||||
function mailboxCursorKey(profileId: string, folder: string, pageSize: number): string {
|
||||
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
|
||||
function mailboxCursorKey(profileId: string, folder: string, pageSize: number, query: string): string {
|
||||
return `${profileId}::${folder || "INBOX"}::${pageSize}::${query.trim()}`;
|
||||
}
|
||||
|
||||
function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance {
|
||||
return {
|
||||
fromCache: response.from_cache === true,
|
||||
refreshing: response.refreshing === true,
|
||||
indexedAt: response.indexed_at ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
|
||||
@@ -627,11 +879,29 @@ function displayFolderFlag(flag: string): string | null {
|
||||
}
|
||||
|
||||
function transportLabel(profile: MailServerProfile): string {
|
||||
const jmap = preferredJmapServer(profile);
|
||||
if (jmap) {
|
||||
const sessionUrl = "session_url" in jmap.config ? jmap.config.session_url : null;
|
||||
return `JMAP · ${String(sessionUrl || "configured endpoint")}`;
|
||||
}
|
||||
const imap = profile.imap;
|
||||
if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3";
|
||||
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
|
||||
}
|
||||
|
||||
function preferredJmapServer(profile: MailServerProfile | null): MailServerProfile["servers"][number] | null {
|
||||
if (!profile) return null;
|
||||
const servers = (profile.servers ?? []).filter((server) => server.protocol === "jmap" && server.is_active);
|
||||
return servers.find((server) => server.is_default) ?? servers[0] ?? null;
|
||||
}
|
||||
|
||||
function mailboxProtocolForProfile(profile: MailServerProfile | null): MailMailboxProtocol | null {
|
||||
if (!profile) return null;
|
||||
if (preferredJmapServer(profile)) return "jmap";
|
||||
if (profile.imap || (profile.servers ?? []).some((server) => server.protocol === "imap" && server.is_active)) return "imap";
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
if (!value) return "-";
|
||||
if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
type MailAddressLookupCandidateLike = {
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export type MailAddressValue = {
|
||||
name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const EMAIL_PATTERN = /([^<>;,\s]+@[^<>;,\s]+)/g;
|
||||
|
||||
export function mailLookupSuggestions(candidates: readonly MailAddressLookupCandidateLike[]): MailAddressValue[] {
|
||||
const seen = new Set<string>();
|
||||
const suggestions: MailAddressValue[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const email = String(candidate.email ?? "").trim().toLocaleLowerCase();
|
||||
if (!email || seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
suggestions.push({ name: candidate.display_name || email, email });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export function mailboxHeaderAddresses(value?: string | null): MailAddressValue[] {
|
||||
const input = String(value ?? "").trim();
|
||||
if (!input) return [];
|
||||
const results: MailAddressValue[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of input.matchAll(EMAIL_PATTERN)) {
|
||||
const email = match[1]?.replace(/[)>]+$/, "").toLocaleLowerCase();
|
||||
if (!email || seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
const prefix = input.slice(Math.max(0, input.lastIndexOf(",", match.index) + 1), match.index).trim();
|
||||
const name = prefix.replace(/[<"']/g, "").trim() || undefined;
|
||||
results.push({ name, email });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function mailtoHref(recipients: readonly MailAddressValue[]): string {
|
||||
const addresses = recipients.map((recipient) => recipient.email.trim()).filter(Boolean);
|
||||
return `mailto:${addresses.map(encodeURIComponent).join(",")}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "jmap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
|
||||
|
||||
export type MailProfilePolicy = {
|
||||
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
|
||||
@@ -8,6 +8,7 @@ export type MailProfilePolicy = {
|
||||
export type MailPolicyValidationInput = {
|
||||
smtpHost?: string | null;
|
||||
imapHost?: string | null;
|
||||
jmapHost?: string | null;
|
||||
envelopeSender?: string | null;
|
||||
fromHeader?: string | null;
|
||||
recipientDomains?: Array<string | null | undefined> | null;
|
||||
@@ -24,6 +25,7 @@ export type MailPolicyValidationMessage = {
|
||||
const patternLabels: Record<MailProfilePatternKey, string> = {
|
||||
smtp_hosts: "i18n:govoplan-mail.smtp_host.2d4a434b",
|
||||
imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751",
|
||||
jmap_hosts: "JMAP host",
|
||||
envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0",
|
||||
from_headers: "i18n:govoplan-mail.from_header.bb78e85d",
|
||||
recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf"
|
||||
@@ -43,6 +45,7 @@ input: MailPolicyValidationInput)
|
||||
const checks: ValueCheck[] = [
|
||||
{ key: "smtp_hosts", value: input.smtpHost ?? "" },
|
||||
{ key: "imap_hosts", value: input.imapHost ?? "" },
|
||||
{ key: "jmap_hosts", value: input.jmapHost ?? "" },
|
||||
{ key: "envelope_senders", value: input.envelopeSender ?? "" },
|
||||
{ key: "from_headers", value: input.fromHeader ?? "" },
|
||||
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type MailProfileProtocol = "smtp" | "imap";
|
||||
export type MailProfileEditSection = MailProfileProtocol;
|
||||
export type MailProfileProtocol = "smtp" | "imap" | "jmap";
|
||||
export type MailProfileEditSection = "smtp" | "imap";
|
||||
export type MailProfilePanelMode = "all" | "server" | "credentials";
|
||||
export type MailProfileCreateStage =
|
||||
| "profile"
|
||||
@@ -54,9 +54,11 @@ export type MailProfileTargetedUpdateParts = {
|
||||
profile: Record<string, unknown>;
|
||||
smtp: Record<string, unknown>;
|
||||
imap: Record<string, unknown> | null;
|
||||
jmap?: Record<string, unknown> | null;
|
||||
credentials: {
|
||||
smtp: MailProfileTransportCredentialsLike;
|
||||
imap: MailProfileTransportCredentialsLike;
|
||||
jmap?: MailProfileTransportCredentialsLike;
|
||||
};
|
||||
clearImap: boolean;
|
||||
};
|
||||
@@ -72,7 +74,7 @@ export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike)
|
||||
}
|
||||
|
||||
export function mailProfileEditTargetInitialSection(target: MailProfileEditTarget): MailProfileEditSection {
|
||||
if (target.kind === "server" || target.kind === "credentials") return target.protocol;
|
||||
if (target.kind === "server" || target.kind === "credentials") return target.protocol === "jmap" ? "imap" : target.protocol;
|
||||
return "smtp";
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ export function mailProfileEditTargetPanelMode(target: MailProfileEditTarget): M
|
||||
}
|
||||
|
||||
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] {
|
||||
if (target.kind === "server" || target.kind === "credentials") return [target.protocol];
|
||||
if (target.kind === "server" || target.kind === "credentials") return target.protocol === "jmap" ? [] : [target.protocol];
|
||||
if (target.kind === "create") return ["smtp", "imap"];
|
||||
return [];
|
||||
}
|
||||
@@ -94,7 +96,7 @@ export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditT
|
||||
}
|
||||
|
||||
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
|
||||
return target.kind !== "profile";
|
||||
return target.kind !== "profile" && !((target.kind === "server" || target.kind === "credentials") && target.protocol === "jmap");
|
||||
}
|
||||
|
||||
export function mailProfileCreateStagePanel(
|
||||
@@ -152,12 +154,16 @@ export function mailProfileTargetedUpdatePayload(
|
||||
if (target.kind === "profile") return parts.profile;
|
||||
if (target.kind === "server") {
|
||||
if (target.protocol === "smtp") return { smtp: parts.smtp };
|
||||
if (target.protocol === "jmap") return { jmap: parts.jmap ?? null };
|
||||
return parts.imap === null
|
||||
? { imap: null, clear_imap: parts.clearImap }
|
||||
: { imap: parts.imap };
|
||||
}
|
||||
if (target.kind === "credentials") {
|
||||
return { credentials: { [target.protocol]: parts.credentials[target.protocol] } };
|
||||
const credentials = target.protocol === "jmap"
|
||||
? parts.credentials.jmap ?? {}
|
||||
: parts.credentials[target.protocol];
|
||||
return { credentials: { [target.protocol]: credentials } };
|
||||
}
|
||||
throw new Error("Create is not an update target");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export type MailboxSyncProvenance = {
|
||||
fromCache: boolean;
|
||||
refreshing: boolean;
|
||||
indexedAt: string | null;
|
||||
};
|
||||
|
||||
export type MailboxSyncState = "live" | "cached" | "refreshing";
|
||||
|
||||
export function isMailboxMessageRead(flags: readonly string[] | null | undefined): boolean {
|
||||
return (flags ?? []).some((flag) => flag.trim().replace(/^\\+/, "").toLocaleLowerCase() === "seen");
|
||||
}
|
||||
|
||||
export function mailboxSyncState(provenance: MailboxSyncProvenance | null): MailboxSyncState | null {
|
||||
if (!provenance) return null;
|
||||
if (provenance.refreshing) return "refreshing";
|
||||
return provenance.fromCache ? "cached" : "live";
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
type MessageReference = { folder: string; uid: string };
|
||||
type ProfileFolderMapping = {
|
||||
imap?: { folder_mappings?: { drafts?: string | null } | null } | null;
|
||||
};
|
||||
|
||||
export type MailboxLaunch = {
|
||||
profileId: string | null;
|
||||
folder: string | null;
|
||||
folderRole: "drafts" | null;
|
||||
messageUid: string | null;
|
||||
};
|
||||
|
||||
export function parseMailboxLaunch(search: string): MailboxLaunch {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
profileId: boundedValue(params.get("profile")),
|
||||
folder: boundedValue(params.get("folder")),
|
||||
folderRole: params.get("folderRole") === "drafts" ? "drafts" : null,
|
||||
messageUid: boundedValue(params.get("message"))
|
||||
};
|
||||
}
|
||||
|
||||
export function mailboxMessageLaunchPath(
|
||||
profileId: string,
|
||||
message: MessageReference
|
||||
): string {
|
||||
return mailPath({
|
||||
profile: profileId,
|
||||
folder: message.folder,
|
||||
message: message.uid
|
||||
});
|
||||
}
|
||||
|
||||
export function mailboxDraftsLaunchPath(profileId: string, folder?: string | null): string {
|
||||
return mailPath(folder
|
||||
? { profile: profileId, folder }
|
||||
: { profile: profileId, folderRole: "drafts" });
|
||||
}
|
||||
|
||||
export function mailboxLaunchFolder(
|
||||
launch: MailboxLaunch,
|
||||
profile: ProfileFolderMapping | null
|
||||
): string | null {
|
||||
if (launch.folder) return launch.folder;
|
||||
if (launch.folderRole === "drafts") {
|
||||
return boundedValue(profile?.imap?.folder_mappings?.drafts ?? null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mailPath(values: Record<string, string>): string {
|
||||
const params = new URLSearchParams(values);
|
||||
return `/mail?${params.toString()}`;
|
||||
}
|
||||
|
||||
function boundedValue(value: string | null): string | null {
|
||||
const clean = value?.trim() ?? "";
|
||||
return clean && clean.length <= 500 ? clean : null;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
||||
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
||||
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
||||
"i18n:govoplan-mail.cached_index_refreshing.75f18a6c": "Cached index refreshing",
|
||||
"i18n:govoplan-mail.cached_mailbox_index.16fe75d1": "Cached mailbox index",
|
||||
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "profiles scoped to campaigns",
|
||||
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles",
|
||||
"i18n:govoplan-mail.campaigns": "Campaigns",
|
||||
@@ -85,6 +87,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
|
||||
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
|
||||
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
|
||||
"i18n:govoplan-mail.live_provider_response.39c46538": "Live provider response",
|
||||
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
|
||||
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
|
||||
"i18n:govoplan-mail.local.dc99d54d": "Local",
|
||||
@@ -95,6 +98,19 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
|
||||
"i18n:govoplan-mail.mail.92379cbb": "Mail",
|
||||
"i18n:govoplan-mail.compose": "Compose",
|
||||
"i18n:govoplan-mail.recipients": "Recipients",
|
||||
"i18n:govoplan-mail.address_suggestions_unavailable": "Address-book suggestions are unavailable. You can still enter an email address manually.",
|
||||
"i18n:govoplan-mail.open_mail_application": "Open mail application",
|
||||
"i18n:govoplan-mail.address_book_actions": "Address-book actions",
|
||||
"i18n:govoplan-mail.save_contacts_to": "Save contacts to",
|
||||
"i18n:govoplan-mail.no_writable_address_book": "No writable address book is available for your account.",
|
||||
"i18n:govoplan-mail.contact_added": "{value0} was added to contacts.",
|
||||
"i18n:govoplan-mail.contact_added_short": "Added",
|
||||
"i18n:govoplan-mail.add_value_to_contacts": "Add {value0} to contacts",
|
||||
"i18n:govoplan-mail.unavailable_address_books": "Unavailable address books and policy reasons",
|
||||
"i18n:govoplan-mail.open_mail": "Open Mail",
|
||||
"i18n:govoplan-mail.quick_access_description": "Recent mailbox messages and mail actions.",
|
||||
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
||||
@@ -139,6 +155,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
|
||||
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
|
||||
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
|
||||
"i18n:govoplan-mail.read.80ca1564": "Read",
|
||||
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
|
||||
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
|
||||
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
|
||||
@@ -180,6 +197,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.to.ae79ea1e": "To",
|
||||
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
|
||||
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
|
||||
"i18n:govoplan-mail.unread.66c78634": "Unread",
|
||||
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
|
||||
"i18n:govoplan-mail.user.9f8a2389": "User",
|
||||
"i18n:govoplan-mail.users": "Users",
|
||||
@@ -214,6 +232,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
||||
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
||||
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
||||
"i18n:govoplan-mail.cached_index_refreshing.75f18a6c": "Zwischengespeicherter Index wird aktualisiert",
|
||||
"i18n:govoplan-mail.cached_mailbox_index.16fe75d1": "Zwischengespeicherter Postfachindex",
|
||||
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "kampagnenbezogene Profile",
|
||||
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Kampagnenbezogene Profile",
|
||||
"i18n:govoplan-mail.campaigns": "Kampagnen",
|
||||
@@ -282,6 +302,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
|
||||
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
|
||||
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
|
||||
"i18n:govoplan-mail.live_provider_response.39c46538": "Aktuelle Provider-Antwort",
|
||||
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
|
||||
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
|
||||
"i18n:govoplan-mail.local.dc99d54d": "Local",
|
||||
@@ -292,6 +313,19 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
|
||||
"i18n:govoplan-mail.mail.92379cbb": "Mail",
|
||||
"i18n:govoplan-mail.compose": "Verfassen",
|
||||
"i18n:govoplan-mail.recipients": "Empfänger",
|
||||
"i18n:govoplan-mail.address_suggestions_unavailable": "Adressbuchvorschläge sind nicht verfügbar. Eine E-Mail-Adresse kann weiterhin manuell eingegeben werden.",
|
||||
"i18n:govoplan-mail.open_mail_application": "Mail-Anwendung öffnen",
|
||||
"i18n:govoplan-mail.address_book_actions": "Adressbuchaktionen",
|
||||
"i18n:govoplan-mail.save_contacts_to": "Kontakte speichern in",
|
||||
"i18n:govoplan-mail.no_writable_address_book": "Für dieses Konto ist kein beschreibbares Adressbuch verfügbar.",
|
||||
"i18n:govoplan-mail.contact_added": "{value0} wurde zu den Kontakten hinzugefügt.",
|
||||
"i18n:govoplan-mail.contact_added_short": "Hinzugefügt",
|
||||
"i18n:govoplan-mail.add_value_to_contacts": "{value0} zu Kontakten hinzufügen",
|
||||
"i18n:govoplan-mail.unavailable_address_books": "Nicht verfügbare Adressbücher und Richtliniengründe",
|
||||
"i18n:govoplan-mail.open_mail": "Mail öffnen",
|
||||
"i18n:govoplan-mail.quick_access_description": "Aktuelle Posteingangsnachrichten und Mail-Aktionen.",
|
||||
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
||||
@@ -336,6 +370,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
|
||||
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
|
||||
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
|
||||
"i18n:govoplan-mail.read.80ca1564": "Gelesen",
|
||||
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
|
||||
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
|
||||
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
|
||||
@@ -377,6 +412,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.to.ae79ea1e": "To",
|
||||
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
|
||||
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
|
||||
"i18n:govoplan-mail.unread.66c78634": "Ungelesen",
|
||||
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
|
||||
"i18n:govoplan-mail.user.9f8a2389": "User",
|
||||
"i18n:govoplan-mail.users": "Benutzer",
|
||||
|
||||
+22
-5
@@ -1,19 +1,30 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule, QuickAccessToolsUiCapability } from "@govoplan/core-webui";
|
||||
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import MailQuickAccess from "./features/mail/MailQuickAccess";
|
||||
import "./styles/mail-profiles.css";
|
||||
|
||||
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||
const MailBouncePage = lazy(() => import("./features/mail/MailBouncePage"));
|
||||
const MailLegacyImportPage = lazy(() => import("./features/mail/MailLegacyImportPage"));
|
||||
const mailboxRead = ["mail:mailbox:read"];
|
||||
const bounceRead = ["mail:bounce:read", "mail:bounce:manage"];
|
||||
const legacyImportAccess = ["mail:pop3:import", "mail:pop3:manage"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
const mailQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||
tools: [
|
||||
{
|
||||
id: "mail.messages",
|
||||
render: (context) => createElement(MailQuickAccess, context)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const mailModule: PlatformWebModule = {
|
||||
id: "mail",
|
||||
@@ -27,16 +38,22 @@ export const mailModule: PlatformWebModule = {
|
||||
{ id: "mail.admin.tenant-servers", moduleId: "mail", kind: "section", label: "Tenant mail servers", order: 60 },
|
||||
{ id: "mail.admin.group-servers", moduleId: "mail", kind: "section", label: "Group mail servers", order: 20 },
|
||||
{ id: "mail.admin.user-servers", moduleId: "mail", kind: "section", label: "User mail servers", order: 20 },
|
||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 }
|
||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 },
|
||||
{ id: "mail.quick_access.messages", moduleId: "mail", kind: "quick_access", label: "Mail Quick Access", order: 80 }
|
||||
],
|
||||
navItems: [
|
||||
{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 },
|
||||
{ to: "/mail/legacy-import", label: "Legacy POP3 import", iconName: "mail", anyOf: legacyImportAccess, order: 52 }
|
||||
],
|
||||
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||
routes: [
|
||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings, auth }) => createElement(MailboxPage, { settings, auth }) },
|
||||
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) }],
|
||||
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) },
|
||||
{ path: "/mail/legacy-import", anyOf: legacyImportAccess, order: 52, render: ({ settings, auth }) => createElement(MailLegacyImportPage, { settings, auth }) }],
|
||||
|
||||
uiCapabilities: {
|
||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
|
||||
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors
|
||||
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors,
|
||||
"quickAccess.tools": mailQuickAccessTools
|
||||
},
|
||||
runtimeUiCapabilities: {
|
||||
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
||||
|
||||
@@ -186,14 +186,6 @@
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mailbox-toolbar,
|
||||
.mailbox-message-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mailbox-toolbar label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
@@ -385,7 +377,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1280px) {
|
||||
.mailbox-layout {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
@@ -396,8 +388,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.mailbox-toolbar,
|
||||
.mailbox-message-toolbar {
|
||||
.mailbox-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -458,13 +449,6 @@
|
||||
|
||||
.mailbox-folder-count {
|
||||
flex: 0 0 auto;
|
||||
min-width: 20px;
|
||||
padding: 1px 6px;
|
||||
border: var(--border-line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mailbox-toolbar.file-manager-toolbar {
|
||||
@@ -576,6 +560,14 @@
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-read .file-list-name strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-unread .file-list-name strong {
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.mailbox-message-row .file-list-name strong {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -599,6 +591,27 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mailbox-read-state,
|
||||
.mailbox-sync-provenance {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-unread .mailbox-read-state {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.mailbox-sync-provenance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mailbox-sync-provenance.is-refreshing {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.mailbox-preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -614,7 +627,57 @@
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.mail-quick-compose {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.mail-quick-compose > label,
|
||||
.mailbox-contact-target > span {
|
||||
color: var(--text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mailbox-contact-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
|
||||
.mailbox-contact-actions h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mailbox-contact-target {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mailbox-contact-candidates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mailbox-contact-policy {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mailbox-contact-policy ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.mailbox-shell.file-manager-shell {
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
mailboxHeaderAddresses,
|
||||
mailLookupSuggestions,
|
||||
mailtoHref
|
||||
} from "../src/features/mail/mailAddressIntegration";
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
|
||||
assertDeepEqual(
|
||||
mailLookupSuggestions([
|
||||
{
|
||||
display_name: "Ada Lovelace",
|
||||
email: "Ada@Example.Test"
|
||||
},
|
||||
{
|
||||
display_name: "Duplicate",
|
||||
email: "ada@example.test"
|
||||
},
|
||||
{
|
||||
display_name: "No email",
|
||||
email: null
|
||||
}
|
||||
]),
|
||||
[{ name: "Ada Lovelace", email: "ada@example.test" }]
|
||||
);
|
||||
|
||||
assertDeepEqual(
|
||||
mailboxHeaderAddresses('Ada Lovelace <ada@example.test>, "Grace Hopper" <grace@example.test>'),
|
||||
[
|
||||
{ name: "Ada Lovelace", email: "ada@example.test" },
|
||||
{ name: "Grace Hopper", email: "grace@example.test" }
|
||||
]
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
mailtoHref([
|
||||
{ name: "Ada Lovelace", email: "ada@example.test" },
|
||||
{ email: "grace@example.test" }
|
||||
]),
|
||||
"mailto:ada%40example.test,grace%40example.test"
|
||||
);
|
||||
|
||||
console.log("mail address integration tests passed");
|
||||
@@ -21,11 +21,13 @@ assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp12.example.org"), f
|
||||
const policy = {
|
||||
whitelist: {
|
||||
smtp_hosts: ["smtp.allowed.test"],
|
||||
jmap_hosts: ["jmap.allowed.test"],
|
||||
from_headers: ["*@allowed.test"],
|
||||
recipient_domains: ["allowed.test"]
|
||||
},
|
||||
blacklist: {
|
||||
smtp_hosts: ["smtp.blocked.test"],
|
||||
jmap_hosts: ["jmap.blocked.test"],
|
||||
envelope_senders: ["blocked@*"]
|
||||
}
|
||||
};
|
||||
@@ -33,14 +35,17 @@ const policy = {
|
||||
assertDeepEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.allowed.test"), { allowed: true, value: "smtp.allowed.test" });
|
||||
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.blocked.test").allowed, false, "blacklist wins for SMTP host");
|
||||
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.other.test").allowed, false, "whitelist blocks unknown SMTP host");
|
||||
assertEqual(mailPolicyValueAllowed(policy, "jmap_hosts", "jmap.blocked.test").allowed, false, "JMAP hostname deny policy is independent");
|
||||
|
||||
const messages = validateMailPolicy(policy, {
|
||||
smtpHost: "smtp.other.test",
|
||||
jmapHost: "jmap.blocked.test",
|
||||
envelopeSender: "blocked@allowed.test",
|
||||
fromHeader: "sender@other.test",
|
||||
recipientDomains: ["allowed.test", "denied.test", "user@denied.test"]
|
||||
});
|
||||
assert(messages.some((item) => item.key === "smtp_hosts" && item.value === "smtp.other.test"));
|
||||
assert(messages.some((item) => item.key === "jmap_hosts" && item.value === "jmap.blocked.test"));
|
||||
assert(messages.some((item) => item.key === "envelope_senders" && item.value === "blocked@allowed.test"));
|
||||
assert(messages.some((item) => item.key === "from_headers" && item.value === "sender@other.test"));
|
||||
assertEqual(messages.filter((item) => item.key === "recipient_domains" && item.value === "denied.test").length, 1, "recipient domains are normalized and de-duplicated");
|
||||
|
||||
@@ -47,6 +47,9 @@ assertEqual(mailProfileEditTargetPanelMode({ kind: "credentials", protocol: "ima
|
||||
assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
|
||||
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "create" }), ["smtp", "imap"]);
|
||||
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["imap"]);
|
||||
assertEqual(mailProfileEditTargetInitialSection({ kind: "server", protocol: "jmap" }), "imap");
|
||||
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "server", protocol: "jmap" }), []);
|
||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "credentials", protocol: "jmap" }), false);
|
||||
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
|
||||
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
|
||||
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
|
||||
@@ -118,9 +121,11 @@ const updateParts = {
|
||||
profile: { name: "Renamed" },
|
||||
smtp: { host: "smtp.example.org" },
|
||||
imap: { host: "imap.example.org" },
|
||||
jmap: { session_url: "https://jmap.example.org/.well-known/jmap" },
|
||||
credentials: {
|
||||
smtp: { username: "smtp-user", password: "smtp-secret" },
|
||||
imap: { username: "imap-user", password: "imap-secret" }
|
||||
imap: { username: "imap-user", password: "imap-secret" },
|
||||
jmap: { password: "jmap-token" }
|
||||
},
|
||||
clearImap: false
|
||||
};
|
||||
@@ -139,6 +144,16 @@ assertDeepEqual(
|
||||
{ credentials: { imap: { username: "imap-user", password: "imap-secret" } } },
|
||||
"credential edits send only the selected protocol"
|
||||
);
|
||||
assertDeepEqual(
|
||||
mailProfileTargetedUpdatePayload({ kind: "server", protocol: "jmap" }, updateParts),
|
||||
{ jmap: { session_url: "https://jmap.example.org/.well-known/jmap" } },
|
||||
"JMAP server edits remain isolated from legacy profile transports"
|
||||
);
|
||||
assertDeepEqual(
|
||||
mailProfileTargetedUpdatePayload({ kind: "credentials", protocol: "jmap" }, updateParts),
|
||||
{ credentials: { jmap: { password: "jmap-token" } } },
|
||||
"JMAP credential edits retain only the selected credential"
|
||||
);
|
||||
assertEqual(
|
||||
mailProfileCreateCredentialsPayload({ username: null }, { password: "" }),
|
||||
undefined,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
import { isMailboxMessageRead, mailboxSyncState } from "../src/features/mail/mailboxDisplay";
|
||||
|
||||
assert(isMailboxMessageRead(["\\Seen"]), "standard IMAP Seen flag marks a message as read");
|
||||
assert(isMailboxMessageRead(["answered", "SEEN"]), "flag matching is case-insensitive and accepts normalized flags");
|
||||
assert(!isMailboxMessageRead(["\\Answered", "\\Flagged"]), "messages without Seen remain unread");
|
||||
assert(!isMailboxMessageRead(undefined), "missing flags fail safely to unread");
|
||||
|
||||
assertEqual(mailboxSyncState({ fromCache: false, refreshing: false, indexedAt: null }), "live");
|
||||
assertEqual(mailboxSyncState({ fromCache: true, refreshing: false, indexedAt: "2026-08-19T09:00:00Z" }), "cached");
|
||||
assertEqual(mailboxSyncState({ fromCache: true, refreshing: true, indexedAt: "2026-08-19T09:00:00Z" }), "refreshing");
|
||||
assertEqual(mailboxSyncState(null), null);
|
||||
@@ -0,0 +1,46 @@
|
||||
function assertEqual(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
import {
|
||||
mailboxDraftsLaunchPath,
|
||||
mailboxLaunchFolder,
|
||||
mailboxMessageLaunchPath,
|
||||
parseMailboxLaunch
|
||||
} from "../src/features/mail/mailboxLaunch";
|
||||
|
||||
assertDeepEqual(
|
||||
parseMailboxLaunch("?profile=profile-1&folder=INBOX%2FTeam&message=42"),
|
||||
{
|
||||
profileId: "profile-1",
|
||||
folder: "INBOX/Team",
|
||||
folderRole: null,
|
||||
messageUid: "42"
|
||||
}
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
mailboxMessageLaunchPath("profile 1", { folder: "INBOX/Team", uid: "42" }),
|
||||
"/mail?profile=profile+1&folder=INBOX%2FTeam&message=42"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxDraftsLaunchPath("profile-1"),
|
||||
"/mail?profile=profile-1&folderRole=drafts"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxDraftsLaunchPath("profile-1", "Entwürfe"),
|
||||
"/mail?profile=profile-1&folder=Entw%C3%BCrfe"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxLaunchFolder(parseMailboxLaunch("?folderRole=drafts"), {
|
||||
imap: { folder_mappings: { drafts: "Entwürfe" } }
|
||||
}),
|
||||
"Entwürfe"
|
||||
);
|
||||
|
||||
console.log("mailbox launch tests passed");
|
||||
@@ -17,11 +17,17 @@
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/mailbox-display.test.ts",
|
||||
"tests/mailbox-folders.test.ts",
|
||||
"tests/mailbox-launch.test.ts",
|
||||
"tests/mail-profile-editor-model.test.ts",
|
||||
"tests/mail-policy-validation.test.ts",
|
||||
"tests/mail-address-integration.test.ts",
|
||||
"src/features/mail/mailboxDisplay.ts",
|
||||
"src/features/mail/mailboxFolders.ts",
|
||||
"src/features/mail/mailboxLaunch.ts",
|
||||
"src/features/mail/mailProfileEditorModel.ts",
|
||||
"src/features/mail/mailPolicyValidation.ts"
|
||||
"src/features/mail/mailPolicyValidation.ts",
|
||||
"src/features/mail/mailAddressIntegration.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user