Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecc1283de7 | ||
|
|
48205e6e46 | ||
|
|
c31f9b69cb | ||
|
|
1cbf4acaf4 | ||
|
|
fd808336bc | ||
|
|
218fef11f1 | ||
|
|
93ecedf607 |
@@ -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,17 +12,18 @@ 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, and bounce metadata. SMTP/IMAP
|
||||
configuration and credentials, encrypted messages and envelopes, folder/UID
|
||||
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
|
||||
@@ -30,7 +31,7 @@ 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
|
||||
@@ -86,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.
|
||||
@@ -143,10 +144,16 @@ Development mailbox routes are registered by the mail module only when the
|
||||
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
|
||||
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.
|
||||
|
||||
|
||||
+109
-33
@@ -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.
|
||||
|
||||
@@ -65,6 +66,10 @@ 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,
|
||||
@@ -89,9 +94,10 @@ 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
|
||||
@@ -108,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
|
||||
@@ -128,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
|
||||
@@ -153,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,
|
||||
@@ -166,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,
|
||||
@@ -183,9 +194,13 @@ 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. The list exposes the provider's `Seen` flag as a
|
||||
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
|
||||
@@ -199,9 +214,62 @@ 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
|
||||
@@ -214,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
|
||||
@@ -237,21 +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, standard folder
|
||||
2. Configure SMTP and optional IMAP, or add an optional JMAP Session endpoint after creating the profile. Set TLS mode, account identity, standard folder
|
||||
mappings, and timeouts. Folder discovery proposes provider-visible Inbox,
|
||||
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.
|
||||
@@ -263,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
|
||||
@@ -284,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
|
||||
@@ -305,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
|
||||
@@ -383,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
|
||||
@@ -436,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.
|
||||
@@ -493,9 +567,11 @@ 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
|
||||
|
||||
@@ -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
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.25",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-mail"
|
||||
version = "0.1.19"
|
||||
version = "0.1.25"
|
||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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,
|
||||
@@ -12,10 +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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -20,6 +20,7 @@ from govoplan_mail.backend.db.models import (
|
||||
MailDeliveryCommand,
|
||||
MailDeliveryReconciliation,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerProfile,
|
||||
)
|
||||
|
||||
@@ -116,6 +117,38 @@ class MailDsarProvider:
|
||||
)
|
||||
)
|
||||
|
||||
pop3_imports = _matching_pop3_imports(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
import_id=references.get("pop3_import"),
|
||||
)
|
||||
for imported in pop3_imports:
|
||||
matching_headers = _matching_pop3_headers(imported, email)
|
||||
append(
|
||||
_record(
|
||||
"mail_pop3_import",
|
||||
imported.id,
|
||||
"mail_imported_message",
|
||||
imported.subject or "Imported legacy message",
|
||||
{
|
||||
"match_fields": list(matching_headers),
|
||||
"subject": _bounded_text(imported.subject),
|
||||
"matching_headers": matching_headers,
|
||||
"date": imported.date,
|
||||
"message_id": _bounded_text(imported.message_id),
|
||||
"body_preview": _bounded_text(imported.body_preview),
|
||||
"size_bytes": imported.size_bytes,
|
||||
"status": imported.status,
|
||||
"imported_at": _iso(imported.imported_at),
|
||||
"deletion_requested": imported.deletion_requested,
|
||||
"deletion_status": imported.deletion_status,
|
||||
},
|
||||
observed_at=imported.updated_at,
|
||||
source_path="/mail/legacy-import",
|
||||
)
|
||||
)
|
||||
|
||||
bounces = _matching_bounces(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
@@ -404,6 +437,57 @@ def _matching_bounces(
|
||||
)
|
||||
|
||||
|
||||
def _matching_pop3_imports(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
email: str | None,
|
||||
import_id: str | None,
|
||||
) -> list[MailPop3Import]:
|
||||
conditions = []
|
||||
if import_id:
|
||||
conditions.append(MailPop3Import.id == import_id)
|
||||
if email:
|
||||
pattern = f"%{_escape_like(email)}%"
|
||||
conditions.extend(
|
||||
func.lower(field).like(pattern, escape="\\")
|
||||
for field in (
|
||||
MailPop3Import.from_header,
|
||||
MailPop3Import.to_header,
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
candidates = _bounded_rows(
|
||||
session.query(MailPop3Import)
|
||||
.filter(MailPop3Import.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailPop3Import.id)
|
||||
)
|
||||
return [
|
||||
row
|
||||
for row in candidates
|
||||
if row.id == import_id or _matching_pop3_headers(row, email)
|
||||
]
|
||||
|
||||
|
||||
def _matching_pop3_headers(
|
||||
row: MailPop3Import,
|
||||
email: str | None,
|
||||
) -> dict[str, list[dict[str, str | None]]]:
|
||||
if email is None:
|
||||
return {}
|
||||
result = {}
|
||||
for role, value in (("from", row.from_header), ("to", row.to_header)):
|
||||
matches = [
|
||||
{"email": address.casefold(), "name": name or None}
|
||||
for name, address in getaddresses([value or ""])
|
||||
if address.casefold() == email
|
||||
]
|
||||
if matches:
|
||||
result[role] = matches[:64]
|
||||
return result
|
||||
|
||||
|
||||
def _matching_commands(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -458,6 +542,7 @@ def _mail_references(subject: DsarSubjectRef) -> dict[str, str]:
|
||||
"mail.message_index": "message_index",
|
||||
"mail.delivery_command": "command",
|
||||
"mail.bounce_observation": "bounce",
|
||||
"mail.pop3_import": "pop3_import",
|
||||
}
|
||||
return {
|
||||
target: value
|
||||
|
||||
@@ -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",
|
||||
@@ -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(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_mail.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,7 +32,9 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAvailabilityExplanation,
|
||||
ProductAreaContribution,
|
||||
ProductSurfaceContribution,
|
||||
QuickAccessTool,
|
||||
RoleTemplate,
|
||||
)
|
||||
@@ -50,8 +55,12 @@ from govoplan_mail.backend.documentation import (
|
||||
)
|
||||
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,
|
||||
)
|
||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||
@@ -66,6 +75,7 @@ _mail_table_retirement_provider = drop_table_retirement_provider(
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
mail_models.MailMailboxMessageIndex,
|
||||
mail_models.MailPop3Import,
|
||||
mail_models.MailDeliveryReconciliation,
|
||||
mail_models.MailDeliveryAttempt,
|
||||
mail_models.MailDeliveryCommand,
|
||||
@@ -94,7 +104,7 @@ def _mail_retirement_provider(session: object | None, module_id: str):
|
||||
plan,
|
||||
destroy_data_warnings=(
|
||||
*plan.destroy_data_warnings,
|
||||
"Mail-owned encrypted SMTP/IMAP passwords are scrubbed with non-secret audit records immediately before tables are dropped; any scrub or audit failure blocks retirement.",
|
||||
"Mail-owned encrypted SMTP/IMAP/JMAP/POP3 credentials are scrubbed with non-secret audit records immediately before tables are dropped; encrypted POP3 import records are then removed with the Mail tables. Any scrub or audit failure blocks retirement.",
|
||||
),
|
||||
destroy_data_executor=executor,
|
||||
)
|
||||
@@ -129,17 +139,32 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP profile metadata."),
|
||||
_permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP/JMAP and governed legacy POP3 profile metadata."),
|
||||
_permission("mail:profile:use", "Use mail profiles", "Select an approved mail profile for delivery."),
|
||||
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
|
||||
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."),
|
||||
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP/JMAP connection tests and explicitly enabled POP3 diagnostics."),
|
||||
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP or JMAP folders and inspect messages without mutating mailbox state."),
|
||||
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
|
||||
_permission(
|
||||
"mail:profile:write_own",
|
||||
"Manage own mail profiles",
|
||||
"Create, edit, and deactivate only the current account's user-scoped mail profiles within effective policy.",
|
||||
),
|
||||
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
|
||||
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP/JMAP/POP3 credentials."),
|
||||
_permission(
|
||||
"mail:pop3:manage",
|
||||
"Manage legacy POP3 imports",
|
||||
"Configure explicitly enabled legacy POP3 sources and their non-destructive or delete-after-import policy.",
|
||||
),
|
||||
_permission(
|
||||
"mail:pop3:import",
|
||||
"Import legacy POP3 messages",
|
||||
"Preview and import bounded messages from an explicitly enabled legacy POP3 source.",
|
||||
),
|
||||
_permission(
|
||||
"mail:pop3:delete",
|
||||
"Delete imported POP3 messages at source",
|
||||
"Request source deletion after a governed local import when the server policy also allows it.",
|
||||
),
|
||||
_permission(
|
||||
"mail:delivery:diagnostic",
|
||||
"Inspect mail delivery diagnostics",
|
||||
@@ -183,6 +208,20 @@ ROLE_TEMPLATES = (
|
||||
"mail:delivery:reconcile",
|
||||
"mail:bounce:read",
|
||||
"mail:bounce:manage",
|
||||
"mail:pop3:manage",
|
||||
"mail:pop3:import",
|
||||
"mail:pop3:delete",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="mail_legacy_import_operator",
|
||||
name="Mail legacy import operator",
|
||||
description="Preview and import from an approved POP3 legacy source without deleting provider messages.",
|
||||
permissions=(
|
||||
"mail:profile:read",
|
||||
"mail:profile:use",
|
||||
"mail:profile:test",
|
||||
"mail:pop3:import",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
@@ -317,10 +356,98 @@ IMAP_PROVIDER = ExternalProviderDeclaration(
|
||||
)
|
||||
|
||||
|
||||
JMAP_PROVIDER = ExternalProviderDeclaration(
|
||||
id=JMAP_PROVIDER_ID,
|
||||
module_id="mail",
|
||||
label="JMAP mailbox projection",
|
||||
maturity="read",
|
||||
operations=("discover", "search", "read", "preview"),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="mailbox_folder",
|
||||
field_groups=("identity", "role", "counts", "revision"),
|
||||
authority_modes=("external_authoritative", "external_mirror"),
|
||||
default_authority_mode="external_mirror",
|
||||
),
|
||||
ProviderObjectDeclaration(
|
||||
object_type="mailbox_message",
|
||||
field_groups=("identity", "headers", "body_preview", "flags", "source_metadata"),
|
||||
authority_modes=("external_authoritative", "external_mirror"),
|
||||
default_authority_mode="external_mirror",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
revision_tokens="JMAP Mailbox and Email state values plus the transport revision identify mailbox observations.",
|
||||
concurrency="Bounded Email/query, Email/get, and Email/changes calls pin one discovered Session and never mutate provider state.",
|
||||
freshness="Mailbox and message index timestamps state when the external JMAP account was last observed.",
|
||||
health="Capability discovery and connection diagnostics remain separate from secret credential fields.",
|
||||
max_read_items=500,
|
||||
evidence="Mailbox index rows retain bounded provider identifiers and observation times.",
|
||||
audit_event_types=("mail.mailbox.read",),
|
||||
correction="A later JMAP refresh replaces the derived projection while source-owned history remains external.",
|
||||
reconciliation="An expired Email state requires a bounded full refresh instead of guessing missed changes.",
|
||||
outage="The last derived index remains readable with stale or unknown freshness where policy permits.",
|
||||
classifications=("confidential", "personal", "special_category"),
|
||||
purposes=("mailbox access", "mailbox search"),
|
||||
retention="Derived mailbox indexes follow Mail retention policy.",
|
||||
secret_handling="JMAP bearer tokens or Basic credentials remain encrypted and are never returned through mailbox or provider-state APIs.",
|
||||
),
|
||||
documentation_topic_ids=("mail.workflow.read-mailbox",),
|
||||
)
|
||||
|
||||
|
||||
POP3_PROVIDER = ExternalProviderDeclaration(
|
||||
id=POP3_PROVIDER_ID,
|
||||
module_id="mail",
|
||||
label="Legacy POP3 governed import",
|
||||
maturity="publish",
|
||||
operations=("discover", "read", "preview", "write", "delete"),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="legacy_mailbox_message",
|
||||
field_groups=("provider_identity", "safe_headers", "body_preview", "size"),
|
||||
authority_modes=("external_authoritative", "governance_overlay"),
|
||||
default_authority_mode="external_authoritative",
|
||||
),
|
||||
ProviderObjectDeclaration(
|
||||
object_type="mail_pop3_import",
|
||||
field_groups=("source_identity", "content_digest", "encrypted_content", "review_state", "deletion_evidence"),
|
||||
authority_modes=("governance_overlay",),
|
||||
default_authority_mode="governance_overlay",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
revision_tokens="Each import pins the explicit POP3 endpoint and credential transport revision and identifies source messages by UIDL.",
|
||||
concurrency="The expected transport revision is checked before credentials are decrypted; missing or changed UIDLs require a fresh preview.",
|
||||
freshness="POP3 previews are live bounded observations; imported records retain their source UIDL and import time.",
|
||||
health="Disabled, enabled, imported, and failed or unknown source-deletion states are projected without exposing secrets.",
|
||||
max_read_items=100,
|
||||
idempotency="Tenant, profile, server, and provider UIDL uniquely identify one governed local import.",
|
||||
retry="Preview and pre-effect download failures are safe to retry; source deletion is never retried blindly after QUIT begins.",
|
||||
timeout_seconds=60,
|
||||
conflicts="A repeated UIDL is reported as an existing import and cannot create duplicate local content.",
|
||||
outcome_unknown="A connection failure while committing POP3 DELE operations is retained as outcome_unknown for operator reconciliation.",
|
||||
outcome_unknown_supported=True,
|
||||
evidence="Encrypted raw message content, bounded safe headers, content digest, UIDL, pinned revision, import audit, and separate deletion outcome are retained.",
|
||||
audit_event_types=("mail.pop3.imported", "mail.pop3.source_deletion"),
|
||||
correction="Operators review the local import; later source or classification corrections do not rewrite import evidence.",
|
||||
rollback="A committed local import is preserved even if optional provider deletion fails or is uncertain.",
|
||||
compensation="Operators reconcile the source mailbox before any further destructive action when deletion is uncertain.",
|
||||
reconciliation="Compare the retained UIDL and digest with the provider mailbox and record an operational decision outside blind automation.",
|
||||
outage="No provider effect occurs while unavailable; already imported encrypted records remain available for governed review.",
|
||||
classifications=("confidential", "personal", "special_category"),
|
||||
purposes=("legacy mailbox migration", "governed message intake"),
|
||||
retention="Imported content and source-deletion evidence follow configured Mail and records-retention policy.",
|
||||
secret_handling="POP3 credentials and imported raw messages are encrypted; neither is returned by preview, import-list, provider-state, or DSAR APIs.",
|
||||
),
|
||||
documentation_topic_ids=("mail.workflow.legacy-pop3-import",),
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="mail",
|
||||
name="Mail",
|
||||
version="0.1.19",
|
||||
version="0.1.25",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
|
||||
provides_interfaces=(
|
||||
@@ -385,7 +512,10 @@ manifest = ModuleManifest(
|
||||
factory=create_mail_search_source,
|
||||
),
|
||||
),
|
||||
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read", "mail:bounce:read", "mail:bounce:manage"), order=50),),
|
||||
nav_items=(
|
||||
NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read", "mail:bounce:read", "mail:bounce:manage"), order=50),
|
||||
NavItem(path="/mail/legacy-import", label="Legacy POP3 import", icon="mail", required_any=("mail:pop3:import", "mail:pop3:manage"), order=52),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="mail",
|
||||
package_name="@govoplan/mail-webui",
|
||||
@@ -403,8 +533,18 @@ manifest = ModuleManifest(
|
||||
order=51,
|
||||
surface_id="mail.bounce-processing",
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/mail/legacy-import",
|
||||
component="MailLegacyImportPage",
|
||||
required_any=("mail:pop3:import", "mail:pop3:manage"),
|
||||
order=52,
|
||||
surface_id="mail.legacy-pop3-import",
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read", "mail:bounce:read", "mail:bounce:manage"), order=50),
|
||||
NavItem(path="/mail/legacy-import", label="Legacy POP3 import", icon="mail", required_any=("mail:pop3:import", "mail:pop3:manage"), order=52),
|
||||
),
|
||||
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read", "mail:bounce:read", "mail:bounce:manage"), order=50),),
|
||||
view_surfaces=(
|
||||
ViewSurface(id="mail.admin.system-servers", module_id="mail", kind="section", label="System mail servers", order=70),
|
||||
ViewSurface(id="mail.admin.tenant-servers", module_id="mail", kind="section", label="Tenant mail servers", order=60),
|
||||
@@ -424,6 +564,39 @@ manifest = ModuleManifest(
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
product_surfaces=(
|
||||
ProductSurfaceContribution(
|
||||
id="communication.messages",
|
||||
module_id="mail",
|
||||
label="i18n:govoplan-core.product_surface.messages",
|
||||
description="i18n:govoplan-core.product_surface.messages_description",
|
||||
icon="mail",
|
||||
entry_path="/messages",
|
||||
route_path="/mail",
|
||||
surface_ids=("mail.nav.mail", "mail.route.mail"),
|
||||
presentations=("task", "reader"),
|
||||
search_source_ids=("mail.mailbox_messages",),
|
||||
help_context_ids=("mail.quick_access.messages",),
|
||||
documentation_topic_ids=("mail.quick-access-and-product-area",),
|
||||
required_any=("mail:mailbox:read",),
|
||||
aliases=("/inbox",),
|
||||
order=10,
|
||||
unavailable=ProductAvailabilityExplanation(
|
||||
reason="authorization",
|
||||
title="i18n:govoplan-core.product_surface.messages_unavailable",
|
||||
description="i18n:govoplan-core.product_surface.messages_unavailable_description",
|
||||
resolution="i18n:govoplan-core.product_surface.messages_unavailable_resolution",
|
||||
responsible_role="i18n:govoplan-core.access_administrator",
|
||||
),
|
||||
degraded=ProductAvailabilityExplanation(
|
||||
reason="provider_degraded",
|
||||
title="i18n:govoplan-core.product_surface.messages_degraded",
|
||||
description="i18n:govoplan-core.product_surface.messages_degraded_description",
|
||||
resolution="i18n:govoplan-core.product_surface.messages_degraded_resolution",
|
||||
responsible_role="i18n:govoplan-core.integration_operator",
|
||||
),
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="mail.messages",
|
||||
@@ -458,6 +631,7 @@ manifest = ModuleManifest(
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
mail_models.MailMailboxMessageIndex,
|
||||
mail_models.MailPop3Import,
|
||||
mail_models.MailDeliveryReconciliation,
|
||||
mail_models.MailDeliveryAttempt,
|
||||
mail_models.MailDeliveryCommand,
|
||||
@@ -490,20 +664,132 @@ manifest = ModuleManifest(
|
||||
capability_documentation={
|
||||
MAIL_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Mail data-subject request provider",
|
||||
summary="Finds isolated mailbox-index, personal-profile, delivery, reconciliation, and bounce metadata without exposing transport secrets.",
|
||||
summary="Finds isolated mailbox-index, imported-message, personal-profile, delivery, reconciliation, and bounce metadata without exposing transport secrets or encrypted raw content.",
|
||||
contract_version="0.1.0",
|
||||
documentation_types=("admin",),
|
||||
audience=("privacy_officer", "mail_admin", "records_manager"),
|
||||
),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="mail.workflow.legacy-pop3-import",
|
||||
title="Import messages from a legacy POP3 mailbox",
|
||||
summary="Preview and import bounded POP3 messages into encrypted local review records, without deleting source messages by default.",
|
||||
body=(
|
||||
"POP3 is a low-priority migration path and is never enabled implicitly. A Mail administrator creates a dedicated POP3 endpoint, enables legacy import, stores an encrypted credential, tests TLS and authentication, and separately decides whether source deletion may ever be requested. An import operator refreshes the live bounded preview, selects messages by stable UIDL, and imports them into encrypted pending-review records. Repeating an import reports duplicates instead of creating more copies. Source messages remain untouched unless the endpoint policy allows delete-after-import, the operator also has mail:pop3:delete, and deletion is explicitly selected for that batch. Mail commits and audits the local import before attempting deletion; failed or uncertain deletion therefore never removes the governed local record and must be reconciled before another destructive attempt. Prefer IMAP or JMAP for ongoing mailbox access."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("mail_admin", "mail_import_operator", "records_manager", "security_reviewer"),
|
||||
order=34,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail",),
|
||||
any_scopes=("mail:pop3:import", "mail:pop3:manage"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Legacy POP3 import", href="/mail/legacy-import", kind="runtime"),
|
||||
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||
DocumentationLink(label="Protocol roadmap", href="govoplan-mail/docs/MAIL_PROTOCOL_ROADMAP.md", kind="repository"),
|
||||
),
|
||||
related_modules=("audit", "records"),
|
||||
unlocks=("Governed retirement of legacy POP3 mailboxes without making destructive retrieval the default.",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Nachrichten aus einem alten POP3-Postfach importieren",
|
||||
"summary": "Begrenzt viele POP3-Nachrichten in verschlüsselte lokale Prüfdatensätze übernehmen, ohne die Quelldaten standardmäßig zu löschen.",
|
||||
"body": (
|
||||
"POP3 ist ausschließlich ein nachrangiger Migrationsweg und wird niemals stillschweigend aktiviert. "
|
||||
"Eine Mail-Administration legt eine eigene POP3-Quelle an, aktiviert den Altimport ausdrücklich, "
|
||||
"hinterlegt Zugangsdaten verschlüsselt, prüft TLS und Anmeldung und entscheidet getrennt, ob das Löschen "
|
||||
"an der Quelle überhaupt angefordert werden darf. Importberechtigte laden eine begrenzte Live-Vorschau, "
|
||||
"wählen Nachrichten anhand stabiler UIDL-Kennungen und übernehmen sie als verschlüsselte Datensätze mit "
|
||||
"ausstehender Prüfung. Wiederholte Importe werden als Duplikate ausgewiesen und nicht erneut gespeichert. "
|
||||
"Quelldaten bleiben unverändert, solange nicht die Endpunktrichtlinie Löschen nach Import erlaubt, die "
|
||||
"ausführende Person zusätzlich mail:pop3:delete besitzt und das Löschen für den konkreten Stapel ausdrücklich "
|
||||
"ausgewählt wird. Mail schreibt und protokolliert zuerst den lokalen Import und versucht erst danach die "
|
||||
"Löschung beim Anbieter. Eine fehlgeschlagene oder unklare Löschung entfernt deshalb niemals den verwalteten "
|
||||
"lokalen Datensatz und muss vor einem weiteren destruktiven Versuch abgestimmt werden. Für den laufenden "
|
||||
"Postfachzugriff sind IMAP oder JMAP vorzuziehen."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/mail/legacy-import",
|
||||
"screen": "Legacy POP3 import",
|
||||
"help_contexts": [
|
||||
"mail.pop3",
|
||||
"mail.pop3.source-editor",
|
||||
"mail.pop3.action.reload",
|
||||
"mail.pop3.action.create-source",
|
||||
"mail.pop3.action.configure-source",
|
||||
"mail.pop3.action.save-source",
|
||||
"mail.pop3.action.test",
|
||||
"mail.pop3.action.preview",
|
||||
"mail.pop3.action.import",
|
||||
"mail.pop3.field.profile",
|
||||
"mail.pop3.field.name",
|
||||
"mail.pop3.field.host",
|
||||
"mail.pop3.field.port",
|
||||
"mail.pop3.field.transport-security",
|
||||
"mail.pop3.field.timeout",
|
||||
"mail.pop3.field.max-message-size",
|
||||
"mail.pop3.field.max-batch-size",
|
||||
"mail.pop3.field.preview-body-lines",
|
||||
"mail.pop3.field.username",
|
||||
"mail.pop3.field.password",
|
||||
"mail.pop3.field.enabled",
|
||||
"mail.pop3.field.allow-delete-after-import",
|
||||
"mail.pop3.field.message-selection",
|
||||
"mail.pop3.field.delete-after-import",
|
||||
"mail.pop3.confirm-delete-source",
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"label": "Transport security",
|
||||
"user_description": "Use TLS or STARTTLS; unencrypted transport remains subject to deployment egress policy and is not the safe default.",
|
||||
},
|
||||
{
|
||||
"label": "Message and batch limits",
|
||||
"user_description": "Bound both each downloaded message and the total imported batch before provider content enters local encrypted storage.",
|
||||
},
|
||||
{
|
||||
"label": "Explicitly enable legacy import",
|
||||
"user_description": "Keeps the source inert until endpoint metadata and its encrypted credential have both been stored.",
|
||||
},
|
||||
{
|
||||
"label": "Permit delete-after-import requests",
|
||||
"user_description": "Allows, but never selects, the separately authorized destructive request; each batch still needs explicit confirmation.",
|
||||
},
|
||||
],
|
||||
"steps": [
|
||||
"Ask a Mail administrator to configure and explicitly enable a dedicated POP3 legacy source.",
|
||||
"Test the source and refresh a bounded preview; no message flags or deletion state are changed.",
|
||||
"Select messages and import them into encrypted pending-review records.",
|
||||
"Use delete-after-import only where policy and a separate destructive permission allow it, then reconcile failed or unknown outcomes.",
|
||||
],
|
||||
"limitations": [
|
||||
"A POP3 server must provide stable UIDL identifiers; otherwise safe duplicate prevention is unavailable and import is refused.",
|
||||
"POP3 has no folder or flag semantics and is not the recommended protocol for ongoing mailbox access.",
|
||||
"Source deletion cannot be rolled back and may have an unknown outcome if the connection fails while QUIT commits deletions.",
|
||||
],
|
||||
"operational_consequences": [
|
||||
"Changing host, port, security, account identity, or import limits rotates the transport revision and invalidates stale previews.",
|
||||
"Enabling source deletion creates an irreversible external effect boundary; unknown QUIT outcomes require reconciliation rather than blind retry.",
|
||||
"Disabling legacy import leaves governed local imports and their evidence available for review while preventing new provider access.",
|
||||
],
|
||||
"verification": "Prove disabled policy, TLS and authentication diagnostics, non-destructive preview/import, UIDL duplicate prevention, encrypted raw retention, separate delete authorization, import-before-delete audit ordering, and failed or outcome-unknown deletion evidence.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.privacy.data-subject-requests",
|
||||
title="Review Mail data in a data-subject request",
|
||||
summary="Collect tenant-scoped Mail metadata while preserving transport evidence and external-mailbox authority.",
|
||||
body=(
|
||||
"Mail's DSAR provider searches the effective tenant by normalized header or bounce-recipient email, direct membership references, and namespaced Mail profile, message-index, delivery-command, or bounce references. It isolates only matching From, To, and Cc parties and returns bounded message-index content, safe personal-profile metadata, and delivery, attempt, reconciliation, and bounce outcomes. It excludes SMTP/IMAP configuration, usernames and credentials, encrypted messages and envelopes, refusal detail, mailbox folders and UIDs, endpoint and credential identifiers, idempotency and worker claims, diagnostics, error text, and opaque evidence. "
|
||||
"Delivery attempts, reconciliation decisions, and bounce observations remain retained as immutable transport and recovery evidence. Mailbox indexes and personal profiles require manual review through Mail and the authoritative external mailbox. The provider performs no direct erasure because deleting a derived index alone would not delete its external source, while changing a profile or delivery command can affect credentials, other users, and preserved evidence."
|
||||
"Mail's DSAR provider searches the effective tenant by normalized header or bounce-recipient email, direct membership references, and namespaced Mail profile, message-index, POP3-import, delivery-command, or bounce references. It isolates only matching From, To, and Cc parties and returns bounded message-index or imported-message content, safe personal-profile metadata, and delivery, attempt, reconciliation, and bounce outcomes. It excludes SMTP/IMAP/JMAP/POP3 configuration, usernames and credentials, encrypted messages and envelopes, POP3 source UIDLs and revisions, refusal detail, mailbox folders and UIDs, endpoint and credential identifiers, idempotency and worker claims, diagnostics, error text, and opaque evidence. "
|
||||
"Delivery attempts, reconciliation decisions, and bounce observations remain retained as immutable transport and recovery evidence. Mailbox indexes, encrypted POP3 imports, and personal profiles require manual review through Mail and the authoritative external mailbox. The provider performs no direct erasure because deleting a derived or imported record alone would not establish the state of its external source, while changing a profile or delivery command can affect credentials, other users, and preserved evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
@@ -520,12 +806,33 @@ manifest = ModuleManifest(
|
||||
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||
),
|
||||
related_modules=("access", "audit", "campaigns", "postbox"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Mail-Daten in einer Auskunftsanfrage prüfen",
|
||||
"summary": "Mandantenbezogene Mail-Metadaten zusammentragen und dabei Transportnachweise sowie die Zuständigkeit des externen Postfachs bewahren.",
|
||||
"body": (
|
||||
"Der Mail-Provider für Betroffenenanfragen durchsucht den wirksamen Mandanten anhand normalisierter "
|
||||
"Absender-, Empfänger- oder Unzustellbarkeitsadressen, direkter Mitgliedschaftsverweise und namensraumgebundener "
|
||||
"Verweise auf Mail-Profile, Nachrichtenindizes, POP3-Importe, Zustellbefehle oder Rückläufer. Er grenzt nur "
|
||||
"passende Absender-, An- und Cc-Beteiligte ab und liefert begrenzte Inhalte aus Nachrichtenindex oder Import, "
|
||||
"sichere Metadaten persönlicher Profile sowie Zustell-, Versuch-, Abstimmungs- und Rückläufergebnisse. "
|
||||
"Ausgeschlossen bleiben SMTP-, IMAP-, JMAP- und POP3-Konfiguration, Benutzernamen und Zugangsdaten, verschlüsselte "
|
||||
"Nachrichten und Umschläge, POP3-UIDLs und Quellrevisionen, Ablehnungsdetails, Postfachordner und UIDs, Endpunkt- "
|
||||
"und Zugangsdatenkennungen, Idempotenz- und Worker-Ansprüche, Diagnosen, Fehlertexte und undurchsichtige Nachweise. "
|
||||
"Zustellversuche, Abstimmungsentscheidungen und Rückläuferbeobachtungen bleiben als unveränderliche Transport- "
|
||||
"und Wiederherstellungsnachweise erhalten. Nachrichtenindizes, verschlüsselte POP3-Importe und persönliche Profile "
|
||||
"müssen in Mail und im maßgeblichen externen Postfach manuell geprüft werden. Der Provider löscht nicht unmittelbar, "
|
||||
"weil das Entfernen eines abgeleiteten oder importierten Datensatzes den Zustand seiner externen Quelle nicht belegt "
|
||||
"und Änderungen an Profilen oder Zustellbefehlen Zugangsdaten, andere Personen und aufbewahrte Nachweise betreffen können."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=tenant-data-subject-requests",
|
||||
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||
"steps": [
|
||||
"Run the Mail provider search and review mailbox-index, profile, delivery, reconciliation, and bounce dispositions.",
|
||||
"Run the Mail provider search and review mailbox-index, POP3-import, profile, delivery, reconciliation, and bounce dispositions.",
|
||||
"Retain immutable transport evidence with its reason.",
|
||||
"Coordinate approved mailbox content deletion with the authoritative external mailbox and then refresh the derived index.",
|
||||
"Use Mail profile lifecycle controls for approved personal-profile changes; do not edit encrypted payload or evidence rows directly.",
|
||||
@@ -621,6 +928,9 @@ manifest = ModuleManifest(
|
||||
body=(
|
||||
"Mail contributes its full mailbox to Communication. When Quick Access is enabled, its compact provider surface "
|
||||
"appears inside the shared Messages drawer alongside independent Postbox and future chat contributions. "
|
||||
"Mail also contributes to the stable Messages product entry: `/messages` and its migration alias `/inbox` open "
|
||||
"Mail only when its mailbox route is currently authorized and visible in the effective View; otherwise another "
|
||||
"available Messages owner is selected or the shell explains how access can be restored. "
|
||||
"Recent entries open the exact authorized profile, folder, and message; Drafts resolves the configured or "
|
||||
"provider-detected Drafts folder, and the full Mail page keeps the versioned Quick Access return context. "
|
||||
"When an active Case requests a selection, choosing a recent message returns only its tenant-bound profile, "
|
||||
@@ -642,6 +952,9 @@ manifest = ModuleManifest(
|
||||
"body": (
|
||||
"Mail ordnet das vollständige Postfach Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die kompakte "
|
||||
"Mail-Oberfläche gemeinsam mit unabhängigen Beiträgen aus Postbox und künftig Chat unter Nachrichten. "
|
||||
"Mail trägt außerdem zum stabilen Produkteinstieg Nachrichten bei: `/messages` und der Migrationsalias `/inbox` "
|
||||
"öffnen Mail nur, wenn die Postfachroute aktuell berechtigt und in der wirksamen View sichtbar ist. Andernfalls "
|
||||
"wählt die Shell einen anderen verfügbaren Nachrichten-Eigentümer oder erklärt die Wiederherstellung des Zugriffs. "
|
||||
"Aktuelle Einträge öffnen das genaue berechtigte Profil, den Ordner und die Nachricht; Entwürfe verwendet "
|
||||
"den konfigurierten oder vom Anbieter erkannten Entwurfsordner. Die vollständige Mail-Seite erhält den "
|
||||
"versionierten Rücksprungkontext. Verfassen öffnet bewusst die konfigurierte Mail-Anwendung des Benutzers, "
|
||||
@@ -719,13 +1032,26 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("mail_user", "mail_admin", "administrator"),
|
||||
related_modules=("search",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Berechtigte Postfachnachrichten durchsuchen",
|
||||
"summary": "Begrenzte Metadaten und Vorschauen aus dem zwischengespeicherten Postfach für die berechtigungsbewusste Plattformsuche bereitstellen.",
|
||||
"body": (
|
||||
"Ist Search installiert, stellt Mail seinen nur lesbaren Postfachzwischenspeicher bereit, niemals Zugangsdaten "
|
||||
"oder unbegrenzte Rohnachrichten. Ergebnisse erfordern sowohl Leseberechtigung für das Postfach als auch "
|
||||
"Nutzungsberechtigung für das Profil. Vor jeder Ausgabe werden aktueller Profilbereich, Richtlinie, Aktivität "
|
||||
"sowie Sichtbarkeit für Mandant, Person, Gruppe oder Campaign erneut geprüft. Postfachaktualisierungen veröffentlichen "
|
||||
"dauerhafte Indexänderungen; für die Abstimmung bleibt ein vollständiger Neuaufbau verfügbar."
|
||||
),
|
||||
}
|
||||
},
|
||||
order=39,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.profiles-and-policy",
|
||||
title="Mail profiles and policy hierarchy",
|
||||
summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.",
|
||||
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
|
||||
summary="Mail sending and mailbox access use reusable SMTP/IMAP/JMAP profiles governed by an effective system, tenant, owner, and campaign policy.",
|
||||
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. SMTP, IMAP, and JMAP host allow/deny rules are inherited independently. JMAP Session discovery fails closed when its advertised API origin differs unless an administrator explicitly allows that origin. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
||||
@@ -747,6 +1073,19 @@ manifest = ModuleManifest(
|
||||
related_modules=("campaigns",),
|
||||
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
|
||||
configuration_keys=("mail_profile_policy",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Mail-Profile und Richtlinienhierarchie",
|
||||
"summary": "Mail-Versand und Postfachzugriff über wiederverwendbare SMTP-/IMAP-/JMAP-Profile und eine wirksame Richtlinie aus System-, Mandanten-, Eigentümer- und gegebenenfalls Campaign-Ebene steuern.",
|
||||
"body": (
|
||||
"Die wirksame Richtlinie entscheidet, ob Personen ausschließlich freigegebene Profile auswählen dürfen "
|
||||
"oder ob auf Personen-, Gruppen- und Campaign-Ebene zusätzliche wiederverwendbare Profile angelegt werden können. "
|
||||
"Host-Freigaben und -Sperren für SMTP, IMAP und JMAP werden unabhängig vererbt. Ein von der JMAP-Session "
|
||||
"angekündigter API-Ursprung muss bei abweichendem Ursprung ausdrücklich freigegeben sein. "
|
||||
"Die Laufzeitdokumentation ergänzt die aktuelle Lage des Mandanten, wenn die handelnde Person die Mail-Profilrichtlinie lesen darf."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/settings?section=mail-profiles",
|
||||
@@ -782,6 +1121,21 @@ manifest = ModuleManifest(
|
||||
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||
),
|
||||
related_modules=("campaigns",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Standardordner eines IMAP-Profils zuordnen",
|
||||
"summary": "Zuordnungen für Posteingang, Gesendet, Entwürfe, Papierkorb, Archiv und Junk am wiederverwendbaren Mail-Profil speichern und durch begrenzte IMAP-Erkennung vorbelegen.",
|
||||
"body": (
|
||||
"Ordnernamen sind Metadaten des Profils beziehungsweise Servers und keine Campaign-Einstellungen. Eine berechtigte "
|
||||
"Profiladministration kann Namen direkt eintragen oder die Ordnererkennung verwenden; leere Zuordnungen behalten "
|
||||
"das automatische Verhalten bei. Vorhandene Werte aus imap.sent_folder werden als Zuordnung für Gesendet angezeigt "
|
||||
"und gespeichert, während das Kompatibilitätsfeld für ältere Verbraucher synchron bleibt. Eine Campaign-spezifische "
|
||||
"Abweichung für den Gesendet-Ordner bleibt für diese Campaign maßgeblich und wird von der Profilerkennung nicht verändert. "
|
||||
"Die Erkennung listet nur die beim Anbieter sichtbaren Namen auf und legt keine externen Ordner an, benennt sie nicht um, "
|
||||
"verschiebt sie nicht und löscht sie nicht."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/settings?section=mail-profiles",
|
||||
@@ -822,6 +1176,20 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
related_modules=("campaigns", "calendar", "audit"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Zustellstatus und Rückläufer verarbeiten",
|
||||
"summary": "Einen berechtigten IMAP-Ordner beobachten und DSN-Ergebnisse mit dauerhaften Mail-Befehlen abgleichen.",
|
||||
"body": (
|
||||
"Mail speichert die ausgehende RFC-Message-ID zusammen mit dem dauerhaften Befehl, verarbeitet begrenzte "
|
||||
"message/delivery-status-Berichte ohne Postfachkennzeichen zu ändern und zeichnet je Empfänger idempotente "
|
||||
"Beobachtungen auf. Die SMTP-Annahme bleibt von einem späteren Rückläufer getrennt. Nicht zuordenbare Berichte "
|
||||
"bleiben zur Prüfung sichtbar; Mail speichert nur begrenzte Diagnosen und einen Digest des Rohinhalts, nicht den "
|
||||
"Rohtext des Rückläufers. Ist Calendar aktiv, leitet derselbe begrenzte Quellscan iCalendar-Teile mit METHOD:REPLY "
|
||||
"zur idempotenten Abstimmung der Teilnehmenden an Calendar weiter, ohne die Nachricht als Rückläufer einzuordnen."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/mail/bounces",
|
||||
@@ -860,6 +1228,21 @@ manifest = ModuleManifest(
|
||||
),
|
||||
related_modules=("campaigns", "access"),
|
||||
unlocks=("Reusable, governed delivery identities without cross-module secret duplication.",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Mail besitzt Transportprofile und Zugangsdaten",
|
||||
"summary": "Andere Module wählen berechtigte Mail-Profile über stabile Kennungen aus; sie kopieren weder SMTP-/IMAP-Einstellungen noch Geheimnisse.",
|
||||
"body": (
|
||||
"Mail verschlüsselt Zugangsdaten, prüft Verbindungen, bewertet Profilbereich und Richtlinie und führt "
|
||||
"revisionsgebundene Transportwirkungen aus, ohne die aufgelöste Konfiguration zurückzugeben. Zufällige gespeicherte "
|
||||
"Revisionen wechseln, wenn sich normalisierte Transportdaten oder die Kontoidentität ändern; eine reine "
|
||||
"Passwortrotation bleibt für bereits aufgebauten fachlichen Willen transparent. Campaign speichert nur "
|
||||
"server.mail_profile_id und bereinigte Zustellnachweise. Campaign-lokale Transportfelder werden abgelehnt; "
|
||||
"ältere Campaign-Datensätze bleiben gesperrt, bis eine ausdrückliche Profilmigration den ursprünglichen "
|
||||
"Auditdatensatz bewahrt und eine bearbeitbare Version aktualisiert."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/settings?section=mail-profiles",
|
||||
@@ -876,7 +1259,7 @@ manifest = ModuleManifest(
|
||||
DocumentationTopic(
|
||||
id="mail.workflow.choose-and-test-profile",
|
||||
title="Choose and test a reusable Mail profile",
|
||||
summary="Select a profile visible in the current context, test its SMTP or IMAP connection, and let the consuming task store only its stable reference.",
|
||||
summary="Select a profile visible in the current context, test its SMTP, IMAP, or JMAP connection, and let the consuming task store only its stable reference.",
|
||||
body="Profile tests verify current connection and authentication using Mail-owned credentials. They do not prove later Campaign policy authorization, deliverability, recipient acceptance, or future availability. Use the picker rather than entering identifiers or copying transport settings into another module.",
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
@@ -893,7 +1276,18 @@ manifest = ModuleManifest(
|
||||
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||
),
|
||||
related_modules=("campaigns",),
|
||||
unlocks=("Reusable SMTP/IMAP identities that consumers can reference without handling credentials.",),
|
||||
unlocks=("Reusable SMTP/IMAP/JMAP identities that consumers can reference without handling credentials.",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Ein wiederverwendbares Mail-Profil auswählen und prüfen",
|
||||
"summary": "Ein im aktuellen Kontext sichtbares Profil auswählen, seine SMTP-, IMAP- oder JMAP-Verbindung prüfen und im verbrauchenden Vorgang nur den stabilen Verweis speichern.",
|
||||
"body": (
|
||||
"Profiltests prüfen die aktuelle Verbindung und Anmeldung mit den von Mail verwalteten Zugangsdaten. Sie belegen "
|
||||
"weder eine spätere Richtlinienfreigabe in Campaign noch Zustellbarkeit, Empfängerannahme oder künftige Verfügbarkeit. "
|
||||
"Verwenden Sie die Auswahlkomponente, statt Kennungen einzugeben oder Transporteinstellungen in ein anderes Modul zu kopieren."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/settings?section=mail-profiles",
|
||||
@@ -905,8 +1299,8 @@ manifest = ModuleManifest(
|
||||
],
|
||||
"steps": [
|
||||
"Open Mail profiles and choose a visible active profile.",
|
||||
"Review its safe scope, SMTP/IMAP availability, and sender identity without expecting credential values.",
|
||||
"Run the relevant SMTP or IMAP connectivity/authentication test against a non-production target first.",
|
||||
"Review its safe scope, SMTP/IMAP/JMAP availability, and sender identity without expecting credential values.",
|
||||
"Run the relevant SMTP, IMAP, or JMAP connectivity/authentication test against a non-production target first.",
|
||||
"Return to the consuming task and select the same profile through its picker.",
|
||||
],
|
||||
"outcome": "The consuming task references an available Mail-owned profile and contains no copied transport configuration or credentials.",
|
||||
@@ -920,8 +1314,8 @@ manifest = ModuleManifest(
|
||||
DocumentationTopic(
|
||||
id="mail.workflow.read-mailbox",
|
||||
title="Read a permitted mailbox without changing it",
|
||||
summary="Choose an IMAP-enabled profile, browse synchronized folders, and inspect bounded message content through the read-only mailbox surface.",
|
||||
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Lists expose provider read/unread flags and live/cache/refresh provenance without mutating them. Message HTML is isolated and sanitized, while attachments, inline references, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
|
||||
summary="Choose an IMAP- or JMAP-enabled profile, browse folders, search or page messages, and inspect bounded content through the read-only mailbox surface.",
|
||||
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. IMAP retains its existing bounded list behavior. JMAP adds capability discovery, server-side text search, query-state cursors, and bounded Email/changes synchronization; an expired state explicitly requires a full refresh. Lists expose provider read/unread flags and provenance without mutating them. Message HTML is isolated and sanitized, while attachment metadata, unavailable content, and provider failures remain explicit. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("mail_user",),
|
||||
@@ -937,18 +1331,33 @@ manifest = ModuleManifest(
|
||||
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||
),
|
||||
unlocks=("Read-only access to an authorized operational mailbox without broadening profile administration rights.",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Ein berechtigtes Postfach ohne Änderungen lesen",
|
||||
"summary": "Ein IMAP- oder JMAP-fähiges Profil auswählen, Ordner durchsuchen und begrenzte Nachrichteninhalte in der nur lesbaren Postfachoberfläche prüfen.",
|
||||
"body": (
|
||||
"Der Postfachzugriff erfordert sowohl Leseberechtigung für das Postfach als auch Nutzungsberechtigung für ein "
|
||||
"im Bereich der handelnden Person sichtbares Profil. IMAP behält sein bisheriges Verhalten; JMAP ergänzt "
|
||||
"serverseitige Suche, zustandsgebundene Seitennavigation und begrenzte inkrementelle Änderungen. Listen zeigen die vom Anbieter gelieferten Gelesen-/Ungelesen-Kennzeichen "
|
||||
"und die Herkunft aus Livezugriff, Zwischenspeicher oder Aktualisierung, ohne diese Zustände zu verändern. HTML-Inhalte "
|
||||
"werden isoliert und bereinigt; Anlagen, Inline-Verweise, nicht verfügbare Inhalte und Providerfehler bleiben ausdrücklich sichtbar. "
|
||||
"Das Auflisten von Ordnern oder Nachrichten darf keine Nachricht als gelesen markieren, verschieben oder löschen und keine "
|
||||
"unbegrenzten Inhalte offenlegen."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/mail",
|
||||
"screen": "Mail",
|
||||
"help_contexts": ["mail.list", "mail.mailbox"],
|
||||
"prerequisites": [
|
||||
"An active visible profile has IMAP configured.",
|
||||
"An active visible profile has IMAP or JMAP configured.",
|
||||
"You may both use that profile and read its mailbox.",
|
||||
],
|
||||
"steps": [
|
||||
"Open Mail and choose an authorized IMAP-enabled profile.",
|
||||
"Select a folder, review the live/cached synchronization label, and page or filter its bounded message index.",
|
||||
"Open Mail and choose an authorized IMAP- or JMAP-enabled profile.",
|
||||
"Select a folder, review the live/cached synchronization label, and page its bounded message index; JMAP searches run at the provider.",
|
||||
"Use the provider-derived read/unread indicator, then open only the message needed for the task.",
|
||||
"Switch between safe plain-text and isolated HTML views as needed, and review attachment or unavailable-content details before closing the preview.",
|
||||
],
|
||||
@@ -963,8 +1372,8 @@ manifest = ModuleManifest(
|
||||
DocumentationTopic(
|
||||
id="mail.reference.credentials-egress-retirement",
|
||||
title="Protect Mail credentials, network egress, and retirement",
|
||||
summary="Keep secrets Mail-owned, pin every SMTP/IMAP peer, bound responses, and delete owned credentials immediately with non-secret audit evidence.",
|
||||
body="Private-network connector access is deployment-wide, but every allowed hostname still resolves to an approved peer that is pinned at socket creation. Unsupported transports fail before connection. Deleting a profile immediately scrubs its owned encrypted SMTP/IMAP passwords and records non-secret audit when secrets existed; a scrub or audit failure rolls the action back. Destructive module retirement applies the same rule before table drop.",
|
||||
summary="Keep secrets Mail-owned, govern every SMTP/IMAP/JMAP/POP3 peer, bound responses, and delete owned credentials immediately with non-secret audit evidence.",
|
||||
body="Private-network connector access is deployment-wide, but every allowed hostname still resolves to an approved peer that is pinned at socket creation. JMAP applies the same egress controls to its Session and advertised API URL, permits cross-origin APIs only through an explicit endpoint allow-list, and bounds complete responses and body values. Unsupported capabilities fail before mailbox data is returned. Deleting a profile immediately scrubs its owned encrypted SMTP/IMAP/JMAP/POP3 secrets and records non-secret audit when secrets existed; a scrub or audit failure rolls the action back. Encrypted POP3 import content follows Mail/records retention. Destructive module retirement applies the same secret-scrub rule before all Mail tables, including imports, are dropped.",
|
||||
layer="evidence",
|
||||
documentation_types=("admin",),
|
||||
audience=("mail_admin", "platform_operator", "security_reviewer", "release_reviewer"),
|
||||
@@ -981,6 +1390,22 @@ manifest = ModuleManifest(
|
||||
),
|
||||
related_modules=("audit",),
|
||||
unlocks=("A testable Mail trust boundary with fail-closed egress and auditable secret disposal.",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Mail-Zugangsdaten, Netzausgänge und Außerbetriebnahme schützen",
|
||||
"summary": "Geheimnisse in Mail belassen, jeden SMTP-/IMAP-/JMAP-/POP3-Gegenpunkt steuern, Antworten begrenzen und eigene Zugangsdaten sofort mit nicht geheimem Auditnachweis löschen.",
|
||||
"body": (
|
||||
"Der Zugriff von Konnektoren auf private Netze wird bereitstellungsweit gesteuert; dennoch wird jeder erlaubte "
|
||||
"Hostname in einen freigegebenen Gegenpunkt aufgelöst und beim Aufbau des Sockets fest gebunden. Nicht unterstützte "
|
||||
"Transporte scheitern vor dem Verbindungsaufbau. Für JMAP gelten dieselben Netzausgangsregeln; abweichende API-Ursprünge "
|
||||
"müssen ausdrücklich freigegeben sein und Antworten sowie Inhalte sind begrenzt. Beim Löschen eines Profils werden dessen verschlüsselte SMTP-, IMAP-, JMAP- "
|
||||
"und POP3-Geheimnisse sofort entfernt und, sofern Geheimnisse vorhanden waren, mit nicht geheimen Angaben protokolliert. "
|
||||
"Scheitert das Entfernen oder das Audit, wird die Aktion zurückgerollt. Verschlüsselte POP3-Importinhalte folgen der "
|
||||
"Aufbewahrung von Mail beziehungsweise Records. Bei einer destruktiven Modulaußerbetriebnahme gilt dieselbe Regel: "
|
||||
"Zuerst werden Geheimnisse entfernt, erst danach alle Mail-Tabellen einschließlich der Importdaten gelöscht."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/settings?section=mail-profiles",
|
||||
@@ -1016,6 +1441,27 @@ manifest = ModuleManifest(
|
||||
),
|
||||
related_modules=("campaigns", "audit"),
|
||||
unlocks=("Profile-backed campaign delivery without cross-module credential or configuration copies.",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Campaign über den Mail-Zustellvertrag integrieren",
|
||||
"summary": "Campaign friert Profilverweis und undurchsichtige Revision ein; Mail berechtigt erneut, prüft die Revision, löst Zugangsdaten auf und führt die Wirkung in einem Aufruf aus.",
|
||||
"body": (
|
||||
"Der Vertrag mail.campaign_delivery 0.2 gibt niemals entschlüsselte Zugangsdaten oder aufgelöste SMTP-/IMAP-Konfiguration zurück. "
|
||||
"Mail vergleicht die erwartete zufällige Transportrevision vor dem Entschlüsseln protokollspezifischer Zugangsdaten und liefert "
|
||||
"nur begrenzte, bereinigte Ergebnisse. Synchrone Stapel berechtigen die vollständige Empfängermenge und prüfen DNS, Netzausgang, "
|
||||
"Verbindung, TLS und Anmeldung vor der ersten Wirkung. Wenn die Bereitstellungsrichtlinie es erlaubt, verwendet Mail die begrenzte "
|
||||
"Verbindung erneut, prüft sie zuvor und verbindet vor der nächsten Nachricht neu, sobald sie veraltet ist. Ein Verbindungsverlust nach "
|
||||
"Beginn von DATA bleibt mit unbekanntem Ergebnis bestehen und wird niemals automatisch wiederholt. Systemische Anmelde-, Absender- oder "
|
||||
"Verbindungsfehler tragen stabile Ursachencodes, damit Campaign verbleibende Aufträge anhält und Verbindung, Neuverbindung, Fehler und "
|
||||
"Pausenfortschritt anzeigt. Campaign besitzt gewöhnliche Empfängeraufträge; Berichtsnachrichten nutzen das verschlüsselte, idempotente "
|
||||
"Zustellbefehls- und Versuchsledger von Mail. Jeder aktuelle SMTP- und Gesendet-Ordner-Versuch übergibt vor dem Providerkontakt eine "
|
||||
"stabile Wirkungskennung an einen von Mail besessenen Core-Wiederherstellungsvorgang. Der Nachweis des Wirkungsbeginns verhindert blinde "
|
||||
"Wiederzustellung; unbekannte Ergebnisse erfordern ausdrückliche Abstimmung, und rohe Empfängerablehnungen erfordern Mail-Diagnoseberechtigung. "
|
||||
"Mail-Ausgangsverarbeitung und Aufbewahrungsläufe werden nach Mandantenberechtigung partitioniert. Das Deaktivieren von Mail lässt bereits "
|
||||
"angenommene Befehle und Nachweise daher zur Klärung durch den Betrieb unverändert bestehen."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/campaigns/{campaign_id}/mail-settings",
|
||||
@@ -1037,7 +1483,7 @@ manifest = ModuleManifest(
|
||||
resolve=documentation_configuration_states,
|
||||
),
|
||||
),
|
||||
external_providers=(SMTP_PROVIDER, IMAP_PROVIDER),
|
||||
external_providers=(SMTP_PROVIDER, IMAP_PROVIDER, JMAP_PROVIDER, POP3_PROVIDER),
|
||||
external_provider_state_providers=(
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="mail",
|
||||
@@ -1049,6 +1495,16 @@ manifest = ModuleManifest(
|
||||
provider_id=IMAP_PROVIDER_ID,
|
||||
provider=imap_provider_states,
|
||||
),
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="mail",
|
||||
provider_id=JMAP_PROVIDER_ID,
|
||||
provider=jmap_provider_states,
|
||||
),
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="mail",
|
||||
provider_id=POP3_PROVIDER_ID,
|
||||
provider=pop3_provider_states,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="communication_participation",
|
||||
@@ -1056,15 +1512,15 @@ manifest = ModuleManifest(
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/MAIL_HANDBOOK.md",
|
||||
test_ref="tests/test_delivery_outbox.py",
|
||||
known_limits=("A complete webmail profile and recovery adoption for future provider-side mailbox mutations are not reference-ready.",),
|
||||
known_limits=("Mailbox access is deliberately read-only. JMAP currently covers Mailbox/get, Email/query, Email/get, and Email/changes only; provider-side mutations, threads, calendars, contacts, and JMAP submission are not implemented. POP3 is supported only as an explicitly enabled, bounded legacy-import path and has no folder or flag semantics.",),
|
||||
supported_authority_modes=(
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governance_overlay",
|
||||
),
|
||||
owned_concepts=("mail profile", "mail delivery command", "delivery attempt", "mailbox projection"),
|
||||
owned_concepts=("mail profile", "mail delivery command", "delivery attempt", "mailbox projection", "governed POP3 import"),
|
||||
non_owned_concepts=("campaign", "notification", "recipient address directory", "external mailbox"),
|
||||
target_tested_providers=(SMTP_PROVIDER_ID, IMAP_PROVIDER_ID),
|
||||
target_tested_providers=(SMTP_PROVIDER_ID, IMAP_PROVIDER_ID, JMAP_PROVIDER_ID, POP3_PROVIDER_ID),
|
||||
recovery_docs=("docs/MAIL_HANDBOOK.md",),
|
||||
security_docs=("docs/MAIL_HANDBOOK.md",),
|
||||
operations_docs=("docs/MAIL_HANDBOOK.md",),
|
||||
@@ -1072,5 +1528,10 @@ manifest = ModuleManifest(
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""add governed POP3 legacy imports
|
||||
|
||||
Revision ID: a4c5d6e7f809
|
||||
Revises: 93b4c5d6e7f8
|
||||
Create Date: 2026-08-22 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a4c5d6e7f809"
|
||||
down_revision = "93b4c5d6e7f8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"mail_pop3_imports",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("profile_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("pop3_server_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("pop3_credential_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("transport_revision", sa.String(length=120), nullable=False),
|
||||
sa.Column("provider_uidl", sa.String(length=500), nullable=False),
|
||||
sa.Column("provider_message_number", sa.Integer(), nullable=True),
|
||||
sa.Column("fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_message_encrypted", sa.Text(), nullable=False),
|
||||
sa.Column("message_id", sa.String(length=998), nullable=True),
|
||||
sa.Column("subject", sa.Text(), nullable=True),
|
||||
sa.Column("from_header", sa.Text(), nullable=True),
|
||||
sa.Column("to_header", sa.Text(), nullable=True),
|
||||
sa.Column("date", sa.String(length=255), nullable=True),
|
||||
sa.Column("body_preview", sa.Text(), nullable=True),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("imported_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("imported_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deletion_requested", sa.Boolean(), nullable=False),
|
||||
sa.Column("deletion_status", sa.String(length=40), nullable=False),
|
||||
sa.Column("deletion_attempted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deletion_error", sa.String(length=500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["profile_id"], ["mail_server_profiles.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["pop3_server_id"], ["mail_server_endpoints.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["imported_by_user_id"], ["access_users.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"profile_id",
|
||||
"pop3_server_id",
|
||||
"provider_uidl",
|
||||
name="uq_mail_pop3_imports_source_uidl",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"profile_id",
|
||||
"pop3_server_id",
|
||||
"fingerprint",
|
||||
"message_id",
|
||||
"status",
|
||||
"imported_at",
|
||||
"imported_by_user_id",
|
||||
"deletion_status",
|
||||
):
|
||||
op.create_index(
|
||||
f"ix_mail_pop3_imports_{column}",
|
||||
"mail_pop3_imports",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_mail_pop3_imports_review",
|
||||
"mail_pop3_imports",
|
||||
["tenant_id", "status", "imported_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("mail_pop3_imports")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -383,7 +383,7 @@ class MailContactCreateResponse(BaseModel):
|
||||
|
||||
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
|
||||
@@ -391,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)
|
||||
@@ -400,7 +476,7 @@ 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
|
||||
@@ -460,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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ from govoplan_mail.backend.db.models import (
|
||||
MailDeliveryCommand,
|
||||
MailDeliveryReconciliation,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY, MailDsarProvider
|
||||
@@ -71,7 +73,9 @@ class MailDsarProviderTests(unittest.TestCase):
|
||||
ChangeSequenceEntry.__table__,
|
||||
DataSubjectRequest.__table__,
|
||||
MailServerProfile.__table__,
|
||||
MailServerEndpoint.__table__,
|
||||
MailMailboxMessageIndex.__table__,
|
||||
MailPop3Import.__table__,
|
||||
MailDeliveryCommand.__table__,
|
||||
MailDeliveryAttempt.__table__,
|
||||
MailDeliveryReconciliation.__table__,
|
||||
@@ -160,6 +164,39 @@ class MailDsarProviderTests(unittest.TestCase):
|
||||
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",
|
||||
@@ -230,6 +267,8 @@ class MailDsarProviderTests(unittest.TestCase):
|
||||
unrelated,
|
||||
tenant_two_profile,
|
||||
tenant_two,
|
||||
pop3_server,
|
||||
pop3_import,
|
||||
command,
|
||||
attempt,
|
||||
reconciliation,
|
||||
@@ -264,6 +303,7 @@ class MailDsarProviderTests(unittest.TestCase):
|
||||
"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])
|
||||
@@ -295,6 +335,11 @@ class MailDsarProviderTests(unittest.TestCase):
|
||||
"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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -592,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(
|
||||
{
|
||||
|
||||
@@ -53,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),
|
||||
{
|
||||
@@ -73,8 +88,55 @@ class MailManifestTests(unittest.TestCase):
|
||||
"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()
|
||||
@@ -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()
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.25",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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");
|
||||
@@ -40,8 +41,37 @@ 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: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
|
||||
|
||||
+188
-9
@@ -164,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;
|
||||
@@ -296,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;
|
||||
@@ -304,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;
|
||||
@@ -503,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,
|
||||
@@ -520,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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -533,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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -550,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
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -565,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(
|
||||
|
||||
@@ -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 { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||
import { 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 {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
updateMailProfilePolicy,
|
||||
testImapSettings,
|
||||
testMailProfileImap,
|
||||
testMailProfileJmap,
|
||||
testMailProfileSmtp,
|
||||
testSmtpSettings,
|
||||
unlinkMailServerCredential,
|
||||
@@ -106,6 +107,15 @@ type ProfileDraft = {
|
||||
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 = {
|
||||
@@ -163,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"
|
||||
@@ -402,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
|
||||
@@ -526,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>
|
||||
@@ -660,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) }
|
||||
]} />;
|
||||
|
||||
@@ -1166,8 +1182,9 @@ function ProfileForm({
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [jmapTestResult, setJmapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapFolderResult, setImapFolderResult] = useState<MailImapFolderListResponse | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "jmap" | "folders" | null>(null);
|
||||
|
||||
const disabled = busy || !canWrite;
|
||||
const credentialDisabled = disabled || !canManageCredentials;
|
||||
@@ -1205,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[]>(
|
||||
@@ -1249,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." : "");
|
||||
@@ -1260,6 +1285,7 @@ function ProfileForm({
|
||||
useEffect(() => {
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
setJmapTestResult(null);
|
||||
setImapFolderResult(null);
|
||||
setMailActionState(null);
|
||||
}, [editing, editTarget]);
|
||||
@@ -1346,6 +1372,24 @@ function ProfileForm({
|
||||
}
|
||||
}
|
||||
|
||||
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 && (
|
||||
@@ -1359,7 +1403,7 @@ 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} />
|
||||
}
|
||||
@@ -1411,10 +1455,47 @@ function ProfileForm({
|
||||
</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" &&
|
||||
<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) =>
|
||||
@@ -1428,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(", ") })}
|
||||
@@ -1446,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(", ") })}
|
||||
@@ -1459,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`} />
|
||||
@@ -1469,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 &&
|
||||
@@ -1574,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>
|
||||
@@ -1586,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>);
|
||||
|
||||
}
|
||||
@@ -1607,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
|
||||
@@ -1669,7 +1785,16 @@ function emptyProfileDraft(): ProfileDraft {
|
||||
imapPassword: "",
|
||||
imapSentFolder: "auto",
|
||||
imapFolderMappings: {},
|
||||
imapTimeout: "30"
|
||||
imapTimeout: "30",
|
||||
jmapSessionUrl: "",
|
||||
jmapAccountId: "",
|
||||
jmapAuthScheme: "bearer",
|
||||
jmapAllowedApiOrigins: "",
|
||||
jmapTimeout: "20",
|
||||
jmapMaxResponseBytes: String(5 * 1024 * 1024),
|
||||
jmapMaxBodyValueBytes: String(1024 * 1024),
|
||||
jmapUsername: "",
|
||||
jmapToken: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1688,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,
|
||||
@@ -1725,7 +1851,16 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
|
||||
...(imapConfig?.folder_mappings ?? {}),
|
||||
...(!imapConfig?.folder_mappings?.sent && imapConfig?.sent_folder && imapConfig.sent_folder !== "auto" ? { sent: imapConfig.sent_folder } : {})
|
||||
}),
|
||||
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30)
|
||||
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30),
|
||||
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: ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1758,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: {
|
||||
@@ -1775,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"],
|
||||
@@ -1796,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;
|
||||
@@ -1906,6 +2041,18 @@ function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -2171,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}`;
|
||||
@@ -2204,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);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
bootstrapMailbox,
|
||||
listMailServerProfiles,
|
||||
lookupMailAddresses,
|
||||
type MailMailboxProtocol,
|
||||
type MailMailboxMessageSummary
|
||||
} from "../../api/mail";
|
||||
import { mailLookupSuggestions, mailtoHref } from "./mailAddressIntegration";
|
||||
@@ -53,9 +54,10 @@ export default function MailQuickAccess({
|
||||
const lookupRequestRef = useRef(0);
|
||||
const load = useCallback(async (): Promise<MailQuickAccessData> => {
|
||||
const profiles = await listMailServerProfiles(settings);
|
||||
const profile = profiles.find((item) => item.is_active && item.imap);
|
||||
const profile = profiles.find((item) => item.is_active && quickAccessMailboxProtocol(item));
|
||||
if (!profile) return { messages: [], available: false };
|
||||
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false);
|
||||
const protocol = quickAccessMailboxProtocol(profile) ?? "imap";
|
||||
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false, protocol);
|
||||
return {
|
||||
profileName: profile.name,
|
||||
profileId: profile.id,
|
||||
@@ -188,6 +190,12 @@ export default function MailQuickAccess({
|
||||
);
|
||||
}
|
||||
|
||||
function quickAccessMailboxProtocol(profile: { imap?: unknown; servers?: Array<{ protocol: string; is_active: boolean; is_default?: boolean }> }): MailMailboxProtocol | null {
|
||||
if (profile.servers?.some((server) => server.protocol === "jmap" && server.is_active)) return "jmap";
|
||||
if (profile.imap || profile.servers?.some((server) => server.protocol === "imap" && server.is_active)) return "imap";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
function formatMessageDate(value?: string | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type MailImapFolderResponse,
|
||||
type MailMailboxMessageDetail,
|
||||
type MailMailboxMessageSummary,
|
||||
type MailMailboxProtocol,
|
||||
type MailServerProfile } from
|
||||
"../../api/mail";
|
||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
||||
@@ -77,29 +78,30 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
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 syncState = mailboxSyncState(messageProvenance);
|
||||
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
|
||||
const folderEmptyText = folderError || (noMailboxProfiles ? "No IMAP- or JMAP-enabled Mail profiles are available." : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
|
||||
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
|
||||
const 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
|
||||
@@ -111,7 +113,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const request = parseMailboxLaunch(location.search);
|
||||
launchRequestRef.current = request;
|
||||
if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return;
|
||||
const usable = profiles.filter((profile) => profile.is_active && profile.imap);
|
||||
const 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 || "";
|
||||
@@ -133,6 +135,15 @@ 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;
|
||||
@@ -193,7 +204,7 @@ 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) =>
|
||||
@@ -224,7 +235,9 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
|
||||
if (!profileId) return;
|
||||
const profileInbox = profiles.find((profile) => profile.id === profileId)?.imap?.folder_mappings?.inbox || "";
|
||||
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)
|
||||
@@ -250,7 +263,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setDetailError("");
|
||||
setError("");
|
||||
try {
|
||||
const response = await bootstrapMailbox(settings, profileId, requestedFolder, 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: [] }];
|
||||
@@ -264,7 +277,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
: 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
|
||||
@@ -301,9 +314,10 @@ 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);
|
||||
@@ -311,7 +325,17 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
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;
|
||||
@@ -356,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));
|
||||
@@ -475,13 +499,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<div className="file-list-sticky">
|
||||
<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>
|
||||
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</ToolbarGroup>
|
||||
<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"]) &&
|
||||
@@ -504,13 +528,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
</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"
|
||||
}}
|
||||
@@ -792,8 +816,8 @@ 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 {
|
||||
@@ -855,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 });
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -152,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",
|
||||
@@ -193,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",
|
||||
@@ -227,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",
|
||||
@@ -295,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",
|
||||
@@ -362,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",
|
||||
@@ -403,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",
|
||||
|
||||
+11
-5
@@ -1,5 +1,5 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule, QuickAccessToolsUiCapability } from "@govoplan/core-webui";
|
||||
import { messagesProductSurfaceTranslations, type MailDevMailboxUiCapability, type MailProfilesUiCapability, type PlatformWebModule, type QuickAccessToolsUiCapability } from "@govoplan/core-webui";
|
||||
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
|
||||
@@ -9,11 +9,13 @@ import "./styles/mail-profiles.css";
|
||||
|
||||
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||
const MailBouncePage = lazy(() => import("./features/mail/MailBouncePage"));
|
||||
const MailLegacyImportPage = lazy(() => import("./features/mail/MailLegacyImportPage"));
|
||||
const mailboxRead = ["mail:mailbox:read"];
|
||||
const bounceRead = ["mail:bounce:read", "mail:bounce:manage"];
|
||||
const legacyImportAccess = ["mail:pop3:import", "mail:pop3:manage"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
en: { ...generatedTranslations.en, ...messagesProductSurfaceTranslations.en },
|
||||
de: { ...generatedTranslations.de, ...messagesProductSurfaceTranslations.de }
|
||||
};
|
||||
const mailQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||
tools: [
|
||||
@@ -39,10 +41,14 @@ export const mailModule: PlatformWebModule = {
|
||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 },
|
||||
{ id: "mail.quick_access.messages", moduleId: "mail", kind: "quick_access", label: "Mail Quick Access", order: 80 }
|
||||
],
|
||||
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||
navItems: [
|
||||
{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 },
|
||||
{ to: "/mail/legacy-import", label: "Legacy POP3 import", iconName: "mail", anyOf: legacyImportAccess, order: 52 }
|
||||
],
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user