Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218fef11f1 | ||
|
|
93ecedf607 | ||
|
|
cfd3847524 | ||
|
|
2fe56fca00 | ||
|
|
34bd5be8d4 | ||
|
|
a044fee379 | ||
|
|
169b81d9db | ||
|
|
2ad122056e | ||
|
|
9ac8847559 | ||
|
|
e0e00d7000 | ||
|
|
393331574a | ||
|
|
bb8c60abb9 | ||
|
|
c59c67bd18 | ||
|
|
761dd76b9e | ||
|
|
28f357e151 | ||
|
|
cff428b475 | ||
|
|
77f44dfab5 | ||
|
|
2c5585ba99 |
@@ -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 profile management, an explicitly enabled legacy POP3 import path, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -12,13 +12,23 @@ 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 profile models, dedicated legacy POP3 sources, policy checks, encrypted credential storage, and profile resolution
|
||||
- SMTP send and IMAP append adapters, including mock transports for development
|
||||
- development mock mailbox endpoints used by test-send flows
|
||||
- WebUI package `@govoplan/mail-webui` with profile management, policy management, and read-only mailbox components
|
||||
- WebUI package `@govoplan/mail-webui` with profile management, policy management, read-only mailbox components, and governed POP3 import
|
||||
|
||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
||||
|
||||
Mail publishes `privacy.dsar.mail` for Core's governed data-subject-request
|
||||
workflow. It isolates matching mailbox header parties and returns bounded index,
|
||||
personal-profile, delivery, reconciliation, bounce, and imported-message metadata. SMTP/IMAP/POP3
|
||||
configuration and credentials, encrypted messages and envelopes, source UIDL
|
||||
and folder/UID
|
||||
locators, worker and idempotency state, diagnostics, and opaque evidence are
|
||||
excluded. Delivery and bounce outcomes remain retained evidence; mailbox and
|
||||
profile changes require coordinated Mail and external-provider review, so the
|
||||
provider does not perform direct erasure.
|
||||
|
||||
## Profile and credential ownership
|
||||
|
||||
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
|
||||
@@ -41,6 +51,18 @@ and requires explicit evidence-backed reconciliation before any deliberate
|
||||
resend. Business readers receive only counts and sanitized state; recipient
|
||||
refusal details require `mail:delivery:diagnostic`.
|
||||
|
||||
Synchronous Campaign batches now preflight DNS, egress, connectivity, TLS, and
|
||||
authentication before the first message, then reuse the authorized SMTP
|
||||
connection for the bounded batch. A health check precedes reuse; a stale
|
||||
connection is reopened before the next message, while a connection loss after
|
||||
DATA starts remains outcome-unknown and is never replayed automatically.
|
||||
Systemic authentication, sender, and connectivity failures pause remaining
|
||||
Campaign jobs instead of producing one failure per recipient. Deployment
|
||||
operators can disable reuse or bound connection lifetime and reconnects with
|
||||
`GOVOPLAN_SMTP_BATCH_REUSE`, `GOVOPLAN_SMTP_BATCH_MAX_MESSAGES`,
|
||||
`GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS`, and
|
||||
`GOVOPLAN_SMTP_BATCH_HEALTH_CHECK`.
|
||||
|
||||
SMTP effects decrypt only SMTP credentials; Sent-folder effects decrypt only
|
||||
IMAP credentials. A connection loss after an effect starts is surfaced as an
|
||||
unknown outcome. Campaign does not automatically retry an unknown IMAP append,
|
||||
@@ -122,10 +144,13 @@ 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 remains deferred. The explicitly enabled POP3 slice is limited to bounded,
|
||||
encrypted, duplicate-safe legacy import; preview and ordinary import are
|
||||
non-destructive, while source deletion requires separate endpoint policy,
|
||||
permission, confirmation, and audit evidence. The protocol decision is
|
||||
documented in [docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md):
|
||||
prefer JMAP for future modern mailbox sync/search and keep POP3 out of normal
|
||||
mailbox browsing. The same roadmap records the
|
||||
approved S/MIME-first, OpenPGP-additional message-protection profile and its
|
||||
no-silent-downgrade requirement.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ mailbox, policy, and delivery-evidence consequences described here.
|
||||
| System/tenant/group/user/campaign profile surfaces | Compare profiles, protocol servers, reusable credentials, status, and scope | Administration/configuration | High because endpoints, credentials, and inheritance control external communication | Shared `ConnectionTree`, stable row actions, textual status, permission/target blockers, and contextual admin help |
|
||||
| Profile creation and focused profile/server/credential editors | Create a governed transport identity or edit one hierarchy object | Guided setup plus adaptive create/edit | High because saving may enable provider access or replace encrypted credentials | Shared `Dialog` and `StageRail` for multi-object setup; focused edit modes show only the selected hierarchy object; field help, connection tests, unsaved-draft guard, and disabled-save reasons |
|
||||
| Mail profile policy card | Narrow visible profiles, lower-scope definitions, hosts, senders, and recipients | Effective-policy editor | High because inherited allow/deny rules govern delivery and lower scopes | Shared policy rows, typed selectors, source path, locked/read-only blocker, dirty-save state, and contextual policy help |
|
||||
| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions |
|
||||
| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `PageLayout`, `ContentGrid`, `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions |
|
||||
| `/mail/bounces` observation table | Inspect correlated or unmatched delivery-status evidence | Evidence/reporting | Medium because recipient and diagnostic data may be sensitive | Bounded sanitized rows, textual status, filters, correlation state, and no raw bounce body |
|
||||
| Bounce watcher removal | Stop future scans while retaining evidence | Destructive confirmation | Medium and reversible by recreating the watcher; observations are retained | Shared `ConfirmDialog` states the immediate consequence and retained evidence |
|
||||
| `mail.profiles` and credential-reference capabilities | Let another module select or validate Mail-owned transport without receiving secrets | Governed capability composition | High because the selected identity can perform external effects | Stable references and Core capability boundaries; no sibling-private WebUI import; authorization and credential resolution remain Mail-owned |
|
||||
@@ -22,6 +22,9 @@ mailbox, policy, and delivery-evidence consequences described here.
|
||||
|
||||
- Loading, success, error, empty results, permission blockers, and destructive
|
||||
confirmation use Core components. Mail does not reproduce the shell.
|
||||
- Bounce processing now delegates its page inset, sticky responsive heading,
|
||||
route actions, alert regions, loading boundary, scrolling, and help audience
|
||||
to Core `PageLayout`; Mail retains only watcher and evidence semantics.
|
||||
- A target-dependent profile surface cannot load until a concrete user, group,
|
||||
or campaign is selected. The blocker identifies the responsible actor and
|
||||
destination instead of silently hiding the editor.
|
||||
|
||||
+125
-20
@@ -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 profile definitions, dedicated legacy POP3 sources, and scope;
|
||||
- encrypted SMTP/IMAP/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 mailbox folder/message access, bounded indexes, and encrypted
|
||||
pending-review records imported from legacy POP3 sources; and
|
||||
- the transport sanitization boundary and throttling behavior. A general
|
||||
Mail-owned provider-attempt/diagnostic ledger remains planned.
|
||||
|
||||
@@ -43,6 +44,32 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the
|
||||
module registry. Optional consumers provide narrow context through capabilities;
|
||||
Mail does not import their ORM or service implementations.
|
||||
|
||||
## Deployment configuration packages
|
||||
|
||||
Mail registers the `mail.configuration` capability for `smtp_profile`
|
||||
fragments. The provider reads the validated `mail.smtp` entry from the
|
||||
installer-generated infrastructure capability receipt. Receipt host and port
|
||||
are authoritative; the generic package workflow asks for missing non-secret
|
||||
transport fields such as security mode. An external relay may operate without
|
||||
authentication, or the operator may select an existing credential-envelope id.
|
||||
Inline usernames, passwords, tokens, and secret values are rejected.
|
||||
|
||||
Tenant scope is the default. A system-scoped profile requires system settings
|
||||
or governance write authority. The fragment's stable slug is its idempotency
|
||||
identity: an absent profile is created, an exact profile is skipped, and a
|
||||
conflicting profile is preserved unless the reviewed fragment explicitly sets
|
||||
`on_conflict` to `update`. Credential bindings are added idempotently and are
|
||||
never removed merely because a package omits a credential reference.
|
||||
|
||||
Preflight does not prove SMTP reachability. After apply, use the normal Mail
|
||||
profile test and Ops health surfaces. If the receipt says SMTP is unavailable,
|
||||
is invalid, or is not mounted, import is blocked with an operator-facing
|
||||
resolution instead of creating a partial profile.
|
||||
|
||||
Configuration packages remain SMTP-focused. A POP3 legacy source is a deliberate
|
||||
operational migration action and is not silently exported, cloned, or enabled by
|
||||
an SMTP profile package.
|
||||
|
||||
## Interface patterns and unavailable actions
|
||||
|
||||
Mail uses the platform's shared explorer, connection tree, adaptive form,
|
||||
@@ -67,9 +94,17 @@ deactivating a profile may scrub Mail-owned credentials as described below.
|
||||
### Profile
|
||||
|
||||
A profile is a reusable, named delivery identity with optional SMTP and IMAP
|
||||
configuration. It has a stable id, lifecycle state, scope, owner context, and
|
||||
non-secret connection metadata. Passwords are write-only encrypted values and
|
||||
are never returned through list/read/capability responses.
|
||||
configuration. It can additionally own a dedicated POP3 endpoint for an
|
||||
explicit legacy-import workflow. It has a stable id, lifecycle state, scope,
|
||||
owner context, and non-secret connection metadata. Passwords are write-only
|
||||
encrypted values and are never returned through list/read/capability responses.
|
||||
|
||||
An IMAP server may map the standard Inbox, Sent, Drafts, Trash, Archive, and
|
||||
Junk roles to exact provider folder names. These mappings belong to the reusable
|
||||
profile/server. Empty roles retain automatic behavior. The historical
|
||||
`imap.sent_folder` value is read as the Sent mapping and remains synchronized
|
||||
for compatibility; a Campaign-specific Sent override still wins for that
|
||||
Campaign.
|
||||
|
||||
Profiles may be scoped to system, tenant, user, group, or campaign context.
|
||||
Scope controls where a profile can be discovered; effective policy can narrow
|
||||
@@ -99,6 +134,11 @@ authorization. Mail re-evaluates profile activity, visibility, policy, and
|
||||
revision immediately before it resolves credentials and performs the
|
||||
effect.
|
||||
|
||||
POP3 imports pin the endpoint/credential transport revision at preview time. A
|
||||
changed revision or a missing provider UIDL stops import and requires a fresh
|
||||
preview. POP3 does not participate in the ordinary mailbox folder/message
|
||||
projection.
|
||||
|
||||
### Provider outcomes
|
||||
|
||||
SMTP acceptance, partial or complete recipient refusal, temporary/permanent
|
||||
@@ -156,13 +196,63 @@ ordinary consumers to bypass reusable profiles.
|
||||
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.
|
||||
flags and common names. The list exposes the provider's `Seen` flag as a
|
||||
read/unread indicator without changing it. It also labels whether the current
|
||||
page came directly from the provider, from the bounded mailbox index, or from
|
||||
an index while a refresh is in progress, including the index timestamp when
|
||||
available.
|
||||
|
||||
Message HTML is displayed only in the shared sandboxed message component.
|
||||
Remote URLs and active markup are removed, embedded `data:`/`cid:` image
|
||||
references remain isolated, and plain text is always available when supplied.
|
||||
Attachments and provider/content failures remain explicit rather than being
|
||||
silently interpreted as an empty message.
|
||||
|
||||
Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must
|
||||
not mutate read/unread, delete, move, or reply state. Message responses are
|
||||
bounded by the deployment response policy; ordinary UI should avoid loading a
|
||||
whole large mailbox or attachment merely to show a list.
|
||||
|
||||
### 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.
|
||||
|
||||
## Profile administration
|
||||
|
||||
### Roles
|
||||
@@ -175,11 +265,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
|
||||
@@ -205,8 +300,10 @@ account's user scope. Grant `mail:profile:write_own` for self-service;
|
||||
`mail:profile:write` remains broad profile administration authority.
|
||||
|
||||
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
|
||||
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
|
||||
behavior, and timeouts. Sender/envelope/recipient constraints belong to
|
||||
2. Configure SMTP, optional IMAP, 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.
|
||||
@@ -264,10 +361,10 @@ tenant posture; it does not expose credential material.
|
||||
|
||||
Private-network connector access is controlled deployment-wide by
|
||||
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Whether private or public targets
|
||||
are allowed, SMTP and IMAP resolve, validate, and connect to the exact approved
|
||||
address records at connection time while retaining the original hostname for
|
||||
TLS SNI and certificate verification. A DNS change cannot redirect the socket
|
||||
after validation.
|
||||
are allowed, SMTP, IMAP, and legacy POP3 resolve, validate, and connect to the
|
||||
exact approved address records at connection time while retaining the original
|
||||
hostname for TLS SNI and certificate verification. A DNS change cannot redirect
|
||||
the socket after validation.
|
||||
|
||||
Transports that cannot pin every connection peer or revalidate protocol-managed
|
||||
redirects/referrals must fail before client construction. Do not weaken this
|
||||
@@ -342,10 +439,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
|
||||
@@ -454,8 +552,15 @@ Before claiming a Mail composition is production-ready:
|
||||
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.
|
||||
- Expanding POP3 beyond the implemented explicit legacy download/import
|
||||
workflow; it has no folder, flag, search, or synchronization contract.
|
||||
- A full mail client with compose/reply/move/delete/read-state mutation.
|
||||
- Quick Access may launch the operating environment's configured composer via
|
||||
`mailto:`. That explicit handoff is not a GovOPlaN Mail delivery: it selects
|
||||
no Mail profile or credential, bypasses no Mail policy, and reports no
|
||||
GovOPlaN delivery result. Recent-message and Drafts links remain read-only
|
||||
deep links into the authorized Mail profile and preserve their Quick Access
|
||||
return context.
|
||||
- Recovery-ledger adoption for future provider-side move, delete, and flag
|
||||
mutations; no such production path exists in the current read-only mailbox.
|
||||
- Proof that process-local throttling coordinates multiple workers when Redis
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# 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 focuses on SMTP sending and IMAP mailbox access. It also provides
|
||||
an explicitly enabled, bounded POP3 legacy-import path. JMAP remains deferred
|
||||
until the IMAP mailbox MVP and protocol-neutral mailbox contract are stable.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- SMTP is the send protocol.
|
||||
- IMAP is the read/append protocol.
|
||||
- POP3 is an optional legacy migration source, never a general mailbox
|
||||
protocol or default profile endpoint.
|
||||
- Mail profile policy, encrypted credentials, mailbox folder parsing, test
|
||||
buttons, and read-only mailbox UI are built around SMTP and IMAP.
|
||||
|
||||
@@ -56,17 +59,30 @@ JMAP should be added only after:
|
||||
|
||||
## 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. Design protocol-neutral mailbox DTOs and
|
||||
prefer JMAP for future modern synchronization/search support.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"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.20",
|
||||
"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.18"
|
||||
version = "0.1.20"
|
||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid
|
||||
from typing import Any
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -40,13 +42,27 @@ from govoplan_mail.backend.sending.imap import (
|
||||
append_message_to_sent,
|
||||
)
|
||||
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
|
||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
|
||||
from govoplan_mail.backend.sending.smtp import (
|
||||
SmtpBatchSession,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
send_email_bytes,
|
||||
)
|
||||
|
||||
|
||||
_ACTIVE_SMTP_BATCH: ContextVar[SmtpBatchSession | None] = ContextVar(
|
||||
"govoplan_mail_active_smtp_batch",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignSmtpDeliveryResult:
|
||||
envelope_recipients: list[str]
|
||||
refused_recipients: dict[str, dict[str, int | str]]
|
||||
connection_sequence: int = 1
|
||||
session_reused: bool = False
|
||||
reconnect_count: int = 0
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
@@ -58,6 +74,23 @@ class CampaignImapAppendResult:
|
||||
folder: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignSmtpBatchState:
|
||||
session: SmtpBatchSession
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "ready"
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
return self.session.connection_count
|
||||
|
||||
@property
|
||||
def reconnect_count(self) -> int:
|
||||
return self.session.reconnect_count
|
||||
|
||||
|
||||
def _sanitized_refusals(
|
||||
refused_recipients: dict[str, tuple[int, bytes | str]],
|
||||
) -> dict[str, dict[str, int | str]]:
|
||||
@@ -95,6 +128,9 @@ def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
|
||||
message,
|
||||
temporary=exc.temporary,
|
||||
outcome_unknown=exc.outcome_unknown,
|
||||
systemic=exc.systemic,
|
||||
reason_code=exc.reason_code,
|
||||
phase=exc.phase,
|
||||
)
|
||||
|
||||
|
||||
@@ -279,6 +315,106 @@ def campaign_profile_delivery_summary(
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def campaign_smtp_batch(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
from_header: str | None,
|
||||
expected_smtp_transport_revision: str,
|
||||
smtp_server_id: str | None = None,
|
||||
smtp_credential_id: str | None = None,
|
||||
) -> Iterator[CampaignSmtpBatchState]:
|
||||
"""Preflight and retain one authorized SMTP connection for a batch."""
|
||||
|
||||
selection = _selection_payload(
|
||||
profile_id=profile_id,
|
||||
smtp_server_id=smtp_server_id,
|
||||
smtp_credential_id=smtp_credential_id,
|
||||
)
|
||||
try:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
selection=selection,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
if _supports_hierarchy(session):
|
||||
context = _campaign_hierarchy_context(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
try:
|
||||
selected_smtp = select_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
)
|
||||
except MailServerHierarchyError as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
current_revision = selected_smtp.transport_revision
|
||||
else:
|
||||
context = None
|
||||
current_revision = campaign_profile_transport_revisions(profile)["smtp"]
|
||||
if current_revision != expected_smtp_transport_revision:
|
||||
raise MailProfileError(
|
||||
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
try:
|
||||
smtp = (
|
||||
resolve_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
).config
|
||||
if context is not None
|
||||
else smtp_config_from_profile(profile)
|
||||
)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
try:
|
||||
assert_mail_policy_allows_send(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
smtp=smtp,
|
||||
imap=None,
|
||||
envelope_sender=envelope_from,
|
||||
from_header=from_header,
|
||||
recipients=envelope_recipients,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise MailProfileError("Mail delivery is blocked by the effective Mail policy.") from None
|
||||
|
||||
smtp_session = SmtpBatchSession(smtp)
|
||||
smtp_session.preflight()
|
||||
token = _ACTIVE_SMTP_BATCH.set(smtp_session)
|
||||
try:
|
||||
yield CampaignSmtpBatchState(session=smtp_session)
|
||||
finally:
|
||||
_ACTIVE_SMTP_BATCH.reset(token)
|
||||
smtp_session.close()
|
||||
|
||||
|
||||
def send_campaign_email_bytes(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -394,6 +530,7 @@ def send_campaign_email_bytes(
|
||||
smtp_config=smtp,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
batch_session=_ACTIVE_SMTP_BATCH.get(),
|
||||
)
|
||||
except SmtpSendError as exc:
|
||||
sanitized = _sanitized_smtp_error(exc)
|
||||
@@ -420,6 +557,9 @@ def send_campaign_email_bytes(
|
||||
sanitized_result = CampaignSmtpDeliveryResult(
|
||||
envelope_recipients=list(result.envelope_recipients),
|
||||
refused_recipients=_sanitized_refusals(result.refused_recipients),
|
||||
connection_sequence=getattr(result, "connection_sequence", 0),
|
||||
session_reused=getattr(result, "session_reused", False),
|
||||
reconnect_count=getattr(result, "reconnect_count", 0),
|
||||
)
|
||||
if recovery is not None:
|
||||
try:
|
||||
@@ -604,6 +744,7 @@ class MailCampaignCapability:
|
||||
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
|
||||
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
|
||||
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
|
||||
campaign_smtp_batch = staticmethod(campaign_smtp_batch)
|
||||
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
|
||||
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
||||
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from govoplan_core.mail.config import (
|
||||
ImapConfig,
|
||||
ImapFolderMappings,
|
||||
ImapServerConfig,
|
||||
SmtpConfig,
|
||||
SmtpServerConfig,
|
||||
@@ -11,9 +14,49 @@ 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
|
||||
|
||||
__all__ = [
|
||||
"ImapConfig",
|
||||
"ImapFolderMappings",
|
||||
"ImapServerConfig",
|
||||
"Pop3Config",
|
||||
"Pop3ServerConfig",
|
||||
"SmtpConfig",
|
||||
"SmtpServerConfig",
|
||||
"StrictModel",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -387,3 +387,72 @@ class MailBounceObservation(Base, TimestampMixin):
|
||||
)
|
||||
matched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class MailPop3Import(Base, TimestampMixin):
|
||||
"""Governed local review record created from a legacy POP3 mailbox."""
|
||||
|
||||
__tablename__ = "mail_pop3_imports"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"profile_id",
|
||||
"pop3_server_id",
|
||||
"provider_uidl",
|
||||
name="uq_mail_pop3_imports_source_uidl",
|
||||
),
|
||||
Index(
|
||||
"ix_mail_pop3_imports_review",
|
||||
"tenant_id",
|
||||
"status",
|
||||
"imported_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
profile_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("mail_server_profiles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
pop3_server_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("mail_server_endpoints.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
pop3_credential_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
transport_revision: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
provider_uidl: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
provider_message_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
raw_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
raw_message_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
message_id: Mapped[str | None] = mapped_column(String(998), nullable=True, index=True)
|
||||
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
from_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
to_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
date: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
body_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(40), default="pending_review", nullable=False, index=True
|
||||
)
|
||||
imported_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
imported_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
deletion_requested: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False
|
||||
)
|
||||
deletion_status: Mapped[str] = mapped_column(
|
||||
String(40), default="not_requested", nullable=False, index=True
|
||||
)
|
||||
deletion_attempted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
deletion_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from email.utils import getaddresses
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import (
|
||||
MailBounceObservation,
|
||||
MailDeliveryAttempt,
|
||||
MailDeliveryCommand,
|
||||
MailDeliveryReconciliation,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerProfile,
|
||||
)
|
||||
|
||||
|
||||
MAIL_DSAR_CAPABILITY = dsar_capability_name("mail")
|
||||
_MAX_RECORDS = 5_000
|
||||
|
||||
|
||||
class MailDsarProvider:
|
||||
provider_id = "mail"
|
||||
module_id = "mail"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
email = _subject_email(subject)
|
||||
membership_ids = _membership_ids(subject)
|
||||
references = _mail_references(subject)
|
||||
if email is None and not membership_ids and not references:
|
||||
return ()
|
||||
|
||||
records: list[DsarRecordRef] = []
|
||||
|
||||
def append(record: DsarRecordRef) -> None:
|
||||
if len(records) >= _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Mail DSAR match limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
records.append(record)
|
||||
|
||||
profiles = _matching_profiles(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
membership_ids=membership_ids,
|
||||
profile_id=references.get("profile"),
|
||||
)
|
||||
for profile in profiles:
|
||||
append(
|
||||
_record(
|
||||
"mail_server_profile",
|
||||
profile.id,
|
||||
"mail_profile",
|
||||
profile.name,
|
||||
{
|
||||
"match_fields": _profile_matching_fields(
|
||||
profile, membership_ids
|
||||
),
|
||||
"name": profile.name,
|
||||
"slug": profile.slug,
|
||||
"description": profile.description,
|
||||
"scope_type": profile.scope_type,
|
||||
"is_active": profile.is_active,
|
||||
"inherit_to_lower_scopes": profile.inherit_to_lower_scopes,
|
||||
},
|
||||
observed_at=profile.updated_at,
|
||||
source_path="/settings?section=mail-profiles",
|
||||
)
|
||||
)
|
||||
|
||||
messages = _matching_messages(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
message_id=references.get("message_index"),
|
||||
)
|
||||
for message in messages:
|
||||
matching_headers = _matching_headers(message, email)
|
||||
append(
|
||||
_record(
|
||||
"mailbox_message_index",
|
||||
message.id,
|
||||
"mailbox_message",
|
||||
message.subject or "Mailbox message",
|
||||
{
|
||||
"match_fields": list(matching_headers),
|
||||
"subject": _bounded_text(message.subject),
|
||||
"matching_headers": matching_headers,
|
||||
"date": message.date,
|
||||
"flags": tuple(
|
||||
str(flag)[:100] for flag in (message.flags or ())[:32]
|
||||
),
|
||||
"size_bytes": message.size_bytes,
|
||||
"body_preview": _bounded_text(message.body_preview),
|
||||
"attachment_count": message.attachment_count,
|
||||
"indexed_at": _iso(message.indexed_at),
|
||||
},
|
||||
observed_at=message.updated_at,
|
||||
source_path="/mail",
|
||||
)
|
||||
)
|
||||
|
||||
pop3_imports = _matching_pop3_imports(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
import_id=references.get("pop3_import"),
|
||||
)
|
||||
for imported in pop3_imports:
|
||||
matching_headers = _matching_pop3_headers(imported, email)
|
||||
append(
|
||||
_record(
|
||||
"mail_pop3_import",
|
||||
imported.id,
|
||||
"mail_imported_message",
|
||||
imported.subject or "Imported legacy message",
|
||||
{
|
||||
"match_fields": list(matching_headers),
|
||||
"subject": _bounded_text(imported.subject),
|
||||
"matching_headers": matching_headers,
|
||||
"date": imported.date,
|
||||
"message_id": _bounded_text(imported.message_id),
|
||||
"body_preview": _bounded_text(imported.body_preview),
|
||||
"size_bytes": imported.size_bytes,
|
||||
"status": imported.status,
|
||||
"imported_at": _iso(imported.imported_at),
|
||||
"deletion_requested": imported.deletion_requested,
|
||||
"deletion_status": imported.deletion_status,
|
||||
},
|
||||
observed_at=imported.updated_at,
|
||||
source_path="/mail/legacy-import",
|
||||
)
|
||||
)
|
||||
|
||||
bounces = _matching_bounces(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
bounce_id=references.get("bounce"),
|
||||
)
|
||||
bounce_command_ids = {row.command_id for row in bounces if row.command_id}
|
||||
commands = _matching_commands(
|
||||
db,
|
||||
tenant_id=tenant_id,
|
||||
membership_ids=membership_ids,
|
||||
command_id=references.get("command"),
|
||||
related_command_ids=bounce_command_ids,
|
||||
)
|
||||
command_ids = {row.id for row in commands}
|
||||
for command in commands:
|
||||
append(
|
||||
_record(
|
||||
"mail_delivery_command",
|
||||
command.id,
|
||||
"mail_delivery_evidence",
|
||||
f"Mail {command.command_type} command",
|
||||
{
|
||||
"match_fields": (
|
||||
["created_by_user_id"]
|
||||
if command.created_by_user_id in membership_ids
|
||||
else (
|
||||
["reference"]
|
||||
if command.id == references.get("command")
|
||||
else ["bounce"]
|
||||
)
|
||||
),
|
||||
"command_type": command.command_type,
|
||||
"source_module": command.source_module,
|
||||
"source_resource_type": command.source_resource_type,
|
||||
"message_sha256": command.message_sha256,
|
||||
"rfc_message_id": command.rfc_message_id,
|
||||
"message_size_bytes": command.message_size_bytes,
|
||||
"recipient_count": command.recipient_count,
|
||||
"status": command.status,
|
||||
"attempt_count": command.attempt_count,
|
||||
"effect_started_at": _iso(command.effect_started_at),
|
||||
"completed_at": _iso(command.completed_at),
|
||||
"accepted_count": command.accepted_count,
|
||||
"refused_count": command.refused_count,
|
||||
"failure_code": command.failure_code,
|
||||
"payload_purged_at": _iso(command.payload_purged_at),
|
||||
},
|
||||
observed_at=command.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Mail delivery commands are immutable transport, retry, and outcome evidence; encrypted payload retention is governed separately.",
|
||||
)
|
||||
)
|
||||
|
||||
for attempt in _command_attempts(db, command_ids):
|
||||
append(
|
||||
_record(
|
||||
"mail_delivery_attempt",
|
||||
attempt.id,
|
||||
"mail_delivery_evidence",
|
||||
f"Mail delivery attempt {attempt.attempt_number}",
|
||||
{
|
||||
"command_id": attempt.command_id,
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"started_at": _iso(attempt.started_at),
|
||||
"effect_started_at": _iso(attempt.effect_started_at),
|
||||
"completed_at": _iso(attempt.completed_at),
|
||||
"accepted_count": attempt.accepted_count,
|
||||
"refused_count": attempt.refused_count,
|
||||
"outcome_code": attempt.outcome_code,
|
||||
},
|
||||
observed_at=attempt.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Per-attempt Mail outcome state is immutable delivery and recovery evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
for reconciliation in _command_reconciliations(db, command_ids):
|
||||
append(
|
||||
_record(
|
||||
"mail_delivery_reconciliation",
|
||||
reconciliation.id,
|
||||
"mail_delivery_evidence",
|
||||
"Mail delivery reconciliation",
|
||||
{
|
||||
"command_id": reconciliation.command_id,
|
||||
"decision": reconciliation.decision,
|
||||
},
|
||||
observed_at=reconciliation.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Mail reconciliation decisions are immutable authorization and recovery evidence.",
|
||||
)
|
||||
)
|
||||
|
||||
for bounce in bounces:
|
||||
append(
|
||||
_record(
|
||||
"mail_bounce_observation",
|
||||
bounce.id,
|
||||
"mail_bounce_evidence",
|
||||
"Mail bounce observation",
|
||||
{
|
||||
"match_fields": (
|
||||
["recipient"]
|
||||
if email and _normalized_email(bounce.recipient) == email
|
||||
else ["reference"]
|
||||
),
|
||||
"command_id": bounce.command_id
|
||||
if bounce.command_id in command_ids
|
||||
else None,
|
||||
"recipient": email
|
||||
if email and _normalized_email(bounce.recipient) == email
|
||||
else None,
|
||||
"action": bounce.action,
|
||||
"status_code": bounce.status_code,
|
||||
"permanent": bounce.permanent,
|
||||
"observed_at": _iso(bounce.observed_at),
|
||||
"matched": bounce.matched,
|
||||
},
|
||||
observed_at=bounce.updated_at,
|
||||
immutable=True,
|
||||
retention_reason="Bounce observations are immutable delivery-status and suppression evidence.",
|
||||
source_path="/mail/bounces",
|
||||
)
|
||||
)
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del session, tenant_id, subject
|
||||
actions = []
|
||||
for record in records:
|
||||
if (
|
||||
record.provider_id != self.provider_id
|
||||
or record.module_id != self.module_id
|
||||
):
|
||||
raise ValueError("Mail DSAR received a foreign provider record.")
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"mail:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}",
|
||||
provider_id="mail",
|
||||
module_id="mail",
|
||||
kind="retain" if record.immutable_evidence else "manual_review",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}",
|
||||
rationale=record.retention_reason
|
||||
or "Mailbox indexes and personal profiles must be reviewed through Mail and the authoritative external mailbox lifecycle.",
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del session, tenant_id, subject, request_id
|
||||
return tuple(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary="Mail erasure requires an authorized Mail/external-mailbox lifecycle action; the DSAR provider does not mutate it directly.",
|
||||
)
|
||||
for action in actions
|
||||
)
|
||||
|
||||
|
||||
def _matching_profiles(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
membership_ids: set[str],
|
||||
profile_id: str | None,
|
||||
) -> list[MailServerProfile]:
|
||||
conditions = []
|
||||
if profile_id:
|
||||
conditions.append(MailServerProfile.id == profile_id)
|
||||
if membership_ids:
|
||||
conditions.extend(
|
||||
(
|
||||
MailServerProfile.created_by_user_id.in_(membership_ids),
|
||||
MailServerProfile.updated_by_user_id.in_(membership_ids),
|
||||
(MailServerProfile.scope_type == "user")
|
||||
& MailServerProfile.scope_id.in_(membership_ids),
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailServerProfile)
|
||||
.filter(MailServerProfile.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailServerProfile.id)
|
||||
)
|
||||
|
||||
|
||||
def _profile_matching_fields(
|
||||
row: MailServerProfile, membership_ids: set[str]
|
||||
) -> list[str]:
|
||||
fields = []
|
||||
if row.scope_type == "user" and row.scope_id in membership_ids:
|
||||
fields.append("scope_id")
|
||||
for field in ("created_by_user_id", "updated_by_user_id"):
|
||||
if getattr(row, field) in membership_ids:
|
||||
fields.append(field)
|
||||
return fields
|
||||
|
||||
|
||||
def _matching_messages(
|
||||
session: Session, *, tenant_id: str, email: str | None, message_id: str | None
|
||||
) -> list[MailMailboxMessageIndex]:
|
||||
conditions = []
|
||||
if message_id:
|
||||
conditions.append(MailMailboxMessageIndex.id == message_id)
|
||||
if email:
|
||||
pattern = f"%{_escape_like(email)}%"
|
||||
conditions.extend(
|
||||
func.lower(field).like(pattern, escape="\\")
|
||||
for field in (
|
||||
MailMailboxMessageIndex.from_header,
|
||||
MailMailboxMessageIndex.to_header,
|
||||
MailMailboxMessageIndex.cc_header,
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
candidates = _bounded_rows(
|
||||
session.query(MailMailboxMessageIndex)
|
||||
.filter(MailMailboxMessageIndex.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailMailboxMessageIndex.id)
|
||||
)
|
||||
return [
|
||||
row
|
||||
for row in candidates
|
||||
if row.id == message_id or _matching_headers(row, email)
|
||||
]
|
||||
|
||||
|
||||
def _matching_headers(
|
||||
row: MailMailboxMessageIndex, email: str | None
|
||||
) -> dict[str, list[dict[str, str | None]]]:
|
||||
if email is None:
|
||||
return {}
|
||||
result = {}
|
||||
for role, value in (
|
||||
("from", row.from_header),
|
||||
("to", row.to_header),
|
||||
("cc", row.cc_header),
|
||||
):
|
||||
matches = [
|
||||
{"email": address.casefold(), "name": name or None}
|
||||
for name, address in getaddresses([value or ""])
|
||||
if address.casefold() == email
|
||||
]
|
||||
if matches:
|
||||
result[role] = matches[:64]
|
||||
return result
|
||||
|
||||
|
||||
def _matching_bounces(
|
||||
session: Session, *, tenant_id: str, email: str | None, bounce_id: str | None
|
||||
) -> list[MailBounceObservation]:
|
||||
conditions = []
|
||||
if bounce_id:
|
||||
conditions.append(MailBounceObservation.id == bounce_id)
|
||||
if email:
|
||||
conditions.append(func.lower(MailBounceObservation.recipient) == email)
|
||||
if not conditions:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailBounceObservation)
|
||||
.filter(MailBounceObservation.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailBounceObservation.id)
|
||||
)
|
||||
|
||||
|
||||
def _matching_pop3_imports(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
email: str | None,
|
||||
import_id: str | None,
|
||||
) -> list[MailPop3Import]:
|
||||
conditions = []
|
||||
if import_id:
|
||||
conditions.append(MailPop3Import.id == import_id)
|
||||
if email:
|
||||
pattern = f"%{_escape_like(email)}%"
|
||||
conditions.extend(
|
||||
func.lower(field).like(pattern, escape="\\")
|
||||
for field in (
|
||||
MailPop3Import.from_header,
|
||||
MailPop3Import.to_header,
|
||||
)
|
||||
)
|
||||
if not conditions:
|
||||
return []
|
||||
candidates = _bounded_rows(
|
||||
session.query(MailPop3Import)
|
||||
.filter(MailPop3Import.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailPop3Import.id)
|
||||
)
|
||||
return [
|
||||
row
|
||||
for row in candidates
|
||||
if row.id == import_id or _matching_pop3_headers(row, email)
|
||||
]
|
||||
|
||||
|
||||
def _matching_pop3_headers(
|
||||
row: MailPop3Import,
|
||||
email: str | None,
|
||||
) -> dict[str, list[dict[str, str | None]]]:
|
||||
if email is None:
|
||||
return {}
|
||||
result = {}
|
||||
for role, value in (("from", row.from_header), ("to", row.to_header)):
|
||||
matches = [
|
||||
{"email": address.casefold(), "name": name or None}
|
||||
for name, address in getaddresses([value or ""])
|
||||
if address.casefold() == email
|
||||
]
|
||||
if matches:
|
||||
result[role] = matches[:64]
|
||||
return result
|
||||
|
||||
|
||||
def _matching_commands(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
membership_ids: set[str],
|
||||
command_id: str | None,
|
||||
related_command_ids: set[str],
|
||||
) -> list[MailDeliveryCommand]:
|
||||
conditions = []
|
||||
if command_id:
|
||||
conditions.append(MailDeliveryCommand.id == command_id)
|
||||
if related_command_ids:
|
||||
conditions.append(MailDeliveryCommand.id.in_(related_command_ids))
|
||||
if membership_ids:
|
||||
conditions.append(MailDeliveryCommand.created_by_user_id.in_(membership_ids))
|
||||
if not conditions:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailDeliveryCommand)
|
||||
.filter(MailDeliveryCommand.tenant_id == tenant_id, or_(*conditions))
|
||||
.order_by(MailDeliveryCommand.id)
|
||||
)
|
||||
|
||||
|
||||
def _command_attempts(
|
||||
session: Session, command_ids: set[str]
|
||||
) -> list[MailDeliveryAttempt]:
|
||||
if not command_ids:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailDeliveryAttempt)
|
||||
.filter(MailDeliveryAttempt.command_id.in_(command_ids))
|
||||
.order_by(MailDeliveryAttempt.id)
|
||||
)
|
||||
|
||||
|
||||
def _command_reconciliations(
|
||||
session: Session, command_ids: set[str]
|
||||
) -> list[MailDeliveryReconciliation]:
|
||||
if not command_ids:
|
||||
return []
|
||||
return _bounded_rows(
|
||||
session.query(MailDeliveryReconciliation)
|
||||
.filter(MailDeliveryReconciliation.command_id.in_(command_ids))
|
||||
.order_by(MailDeliveryReconciliation.id)
|
||||
)
|
||||
|
||||
|
||||
def _mail_references(subject: DsarSubjectRef) -> dict[str, str]:
|
||||
aliases = {
|
||||
"mail.profile": "profile",
|
||||
"mail.message_index": "message_index",
|
||||
"mail.delivery_command": "command",
|
||||
"mail.bounce_observation": "bounce",
|
||||
"mail.pop3_import": "pop3_import",
|
||||
}
|
||||
return {
|
||||
target: value
|
||||
for key, target in aliases.items()
|
||||
if (value := str(subject.external_references.get(key) or "").strip())
|
||||
}
|
||||
|
||||
|
||||
def _membership_ids(subject: DsarSubjectRef) -> set[str]:
|
||||
values = [subject.membership_id]
|
||||
values.extend(
|
||||
subject.external_references.get(key)
|
||||
for key in (
|
||||
"mail.user",
|
||||
"mail.membership",
|
||||
"access.membership",
|
||||
"membership_id",
|
||||
)
|
||||
)
|
||||
return {value for item in values if (value := str(item or "").strip())}
|
||||
|
||||
|
||||
def _subject_email(subject: DsarSubjectRef) -> str | None:
|
||||
values = [subject.email, subject.external_references.get("mail.email")]
|
||||
normalized = {email for value in values if (email := _normalized_email(value))}
|
||||
return normalized.pop() if len(normalized) == 1 else None
|
||||
|
||||
|
||||
def _normalized_email(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
value = value.strip().casefold()
|
||||
return value or None
|
||||
|
||||
|
||||
def _record(
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
category: str,
|
||||
title: str,
|
||||
data: Mapping[str, object],
|
||||
*,
|
||||
observed_at: datetime | None = None,
|
||||
immutable: bool = False,
|
||||
retention_reason: str | None = None,
|
||||
source_path: str | None = None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="mail",
|
||||
module_id="mail",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category=category,
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=observed_at,
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=retention_reason,
|
||||
source_path=source_path,
|
||||
)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Mail DSAR provider requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_rows(query: object) -> list[object]:
|
||||
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Mail DSAR match limit exceeded; narrow the subject selectors."
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _bounded_text(value: str | None) -> str | None:
|
||||
return value[:2_000] if value else None
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
__all__ = ["MAIL_DSAR_CAPABILITY", "MailDsarProvider"]
|
||||
@@ -126,7 +126,7 @@ def slugify_profile_name(value: str) -> str:
|
||||
|
||||
|
||||
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]:
|
||||
payload = config.model_dump(mode="json")
|
||||
payload = config.model_dump(mode="json", exclude_none=True)
|
||||
username_was_supplied = "username" in config.model_fields_set
|
||||
password_was_supplied = "password" in config.model_fields_set
|
||||
username = payload.pop("username", None)
|
||||
|
||||
@@ -10,9 +10,12 @@ from govoplan_core.core.mail import (
|
||||
CAPABILITY_MAIL_BOUNCE_PROCESSING,
|
||||
CAPABILITY_MAIL_DELIVERY_OUTBOX,
|
||||
CAPABILITY_MAIL_NOTIFICATION_DELIVERY,
|
||||
CAPABILITY_MAIL_POSTBOX_BRIDGE,
|
||||
)
|
||||
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_DELIVERY
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationConfigurationProviderRegistration,
|
||||
DocumentationLink,
|
||||
@@ -26,6 +29,8 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
QuickAccessTool,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
@@ -38,17 +43,21 @@ from govoplan_core.core.provider_governance import (
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_mail.backend.configuration_provider import MAIL_CONFIGURATION_CAPABILITY
|
||||
from govoplan_mail.backend.documentation import (
|
||||
documentation_configuration_states,
|
||||
documentation_topics,
|
||||
)
|
||||
from govoplan_mail.backend.provider_state import (
|
||||
IMAP_PROVIDER_ID,
|
||||
POP3_PROVIDER_ID,
|
||||
SMTP_PROVIDER_ID,
|
||||
imap_provider_states,
|
||||
pop3_provider_states,
|
||||
smtp_provider_states,
|
||||
)
|
||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY
|
||||
from govoplan_mail.backend.search_source import create_mail_search_source
|
||||
|
||||
|
||||
@@ -59,6 +68,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,
|
||||
@@ -87,12 +97,26 @@ 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/POP3 passwords 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,
|
||||
)
|
||||
|
||||
|
||||
def _configuration_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_mail.backend.configuration_provider import SqlMailConfigurationProvider
|
||||
|
||||
return SqlMailConfigurationProvider()
|
||||
|
||||
|
||||
def _mail_dsar_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_mail.backend.dsar_provider import MailDsarProvider
|
||||
|
||||
return MailDsarProvider()
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
@@ -108,9 +132,9 @@ 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 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:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests and explicitly enabled POP3 diagnostics."),
|
||||
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."),
|
||||
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
|
||||
_permission(
|
||||
@@ -118,7 +142,22 @@ PERMISSIONS = (
|
||||
"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/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",
|
||||
@@ -162,6 +201,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(
|
||||
@@ -296,18 +349,68 @@ IMAP_PROVIDER = ExternalProviderDeclaration(
|
||||
)
|
||||
|
||||
|
||||
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.18",
|
||||
version="0.1.20",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns", "addresses", "calendar", "search"),
|
||||
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
|
||||
ModuleInterfaceProvider(name="mail.delivery_commands", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="mail.delivery_outbox", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name="mail.bounce_processing", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name=CAPABILITY_MAIL_POSTBOX_BRIDGE, version="1.0.0"),
|
||||
ModuleInterfaceProvider(name=MAIL_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -334,6 +437,18 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="addresses.contact_writer",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_POSTBOX_DELIVERY,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
@@ -350,7 +465,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",
|
||||
@@ -368,14 +486,53 @@ 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),
|
||||
ViewSurface(id="mail.admin.group-servers", module_id="mail", kind="section", label="Group mail servers", order=20),
|
||||
ViewSurface(id="mail.admin.user-servers", module_id="mail", kind="section", label="User mail servers", order=20),
|
||||
ViewSurface(id="mail.settings.profiles", module_id="mail", kind="section", label="Personal mail profiles", order=10),
|
||||
ViewSurface(id="mail.quick_access.messages", module_id="mail", kind="quick_access", label="Mail Quick Access", order=80),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="communication",
|
||||
module_id="mail",
|
||||
label="i18n:govoplan-core.product_area.communication",
|
||||
icon="mail",
|
||||
description="i18n:govoplan-core.product_area.communication_description",
|
||||
surface_ids=("mail.nav.mail", "mail.route.mail"),
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
quick_access_tools=(
|
||||
QuickAccessTool(
|
||||
id="mail.messages",
|
||||
module_id="mail",
|
||||
category_id="messages",
|
||||
label="i18n:govoplan-mail.mail.92379cbb",
|
||||
description="i18n:govoplan-mail.quick_access_description",
|
||||
surface_id="mail.quick_access.messages",
|
||||
icon="mail",
|
||||
full_page_path="/mail",
|
||||
required_any=("mail:mailbox:read",),
|
||||
order=10,
|
||||
modes=("browse", "compose", "select"),
|
||||
returned_reference_kinds=("mail.message",),
|
||||
help_context_id="mail.quick_access.messages",
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
@@ -394,6 +551,7 @@ manifest = ModuleManifest(
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
mail_models.MailMailboxMessageIndex,
|
||||
mail_models.MailPop3Import,
|
||||
mail_models.MailDeliveryReconciliation,
|
||||
mail_models.MailDeliveryAttempt,
|
||||
mail_models.MailDeliveryCommand,
|
||||
@@ -403,6 +561,7 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
MAIL_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
|
||||
"govoplan_mail.backend.capabilities",
|
||||
@@ -416,8 +575,271 @@ manifest = ModuleManifest(
|
||||
"govoplan_mail.backend.bounce_processing",
|
||||
fromlist=["SqlMailBounceProcessingProvider"],
|
||||
).SqlMailBounceProcessingProvider(),
|
||||
CAPABILITY_MAIL_POSTBOX_BRIDGE: lambda context: __import__(
|
||||
"govoplan_mail.backend.postbox_bridge",
|
||||
fromlist=["create_postbox_bridge"],
|
||||
).create_postbox_bridge(context),
|
||||
MAIL_DSAR_CAPABILITY: _mail_dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
MAIL_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Mail data-subject request provider",
|
||||
summary="Finds isolated mailbox-index, 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.",),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/mail/legacy-import",
|
||||
"screen": "Legacy POP3 import",
|
||||
"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.",
|
||||
],
|
||||
"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, 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/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",),
|
||||
audience=("privacy_officer", "mail_admin", "records_manager", "operator"),
|
||||
order=35,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail", "access"),
|
||||
any_scopes=("access:privacy:read", "access:privacy:manage", "access:privacy:erase"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Data-subject requests", href="/admin?section=tenant-data-subject-requests", kind="runtime"),
|
||||
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||
),
|
||||
related_modules=("access", "audit", "campaigns", "postbox"),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=tenant-data-subject-requests",
|
||||
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||
"steps": [
|
||||
"Run the Mail provider search and review mailbox-index, 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.",
|
||||
],
|
||||
"limitations": [
|
||||
"Encrypted outbound payloads cannot be searched by recipient without an independently corroborated Mail command reference.",
|
||||
"External mailbox deletion is outside the DSAR provider and must be coordinated with the configured provider.",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.postbox.bridge",
|
||||
title="Bridge selected Mail observations into Postbox",
|
||||
summary="Deliver one immutable IMAP observation to an explicit function Postbox without turning Postbox into a mailbox account.",
|
||||
body=(
|
||||
"Mail exposes an optional, idempotent bridge capability. A configured caller supplies an exact Mail profile, folder, UIDVALIDITY, UID, raw observation, "
|
||||
"and typed Postbox target. Mail parses bounded headers, plaintext, participants, and attachment evidence; Postbox owns target resolution, "
|
||||
"delivery, retention, receipts, and access. The bridge stores no credentials in Postbox and never treats account login as Postbox membership."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("mail_user", "mail_admin", "administrator"),
|
||||
related_modules=("postbox", "idm", "organizations"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Ausgewählte Mail-Beobachtungen an Postbox übergeben",
|
||||
"summary": "Eine unveränderliche IMAP-Beobachtung an ein ausdrücklich bestimmtes Funktionspostfach liefern, ohne Postbox zu einem Mailkonto zu machen.",
|
||||
"body": (
|
||||
"Mail stellt eine optionale, idempotente Bridge-Capability bereit. Ein konfigurierter Aufrufer übergibt ein exaktes Mailprofil, Ordner, UIDVALIDITY, UID, die rohe Beobachtung "
|
||||
"und ein typisiertes Postbox-Ziel. Mail liest begrenzte Kopfzeilen, Klartext, Beteiligte und Anlagennachweise; Postbox besitzt Zielauflösung, Zustellung, "
|
||||
"Aufbewahrung, Belege und Zugriff. Die Bridge speichert keine Zugangsdaten in Postbox und behandelt die Kontoanmeldung niemals als Postfachmitgliedschaft."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"kind": "guide", "help_contexts": ["mail.postbox-bridge"]},
|
||||
order=36,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.configuration-package.smtp-profile",
|
||||
title="Provision SMTP profiles from deployment receipts",
|
||||
summary="Review and apply an idempotent Mail profile without moving plaintext credentials through a package.",
|
||||
body=(
|
||||
"The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. "
|
||||
"Missing non-secret transport fields are collected by preflight; authentication is represented only by an existing credential-envelope id. "
|
||||
"Tenant scope is the default, system scope requires system authority, conflicting profiles are preserved unless update is explicitly reviewed, "
|
||||
"and an unchanged second apply is a no-op. SMTP reachability remains a separate Mail profile test."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("mail_admin", "administrator", "operator"),
|
||||
related_modules=("core", "ops", "access"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail", "access"),
|
||||
any_scopes=("admin:settings:read", "system:settings:read"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Configuration packages",
|
||||
href="/admin?section=configuration-packages",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Mail handbook",
|
||||
href="govoplan-mail/docs/MAIL_HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "SMTP-Profile aus Bereitstellungsnachweisen einrichten",
|
||||
"summary": "Ein idempotentes Mail-Profil prüfen und anwenden, ohne Klartext-Zugangsdaten durch ein Konfigurationspaket zu übertragen.",
|
||||
"body": (
|
||||
"Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. "
|
||||
"Fehlende nicht geheime Transportangaben werden im Preflight abgefragt; Authentifizierung wird ausschließlich durch die ID eines vorhandenen Zugangsdaten-Umschlags referenziert. "
|
||||
"Mandantenbezug ist der Standard, Systembezug erfordert Systemberechtigung, abweichende vorhandene Profile bleiben ohne ausdrücklich geprüfte Aktualisierung unverändert, "
|
||||
"und eine unveränderte zweite Anwendung bleibt wirkungslos. Die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=configuration-packages",
|
||||
"help_contexts": ["admin.configuration-packages", "mail.admin.profiles"],
|
||||
},
|
||||
order=4,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.quick-access-and-product-area",
|
||||
title="Mail in Communication and Messages",
|
||||
summary="Use Mail in the Communication product area and the shared Messages Quick Access drawer.",
|
||||
body=(
|
||||
"Mail contributes its full mailbox to Communication. When Quick Access is enabled, its compact provider surface "
|
||||
"appears inside the shared Messages drawer alongside independent Postbox and future chat contributions. "
|
||||
"Recent entries open the exact authorized profile, folder, and message; Drafts resolves the configured or "
|
||||
"provider-detected Drafts folder, and the full Mail page keeps the versioned Quick Access return context. "
|
||||
"When an active Case requests a selection, choosing a recent message returns only its tenant-bound profile, "
|
||||
"folder, UID version, label, and owner route through the shared result contract. Cases discards the label and "
|
||||
"does not receive headers, participants, preview, body, attachments, credentials, or an access decision; Mail "
|
||||
"reauthorizes the mailbox list now and the exact message again whenever its owner route is opened. "
|
||||
"Compose deliberately launches the user's configured mail application because GovOPlaN Mail's mailbox is "
|
||||
"read-only; it neither selects a GovOPlaN transport profile nor claims a GovOPlaN delivery. The shared drawer "
|
||||
"does not merge channel state, credentials, custody, delivery semantics, or authorization."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("mail_user", "mail_admin", "administrator"),
|
||||
related_modules=("quick_access", "views", "postbox"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Mail in Kommunikation und Nachrichten",
|
||||
"summary": "Mail im Produktbereich Kommunikation und in der gemeinsamen Schnellzugriffseinblendung Nachrichten verwenden.",
|
||||
"body": (
|
||||
"Mail ordnet das vollständige Postfach Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die kompakte "
|
||||
"Mail-Oberfläche gemeinsam mit unabhängigen Beiträgen aus Postbox und künftig Chat unter Nachrichten. "
|
||||
"Aktuelle Einträge öffnen das genaue berechtigte Profil, den Ordner und die Nachricht; Entwürfe verwendet "
|
||||
"den konfigurierten oder vom Anbieter erkannten Entwurfsordner. Die vollständige Mail-Seite erhält den "
|
||||
"versionierten Rücksprungkontext. Verfassen öffnet bewusst die konfigurierte Mail-Anwendung des Benutzers, "
|
||||
"Bei einem aktiven Vorgang gibt die Auswahl einer aktuellen Nachricht nur den mandantengebundenen Profil-, "
|
||||
"Ordner- und UID-Verweis sowie die Eigentümerroute über den gemeinsamen Ergebnisvertrag zurück. Vorgänge "
|
||||
"übernehmen weder Kopfzeilen, Beteiligte, Vorschau, Inhalt, Anlagen, Zugangsdaten noch eine Zugriffsentscheidung; "
|
||||
"Mail prüft den Zugriff beim Auflisten und beim späteren Öffnen erneut. "
|
||||
"da das GovOPlaN-Mail-Postfach nur lesend arbeitet; dabei wird weder ein GovOPlaN-Transportprofil gewählt "
|
||||
"noch eine GovOPlaN-Zustellung behauptet. Die gemeinsame Darstellung führt weder Kanalzustand noch "
|
||||
"Zugangsdaten, Verwahrung, Zustelllogik oder Berechtigungen zusammen."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"kind": "reference", "help_contexts": ["mail.quick_access.messages"]},
|
||||
order=37,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.address-book-integration",
|
||||
title="Use address-book contacts in Mail",
|
||||
summary="Autocomplete recipients and add message participants to an explicitly writable address book when Addresses is installed.",
|
||||
body=(
|
||||
"Mail resolves the optional addresses.lookup and addresses.contact_writer capabilities through the platform registry. "
|
||||
"Quick Access compose accepts manual recipients in every installation and adds visible contact suggestions when lookup is available, "
|
||||
"then opens the user's configured mail application. A selected mailbox message offers add-contact actions only through writer decisions "
|
||||
"returned for the current principal. Read-only connector books and policy-blocked targets remain disabled with the owning Addresses reason. "
|
||||
"Mail never imports Addresses models, bypasses its scope checks, or changes its read-only mailbox and external-compose custody model."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("mail_user", "mail_admin", "administrator"),
|
||||
related_modules=("addresses",),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail",),
|
||||
required_scopes=("mail:profile:use",),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Mail", href="/mail", kind="runtime"),
|
||||
DocumentationLink(label="Addresses", href="/addresses", kind="runtime"),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Adressbuchkontakte in Mail verwenden",
|
||||
"summary": "Empfänger vervollständigen und Nachrichtenbeteiligte in einem ausdrücklich beschreibbaren Adressbuch speichern, wenn Addresses installiert ist.",
|
||||
"body": (
|
||||
"Mail löst die optionalen Capabilities addresses.lookup und addresses.contact_writer über die Plattformregistrierung auf. "
|
||||
"Beim Verfassen im Schnellzugriff können Empfänger immer manuell eingegeben werden; bei verfügbarer Suche kommen sichtbare Kontaktvorschläge hinzu, "
|
||||
"anschließend öffnet sich die konfigurierte Mail-Anwendung. Für eine ausgewählte Postfachnachricht werden Kontaktaktionen ausschließlich anhand der "
|
||||
"Writer-Entscheidungen für den aktuellen Principal angeboten. Schreibgeschützte Connector-Adressbücher und durch Richtlinien gesperrte Ziele bleiben "
|
||||
"mit dem von Addresses gelieferten Grund deaktiviert. Mail importiert keine Addresses-Modelle, umgeht keine Bereichsprüfung und ändert weder das "
|
||||
"nur lesende Postfach noch das Verwahrungsmodell des externen Verfassens."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/mail",
|
||||
"help_contexts": ["mail.quick_access.messages", "mail.mailbox"],
|
||||
},
|
||||
order=38,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.search.mailbox-messages",
|
||||
title="Search authorized mailbox messages",
|
||||
@@ -433,7 +855,7 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("mail_user", "mail_admin", "administrator"),
|
||||
related_modules=("search",),
|
||||
order=38,
|
||||
order=39,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.profiles-and-policy",
|
||||
@@ -473,6 +895,44 @@ manifest = ModuleManifest(
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.profile-standard-folder-mappings",
|
||||
title="Map standard folders for an IMAP profile",
|
||||
summary="Store Inbox, Sent, Drafts, Trash, Archive, and Junk mappings on the reusable Mail profile and populate them from bounded IMAP discovery.",
|
||||
body=(
|
||||
"Folder names are profile/server metadata, not Campaign settings. An authorized profile administrator may enter names directly or use folder discovery; empty mappings retain automatic behavior. Existing imap.sent_folder values are presented and persisted as the Sent mapping, while the compatibility field remains synchronized for consumers that still read it. A Campaign-specific Sent-folder override remains authoritative for that Campaign and is not rewritten by profile discovery. Folder discovery lists provider-visible names without creating, renaming, moving, or deleting remote folders."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("mail_admin", "campaign_admin", "mail_user"),
|
||||
order=40,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail",),
|
||||
required_scopes=("mail:profile:read",),
|
||||
any_scopes=("mail:profile:write", "mail:profile:write_own", "mail:profile:test"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
||||
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
|
||||
),
|
||||
related_modules=("campaigns",),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/settings?section=mail-profiles",
|
||||
"screen": "Mail profiles",
|
||||
"section": "IMAP standard folder mappings",
|
||||
"help_contexts": ["mail.profiles", "mail.admin.profiles"],
|
||||
"steps": [
|
||||
"Open an editable IMAP server in a reusable Mail profile.",
|
||||
"Choose Detect folders to load the provider-visible folder names, or enter exact names manually.",
|
||||
"Apply the detected mappings, review every role, and leave uncertain roles empty for automatic behavior.",
|
||||
"Save the profile and reload it to verify the mappings were retained.",
|
||||
],
|
||||
"verification": "Confirm legacy Sent-only profiles show the same effective Sent mapping, and confirm a Campaign-local Sent override remains unchanged.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.bounce-processing",
|
||||
title="Delivery-status and bounce processing",
|
||||
@@ -596,8 +1056,8 @@ manifest = ModuleManifest(
|
||||
DocumentationTopic(
|
||||
id="mail.workflow.read-mailbox",
|
||||
title="Read a permitted mailbox without changing it",
|
||||
summary="Choose an IMAP-enabled profile, browse folders, and inspect bounded message content through the read-only mailbox surface.",
|
||||
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
|
||||
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.",
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("mail_user",),
|
||||
@@ -624,8 +1084,9 @@ manifest = ModuleManifest(
|
||||
],
|
||||
"steps": [
|
||||
"Open Mail and choose an authorized IMAP-enabled profile.",
|
||||
"Select a folder and page through its bounded message index.",
|
||||
"Open only the message needed for the task and close it when finished.",
|
||||
"Select a folder, review the live/cached synchronization label, and page or filter its bounded message index.",
|
||||
"Use the provider-derived read/unread indicator, then open only the message needed for the task.",
|
||||
"Switch between safe plain-text and isolated HTML views as needed, and review attachment or unavailable-content details before closing the preview.",
|
||||
],
|
||||
"outcome": "The required message was inspected without changing provider mailbox state.",
|
||||
"verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.",
|
||||
@@ -638,8 +1099,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, pin every SMTP/IMAP/POP3 peer, bound responses, and delete owned credentials immediately with non-secret audit evidence.",
|
||||
body="Private-network connector access is deployment-wide, but every allowed hostname still resolves to an approved peer that is pinned at socket creation. Unsupported transports fail before connection. Deleting a profile immediately scrubs its owned encrypted SMTP/IMAP/POP3 passwords 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"),
|
||||
@@ -673,7 +1134,7 @@ manifest = ModuleManifest(
|
||||
id="mail.reference.campaign-delivery-contract",
|
||||
title="Integrate Campaign through the Mail delivery contract",
|
||||
summary="Campaign freezes a Mail profile reference and opaque revision; Mail re-authorizes, revision-checks, resolves credentials, and performs the effect in one call.",
|
||||
body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Every current SMTP and Sent-folder attempt passes a stable effect identifier into a Mail-owned Core recovery operation before provider contact. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority. Mail outbox dispatch and retention scans are partitioned by tenant entitlement, so disabling Mail leaves accepted commands and evidence untouched for operator resolution.",
|
||||
body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Synchronous batches authorize the complete recipient set and preflight DNS, egress, connectivity, TLS, and authentication before the first effect. Mail reuses the bounded connection when deployment policy permits, health-checks it before reuse, and reconnects before the next message when a stale connection is detected. A connection loss after DATA begins remains outcome-unknown and is never replayed. Systemic authentication, sender, or connectivity failures carry stable reason codes so Campaign pauses remaining queued work and shows connection, reconnect, failure, and pause progress. Campaign owns ordinary recipient jobs; report messages use Mail's encrypted idempotent delivery-command and attempt ledger. Every current SMTP and Sent-folder attempt passes a stable effect identifier into a Mail-owned Core recovery operation before provider contact. Effect-start evidence prevents blind redelivery, unknown outcomes require explicit reconciliation, and raw recipient refusals require Mail diagnostic authority. Mail outbox dispatch and retention scans are partitioned by tenant entitlement, so disabling Mail leaves accepted commands and evidence untouched for operator resolution.",
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
|
||||
@@ -696,7 +1157,7 @@ manifest = ModuleManifest(
|
||||
"route": "/campaigns/{campaign_id}/mail-settings",
|
||||
"screen": "Campaign Mail settings",
|
||||
"section": "Mail-owned profile and transport boundary",
|
||||
"verification": "Prove stale revisions fail before credential decryption, SMTP never decrypts IMAP credentials, IMAP never decrypts SMTP credentials, provider details are sanitized, and the interface/version gate passes.",
|
||||
"verification": "Prove stale revisions fail before credential decryption, batch preflight fails before DATA, two messages reuse one healthy connection, a stale connection reconnects before the next message, post-DATA disconnect is never replayed, systemic failures pause remaining jobs, provider details are sanitized, and the interface/version gate passes.",
|
||||
"related_topic_ids": [
|
||||
"mail.profile-ownership-and-consumers",
|
||||
"campaigns.mail-profile-user-journey",
|
||||
@@ -712,7 +1173,7 @@ manifest = ModuleManifest(
|
||||
resolve=documentation_configuration_states,
|
||||
),
|
||||
),
|
||||
external_providers=(SMTP_PROVIDER, IMAP_PROVIDER),
|
||||
external_providers=(SMTP_PROVIDER, IMAP_PROVIDER, POP3_PROVIDER),
|
||||
external_provider_state_providers=(
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="mail",
|
||||
@@ -724,6 +1185,11 @@ manifest = ModuleManifest(
|
||||
provider_id=IMAP_PROVIDER_ID,
|
||||
provider=imap_provider_states,
|
||||
),
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id="mail",
|
||||
provider_id=POP3_PROVIDER_ID,
|
||||
provider=pop3_provider_states,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="communication_participation",
|
||||
@@ -731,15 +1197,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=("A complete webmail profile and recovery adoption for future provider-side mailbox mutations are not reference-ready; 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, POP3_PROVIDER_ID),
|
||||
recovery_docs=("docs/MAIL_HANDBOOK.md",),
|
||||
security_docs=("docs/MAIL_HANDBOOK.md",),
|
||||
operations_docs=("docs/MAIL_HANDBOOK.md",),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""add governed POP3 legacy imports
|
||||
|
||||
Revision ID: a4c5d6e7f809
|
||||
Revises: 93b4c5d6e7f8
|
||||
Create Date: 2026-08-22 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a4c5d6e7f809"
|
||||
down_revision = "93b4c5d6e7f8"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"mail_pop3_imports",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("profile_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("pop3_server_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("pop3_credential_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("transport_revision", sa.String(length=120), nullable=False),
|
||||
sa.Column("provider_uidl", sa.String(length=500), nullable=False),
|
||||
sa.Column("provider_message_number", sa.Integer(), nullable=True),
|
||||
sa.Column("fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("raw_message_encrypted", sa.Text(), nullable=False),
|
||||
sa.Column("message_id", sa.String(length=998), nullable=True),
|
||||
sa.Column("subject", sa.Text(), nullable=True),
|
||||
sa.Column("from_header", sa.Text(), nullable=True),
|
||||
sa.Column("to_header", sa.Text(), nullable=True),
|
||||
sa.Column("date", sa.String(length=255), nullable=True),
|
||||
sa.Column("body_preview", sa.Text(), nullable=True),
|
||||
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("imported_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("imported_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("deletion_requested", sa.Boolean(), nullable=False),
|
||||
sa.Column("deletion_status", sa.String(length=40), nullable=False),
|
||||
sa.Column("deletion_attempted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deletion_error", sa.String(length=500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["profile_id"], ["mail_server_profiles.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["pop3_server_id"], ["mail_server_endpoints.id"], ondelete="RESTRICT"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["imported_by_user_id"], ["access_users.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"profile_id",
|
||||
"pop3_server_id",
|
||||
"provider_uidl",
|
||||
name="uq_mail_pop3_imports_source_uidl",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"profile_id",
|
||||
"pop3_server_id",
|
||||
"fingerprint",
|
||||
"message_id",
|
||||
"status",
|
||||
"imported_at",
|
||||
"imported_by_user_id",
|
||||
"deletion_status",
|
||||
):
|
||||
op.create_index(
|
||||
f"ix_mail_pop3_imports_{column}",
|
||||
"mail_pop3_imports",
|
||||
[column],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_mail_pop3_imports_review",
|
||||
"mail_pop3_imports",
|
||||
["tenant_id", "status", "imported_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("mail_pop3_imports")
|
||||
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.security.secrets import encrypt_secret
|
||||
from govoplan_mail.backend.db.models import MailPop3Import, MailServerEndpoint
|
||||
from govoplan_mail.backend.sending.pop3 import Pop3DownloadedMessage
|
||||
|
||||
|
||||
class Pop3ImportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pop3ImportResult:
|
||||
imported: tuple[MailPop3Import, ...]
|
||||
duplicates: tuple[MailPop3Import, ...]
|
||||
|
||||
|
||||
def create_pop3_imports(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
profile_id: str,
|
||||
pop3_server_id: str,
|
||||
pop3_credential_id: str | None,
|
||||
transport_revision: str,
|
||||
messages: Iterable[Pop3DownloadedMessage],
|
||||
user_id: str | None,
|
||||
deletion_requested: bool,
|
||||
) -> Pop3ImportResult:
|
||||
downloaded = tuple(messages)
|
||||
if not downloaded:
|
||||
raise Pop3ImportError("No POP3 messages were downloaded for import")
|
||||
uidls = [item.uidl for item in downloaded]
|
||||
if len(uidls) != len(set(uidls)):
|
||||
raise Pop3ImportError("The POP3 download contained duplicate UIDL identifiers")
|
||||
|
||||
# Serialize imports per source before checking UIDLs. The database unique
|
||||
# constraint remains the last line of defense, while this lock lets a
|
||||
# concurrent request observe the first request's committed rows and report
|
||||
# them as duplicates instead of surfacing an integrity error.
|
||||
source = session.scalar(
|
||||
select(MailServerEndpoint)
|
||||
.where(
|
||||
MailServerEndpoint.id == pop3_server_id,
|
||||
MailServerEndpoint.profile_id == profile_id,
|
||||
or_(
|
||||
MailServerEndpoint.tenant_id == tenant_id,
|
||||
MailServerEndpoint.tenant_id.is_(None),
|
||||
),
|
||||
MailServerEndpoint.protocol == "pop3",
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if source is None:
|
||||
raise Pop3ImportError("The selected POP3 source is unavailable")
|
||||
|
||||
existing = {
|
||||
row.provider_uidl: row
|
||||
for row in session.scalars(
|
||||
select(MailPop3Import).where(
|
||||
MailPop3Import.tenant_id == tenant_id,
|
||||
MailPop3Import.profile_id == profile_id,
|
||||
MailPop3Import.pop3_server_id == pop3_server_id,
|
||||
MailPop3Import.provider_uidl.in_(uidls),
|
||||
)
|
||||
)
|
||||
}
|
||||
imported: list[MailPop3Import] = []
|
||||
duplicates: list[MailPop3Import] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
for message in downloaded:
|
||||
duplicate = existing.get(message.uidl)
|
||||
if duplicate is not None:
|
||||
duplicates.append(duplicate)
|
||||
continue
|
||||
encrypted = encrypt_secret(base64.b64encode(message.raw).decode("ascii"))
|
||||
if not encrypted:
|
||||
raise Pop3ImportError("The downloaded POP3 message could not be encrypted")
|
||||
summary = message.summary
|
||||
row = MailPop3Import(
|
||||
tenant_id=tenant_id,
|
||||
profile_id=profile_id,
|
||||
pop3_server_id=pop3_server_id,
|
||||
pop3_credential_id=pop3_credential_id,
|
||||
transport_revision=_required_revision(transport_revision),
|
||||
provider_uidl=message.uidl,
|
||||
provider_message_number=message.message_number,
|
||||
fingerprint=_fingerprint(
|
||||
tenant_id=tenant_id,
|
||||
profile_id=profile_id,
|
||||
pop3_server_id=pop3_server_id,
|
||||
uidl=message.uidl,
|
||||
raw_sha256=message.raw_sha256,
|
||||
),
|
||||
raw_sha256=message.raw_sha256,
|
||||
raw_message_encrypted=encrypted,
|
||||
message_id=summary.message_id,
|
||||
subject=summary.subject,
|
||||
from_header=summary.from_header,
|
||||
to_header=summary.to_header,
|
||||
date=summary.date,
|
||||
body_preview=summary.body_preview,
|
||||
size_bytes=len(message.raw),
|
||||
status="pending_review",
|
||||
imported_at=now,
|
||||
imported_by_user_id=user_id,
|
||||
deletion_requested=bool(deletion_requested),
|
||||
deletion_status=("pending" if deletion_requested else "not_requested"),
|
||||
)
|
||||
session.add(row)
|
||||
imported.append(row)
|
||||
session.flush()
|
||||
return Pop3ImportResult(imported=tuple(imported), duplicates=tuple(duplicates))
|
||||
|
||||
|
||||
def list_pop3_imports(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
profile_id: str | None = None,
|
||||
profile_ids: Iterable[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> tuple[MailPop3Import, ...]:
|
||||
statement = select(MailPop3Import).where(
|
||||
MailPop3Import.tenant_id == tenant_id
|
||||
)
|
||||
if profile_id:
|
||||
statement = statement.where(MailPop3Import.profile_id == profile_id)
|
||||
elif profile_ids is not None:
|
||||
allowed = tuple(dict.fromkeys(str(value) for value in profile_ids if value))
|
||||
if not allowed:
|
||||
return ()
|
||||
statement = statement.where(MailPop3Import.profile_id.in_(allowed))
|
||||
rows = session.scalars(
|
||||
statement.order_by(
|
||||
MailPop3Import.imported_at.desc(),
|
||||
MailPop3Import.id.desc(),
|
||||
).limit(max(1, min(int(limit), 500)))
|
||||
)
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def mark_pop3_deletion_result(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
import_ids: Iterable[str],
|
||||
status: str,
|
||||
error: str | None = None,
|
||||
) -> tuple[MailPop3Import, ...]:
|
||||
clean_status = str(status or "").strip().casefold()
|
||||
if clean_status not in {"succeeded", "failed", "outcome_unknown"}:
|
||||
raise Pop3ImportError("Unsupported POP3 deletion result")
|
||||
ids = tuple(dict.fromkeys(str(value).strip() for value in import_ids if str(value).strip()))
|
||||
if not ids:
|
||||
return ()
|
||||
rows = tuple(
|
||||
session.scalars(
|
||||
select(MailPop3Import)
|
||||
.where(
|
||||
MailPop3Import.tenant_id == tenant_id,
|
||||
MailPop3Import.id.in_(ids),
|
||||
MailPop3Import.deletion_requested.is_(True),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
)
|
||||
if len(rows) != len(ids):
|
||||
raise Pop3ImportError("One or more POP3 import records are unavailable")
|
||||
now = datetime.now(timezone.utc)
|
||||
safe_error = _bounded_error(error)
|
||||
for row in rows:
|
||||
row.deletion_status = clean_status
|
||||
row.deletion_attempted_at = now
|
||||
row.deletion_error = safe_error
|
||||
session.flush()
|
||||
return rows
|
||||
|
||||
|
||||
def pop3_import_payload(row: MailPop3Import) -> dict[str, object]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"profile_id": row.profile_id,
|
||||
"pop3_server_id": row.pop3_server_id,
|
||||
"transport_revision": row.transport_revision,
|
||||
"provider_uidl": row.provider_uidl,
|
||||
"message_id": row.message_id,
|
||||
"subject": row.subject,
|
||||
"from_header": row.from_header,
|
||||
"to_header": row.to_header,
|
||||
"date": row.date,
|
||||
"body_preview": row.body_preview,
|
||||
"size_bytes": row.size_bytes,
|
||||
"raw_sha256": row.raw_sha256,
|
||||
"status": row.status,
|
||||
"imported_at": row.imported_at,
|
||||
"deletion_requested": row.deletion_requested,
|
||||
"deletion_status": row.deletion_status,
|
||||
"deletion_attempted_at": row.deletion_attempted_at,
|
||||
"deletion_error": row.deletion_error,
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint(
|
||||
*,
|
||||
tenant_id: str,
|
||||
profile_id: str,
|
||||
pop3_server_id: str,
|
||||
uidl: str,
|
||||
raw_sha256: str,
|
||||
) -> str:
|
||||
material = "\x1f".join(
|
||||
(tenant_id, profile_id, pop3_server_id, uidl, raw_sha256)
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(material).hexdigest()
|
||||
|
||||
|
||||
def _required_revision(value: object) -> str:
|
||||
clean = str(value or "").strip()
|
||||
if not clean or len(clean) > 120:
|
||||
raise Pop3ImportError("A valid POP3 transport revision is required")
|
||||
return clean
|
||||
|
||||
|
||||
def _bounded_error(value: str | None) -> str | None:
|
||||
clean = " ".join(str(value or "").split())
|
||||
return clean[:500] or None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Pop3ImportError",
|
||||
"Pop3ImportResult",
|
||||
"create_pop3_imports",
|
||||
"list_pop3_imports",
|
||||
"mark_pop3_deletion_result",
|
||||
"pop3_import_payload",
|
||||
]
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from email import policy
|
||||
from email.message import Message
|
||||
from email.parser import BytesParser
|
||||
from email.utils import getaddresses
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.mail import (
|
||||
MailPostboxBridgeProvider,
|
||||
MailPostboxBridgeRequest,
|
||||
MailPostboxBridgeResult,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxAttachmentRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxParticipantRef,
|
||||
PostboxTargetRef,
|
||||
postbox_delivery_provider,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
from govoplan_mail.backend.runtime import configure_runtime, get_registry
|
||||
|
||||
|
||||
MAX_BRIDGE_MESSAGE_BYTES = 50 * 1024 * 1024
|
||||
MAX_POSTBOX_BODY_CHARS = 500_000
|
||||
|
||||
|
||||
class MailPostboxBridgeError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MailPostboxBridge(MailPostboxBridgeProvider):
|
||||
"""Translate immutable IMAP observations into native Postbox delivery."""
|
||||
|
||||
def bridge_message(
|
||||
self,
|
||||
session: object,
|
||||
request: MailPostboxBridgeRequest,
|
||||
) -> MailPostboxBridgeResult:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Mail Postbox bridging requires a SQLAlchemy session.")
|
||||
if not isinstance(request.target, PostboxTargetRef):
|
||||
raise MailPostboxBridgeError("A typed Postbox target is required.")
|
||||
if len(request.raw_message) > MAX_BRIDGE_MESSAGE_BYTES:
|
||||
raise MailPostboxBridgeError("Mail message exceeds the Postbox bridge limit.")
|
||||
profile = session.get(MailServerProfile, request.profile_id)
|
||||
if profile is None or profile.tenant_id != request.tenant_id:
|
||||
raise MailPostboxBridgeError("Mail profile not found.")
|
||||
folder = request.folder.strip()
|
||||
uid = request.uid.strip()
|
||||
uidvalidity = request.uidvalidity.strip()
|
||||
if not folder or not uid or not uidvalidity:
|
||||
raise MailPostboxBridgeError(
|
||||
"Mail folder, UIDVALIDITY, and immutable UID are required."
|
||||
)
|
||||
try:
|
||||
message = BytesParser(policy=policy.default).parsebytes(request.raw_message)
|
||||
except Exception as exc:
|
||||
raise MailPostboxBridgeError("Mail message could not be parsed.") from exc
|
||||
|
||||
provider = postbox_delivery_provider(get_registry())
|
||||
if provider is None:
|
||||
raise MailPostboxBridgeError("Postbox delivery is not available.")
|
||||
source_digest = hashlib.sha256(request.raw_message).hexdigest()
|
||||
source_key = hashlib.sha256(
|
||||
f"{request.tenant_id}\0{request.profile_id}\0{folder}\0{uidvalidity}\0{uid}".encode("utf-8")
|
||||
).hexdigest()
|
||||
result = provider.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id=request.tenant_id,
|
||||
target=request.target,
|
||||
producer_module="mail",
|
||||
producer_resource_type="imap_message",
|
||||
producer_resource_id=source_key,
|
||||
idempotency_key=f"mail-postbox:{source_key}:{source_digest}",
|
||||
subject=_header(message, "Subject") or "(No subject)",
|
||||
body_text=_plain_text_body(message),
|
||||
sender_label=_header(message, "From"),
|
||||
classification=request.classification,
|
||||
participants=_participants(message),
|
||||
attachments=_attachments(
|
||||
message,
|
||||
profile_id=request.profile_id,
|
||||
folder=folder,
|
||||
uidvalidity=uidvalidity,
|
||||
uid=uid,
|
||||
),
|
||||
metadata={
|
||||
**dict(request.metadata),
|
||||
"transport": "mail-imap",
|
||||
"mail_profile_id": request.profile_id,
|
||||
"mailbox_folder": folder,
|
||||
"mailbox_uidvalidity": uidvalidity,
|
||||
"mailbox_uid": uid,
|
||||
"rfc_message_id": _header(message, "Message-ID"),
|
||||
"raw_sha256": source_digest,
|
||||
},
|
||||
),
|
||||
)
|
||||
return MailPostboxBridgeResult(
|
||||
postbox_id=result.postbox_id,
|
||||
message_id=result.message_id,
|
||||
delivery_id=result.delivery_id,
|
||||
duplicate=result.duplicate,
|
||||
source_digest=source_digest,
|
||||
)
|
||||
|
||||
|
||||
def _header(message: Message, name: str) -> str | None:
|
||||
value = " ".join(str(message.get(name) or "").split())
|
||||
return value[:1000] or None
|
||||
|
||||
|
||||
def _plain_text_body(message: Message) -> str | None:
|
||||
candidates = message.walk() if message.is_multipart() else (message,)
|
||||
for part in candidates:
|
||||
if part.get_content_type() != "text/plain":
|
||||
continue
|
||||
if part.get_content_disposition() == "attachment":
|
||||
continue
|
||||
try:
|
||||
value = part.get_content()
|
||||
except Exception:
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
value = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||
normalized = str(value).strip()
|
||||
if normalized:
|
||||
return normalized[:MAX_POSTBOX_BODY_CHARS]
|
||||
return None
|
||||
|
||||
|
||||
def _participants(message: Message) -> tuple[PostboxParticipantRef, ...]:
|
||||
result: list[PostboxParticipantRef] = []
|
||||
for kind, headers in (
|
||||
("sender", ("From",)),
|
||||
("to", ("To",)),
|
||||
("cc", ("Cc",)),
|
||||
("bcc", ("Bcc",)),
|
||||
):
|
||||
for name, address in getaddresses(
|
||||
[str(value) for header in headers for value in message.get_all(header, [])]
|
||||
):
|
||||
clean_address = address.strip()[:500]
|
||||
if not clean_address:
|
||||
continue
|
||||
result.append(
|
||||
PostboxParticipantRef(
|
||||
kind=kind,
|
||||
reference_type="external_email",
|
||||
label=name.strip()[:500] or None,
|
||||
address=clean_address,
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _attachments(
|
||||
message: Message,
|
||||
*,
|
||||
profile_id: str,
|
||||
folder: str,
|
||||
uidvalidity: str,
|
||||
uid: str,
|
||||
) -> tuple[PostboxAttachmentRef, ...]:
|
||||
result: list[PostboxAttachmentRef] = []
|
||||
for index, part in enumerate(message.walk()):
|
||||
filename = part.get_filename()
|
||||
if part.get_content_disposition() != "attachment" and not filename:
|
||||
continue
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
reference_id = hashlib.sha256(
|
||||
f"{profile_id}\0{folder}\0{uidvalidity}\0{uid}\0{index}\0{digest}".encode("utf-8")
|
||||
).hexdigest()
|
||||
result.append(
|
||||
PostboxAttachmentRef(
|
||||
reference_type="mail_attachment",
|
||||
reference_id=reference_id,
|
||||
name=str(filename or f"attachment-{index + 1}")[:1000],
|
||||
media_type=part.get_content_type(),
|
||||
size_bytes=len(payload),
|
||||
digest=digest,
|
||||
metadata={
|
||||
"mail_profile_id": profile_id,
|
||||
"mailbox_folder": folder,
|
||||
"mailbox_uidvalidity": uidvalidity,
|
||||
"mailbox_uid": uid,
|
||||
"mime_part_index": index,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def create_postbox_bridge(context: ModuleContext) -> MailPostboxBridge:
|
||||
configure_runtime(registry=context.registry, settings=context.settings)
|
||||
return MailPostboxBridge()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MailPostboxBridge",
|
||||
"MailPostboxBridgeError",
|
||||
"create_postbox_bridge",
|
||||
]
|
||||
@@ -16,6 +16,7 @@ from govoplan_mail.backend.db.models import (
|
||||
MailDeliveryCommand,
|
||||
MailMailboxFolderIndex,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
@@ -23,6 +24,7 @@ from govoplan_mail.backend.db.models import (
|
||||
|
||||
SMTP_PROVIDER_ID = "mail.smtp_delivery"
|
||||
IMAP_PROVIDER_ID = "mail.imap_mailbox"
|
||||
POP3_PROVIDER_ID = "mail.pop3_legacy_import"
|
||||
_CURRENT_INDEX_WINDOW = timedelta(minutes=30)
|
||||
|
||||
|
||||
@@ -38,6 +40,41 @@ def imap_provider_states(
|
||||
return _mail_provider_states(context, protocol="imap")
|
||||
|
||||
|
||||
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,
|
||||
*,
|
||||
@@ -194,6 +231,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,
|
||||
*,
|
||||
@@ -321,6 +389,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 +469,9 @@ def _aware(value: object | None) -> datetime | None:
|
||||
|
||||
__all__ = [
|
||||
"IMAP_PROVIDER_ID",
|
||||
"POP3_PROVIDER_ID",
|
||||
"SMTP_PROVIDER_ID",
|
||||
"imap_provider_states",
|
||||
"pop3_provider_states",
|
||||
"smtp_provider_states",
|
||||
]
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_mail.backend.schemas import (
|
||||
MailAddressLookupCandidate,
|
||||
MailAddressLookupResponse,
|
||||
MailAddressWriteTarget,
|
||||
MailAddressWriteTargetResponse,
|
||||
MailContactCreateRequest,
|
||||
MailContactCreateResponse,
|
||||
MailConnectionTestResponse,
|
||||
MailBounceObservationListResponse,
|
||||
MailBounceObservationResponse,
|
||||
@@ -39,6 +44,13 @@ from govoplan_mail.backend.schemas import (
|
||||
MailProfilePolicyResponse,
|
||||
MailProfilePolicyUpdateRequest,
|
||||
MailSettingsDeltaResponse,
|
||||
MailPop3ImportListResponse,
|
||||
MailPop3ImportRecordResponse,
|
||||
MailPop3ImportRequest,
|
||||
MailPop3ImportResponse,
|
||||
MailPop3MessagePreviewResponse,
|
||||
MailPop3PreviewRequest,
|
||||
MailPop3PreviewResponse,
|
||||
MailServerProfileCreateRequest,
|
||||
MailServerEndpointCreateRequest,
|
||||
MailServerEndpointResponse,
|
||||
@@ -87,7 +99,15 @@ from govoplan_mail.backend.mail_profiles import (
|
||||
smtp_config_from_profile,
|
||||
update_mail_server_profile,
|
||||
)
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||
from govoplan_mail.backend.config import ImapConfig, Pop3Config, SmtpConfig
|
||||
from govoplan_mail.backend.db.models import MailPop3Import
|
||||
from govoplan_mail.backend.pop3_imports import (
|
||||
Pop3ImportError,
|
||||
create_pop3_imports,
|
||||
list_pop3_imports,
|
||||
mark_pop3_deletion_result,
|
||||
pop3_import_payload,
|
||||
)
|
||||
from govoplan_mail.backend.runtime import get_registry
|
||||
from govoplan_mail.backend.recovery import (
|
||||
MailRecoveryError,
|
||||
@@ -135,6 +155,14 @@ from govoplan_mail.backend.server_hierarchy import (
|
||||
)
|
||||
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, load_imap_mailbox_bootstrap, test_imap_login
|
||||
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
||||
from govoplan_mail.backend.sending.pop3 import (
|
||||
Pop3ConfigurationError,
|
||||
Pop3ProviderError,
|
||||
delete_pop3_messages,
|
||||
download_pop3_messages,
|
||||
preview_pop3_messages,
|
||||
test_pop3_login,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/mail", tags=["mail"])
|
||||
|
||||
@@ -149,6 +177,7 @@ MAIL_CREDENTIAL_RESOURCE = "mail_credential"
|
||||
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
|
||||
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
|
||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
CAPABILITY_ADDRESSES_CONTACT_WRITER = "addresses.contact_writer"
|
||||
bounce_provider = SqlMailBounceProcessingProvider()
|
||||
|
||||
|
||||
@@ -416,6 +445,15 @@ def _capability_payload(value: object) -> dict[str, Any]:
|
||||
"source_ref",
|
||||
"source_revision",
|
||||
"provenance",
|
||||
"address_book_label",
|
||||
"operation",
|
||||
"allowed",
|
||||
"reason",
|
||||
"message",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"read_only",
|
||||
"required_scopes",
|
||||
):
|
||||
if hasattr(value, key):
|
||||
payload[key] = getattr(value, key)
|
||||
@@ -1179,6 +1217,7 @@ def _mailbox_folder_response(
|
||||
message=f"Found {len(folders)} IMAP folder(s).",
|
||||
folders=folders,
|
||||
detected_sent_folder=result.detected_sent_folder,
|
||||
detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {},
|
||||
from_cache=from_cache,
|
||||
refreshing=refreshing,
|
||||
indexed_at=indexed_at,
|
||||
@@ -1360,6 +1399,78 @@ def lookup_mail_addresses(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/address-write-targets", response_model=MailAddressWriteTargetResponse)
|
||||
def list_mail_address_write_targets(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> MailAddressWriteTargetResponse:
|
||||
_require_scope(principal, "mail:profile:use")
|
||||
capability = _registry_capability(CAPABILITY_ADDRESSES_CONTACT_WRITER)
|
||||
if capability is None or not hasattr(capability, "list_write_targets"):
|
||||
return MailAddressWriteTargetResponse(available=False, targets=[])
|
||||
targets = getattr(capability, "list_write_targets")(session, principal, operation="create_contact")
|
||||
return MailAddressWriteTargetResponse(
|
||||
available=True,
|
||||
targets=[MailAddressWriteTarget.model_validate(_capability_payload(target)) for target in targets],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/address-contacts",
|
||||
response_model=MailContactCreateResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_mail_address_contact(
|
||||
payload: MailContactCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> MailContactCreateResponse:
|
||||
_require_scope(principal, "mail:profile:use")
|
||||
capability = _registry_capability(CAPABILITY_ADDRESSES_CONTACT_WRITER)
|
||||
if capability is None or not all(
|
||||
hasattr(capability, method)
|
||||
for method in ("can_write_to_address_book", "create_contact")
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Address-book contact writing is not available.",
|
||||
)
|
||||
decision = getattr(capability, "can_write_to_address_book")(
|
||||
session,
|
||||
principal,
|
||||
address_book_id=payload.address_book_id,
|
||||
operation="create_contact",
|
||||
)
|
||||
decision_payload = _capability_payload(decision)
|
||||
if decision_payload.get("allowed") is not True:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(decision_payload.get("message") or "The selected address book is not writable."),
|
||||
)
|
||||
try:
|
||||
result = getattr(capability, "create_contact")(
|
||||
session,
|
||||
principal,
|
||||
address_book_id=payload.address_book_id,
|
||||
payload={
|
||||
"display_name": payload.display_name or payload.email,
|
||||
"emails": [{"label": "Mail", "email": payload.email, "is_primary": True}],
|
||||
},
|
||||
provenance={
|
||||
"consumer_module": MAIL_MODULE_ID,
|
||||
"consumer_workflow": "mailbox_add_contact",
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
except ValueError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return MailContactCreateResponse.model_validate(_capability_payload(result))
|
||||
|
||||
|
||||
@router.get("/settings/delta", response_model=MailSettingsDeltaResponse)
|
||||
def mail_settings_delta(
|
||||
scope_type: str = Query(default="tenant"),
|
||||
@@ -1506,6 +1617,8 @@ def create_profile_server(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
if payload.protocol == "pop3":
|
||||
_require_scope(principal, "mail:pop3:manage")
|
||||
try:
|
||||
profile = _profile_for_mutation(
|
||||
session,
|
||||
@@ -1565,6 +1678,8 @@ def update_profile_server(
|
||||
profile_id=profile_id,
|
||||
server_id=server_id,
|
||||
)
|
||||
if server.protocol == "pop3":
|
||||
_require_scope(principal, "mail:pop3:manage")
|
||||
update_mail_server_endpoint(
|
||||
session,
|
||||
server=server,
|
||||
@@ -1616,6 +1731,8 @@ def deactivate_profile_server(
|
||||
profile_id=profile_id,
|
||||
server_id=server_id,
|
||||
)
|
||||
if server.protocol == "pop3":
|
||||
_require_scope(principal, "mail:pop3:manage")
|
||||
update_mail_server_endpoint(
|
||||
session,
|
||||
server=server,
|
||||
@@ -2276,7 +2393,7 @@ def update_profile(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
config=smtp_config.model_dump(mode="json"),
|
||||
config=smtp_config.model_dump(mode="json", exclude_none=True),
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
imap_server = None
|
||||
@@ -2286,7 +2403,7 @@ def update_profile(
|
||||
profile=profile,
|
||||
protocol="imap",
|
||||
config=(
|
||||
imap_config.model_dump(mode="json")
|
||||
imap_config.model_dump(mode="json", exclude_none=True)
|
||||
if imap_config is not None and not payload.clear_imap
|
||||
else None
|
||||
),
|
||||
@@ -2579,6 +2696,359 @@ def test_profile_imap(
|
||||
return MailConnectionTestResponse(ok=False, protocol="imap", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
|
||||
|
||||
|
||||
def _resolve_profile_pop3_transport(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
profile_id: str,
|
||||
server_id: str,
|
||||
credential_id: str | None,
|
||||
):
|
||||
profile = _get_profile_for_principal(
|
||||
session,
|
||||
principal=principal,
|
||||
profile_id=profile_id,
|
||||
require_active=True,
|
||||
)
|
||||
resolved = resolve_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="pop3",
|
||||
context=_transport_context_for_principal(
|
||||
session,
|
||||
principal=principal,
|
||||
),
|
||||
server_id=server_id,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
if resolved.server is None or not isinstance(resolved.config, Pop3Config):
|
||||
raise MailServerHierarchyError("The selected POP3 server is unavailable")
|
||||
return profile, resolved
|
||||
|
||||
|
||||
@router.post(
|
||||
"/profiles/{profile_id}/test-pop3",
|
||||
response_model=MailConnectionTestResponse,
|
||||
)
|
||||
def test_profile_pop3(
|
||||
profile_id: str,
|
||||
server_id: str = Query(...),
|
||||
credential_id: str | None = Query(default=None),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "mail:profile:test")
|
||||
_require_scope(principal, "mail:profile:use")
|
||||
_require_any_scope(principal, "mail:pop3:manage", "mail:pop3:import")
|
||||
try:
|
||||
_profile, resolved = _resolve_profile_pop3_transport(
|
||||
session,
|
||||
principal=principal,
|
||||
profile_id=profile_id,
|
||||
server_id=server_id,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
result = test_pop3_login(pop3_config=resolved.config)
|
||||
return MailConnectionTestResponse(
|
||||
ok=True,
|
||||
protocol="pop3",
|
||||
host=result.host,
|
||||
port=result.port,
|
||||
security=result.security,
|
||||
message="POP3 connection successful.",
|
||||
details={
|
||||
"authenticated": result.authenticated,
|
||||
"message_count": result.message_count,
|
||||
"mailbox_size_bytes": result.mailbox_size_bytes,
|
||||
"legacy_import": True,
|
||||
},
|
||||
)
|
||||
except (MailProfileError, MailServerHierarchyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (Pop3ConfigurationError, Pop3ProviderError) as exc:
|
||||
return MailConnectionTestResponse(
|
||||
ok=False,
|
||||
protocol="pop3",
|
||||
message=_safe_error_message(exc),
|
||||
details={"error_type": exc.__class__.__name__, "legacy_import": True},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/profiles/{profile_id}/pop3/preview",
|
||||
response_model=MailPop3PreviewResponse,
|
||||
)
|
||||
def preview_profile_pop3_import(
|
||||
profile_id: str,
|
||||
payload: MailPop3PreviewRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "mail:profile:use")
|
||||
_require_scope(principal, "mail:pop3:import")
|
||||
try:
|
||||
_profile, resolved = _resolve_profile_pop3_transport(
|
||||
session,
|
||||
principal=principal,
|
||||
profile_id=profile_id,
|
||||
server_id=payload.server_id,
|
||||
credential_id=payload.credential_id,
|
||||
)
|
||||
result = preview_pop3_messages(
|
||||
pop3_config=resolved.config,
|
||||
limit=payload.limit,
|
||||
)
|
||||
uidls = [message.uidl for message in result.messages]
|
||||
imported_uidls = set(
|
||||
session.scalars(
|
||||
select(MailPop3Import.provider_uidl).where(
|
||||
MailPop3Import.tenant_id == principal.tenant_id,
|
||||
MailPop3Import.profile_id == profile_id,
|
||||
MailPop3Import.pop3_server_id == resolved.server.id,
|
||||
MailPop3Import.provider_uidl.in_(uidls),
|
||||
)
|
||||
)
|
||||
) if uidls else set()
|
||||
return MailPop3PreviewResponse(
|
||||
profile_id=profile_id,
|
||||
server_id=resolved.server.id,
|
||||
transport_revision=resolved.transport_revision,
|
||||
host=result.host,
|
||||
port=result.port,
|
||||
security=result.security,
|
||||
message_count=result.message_count,
|
||||
mailbox_size_bytes=result.mailbox_size_bytes,
|
||||
delete_after_import_allowed=resolved.config.allow_delete_after_import,
|
||||
messages=[
|
||||
MailPop3MessagePreviewResponse(
|
||||
message_number=message.message_number,
|
||||
uidl=message.uidl,
|
||||
subject=message.subject,
|
||||
from_header=message.from_header,
|
||||
to_header=message.to_header,
|
||||
date=message.date,
|
||||
message_id=message.message_id,
|
||||
size_bytes=message.size_bytes,
|
||||
body_preview=message.body_preview,
|
||||
already_imported=message.uidl in imported_uidls,
|
||||
)
|
||||
for message in result.messages
|
||||
],
|
||||
)
|
||||
except (MailProfileError, MailServerHierarchyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Pop3ConfigurationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Pop3ProviderError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/profiles/{profile_id}/pop3/import",
|
||||
response_model=MailPop3ImportResponse,
|
||||
)
|
||||
def import_profile_pop3_messages(
|
||||
profile_id: str,
|
||||
payload: MailPop3ImportRequest,
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "mail:profile:use")
|
||||
_require_scope(principal, "mail:pop3:import")
|
||||
if payload.delete_after_import:
|
||||
_require_scope(principal, "mail:pop3:delete")
|
||||
try:
|
||||
_profile, resolved = _resolve_profile_pop3_transport(
|
||||
session,
|
||||
principal=principal,
|
||||
profile_id=profile_id,
|
||||
server_id=payload.server_id,
|
||||
credential_id=payload.credential_id,
|
||||
)
|
||||
if resolved.transport_revision != payload.expected_transport_revision:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The POP3 server or credential selection changed; refresh the preview before importing",
|
||||
)
|
||||
if payload.delete_after_import and not resolved.config.allow_delete_after_import:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Delete-after-import is disabled for the selected POP3 server",
|
||||
)
|
||||
downloaded = download_pop3_messages(
|
||||
pop3_config=resolved.config,
|
||||
uidls=payload.uidls,
|
||||
)
|
||||
imported = create_pop3_imports(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
profile_id=profile_id,
|
||||
pop3_server_id=resolved.server.id,
|
||||
pop3_credential_id=(
|
||||
resolved.credential.id if resolved.credential is not None else None
|
||||
),
|
||||
transport_revision=resolved.transport_revision,
|
||||
messages=downloaded,
|
||||
user_id=principal.user.id,
|
||||
deletion_requested=payload.delete_after_import,
|
||||
)
|
||||
aggregate_digest = hashlib.sha256(
|
||||
"|".join(sorted(item.raw_sha256 for item in downloaded)).encode("ascii")
|
||||
).hexdigest()
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
action="mail.pop3.imported",
|
||||
object_type="mail_pop3_import_batch",
|
||||
object_id=aggregate_digest,
|
||||
details={
|
||||
"profile_id": profile_id,
|
||||
"server_id": resolved.server.id,
|
||||
"transport_revision": resolved.transport_revision,
|
||||
"selected_count": len(downloaded),
|
||||
"imported_count": len(imported.imported),
|
||||
"duplicate_count": len(imported.duplicates),
|
||||
"delete_after_import": payload.delete_after_import,
|
||||
"content_digest": aggregate_digest,
|
||||
},
|
||||
)
|
||||
# The governed local copy and its audit evidence become durable before
|
||||
# any separately authorized provider deletion is attempted.
|
||||
session.commit()
|
||||
|
||||
deletion_status = "not_requested"
|
||||
if payload.delete_after_import:
|
||||
if not imported.imported:
|
||||
deletion_status = "skipped_no_new_messages"
|
||||
else:
|
||||
new_uidls = [row.provider_uidl for row in imported.imported]
|
||||
try:
|
||||
delete_pop3_messages(
|
||||
pop3_config=resolved.config,
|
||||
uidls=new_uidls,
|
||||
)
|
||||
deletion_status = "succeeded"
|
||||
deletion_error = None
|
||||
except Pop3ProviderError as exc:
|
||||
deletion_status = (
|
||||
"outcome_unknown" if exc.outcome_unknown else "failed"
|
||||
)
|
||||
deletion_error = str(exc)
|
||||
rows = mark_pop3_deletion_result(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
import_ids=[row.id for row in imported.imported],
|
||||
status=deletion_status,
|
||||
error=deletion_error,
|
||||
)
|
||||
# Persist the provider outcome independently of the audit
|
||||
# projection. If audit insertion is unavailable after the
|
||||
# irreversible provider operation, the import record still
|
||||
# retains the result for reconciliation and the pre-effect
|
||||
# import audit already proves that deletion was requested.
|
||||
session.commit()
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
action="mail.pop3.source_deletion",
|
||||
object_type="mail_pop3_import_batch",
|
||||
object_id=aggregate_digest,
|
||||
details={
|
||||
"profile_id": profile_id,
|
||||
"server_id": resolved.server.id,
|
||||
"import_count": len(rows),
|
||||
"status": deletion_status,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
return MailPop3ImportResponse(
|
||||
imports=[
|
||||
MailPop3ImportRecordResponse.model_validate(
|
||||
pop3_import_payload(row)
|
||||
)
|
||||
for row in imported.imported
|
||||
],
|
||||
duplicate_uidls=sorted(row.provider_uidl for row in imported.duplicates),
|
||||
deletion_status=deletion_status,
|
||||
)
|
||||
except HTTPException:
|
||||
session.rollback()
|
||||
raise
|
||||
except (MailProfileError, MailServerHierarchyError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (Pop3ConfigurationError, Pop3ImportError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Pop3ProviderError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/pop3/imports", response_model=MailPop3ImportListResponse)
|
||||
def get_pop3_imports(
|
||||
profile_id: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_scope(principal, "mail:pop3:import")
|
||||
visible_profile_ids = {
|
||||
profile.id
|
||||
for profile in list_mail_server_profiles(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
include_inactive=True,
|
||||
**_profile_actor_kwargs(principal, administrative_visibility=True),
|
||||
)
|
||||
}
|
||||
if profile_id and profile_id not in visible_profile_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Mail-server profile not found",
|
||||
)
|
||||
rows = list_pop3_imports(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
profile_id=profile_id,
|
||||
profile_ids=visible_profile_ids,
|
||||
limit=limit,
|
||||
)
|
||||
return MailPop3ImportListResponse(
|
||||
imports=[
|
||||
MailPop3ImportRecordResponse.model_validate(pop3_import_payload(row))
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/list-imap-folders", response_model=MailImapFolderListResponse)
|
||||
def list_profile_imap_folders(
|
||||
profile_id: str,
|
||||
@@ -3063,6 +3533,7 @@ def list_imap_folder_settings(
|
||||
message=f"Found {len(folders)} IMAP folder(s).",
|
||||
folders=folders,
|
||||
detected_sent_folder=result.detected_sent_folder,
|
||||
detected_folder_mappings=getattr(result, "detected_folder_mappings", None) or {},
|
||||
)
|
||||
except Exception as exc:
|
||||
return MailImapFolderListResponse(
|
||||
|
||||
@@ -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", "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", "pop3"]
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
inherit_to_lower_scopes: bool | None = None
|
||||
@@ -344,9 +344,46 @@ class MailAddressLookupResponse(BaseModel):
|
||||
candidates: list[MailAddressLookupCandidate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailAddressWriteTarget(BaseModel):
|
||||
address_book_id: str
|
||||
address_book_label: str | None = None
|
||||
operation: str = "create_contact"
|
||||
allowed: bool = False
|
||||
reason: str
|
||||
message: str
|
||||
scope_type: str | None = None
|
||||
scope_id: str | None = None
|
||||
source_kind: str | None = None
|
||||
read_only: bool = False
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailAddressWriteTargetResponse(BaseModel):
|
||||
available: bool = False
|
||||
targets: list[MailAddressWriteTarget] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailContactCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
address_book_id: str = Field(min_length=1, max_length=36)
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
email: str = Field(min_length=3, max_length=320, pattern=r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||||
|
||||
|
||||
class MailContactCreateResponse(BaseModel):
|
||||
contact_id: str
|
||||
address_book_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
source_kind: str = "local"
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailConnectionTestResponse(BaseModel):
|
||||
ok: bool
|
||||
protocol: Literal["smtp", "imap"]
|
||||
protocol: Literal["smtp", "imap", "pop3"]
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
@@ -354,6 +391,82 @@ class MailConnectionTestResponse(BaseModel):
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailPop3PreviewRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
server_id: str = Field(min_length=1, max_length=36)
|
||||
credential_id: str | None = Field(default=None, max_length=36)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class MailPop3MessagePreviewResponse(BaseModel):
|
||||
message_number: int
|
||||
uidl: str
|
||||
subject: str | None = None
|
||||
from_header: str | None = None
|
||||
to_header: str | None = None
|
||||
date: str | None = None
|
||||
message_id: str | None = None
|
||||
size_bytes: int = 0
|
||||
body_preview: str | None = None
|
||||
already_imported: bool = False
|
||||
|
||||
|
||||
class MailPop3PreviewResponse(BaseModel):
|
||||
profile_id: str
|
||||
server_id: str
|
||||
transport_revision: str
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
message_count: int
|
||||
mailbox_size_bytes: int
|
||||
delete_after_import_allowed: bool = False
|
||||
messages: list[MailPop3MessagePreviewResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailPop3ImportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
server_id: str = Field(min_length=1, max_length=36)
|
||||
credential_id: str | None = Field(default=None, max_length=36)
|
||||
expected_transport_revision: str = Field(min_length=1, max_length=120)
|
||||
uidls: list[str] = Field(min_length=1, max_length=100)
|
||||
delete_after_import: bool = False
|
||||
|
||||
|
||||
class MailPop3ImportRecordResponse(BaseModel):
|
||||
id: str
|
||||
profile_id: str
|
||||
pop3_server_id: str
|
||||
transport_revision: str
|
||||
provider_uidl: str
|
||||
message_id: str | None = None
|
||||
subject: str | None = None
|
||||
from_header: str | None = None
|
||||
to_header: str | None = None
|
||||
date: str | None = None
|
||||
body_preview: str | None = None
|
||||
size_bytes: int
|
||||
raw_sha256: str
|
||||
status: str
|
||||
imported_at: datetime
|
||||
deletion_requested: bool = False
|
||||
deletion_status: str
|
||||
deletion_attempted_at: datetime | None = None
|
||||
deletion_error: str | None = None
|
||||
|
||||
|
||||
class MailPop3ImportResponse(BaseModel):
|
||||
imports: list[MailPop3ImportRecordResponse] = Field(default_factory=list)
|
||||
duplicate_uidls: list[str] = Field(default_factory=list)
|
||||
deletion_status: str = "not_requested"
|
||||
|
||||
|
||||
class MailPop3ImportListResponse(BaseModel):
|
||||
imports: list[MailPop3ImportRecordResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailImapFolderResponse(BaseModel):
|
||||
name: str
|
||||
flags: list[str] = Field(default_factory=list)
|
||||
@@ -370,6 +483,7 @@ class MailImapFolderListResponse(BaseModel):
|
||||
message: str
|
||||
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
||||
detected_sent_folder: str | None = None
|
||||
detected_folder_mappings: dict[str, str] = Field(default_factory=dict)
|
||||
from_cache: bool = False
|
||||
refreshing: bool = False
|
||||
indexed_at: datetime | None = None
|
||||
|
||||
@@ -103,6 +103,7 @@ class ImapFolderListResult:
|
||||
security: str
|
||||
folders: list[ImapMailboxInfo]
|
||||
detected_sent_folder: str | None = None
|
||||
detected_folder_mappings: dict[str, str] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -317,25 +318,47 @@ def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str
|
||||
return None
|
||||
|
||||
|
||||
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
||||
for name, flags in parsed:
|
||||
if "\\sent" in flags or "\\sentmail" in flags:
|
||||
return name
|
||||
_STANDARD_FOLDER_FLAGS: dict[str, tuple[str, ...]] = {
|
||||
"inbox": ("\\inbox",),
|
||||
"sent": ("\\sent", "\\sentmail"),
|
||||
"drafts": ("\\drafts",),
|
||||
"trash": ("\\trash",),
|
||||
"archive": ("\\archive", "\\all"),
|
||||
"junk": ("\\junk", "\\spam"),
|
||||
}
|
||||
|
||||
common_names = [
|
||||
"Sent",
|
||||
"Sent Items",
|
||||
"Sent Messages",
|
||||
"Gesendet",
|
||||
"Gesendete Elemente",
|
||||
"INBOX.Sent",
|
||||
"INBOX/Sent",
|
||||
]
|
||||
names = {name.lower(): name for name, _ in parsed}
|
||||
for candidate in common_names:
|
||||
if candidate.lower() in names:
|
||||
return names[candidate.lower()]
|
||||
return None
|
||||
_STANDARD_FOLDER_NAMES: dict[str, tuple[str, ...]] = {
|
||||
"inbox": ("INBOX", "Posteingang"),
|
||||
"sent": ("Sent", "Sent Items", "Sent Messages", "Gesendet", "Gesendete Elemente", "INBOX.Sent", "INBOX/Sent"),
|
||||
"drafts": ("Drafts", "Entwürfe", "Entwuerfe"),
|
||||
"trash": ("Trash", "Deleted Items", "Gelöscht", "Geloescht", "Papierkorb"),
|
||||
"archive": ("Archive", "Archives", "Archiv"),
|
||||
"junk": ("Junk", "Spam", "Junk Email", "Unerwünscht", "Unerwuenscht"),
|
||||
}
|
||||
|
||||
|
||||
def _detect_standard_folder_mappings(parsed: list[tuple[str, set[str]]]) -> dict[str, str]:
|
||||
detected: dict[str, str] = {}
|
||||
for role, accepted_flags in _STANDARD_FOLDER_FLAGS.items():
|
||||
for name, flags in parsed:
|
||||
if any(flag in flags for flag in accepted_flags):
|
||||
detected[role] = name
|
||||
break
|
||||
|
||||
names = {name.casefold(): name for name, _ in parsed}
|
||||
for role, candidates in _STANDARD_FOLDER_NAMES.items():
|
||||
if role in detected:
|
||||
continue
|
||||
for candidate in candidates:
|
||||
match = names.get(candidate.casefold())
|
||||
if match:
|
||||
detected[role] = match
|
||||
break
|
||||
return detected
|
||||
|
||||
|
||||
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
|
||||
return _detect_standard_folder_mappings(parsed).get("sent")
|
||||
|
||||
|
||||
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
|
||||
@@ -402,12 +425,18 @@ def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||
name = str(item["name"])
|
||||
count = sum(1 for record in records if _mock_folder_matches(record, name))
|
||||
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
|
||||
parsed = [
|
||||
(str(item["name"]), {str(flag).lower() for flag in item.get("flags") or []})
|
||||
for item in MOCK_IMAP_FOLDERS
|
||||
]
|
||||
detected = _detect_standard_folder_mappings(parsed)
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folders=folders,
|
||||
detected_sent_folder="Sent",
|
||||
detected_sent_folder=detected.get("sent"),
|
||||
detected_folder_mappings=detected,
|
||||
)
|
||||
|
||||
|
||||
@@ -438,12 +467,14 @@ def _list_imap_folders_on_client(
|
||||
)
|
||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
|
||||
|
||||
detected = _detect_standard_folder_mappings(parsed)
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=security,
|
||||
folders=folders,
|
||||
detected_sent_folder=_detect_sent_folder(parsed),
|
||||
detected_sent_folder=detected.get("sent"),
|
||||
detected_folder_mappings=detected,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import poplib
|
||||
import socket
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from email import policy
|
||||
from email.message import Message
|
||||
from email.parser import BytesParser
|
||||
from typing import Iterable
|
||||
|
||||
from govoplan_core.security.outbound_http import (
|
||||
OutboundHttpError,
|
||||
create_outbound_connection,
|
||||
validate_outbound_host,
|
||||
)
|
||||
from govoplan_mail.backend.config import Pop3Config, TransportSecurity
|
||||
|
||||
|
||||
class _OutboundPolicyPOP3(poplib.POP3):
|
||||
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
|
||||
return create_outbound_connection(
|
||||
self.host,
|
||||
self.port,
|
||||
timeout=timeout,
|
||||
label="POP3 legacy import",
|
||||
)
|
||||
|
||||
|
||||
class _OutboundPolicyPOP3SSL(poplib.POP3_SSL):
|
||||
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
|
||||
sock = create_outbound_connection(
|
||||
self.host,
|
||||
self.port,
|
||||
timeout=timeout,
|
||||
label="POP3 legacy import",
|
||||
)
|
||||
try:
|
||||
return self.context.wrap_socket(sock, server_hostname=self.host)
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
|
||||
|
||||
class Pop3ConfigurationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class Pop3ProviderError(RuntimeError):
|
||||
def __init__(self, message: str, *, outcome_unknown: bool = False):
|
||||
super().__init__(message)
|
||||
self.outcome_unknown = outcome_unknown
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pop3LoginTestResult:
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
authenticated: bool
|
||||
message_count: int
|
||||
mailbox_size_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pop3MessageSummary:
|
||||
message_number: int
|
||||
uidl: str
|
||||
subject: str | None
|
||||
from_header: str | None
|
||||
to_header: str | None
|
||||
date: str | None
|
||||
message_id: str | None
|
||||
size_bytes: int
|
||||
body_preview: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pop3PreviewResult:
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
message_count: int
|
||||
mailbox_size_bytes: int
|
||||
messages: tuple[Pop3MessageSummary, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pop3DownloadedMessage:
|
||||
message_number: int
|
||||
uidl: str
|
||||
raw: bytes
|
||||
raw_sha256: str
|
||||
summary: Pop3MessageSummary
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pop3DeletionResult:
|
||||
deleted_uidls: tuple[str, ...]
|
||||
|
||||
|
||||
def _require_pop3_config(config: Pop3Config) -> tuple[str, int]:
|
||||
if not config.legacy_import_enabled:
|
||||
raise Pop3ConfigurationError(
|
||||
"POP3 legacy import is disabled for the selected server"
|
||||
)
|
||||
if not config.host:
|
||||
raise Pop3ConfigurationError("POP3 host is required")
|
||||
if not config.port:
|
||||
raise Pop3ConfigurationError("POP3 port is required")
|
||||
if not config.username or not config.password:
|
||||
raise Pop3ConfigurationError("POP3 username and password are required")
|
||||
return config.host, config.port
|
||||
|
||||
|
||||
def _open_pop3(config: Pop3Config) -> poplib.POP3:
|
||||
host, port = _require_pop3_config(config)
|
||||
try:
|
||||
validate_outbound_host(host, port=port, label="POP3 legacy import")
|
||||
except OutboundHttpError as exc:
|
||||
raise Pop3ConfigurationError(str(exc)) from exc
|
||||
|
||||
context = ssl.create_default_context()
|
||||
client: poplib.POP3 | None = None
|
||||
try:
|
||||
if config.security == TransportSecurity.TLS:
|
||||
client = _OutboundPolicyPOP3SSL(
|
||||
host=host,
|
||||
port=port,
|
||||
timeout=config.timeout_seconds,
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
client = _OutboundPolicyPOP3(
|
||||
host=host,
|
||||
port=port,
|
||||
timeout=config.timeout_seconds,
|
||||
)
|
||||
if config.security == TransportSecurity.STARTTLS:
|
||||
client.stls(context=context)
|
||||
client.user(config.username)
|
||||
client.pass_(config.password)
|
||||
return client
|
||||
except ssl.SSLError as exc:
|
||||
_close_without_commit(client)
|
||||
raise Pop3ProviderError("POP3 TLS negotiation failed") from exc
|
||||
except poplib.error_proto as exc:
|
||||
_close_without_commit(client)
|
||||
raise Pop3ProviderError("POP3 authentication failed") from exc
|
||||
except (OSError, socket.error) as exc:
|
||||
_close_without_commit(client)
|
||||
raise Pop3ProviderError("POP3 connection failed") from exc
|
||||
except Exception:
|
||||
_close_without_commit(client)
|
||||
raise
|
||||
|
||||
|
||||
def test_pop3_login(*, pop3_config: Pop3Config) -> Pop3LoginTestResult:
|
||||
client = _open_pop3(pop3_config)
|
||||
try:
|
||||
message_count, mailbox_size = client.stat()
|
||||
return Pop3LoginTestResult(
|
||||
host=str(pop3_config.host),
|
||||
port=int(pop3_config.port or 0),
|
||||
security=pop3_config.security.value,
|
||||
authenticated=True,
|
||||
message_count=int(message_count),
|
||||
mailbox_size_bytes=int(mailbox_size),
|
||||
)
|
||||
except poplib.error_proto as exc:
|
||||
raise Pop3ProviderError("POP3 mailbox statistics are unavailable") from exc
|
||||
finally:
|
||||
_quit_without_deletions(client)
|
||||
|
||||
|
||||
def preview_pop3_messages(
|
||||
*,
|
||||
pop3_config: Pop3Config,
|
||||
limit: int = 50,
|
||||
) -> Pop3PreviewResult:
|
||||
clean_limit = max(1, min(int(limit), 100))
|
||||
client = _open_pop3(pop3_config)
|
||||
try:
|
||||
message_count, mailbox_size = client.stat()
|
||||
uidls = _uidl_map(client)
|
||||
sizes = _size_map(client)
|
||||
selected_numbers = sorted(uidls, reverse=True)[:clean_limit]
|
||||
messages = tuple(
|
||||
_preview_message(
|
||||
client,
|
||||
message_number=number,
|
||||
uidl=uidls[number],
|
||||
size_bytes=sizes.get(number, 0),
|
||||
body_lines=pop3_config.preview_body_lines,
|
||||
max_message_bytes=pop3_config.max_message_bytes,
|
||||
)
|
||||
for number in selected_numbers
|
||||
)
|
||||
return Pop3PreviewResult(
|
||||
host=str(pop3_config.host),
|
||||
port=int(pop3_config.port or 0),
|
||||
security=pop3_config.security.value,
|
||||
message_count=int(message_count),
|
||||
mailbox_size_bytes=int(mailbox_size),
|
||||
messages=messages,
|
||||
)
|
||||
except poplib.error_proto as exc:
|
||||
raise Pop3ProviderError("POP3 message preview failed") from exc
|
||||
finally:
|
||||
_quit_without_deletions(client)
|
||||
|
||||
|
||||
def download_pop3_messages(
|
||||
*,
|
||||
pop3_config: Pop3Config,
|
||||
uidls: Iterable[str],
|
||||
) -> tuple[Pop3DownloadedMessage, ...]:
|
||||
selected_uidls = tuple(dict.fromkeys(_required_uidl(value) for value in uidls))
|
||||
if not selected_uidls:
|
||||
raise Pop3ConfigurationError("Select at least one POP3 message to import")
|
||||
if len(selected_uidls) > 100:
|
||||
raise Pop3ConfigurationError("At most 100 POP3 messages can be imported at once")
|
||||
|
||||
client = _open_pop3(pop3_config)
|
||||
try:
|
||||
uidl_by_number = _uidl_map(client)
|
||||
number_by_uidl = {uidl: number for number, uidl in uidl_by_number.items()}
|
||||
missing = [uidl for uidl in selected_uidls if uidl not in number_by_uidl]
|
||||
if missing:
|
||||
raise Pop3ProviderError(
|
||||
"One or more previewed POP3 messages are no longer available; refresh the preview"
|
||||
)
|
||||
sizes = _size_map(client)
|
||||
advertised_batch_size = sum(
|
||||
max(0, int(sizes.get(number_by_uidl[uidl], 0)))
|
||||
for uidl in selected_uidls
|
||||
)
|
||||
if advertised_batch_size > pop3_config.max_batch_bytes:
|
||||
raise Pop3ProviderError(
|
||||
"The selected POP3 messages exceed the configured batch size limit"
|
||||
)
|
||||
downloaded: list[Pop3DownloadedMessage] = []
|
||||
downloaded_bytes = 0
|
||||
for uidl in selected_uidls:
|
||||
number = number_by_uidl[uidl]
|
||||
advertised_size = sizes.get(number, 0)
|
||||
if advertised_size > pop3_config.max_message_bytes:
|
||||
raise Pop3ProviderError(
|
||||
f"POP3 message {uidl} exceeds the configured import size limit"
|
||||
)
|
||||
_response, lines, _octets = client.retr(number)
|
||||
raw = _message_bytes(lines)
|
||||
if len(raw) > pop3_config.max_message_bytes:
|
||||
raise Pop3ProviderError(
|
||||
f"POP3 message {uidl} exceeds the configured import size limit"
|
||||
)
|
||||
downloaded_bytes += len(raw)
|
||||
if downloaded_bytes > pop3_config.max_batch_bytes:
|
||||
raise Pop3ProviderError(
|
||||
"The selected POP3 messages exceed the configured batch size limit"
|
||||
)
|
||||
summary = _message_summary(
|
||||
raw,
|
||||
message_number=number,
|
||||
uidl=uidl,
|
||||
size_bytes=len(raw),
|
||||
)
|
||||
downloaded.append(
|
||||
Pop3DownloadedMessage(
|
||||
message_number=number,
|
||||
uidl=uidl,
|
||||
raw=raw,
|
||||
raw_sha256=hashlib.sha256(raw).hexdigest(),
|
||||
summary=summary,
|
||||
)
|
||||
)
|
||||
return tuple(downloaded)
|
||||
except poplib.error_proto as exc:
|
||||
raise Pop3ProviderError("POP3 message download failed") from exc
|
||||
finally:
|
||||
_quit_without_deletions(client)
|
||||
|
||||
|
||||
def delete_pop3_messages(
|
||||
*,
|
||||
pop3_config: Pop3Config,
|
||||
uidls: Iterable[str],
|
||||
) -> Pop3DeletionResult:
|
||||
selected_uidls = tuple(dict.fromkeys(_required_uidl(value) for value in uidls))
|
||||
if not selected_uidls:
|
||||
return Pop3DeletionResult(deleted_uidls=())
|
||||
if not pop3_config.allow_delete_after_import:
|
||||
raise Pop3ConfigurationError(
|
||||
"POP3 delete-after-import is disabled for the selected server"
|
||||
)
|
||||
|
||||
client = _open_pop3(pop3_config)
|
||||
quit_started = False
|
||||
try:
|
||||
number_by_uidl = {
|
||||
uidl: number for number, uidl in _uidl_map(client).items()
|
||||
}
|
||||
missing = [uidl for uidl in selected_uidls if uidl not in number_by_uidl]
|
||||
if missing:
|
||||
raise Pop3ProviderError(
|
||||
"One or more imported POP3 messages are no longer available for deletion"
|
||||
)
|
||||
for uidl in selected_uidls:
|
||||
client.dele(number_by_uidl[uidl])
|
||||
quit_started = True
|
||||
client.quit()
|
||||
return Pop3DeletionResult(deleted_uidls=selected_uidls)
|
||||
except Pop3ProviderError:
|
||||
_close_without_commit(client)
|
||||
raise
|
||||
except poplib.error_proto as exc:
|
||||
_close_without_commit(client)
|
||||
raise Pop3ProviderError(
|
||||
"POP3 deletion outcome is unknown" if quit_started else "POP3 deletion was rejected",
|
||||
outcome_unknown=quit_started,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
_close_without_commit(client)
|
||||
raise Pop3ProviderError(
|
||||
"POP3 deletion outcome is unknown" if quit_started else "POP3 deletion failed",
|
||||
outcome_unknown=quit_started,
|
||||
) from exc
|
||||
|
||||
|
||||
def _uidl_map(client: poplib.POP3) -> dict[int, str]:
|
||||
_response, lines, _octets = client.uidl()
|
||||
result: dict[int, str] = {}
|
||||
for raw_line in lines:
|
||||
parts = bytes(raw_line).decode("utf-8", errors="replace").split(maxsplit=1)
|
||||
if len(parts) != 2 or not parts[0].isdigit():
|
||||
continue
|
||||
uidl = _required_uidl(parts[1])
|
||||
result[int(parts[0])] = uidl
|
||||
if not result:
|
||||
raise Pop3ProviderError(
|
||||
"The POP3 server does not provide stable UIDL identifiers; safe import is unavailable"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _size_map(client: poplib.POP3) -> dict[int, int]:
|
||||
_response, lines, _octets = client.list()
|
||||
result: dict[int, int] = {}
|
||||
for raw_line in lines:
|
||||
parts = bytes(raw_line).decode("ascii", errors="ignore").split(maxsplit=1)
|
||||
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
|
||||
result[int(parts[0])] = int(parts[1])
|
||||
return result
|
||||
|
||||
|
||||
def _preview_message(
|
||||
client: poplib.POP3,
|
||||
*,
|
||||
message_number: int,
|
||||
uidl: str,
|
||||
size_bytes: int,
|
||||
body_lines: int,
|
||||
max_message_bytes: int,
|
||||
) -> Pop3MessageSummary:
|
||||
raw: bytes | None = None
|
||||
try:
|
||||
_response, lines, _octets = client.top(message_number, body_lines)
|
||||
raw = _message_bytes(lines)
|
||||
except (poplib.error_proto, AttributeError):
|
||||
# TOP is optional. Never use RETR as a preview fallback when the
|
||||
# advertised message already exceeds the configured download bound.
|
||||
if size_bytes > max_message_bytes:
|
||||
return Pop3MessageSummary(
|
||||
message_number=message_number,
|
||||
uidl=uidl,
|
||||
subject=None,
|
||||
from_header=None,
|
||||
to_header=None,
|
||||
date=None,
|
||||
message_id=None,
|
||||
size_bytes=size_bytes,
|
||||
body_preview=None,
|
||||
)
|
||||
_response, lines, _octets = client.retr(message_number)
|
||||
raw = _message_bytes(lines)
|
||||
if len(raw) > min(max_message_bytes, 256 * 1024):
|
||||
return Pop3MessageSummary(
|
||||
message_number=message_number,
|
||||
uidl=uidl,
|
||||
subject=None,
|
||||
from_header=None,
|
||||
to_header=None,
|
||||
date=None,
|
||||
message_id=None,
|
||||
size_bytes=size_bytes,
|
||||
body_preview=None,
|
||||
)
|
||||
return _message_summary(
|
||||
raw,
|
||||
message_number=message_number,
|
||||
uidl=uidl,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _message_summary(
|
||||
raw: bytes,
|
||||
*,
|
||||
message_number: int,
|
||||
uidl: str,
|
||||
size_bytes: int,
|
||||
) -> Pop3MessageSummary:
|
||||
message = BytesParser(policy=policy.default).parsebytes(raw)
|
||||
return Pop3MessageSummary(
|
||||
message_number=message_number,
|
||||
uidl=uidl,
|
||||
subject=_header(message, "Subject"),
|
||||
from_header=_header(message, "From"),
|
||||
to_header=_header(message, "To"),
|
||||
date=_header(message, "Date"),
|
||||
message_id=_header(message, "Message-ID"),
|
||||
size_bytes=max(0, int(size_bytes)),
|
||||
body_preview=_body_preview(message),
|
||||
)
|
||||
|
||||
|
||||
def _header(message: Message, name: str) -> str | None:
|
||||
value = message.get(name)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text[:2_000] or None
|
||||
|
||||
|
||||
def _body_preview(message: Message) -> str | None:
|
||||
body = message.get_body(preferencelist=("plain",)) if message.is_multipart() else message
|
||||
if body is None:
|
||||
return None
|
||||
try:
|
||||
text = body.get_content()
|
||||
except Exception:
|
||||
payload = body.get_payload(decode=True)
|
||||
text = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else str(payload or "")
|
||||
normalized = " ".join(str(text).split())
|
||||
return normalized[:500] or None
|
||||
|
||||
|
||||
def _message_bytes(lines: Iterable[bytes]) -> bytes:
|
||||
return b"\r\n".join(bytes(line) for line in lines) + b"\r\n"
|
||||
|
||||
|
||||
def _required_uidl(value: object) -> str:
|
||||
clean = str(value or "").strip()
|
||||
if not clean or len(clean) > 500 or any(char.isspace() for char in clean):
|
||||
raise Pop3ConfigurationError("POP3 UIDL must be a non-empty token")
|
||||
return clean
|
||||
|
||||
|
||||
def _quit_without_deletions(client: poplib.POP3) -> None:
|
||||
try:
|
||||
client.quit()
|
||||
except Exception:
|
||||
_close_without_commit(client)
|
||||
|
||||
|
||||
def _close_without_commit(client: poplib.POP3 | None) -> None:
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.rset()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Pop3ConfigurationError",
|
||||
"Pop3DeletionResult",
|
||||
"Pop3DownloadedMessage",
|
||||
"Pop3LoginTestResult",
|
||||
"Pop3MessageSummary",
|
||||
"Pop3PreviewResult",
|
||||
"Pop3ProviderError",
|
||||
"delete_pop3_messages",
|
||||
"download_pop3_messages",
|
||||
"preview_pop3_messages",
|
||||
"test_pop3_login",
|
||||
]
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
@@ -64,10 +65,22 @@ class SmtpSendError(RuntimeError):
|
||||
started, so automatic retry is intentionally forbidden.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
temporary: bool = False,
|
||||
outcome_unknown: bool = False,
|
||||
systemic: bool = False,
|
||||
reason_code: str | None = None,
|
||||
phase: str = "send",
|
||||
):
|
||||
super().__init__(message)
|
||||
self.temporary = temporary
|
||||
self.outcome_unknown = outcome_unknown
|
||||
self.systemic = systemic
|
||||
self.reason_code = reason_code
|
||||
self.phase = phase
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -86,12 +99,315 @@ class SmtpSendResult:
|
||||
envelope_from: str
|
||||
envelope_recipients: list[str]
|
||||
refused_recipients: dict[str, tuple[int, bytes | str]]
|
||||
connection_sequence: int = 1
|
||||
session_reused: bool = False
|
||||
reconnect_count: int = 0
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
return len(self.envelope_recipients) - len(self.refused_recipients)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmtpBatchPolicy:
|
||||
reuse_connections: bool = True
|
||||
max_messages_per_connection: int = 100
|
||||
reconnect_attempts: int = 1
|
||||
health_check_before_reuse: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "SmtpBatchPolicy":
|
||||
return cls(
|
||||
reuse_connections=_environment_bool("GOVOPLAN_SMTP_BATCH_REUSE", True),
|
||||
max_messages_per_connection=_environment_int(
|
||||
"GOVOPLAN_SMTP_BATCH_MAX_MESSAGES",
|
||||
default=100,
|
||||
minimum=1,
|
||||
maximum=10_000,
|
||||
),
|
||||
reconnect_attempts=_environment_int(
|
||||
"GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS",
|
||||
default=1,
|
||||
minimum=0,
|
||||
maximum=5,
|
||||
),
|
||||
health_check_before_reuse=_environment_bool(
|
||||
"GOVOPLAN_SMTP_BATCH_HEALTH_CHECK",
|
||||
True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmtpBatchPreflightResult:
|
||||
ready: bool
|
||||
authenticated: bool
|
||||
connection_sequence: int
|
||||
reconnect_count: int
|
||||
|
||||
|
||||
class SmtpBatchSession:
|
||||
"""Bounded reusable SMTP connection for one already-authorized batch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
smtp_config: SmtpConfig,
|
||||
*,
|
||||
policy: SmtpBatchPolicy | None = None,
|
||||
) -> None:
|
||||
self.smtp_config = smtp_config
|
||||
self.policy = policy or SmtpBatchPolicy.from_environment()
|
||||
self._smtp: smtplib.SMTP | None = None
|
||||
self._connection_sequence = 0
|
||||
self._reconnect_count = 0
|
||||
self._messages_on_connection = 0
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
return self._connection_sequence
|
||||
|
||||
@property
|
||||
def reconnect_count(self) -> int:
|
||||
return self._reconnect_count
|
||||
|
||||
def preflight(self) -> SmtpBatchPreflightResult:
|
||||
"""Validate DNS/egress/connectivity/TLS/auth before a provider effect."""
|
||||
|
||||
if self._closed:
|
||||
raise SmtpSendError(
|
||||
"SMTP batch session is closed.",
|
||||
systemic=True,
|
||||
reason_code="batch_session_closed",
|
||||
phase="preflight",
|
||||
)
|
||||
_require_smtp_config(self.smtp_config)
|
||||
if is_mock_smtp_host(self.smtp_config.host):
|
||||
if self._connection_sequence == 0:
|
||||
self._connection_sequence = 1
|
||||
return self._preflight_result()
|
||||
if self._smtp is None:
|
||||
self._connect_with_retries()
|
||||
return self._preflight_result()
|
||||
|
||||
def send(
|
||||
self,
|
||||
message: EmailMessage | bytes,
|
||||
*,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
) -> SmtpSendResult:
|
||||
host, port, recipients = _prepare_smtp_send(
|
||||
smtp_config=self.smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
if is_mock_smtp_host(self.smtp_config.host):
|
||||
preflight = self.preflight()
|
||||
_accepted, refused = _send_mock_smtp_payload(
|
||||
message,
|
||||
smtp_config=self.smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
)
|
||||
self._messages_on_connection += 1
|
||||
return _smtp_send_result(
|
||||
smtp_config=self.smtp_config,
|
||||
host=host,
|
||||
port=port,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
refused=refused,
|
||||
connection_sequence=preflight.connection_sequence,
|
||||
session_reused=self._messages_on_connection > 1,
|
||||
reconnect_count=preflight.reconnect_count,
|
||||
)
|
||||
|
||||
reused = self._prepare_connection_for_send()
|
||||
smtp = self._smtp
|
||||
if smtp is None: # Defensive: preflight either opens or raises.
|
||||
raise SmtpSendError(
|
||||
"SMTP preflight did not establish a connection.",
|
||||
temporary=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connectivity_unavailable",
|
||||
phase="preflight",
|
||||
)
|
||||
try:
|
||||
if isinstance(message, bytes):
|
||||
refused = smtp.sendmail(envelope_from, recipients, message)
|
||||
else:
|
||||
refused = smtp.send_message(
|
||||
message,
|
||||
from_addr=envelope_from,
|
||||
to_addrs=recipients,
|
||||
)
|
||||
except smtplib.SMTPRecipientsRefused as exc:
|
||||
raise SmtpSendError(
|
||||
f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}",
|
||||
temporary=False,
|
||||
reason_code="smtp_recipients_refused",
|
||||
) from exc
|
||||
except smtplib.SMTPSenderRefused as exc:
|
||||
self._discard_connection()
|
||||
raise SmtpSendError(
|
||||
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=True,
|
||||
reason_code="smtp_sender_refused",
|
||||
) from exc
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
disconnected = int(exc.smtp_code) == 421
|
||||
if disconnected:
|
||||
self._discard_connection()
|
||||
raise SmtpSendError(
|
||||
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=disconnected,
|
||||
reason_code="smtp_connection_closed" if disconnected else "smtp_message_rejected",
|
||||
) from exc
|
||||
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
|
||||
self._discard_connection()
|
||||
raise SmtpSendError(
|
||||
f"SMTP outcome is unknown after transmission started: {exc}",
|
||||
outcome_unknown=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connection_lost_after_transmission",
|
||||
) from exc
|
||||
|
||||
self._messages_on_connection += 1
|
||||
result = _smtp_send_result(
|
||||
smtp_config=self.smtp_config,
|
||||
host=host,
|
||||
port=port,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
refused=refused,
|
||||
connection_sequence=self._connection_sequence,
|
||||
session_reused=reused,
|
||||
reconnect_count=self._reconnect_count,
|
||||
)
|
||||
if not self.policy.reuse_connections:
|
||||
self._discard_connection()
|
||||
return result
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
self._discard_connection()
|
||||
|
||||
def __enter__(self) -> "SmtpBatchSession":
|
||||
self.preflight()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _traceback) -> None:
|
||||
self.close()
|
||||
|
||||
def _preflight_result(self) -> SmtpBatchPreflightResult:
|
||||
return SmtpBatchPreflightResult(
|
||||
ready=True,
|
||||
authenticated=bool(self.smtp_config.username and self.smtp_config.password),
|
||||
connection_sequence=self._connection_sequence,
|
||||
reconnect_count=self._reconnect_count,
|
||||
)
|
||||
|
||||
def _prepare_connection_for_send(self) -> bool:
|
||||
reused = self._smtp is not None and self._messages_on_connection > 0
|
||||
if self._smtp is not None and self._messages_on_connection >= self.policy.max_messages_per_connection:
|
||||
self._discard_connection()
|
||||
reused = False
|
||||
elif reused and self.policy.health_check_before_reuse:
|
||||
try:
|
||||
code, _message = self._smtp.noop()
|
||||
if int(code) >= 400:
|
||||
raise smtplib.SMTPServerDisconnected(f"SMTP NOOP returned {code}")
|
||||
except (OSError, smtplib.SMTPException):
|
||||
self._discard_connection()
|
||||
reused = False
|
||||
self.preflight()
|
||||
return reused and self._smtp is not None
|
||||
|
||||
def _connect_with_retries(self) -> None:
|
||||
last_error: BaseException | None = None
|
||||
for attempt in range(self.policy.reconnect_attempts + 1):
|
||||
try:
|
||||
smtp = _open_smtp(self.smtp_config)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise SmtpSendError(
|
||||
"SMTP authentication failed during batch preflight.",
|
||||
systemic=True,
|
||||
reason_code="smtp_authentication_failed",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
except SmtpConfigurationError:
|
||||
raise
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
temporary = 400 <= int(exc.smtp_code) < 500
|
||||
last_error = exc
|
||||
if not temporary or attempt >= self.policy.reconnect_attempts:
|
||||
raise SmtpSendError(
|
||||
"SMTP server rejected batch preflight.",
|
||||
temporary=temporary,
|
||||
systemic=True,
|
||||
reason_code="smtp_preflight_rejected",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
continue
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
last_error = exc
|
||||
if attempt >= self.policy.reconnect_attempts:
|
||||
raise SmtpSendError(
|
||||
"SMTP connectivity is unavailable during batch preflight.",
|
||||
temporary=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connectivity_unavailable",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
continue
|
||||
self._smtp = smtp
|
||||
if attempt > 0 or self._connection_sequence > 0:
|
||||
self._reconnect_count += 1
|
||||
self._connection_sequence += 1
|
||||
self._messages_on_connection = 0
|
||||
return
|
||||
raise SmtpSendError(
|
||||
f"SMTP batch preflight failed: {last_error}",
|
||||
temporary=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connectivity_unavailable",
|
||||
phase="preflight",
|
||||
)
|
||||
|
||||
def _discard_connection(self) -> None:
|
||||
smtp, self._smtp = self._smtp, None
|
||||
self._messages_on_connection = 0
|
||||
if smtp is None:
|
||||
return
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception as quit_exc:
|
||||
_log_smtp_cleanup_failure("closing batch connection", quit_exc)
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception as close_exc:
|
||||
_log_smtp_cleanup_failure("closing batch socket", close_exc)
|
||||
|
||||
|
||||
def _environment_bool(name: str, default: bool) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _environment_int(name: str, *, default: int, minimum: int, maximum: int) -> int:
|
||||
value = os.getenv(name)
|
||||
try:
|
||||
parsed = int(value) if value is not None else default
|
||||
except ValueError:
|
||||
parsed = default
|
||||
return max(minimum, min(maximum, parsed))
|
||||
|
||||
|
||||
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
|
||||
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
|
||||
|
||||
@@ -324,15 +640,27 @@ def _send_network_smtp_payload(
|
||||
raise SmtpSendError(
|
||||
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=False,
|
||||
systemic=True,
|
||||
reason_code="smtp_authentication_failed",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
raise SmtpSendError(
|
||||
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=True,
|
||||
reason_code="smtp_preflight_rejected",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
# No message transmission has begun yet; a later explicit retry is safe.
|
||||
raise SmtpSendError(f"SMTP connection failed: {exc}", temporary=True) from exc
|
||||
raise SmtpSendError(
|
||||
f"SMTP connection failed: {exc}",
|
||||
temporary=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connectivity_unavailable",
|
||||
phase="preflight",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
if isinstance(message, bytes):
|
||||
@@ -352,6 +680,8 @@ def _send_network_smtp_payload(
|
||||
raise SmtpSendError(
|
||||
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=True,
|
||||
reason_code="smtp_sender_refused",
|
||||
) from exc
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
# An explicit SMTP response means the server rejected the transaction;
|
||||
@@ -359,6 +689,8 @@ def _send_network_smtp_payload(
|
||||
raise SmtpSendError(
|
||||
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
|
||||
temporary=400 <= int(exc.smtp_code) < 500,
|
||||
systemic=int(exc.smtp_code) == 421,
|
||||
reason_code="smtp_connection_closed" if int(exc.smtp_code) == 421 else "smtp_message_rejected",
|
||||
) from exc
|
||||
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
|
||||
# A connection loss after DATA began can happen after the server accepted
|
||||
@@ -366,6 +698,8 @@ def _send_network_smtp_payload(
|
||||
raise SmtpSendError(
|
||||
f"SMTP outcome is unknown after transmission started: {exc}",
|
||||
outcome_unknown=True,
|
||||
systemic=True,
|
||||
reason_code="smtp_connection_lost_after_transmission",
|
||||
) from exc
|
||||
finally:
|
||||
try:
|
||||
@@ -387,6 +721,9 @@ def _smtp_send_result(
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
refused: dict[str, tuple[int, bytes]],
|
||||
connection_sequence: int = 1,
|
||||
session_reused: bool = False,
|
||||
reconnect_count: int = 0,
|
||||
) -> SmtpSendResult:
|
||||
return SmtpSendResult(
|
||||
host=host,
|
||||
@@ -395,6 +732,9 @@ def _smtp_send_result(
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=list(envelope_recipients),
|
||||
refused_recipients=_decode_refused(refused),
|
||||
connection_sequence=connection_sequence,
|
||||
session_reused=session_reused,
|
||||
reconnect_count=reconnect_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -404,15 +744,19 @@ def send_email_bytes(
|
||||
smtp_config: SmtpConfig,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
batch_session: SmtpBatchSession | None = None,
|
||||
) -> SmtpSendResult:
|
||||
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
|
||||
|
||||
return _send_smtp_payload(
|
||||
message_bytes,
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
if batch_session is not None:
|
||||
if batch_session.smtp_config != smtp_config:
|
||||
raise SmtpConfigurationError("SMTP batch session does not match the resolved transport.")
|
||||
return batch_session.send(
|
||||
message_bytes,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
return _send_smtp_payload(message_bytes, smtp_config=smtp_config, envelope_from=envelope_from, envelope_recipients=envelope_recipients)
|
||||
|
||||
|
||||
def send_email_message(
|
||||
|
||||
@@ -23,6 +23,8 @@ from govoplan_core.security.secrets import decrypt_secret
|
||||
from govoplan_mail.backend.config import (
|
||||
ImapConfig,
|
||||
ImapServerConfig,
|
||||
Pop3Config,
|
||||
Pop3ServerConfig,
|
||||
SmtpConfig,
|
||||
SmtpServerConfig,
|
||||
)
|
||||
@@ -34,7 +36,7 @@ from govoplan_mail.backend.db.models import (
|
||||
)
|
||||
|
||||
|
||||
MAIL_SERVER_PROTOCOLS = frozenset({"smtp", "imap"})
|
||||
MAIL_SERVER_PROTOCOLS = frozenset({"smtp", "imap", "pop3"})
|
||||
|
||||
|
||||
class MailServerHierarchyError(RuntimeError):
|
||||
@@ -68,7 +70,7 @@ class ResolvedMailTransport:
|
||||
profile: MailServerProfile
|
||||
server: MailServerEndpoint | None
|
||||
credential: CredentialEnvelope | None
|
||||
config: SmtpConfig | ImapConfig
|
||||
config: SmtpConfig | ImapConfig | Pop3Config
|
||||
transport_revision: str
|
||||
|
||||
|
||||
@@ -358,7 +360,7 @@ def initialize_profile_hierarchy(
|
||||
for protocol, value in (("smtp", smtp), ("imap", imap)):
|
||||
if value is None or protocol in existing:
|
||||
continue
|
||||
raw = value.model_dump(mode="json") if hasattr(value, "model_dump") else dict(value)
|
||||
raw = value.model_dump(mode="json", exclude_none=True) if hasattr(value, "model_dump") else dict(value)
|
||||
credentials = {
|
||||
"username": raw.pop("username", None),
|
||||
"password": raw.pop("password", None),
|
||||
@@ -951,6 +953,10 @@ def resolve_mail_transport(
|
||||
)
|
||||
)
|
||||
if server is None:
|
||||
if clean_protocol == "pop3":
|
||||
raise MailServerHierarchyError(
|
||||
"The selected Mail profile has no active POP3 legacy-import server"
|
||||
)
|
||||
return _legacy_resolved_transport(profile, clean_protocol)
|
||||
binding, credential = _selected_server_credential(
|
||||
session,
|
||||
@@ -978,16 +984,17 @@ 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 | 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)
|
||||
else:
|
||||
config = Pop3Config.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise MailServerHierarchyError(
|
||||
f"The selected {clean_protocol.upper()} server configuration is invalid"
|
||||
@@ -1030,6 +1037,14 @@ def select_mail_transport(
|
||||
)
|
||||
)
|
||||
if server is None:
|
||||
if clean_protocol == "pop3":
|
||||
return SelectedMailTransport(
|
||||
profile=profile,
|
||||
server=None,
|
||||
credential=None,
|
||||
available=False,
|
||||
transport_revision="unconfigured",
|
||||
)
|
||||
legacy_config = (
|
||||
profile.smtp_config
|
||||
if clean_protocol == "smtp"
|
||||
@@ -1302,11 +1317,12 @@ 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)
|
||||
else:
|
||||
model = Pop3ServerConfig.model_validate(payload)
|
||||
except Exception as exc:
|
||||
raise MailServerHierarchyError(
|
||||
f"Invalid {protocol.upper()} server configuration"
|
||||
@@ -1347,7 +1363,7 @@ 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 or pop3")
|
||||
return clean
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from govoplan_mail.backend.router import (
|
||||
create_mail_address_contact,
|
||||
list_mail_address_write_targets,
|
||||
lookup_mail_addresses,
|
||||
)
|
||||
from govoplan_mail.backend.schemas import MailContactCreateRequest
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rollbacks += 1
|
||||
|
||||
|
||||
class _Writer:
|
||||
def __init__(self, *, allowed: bool = True, read_only: bool = False) -> None:
|
||||
self.allowed = allowed
|
||||
self.read_only = read_only
|
||||
self.created_payload = None
|
||||
self.created_provenance = None
|
||||
|
||||
def list_write_targets(self, _session, _principal, *, operation):
|
||||
return (
|
||||
SimpleNamespace(
|
||||
address_book_id="book-1",
|
||||
address_book_label="Personal contacts",
|
||||
operation=operation,
|
||||
allowed=self.allowed,
|
||||
reason="allowed" if self.allowed else "read_only_source",
|
||||
message="Contact can be added." if self.allowed else "This source is read-only.",
|
||||
scope_type="user",
|
||||
scope_id="user-1",
|
||||
source_kind="local" if self.allowed else "ldap",
|
||||
read_only=self.read_only,
|
||||
required_scopes=("addresses:contacts:write",),
|
||||
provenance={"policy": "addresses"},
|
||||
),
|
||||
)
|
||||
|
||||
def can_write_to_address_book(self, _session, _principal, *, address_book_id, operation):
|
||||
return self.list_write_targets(_session, _principal, operation=operation)[0]
|
||||
|
||||
def create_contact(self, _session, _principal, *, address_book_id, payload, provenance):
|
||||
self.created_payload = payload
|
||||
self.created_provenance = provenance
|
||||
return SimpleNamespace(
|
||||
contact_id="contact-1",
|
||||
address_book_id=address_book_id,
|
||||
display_name=payload["display_name"],
|
||||
email=payload["emails"][0]["email"],
|
||||
source_kind="local",
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
def _principal():
|
||||
return SimpleNamespace(has=lambda scope: scope == "mail:profile:use")
|
||||
|
||||
|
||||
class MailAddressIntegrationTests(unittest.TestCase):
|
||||
def test_optional_capabilities_fail_open_for_mail(self) -> None:
|
||||
with patch("govoplan_mail.backend.router._registry_capability", return_value=None):
|
||||
lookup = lookup_mail_addresses(query="ada", limit=25, session=_Session(), principal=_principal())
|
||||
targets = list_mail_address_write_targets(session=_Session(), principal=_principal())
|
||||
|
||||
self.assertFalse(lookup.available)
|
||||
self.assertEqual(lookup.candidates, [])
|
||||
self.assertFalse(targets.available)
|
||||
self.assertEqual(targets.targets, [])
|
||||
|
||||
def test_write_target_preserves_read_only_decision(self) -> None:
|
||||
writer = _Writer(allowed=False, read_only=True)
|
||||
with patch("govoplan_mail.backend.router._registry_capability", return_value=writer):
|
||||
response = list_mail_address_write_targets(session=_Session(), principal=_principal())
|
||||
|
||||
self.assertTrue(response.available)
|
||||
self.assertFalse(response.targets[0].allowed)
|
||||
self.assertTrue(response.targets[0].read_only)
|
||||
self.assertEqual(response.targets[0].reason, "read_only_source")
|
||||
|
||||
def test_blocked_target_cannot_be_bypassed_by_create(self) -> None:
|
||||
session = _Session()
|
||||
writer = _Writer(allowed=False, read_only=True)
|
||||
with (
|
||||
patch("govoplan_mail.backend.router._registry_capability", return_value=writer),
|
||||
self.assertRaises(HTTPException) as raised,
|
||||
):
|
||||
create_mail_address_contact(
|
||||
MailContactCreateRequest(
|
||||
address_book_id="book-1",
|
||||
display_name="Ada Lovelace",
|
||||
email="ada@example.test",
|
||||
),
|
||||
session=session,
|
||||
principal=_principal(),
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 422)
|
||||
self.assertEqual(session.commits, 0)
|
||||
|
||||
def test_allowed_create_uses_writer_and_records_consumer_provenance(self) -> None:
|
||||
session = _Session()
|
||||
writer = _Writer()
|
||||
with patch("govoplan_mail.backend.router._registry_capability", return_value=writer):
|
||||
result = create_mail_address_contact(
|
||||
MailContactCreateRequest(
|
||||
address_book_id="book-1",
|
||||
display_name="Ada Lovelace",
|
||||
email="ada@example.test",
|
||||
),
|
||||
session=session,
|
||||
principal=_principal(),
|
||||
)
|
||||
|
||||
self.assertEqual(result.contact_id, "contact-1")
|
||||
self.assertEqual(writer.created_payload["emails"][0]["email"], "ada@example.test")
|
||||
self.assertEqual(writer.created_provenance["consumer_module"], "mail")
|
||||
self.assertEqual(session.commits, 1)
|
||||
|
||||
def test_proxy_rejects_invalid_email_before_calling_writer(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
MailContactCreateRequest(address_book_id="book-1", email="not-an-email")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,388 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import replace
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPreflightContext,
|
||||
)
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.core.infrastructure_capabilities import (
|
||||
infrastructure_capability_receipt_from_mapping,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_core.security.credential_envelopes import CredentialEnvelope
|
||||
from govoplan_mail.backend.configuration_provider import (
|
||||
MAIL_CONFIGURATION_CAPABILITY,
|
||||
SqlMailConfigurationProvider,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import (
|
||||
MailProfilePolicy,
|
||||
MailServerCredentialBinding,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
from govoplan_mail.backend.manifest import manifest
|
||||
|
||||
|
||||
def _receipt():
|
||||
return infrastructure_capability_receipt_from_mapping(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"installation_id": "mail-provider-test",
|
||||
"profile": "evaluation",
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "mail.smtp",
|
||||
"label": "SMTP delivery",
|
||||
"state": "available_unconfigured",
|
||||
"source": "installer-managed-test",
|
||||
"detail": "GreenMail is available for profile binding.",
|
||||
"endpoint": {
|
||||
"scheme": "smtp",
|
||||
"host": "test-mail",
|
||||
"port": 3025,
|
||||
},
|
||||
"secret_refs": [],
|
||||
"dependent_modules": ["mail"],
|
||||
}
|
||||
],
|
||||
"post_install_tasks": [
|
||||
{
|
||||
"id": "mail.smtp-profile",
|
||||
"resume_key": "mail-provider-test:mail.smtp-profile:v1",
|
||||
"capability_id": "mail.smtp",
|
||||
"state": "pending",
|
||||
"owner_module": "mail",
|
||||
"summary": "Create a Mail SMTP profile.",
|
||||
"required_inputs": ["credential envelope reference when required"],
|
||||
"secret_boundary": "credential-envelope-reference-only",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _external_receipt(*, credential_required: bool = False):
|
||||
return infrastructure_capability_receipt_from_mapping(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"installation_id": "mail-provider-external",
|
||||
"profile": "production",
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "mail.smtp",
|
||||
"label": "SMTP delivery",
|
||||
"state": "available_unconfigured",
|
||||
"source": "operator-supplied",
|
||||
"detail": "An external relay needs reviewed Mail configuration.",
|
||||
"endpoint": {},
|
||||
"secret_refs": (
|
||||
["env:SMTP_CREDENTIAL_ENVELOPE_REF"]
|
||||
if credential_required
|
||||
else []
|
||||
),
|
||||
"dependent_modules": ["mail"],
|
||||
}
|
||||
],
|
||||
"post_install_tasks": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class MailConfigurationProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tempdir = tempfile.TemporaryDirectory(prefix="govoplan-mail-config-")
|
||||
self.addCleanup(self.tempdir.cleanup)
|
||||
database_path = Path(self.tempdir.name) / "mail.sqlite3"
|
||||
self.engine = create_engine(f"sqlite:///{database_path}")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=(
|
||||
access_models.User.__table__,
|
||||
SystemSettings.__table__,
|
||||
CredentialEnvelope.__table__,
|
||||
MailServerProfile.__table__,
|
||||
MailServerEndpoint.__table__,
|
||||
MailServerCredentialBinding.__table__,
|
||||
MailProfilePolicy.__table__,
|
||||
),
|
||||
)
|
||||
configure_database(
|
||||
f"sqlite:///{database_path}",
|
||||
engine=self.engine,
|
||||
dispose_previous=True,
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
session.add(
|
||||
MailProfilePolicy(
|
||||
id="tenant-policy",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
policy={},
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
CredentialEnvelope(
|
||||
id="credential-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="SMTP credential",
|
||||
credential_kind="username_password",
|
||||
public_data={"username": "mailer"},
|
||||
secret_data_encrypted="encrypted-outside-package",
|
||||
secret_keys=["password"],
|
||||
allowed_modules=["mail"],
|
||||
allowed_server_refs=[],
|
||||
inherit_to_lower_scopes=True,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
self.addCleanup(self._cleanup_database)
|
||||
self.provider = SqlMailConfigurationProvider()
|
||||
self.context = ConfigurationPreflightContext(
|
||||
tenant_id="tenant-1",
|
||||
operator_user_id=None,
|
||||
infrastructure_receipt=_receipt(),
|
||||
)
|
||||
|
||||
def _cleanup_database(self) -> None:
|
||||
reset_database()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None:
|
||||
self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories)
|
||||
description = self.provider.describe()
|
||||
self.assertEqual(("smtp_profile",), description.fragment_types)
|
||||
|
||||
def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={"credential_envelope_id": "credential-1"},
|
||||
)
|
||||
|
||||
first_plan = self.provider.preflight(fragment, self.context)
|
||||
self.assertEqual("create", first_plan.plan[0].action)
|
||||
self.assertFalse(
|
||||
any(item.severity == "blocker" for item in first_plan.diagnostics)
|
||||
)
|
||||
first_apply = self.provider.apply(fragment, {}, self.context)
|
||||
self.assertIn("test-smtp", first_apply.created_refs)
|
||||
|
||||
second_plan = self.provider.preflight(fragment, self.context)
|
||||
second_apply = self.provider.apply(fragment, {}, self.context)
|
||||
|
||||
self.assertEqual("skip", second_plan.plan[0].action)
|
||||
self.assertEqual({}, second_apply.created_refs)
|
||||
self.assertEqual({}, second_apply.updated_refs)
|
||||
with self.SessionLocal() as session:
|
||||
profiles = session.scalars(select(MailServerProfile)).all()
|
||||
servers = session.scalars(select(MailServerEndpoint)).all()
|
||||
bindings = session.scalars(select(MailServerCredentialBinding)).all()
|
||||
self.assertEqual(1, len(profiles))
|
||||
self.assertEqual("test-mail", profiles[0].smtp_config["host"])
|
||||
self.assertEqual("plain", profiles[0].smtp_config["security"])
|
||||
self.assertEqual(1, len(servers))
|
||||
self.assertEqual("test-mail", servers[0].config["host"])
|
||||
self.assertEqual(1, len(bindings))
|
||||
self.assertEqual("credential-1", bindings[0].credential_id)
|
||||
|
||||
def test_conflicting_existing_profile_is_preserved_by_default(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={},
|
||||
)
|
||||
self.provider.apply(fragment, {}, self.context)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
server.config = {**server.config, "host": "manually-changed.example.test"}
|
||||
session.commit()
|
||||
|
||||
plan = self.provider.preflight(fragment, self.context)
|
||||
result = self.provider.apply(fragment, {}, self.context)
|
||||
|
||||
self.assertEqual("blocked", plan.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail_configuration_conflict",
|
||||
{item.code for item in result.diagnostics},
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
self.assertEqual("manually-changed.example.test", server.config["host"])
|
||||
|
||||
def test_explicit_conflict_update_reconciles_then_becomes_noop(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="test-smtp",
|
||||
payload={},
|
||||
)
|
||||
self.provider.apply(fragment, {}, self.context)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
server.config = {**server.config, "host": "manually-changed.example.test"}
|
||||
session.commit()
|
||||
update_fragment = replace(
|
||||
fragment,
|
||||
payload={"on_conflict": "update"},
|
||||
)
|
||||
|
||||
plan = self.provider.preflight(update_fragment, self.context)
|
||||
result = self.provider.apply(update_fragment, {}, self.context)
|
||||
settled = self.provider.preflight(update_fragment, self.context)
|
||||
|
||||
self.assertEqual("update", plan.plan[0].action)
|
||||
self.assertIn("test-smtp", result.updated_refs)
|
||||
self.assertEqual("skip", settled.plan[0].action)
|
||||
with self.SessionLocal() as session:
|
||||
server = session.scalar(select(MailServerEndpoint))
|
||||
assert server is not None
|
||||
self.assertEqual("test-mail", server.config["host"])
|
||||
|
||||
def test_inline_credentials_are_rejected(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
payload={
|
||||
"smtp": {
|
||||
"host": "test-mail",
|
||||
"port": 3025,
|
||||
"security": "plain",
|
||||
"password": "must-not-cross-boundary",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
plan = self.provider.preflight(fragment, self.context)
|
||||
|
||||
self.assertEqual("blocked", plan.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail_configuration_secret_forbidden",
|
||||
{item.code for item in plan.diagnostics},
|
||||
)
|
||||
|
||||
def test_external_relay_collects_missing_non_secret_inputs(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="external-smtp",
|
||||
payload={},
|
||||
)
|
||||
missing = self.provider.preflight(
|
||||
fragment,
|
||||
replace(self.context, infrastructure_receipt=_external_receipt()),
|
||||
)
|
||||
ready = self.provider.preflight(
|
||||
fragment,
|
||||
replace(
|
||||
self.context,
|
||||
infrastructure_receipt=_external_receipt(),
|
||||
supplied_data={
|
||||
"mail.smtp.external-smtp.host": "smtp.example.test",
|
||||
"mail.smtp.external-smtp.port": 587,
|
||||
"mail.smtp.external-smtp.security": "starttls",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", missing.plan[0].action)
|
||||
self.assertEqual(
|
||||
{
|
||||
"mail.smtp.external-smtp.host",
|
||||
"mail.smtp.external-smtp.port",
|
||||
"mail.smtp.external-smtp.security",
|
||||
},
|
||||
{item.key for item in missing.required_data if item.required},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
def test_receipt_secret_reference_requires_credential_envelope_reference(
|
||||
self,
|
||||
) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="authenticated-smtp",
|
||||
payload={
|
||||
"smtp": {
|
||||
"host": "smtp.example.test",
|
||||
"port": 587,
|
||||
"security": "starttls",
|
||||
}
|
||||
},
|
||||
)
|
||||
context = replace(
|
||||
self.context,
|
||||
infrastructure_receipt=_external_receipt(credential_required=True),
|
||||
)
|
||||
|
||||
missing = self.provider.preflight(fragment, context)
|
||||
ready = self.provider.preflight(
|
||||
ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
fragment_id="authenticated-smtp",
|
||||
payload={
|
||||
**fragment.payload,
|
||||
"credential_envelope_id": "credential-1",
|
||||
},
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", missing.plan[0].action)
|
||||
self.assertIn(
|
||||
"mail.smtp.authenticated-smtp.credential_envelope_id",
|
||||
{item.key for item in missing.required_data if item.required},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
def test_system_profile_requires_system_configuration_authority(self) -> None:
|
||||
fragment = ConfigurationPackageFragment(
|
||||
module_id="mail",
|
||||
fragment_type="smtp_profile",
|
||||
payload={"profile": {"scope_type": "system"}},
|
||||
)
|
||||
|
||||
blocked = self.provider.preflight(fragment, self.context)
|
||||
ready = self.provider.preflight(
|
||||
fragment,
|
||||
replace(
|
||||
self.context,
|
||||
operator_scopes=frozenset({"system:settings:write"}),
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("blocked", blocked.plan[0].action)
|
||||
self.assertIn(
|
||||
"system_configuration_authority_required",
|
||||
{item.code for item in blocked.diagnostics},
|
||||
)
|
||||
self.assertEqual("create", ready.plan[0].action)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,419 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
DataSubjectRequest,
|
||||
create_data_subject_request,
|
||||
plan_data_subject_erasure,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_mail.backend.db.models import (
|
||||
MailBounceObservation,
|
||||
MailDeliveryAttempt,
|
||||
MailDeliveryCommand,
|
||||
MailDeliveryReconciliation,
|
||||
MailMailboxMessageIndex,
|
||||
MailPop3Import,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY, MailDsarProvider
|
||||
from govoplan_mail.backend.manifest import manifest
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider, active=True):
|
||||
self.provider = provider
|
||||
self.active = active
|
||||
|
||||
def capability_names(self):
|
||||
return (MAIL_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
assert name == MAIL_DSAR_CAPABILITY
|
||||
return "mail"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
active = self.active
|
||||
|
||||
class Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State", (), {"effective_modules": ("mail",) if active else ()}
|
||||
)()
|
||||
|
||||
return Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
assert name == MAIL_DSAR_CAPABILITY
|
||||
return self.provider
|
||||
|
||||
|
||||
class MailDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
DataSubjectRequest.__table__,
|
||||
MailServerProfile.__table__,
|
||||
MailServerEndpoint.__table__,
|
||||
MailMailboxMessageIndex.__table__,
|
||||
MailPop3Import.__table__,
|
||||
MailDeliveryCommand.__table__,
|
||||
MailDeliveryAttempt.__table__,
|
||||
MailDeliveryReconciliation.__table__,
|
||||
MailBounceObservation.__table__,
|
||||
],
|
||||
)
|
||||
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||
now = datetime.now(timezone.utc)
|
||||
account = Account(
|
||||
id="account-1",
|
||||
email="subject@example.test",
|
||||
normalized_email="subject@example.test",
|
||||
display_name="Subject",
|
||||
)
|
||||
user = User(
|
||||
id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id=account.id,
|
||||
email="subject@example.test",
|
||||
display_name="Subject",
|
||||
)
|
||||
profile = MailServerProfile(
|
||||
id="profile-subject",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="user",
|
||||
scope_id=user.id,
|
||||
name="Personal mail",
|
||||
slug="personal",
|
||||
smtp_config={"host": "smtp-secret-do-not-export"},
|
||||
smtp_username="smtp-user-do-not-export",
|
||||
smtp_password_encrypted="smtp-cipher-do-not-export",
|
||||
imap_config={"host": "imap-secret-do-not-export"},
|
||||
imap_username="imap-user-do-not-export",
|
||||
imap_password_encrypted="imap-cipher-do-not-export",
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
message = MailMailboxMessageIndex(
|
||||
id="message-subject",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=profile.id,
|
||||
folder="INBOX-secret-do-not-export",
|
||||
uid="uid-secret-do-not-export",
|
||||
uid_int=1,
|
||||
sort_position=1,
|
||||
subject="Subject notice",
|
||||
from_header="Office <office@example.test>",
|
||||
to_header="Subject Person <Subject@Example.Test>",
|
||||
cc_header="Unrelated Person <other@example.test>",
|
||||
date="2026-08-20",
|
||||
message_id="message-locator-do-not-export",
|
||||
flags=["\\Seen"],
|
||||
size_bytes=42,
|
||||
body_preview="Message preview for the subject",
|
||||
attachment_count=1,
|
||||
indexed_at=now,
|
||||
)
|
||||
unrelated = MailMailboxMessageIndex(
|
||||
id="message-other",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=profile.id,
|
||||
folder="INBOX",
|
||||
uid="2",
|
||||
uid_int=2,
|
||||
sort_position=2,
|
||||
subject="Unrelated message do not export",
|
||||
from_header="other@example.test",
|
||||
to_header="someone@example.test",
|
||||
indexed_at=now,
|
||||
)
|
||||
tenant_two_profile = MailServerProfile(
|
||||
id="profile-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
name="Tenant two",
|
||||
slug="tenant-two",
|
||||
smtp_config={},
|
||||
)
|
||||
tenant_two = MailMailboxMessageIndex(
|
||||
id="message-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
profile_id=tenant_two_profile.id,
|
||||
folder="INBOX",
|
||||
uid="1",
|
||||
uid_int=1,
|
||||
sort_position=1,
|
||||
subject="Tenant two message do not export",
|
||||
to_header="subject@example.test",
|
||||
indexed_at=now,
|
||||
)
|
||||
pop3_server = MailServerEndpoint(
|
||||
id="pop3-server-subject",
|
||||
profile_id=profile.id,
|
||||
tenant_id="tenant-1",
|
||||
protocol="pop3",
|
||||
name="Legacy POP3",
|
||||
config={"host": "pop3-secret-do-not-export"},
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
)
|
||||
pop3_import = MailPop3Import(
|
||||
id="pop3-import-subject",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=profile.id,
|
||||
pop3_server_id=pop3_server.id,
|
||||
pop3_credential_id="credential-secret-do-not-export",
|
||||
transport_revision="pop3-revision-secret-do-not-export",
|
||||
provider_uidl="provider-uidl-secret-do-not-export",
|
||||
fingerprint="e" * 64,
|
||||
raw_sha256="f" * 64,
|
||||
raw_message_encrypted="pop3-message-cipher-do-not-export",
|
||||
message_id="pop3-message-id",
|
||||
subject="Imported subject notice",
|
||||
from_header="Legacy office <legacy@example.test>",
|
||||
to_header="Subject Person <subject@example.test>",
|
||||
date="2026-08-19",
|
||||
body_preview="Imported message preview for the subject",
|
||||
size_bytes=84,
|
||||
status="pending_review",
|
||||
imported_at=now,
|
||||
deletion_requested=False,
|
||||
deletion_status="not_requested",
|
||||
)
|
||||
command = MailDeliveryCommand(
|
||||
id="command-subject",
|
||||
tenant_id="tenant-1",
|
||||
command_type="send",
|
||||
source_module="notifications",
|
||||
source_resource_type="notification",
|
||||
idempotency_key="idempotency-do-not-export",
|
||||
canonical_request_hash="a" * 64,
|
||||
profile_id=profile.id,
|
||||
expected_smtp_transport_revision="revision-secret",
|
||||
envelope_recipients_encrypted="recipient-cipher-do-not-export",
|
||||
message_encrypted="message-cipher-do-not-export",
|
||||
message_sha256="b" * 64,
|
||||
rfc_message_id="rfc-message-id",
|
||||
message_size_bytes=100,
|
||||
recipient_count=1,
|
||||
status="succeeded",
|
||||
attempt_count=1,
|
||||
accepted_count=1,
|
||||
created_by_user_id=user.id,
|
||||
completed_at=now,
|
||||
expires_at=now + timedelta(days=30),
|
||||
)
|
||||
attempt = MailDeliveryAttempt(
|
||||
id="attempt-subject",
|
||||
command_id=command.id,
|
||||
attempt_number=1,
|
||||
worker_id="worker-do-not-export",
|
||||
status="succeeded",
|
||||
started_at=now,
|
||||
completed_at=now,
|
||||
accepted_count=1,
|
||||
diagnostic_summary="diagnostic-do-not-export",
|
||||
)
|
||||
reconciliation = MailDeliveryReconciliation(
|
||||
id="reconciliation-subject",
|
||||
command_id=command.id,
|
||||
decision="confirmed_sent",
|
||||
evidence_reference="private-reference-do-not-export",
|
||||
note_encrypted="private-note-do-not-export",
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
bounce = MailBounceObservation(
|
||||
id="bounce-subject",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=profile.id,
|
||||
folder="bounce-folder-do-not-export",
|
||||
uid="bounce-uid-do-not-export",
|
||||
fingerprint="c" * 64,
|
||||
raw_sha256="d" * 64,
|
||||
original_message_id="original-id-do-not-export",
|
||||
command_id=command.id,
|
||||
recipient="subject@example.test",
|
||||
action="failed",
|
||||
status_code="5.1.1",
|
||||
diagnostic="bounce-diagnostic-do-not-export",
|
||||
permanent=True,
|
||||
observed_at=now,
|
||||
matched=True,
|
||||
evidence={"secret": "bounce-evidence-do-not-export"},
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
account,
|
||||
user,
|
||||
profile,
|
||||
message,
|
||||
unrelated,
|
||||
tenant_two_profile,
|
||||
tenant_two,
|
||||
pop3_server,
|
||||
pop3_import,
|
||||
command,
|
||||
attempt,
|
||||
reconciliation,
|
||||
bounce,
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = MailDsarProvider()
|
||||
self.subject = DsarSubjectRef(
|
||||
membership_id=user.id, email="subject@example.test"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_manifest_and_minimized_tenant_scoped_search(self):
|
||||
self.assertIn(
|
||||
MAIL_DSAR_CAPABILITY, {item.name for item in manifest.provides_interfaces}
|
||||
)
|
||||
self.assertIsInstance(
|
||||
manifest.capability_factories[MAIL_DSAR_CAPABILITY](None), DsarProvider
|
||||
)
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self.subject
|
||||
)
|
||||
self.assertTrue(
|
||||
{
|
||||
"mail_server_profile",
|
||||
"mailbox_message_index",
|
||||
"mail_delivery_command",
|
||||
"mail_delivery_attempt",
|
||||
"mail_delivery_reconciliation",
|
||||
"mail_bounce_observation",
|
||||
"mail_pop3_import",
|
||||
}.issubset({r.resource_type for r in records})
|
||||
)
|
||||
serialized = repr([record.to_dict() for record in records])
|
||||
for hidden in (
|
||||
"other@example.test",
|
||||
"Unrelated Person",
|
||||
"message-other",
|
||||
"Unrelated message do not export",
|
||||
"message-tenant-2",
|
||||
"Tenant two message do not export",
|
||||
"smtp-secret-do-not-export",
|
||||
"smtp-user-do-not-export",
|
||||
"smtp-cipher-do-not-export",
|
||||
"imap-secret-do-not-export",
|
||||
"imap-user-do-not-export",
|
||||
"imap-cipher-do-not-export",
|
||||
"INBOX-secret-do-not-export",
|
||||
"uid-secret-do-not-export",
|
||||
"message-locator-do-not-export",
|
||||
"idempotency-do-not-export",
|
||||
"recipient-cipher-do-not-export",
|
||||
"message-cipher-do-not-export",
|
||||
"worker-do-not-export",
|
||||
"diagnostic-do-not-export",
|
||||
"private-reference-do-not-export",
|
||||
"private-note-do-not-export",
|
||||
"bounce-folder-do-not-export",
|
||||
"bounce-uid-do-not-export",
|
||||
"original-id-do-not-export",
|
||||
"bounce-diagnostic-do-not-export",
|
||||
"bounce-evidence-do-not-export",
|
||||
"pop3-secret-do-not-export",
|
||||
"credential-secret-do-not-export",
|
||||
"pop3-revision-secret-do-not-export",
|
||||
"provider-uidl-secret-do-not-export",
|
||||
"pop3-message-cipher-do-not-export",
|
||||
):
|
||||
self.assertNotIn(hidden, serialized)
|
||||
|
||||
def test_conflicting_email_fails_closed(self):
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
email="subject@example.test",
|
||||
external_references={"mail.email": "other@example.test"},
|
||||
),
|
||||
)
|
||||
self.assertEqual((), records)
|
||||
|
||||
def test_plan_preserves_evidence_and_executes_nothing(self):
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=self.subject
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session, tenant_id="tenant-1", subject=self.subject, records=records
|
||||
)
|
||||
self.assertTrue({"retain", "manual_review"}.issubset({a.kind for a in actions}))
|
||||
self.assertFalse(any(action.executable for action in actions))
|
||||
|
||||
def test_core_workflow_discovers_active_and_skips_disabled_provider(self):
|
||||
request = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-MAIL-1",
|
||||
request_kind="access_and_erasure",
|
||||
subject=self.subject,
|
||||
purpose="Authorized request",
|
||||
legal_basis="GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=request,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(["mail"], request.coverage["covered_modules"])
|
||||
plan_data_subject_erasure(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=request,
|
||||
expected_revision=2,
|
||||
)
|
||||
self.assertFalse(
|
||||
any(action["executable"] for action in request.erasure_plan["actions"])
|
||||
)
|
||||
disabled = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-MAIL-OFF",
|
||||
request_kind="access",
|
||||
subject=self.subject,
|
||||
purpose="Coverage",
|
||||
legal_basis=None,
|
||||
due_at=None,
|
||||
requested_by_account_id="officer",
|
||||
)
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, active=False),
|
||||
row=disabled,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(
|
||||
[MAIL_DSAR_CAPABILITY], disabled.coverage["inactive_provider_capabilities"]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,6 +8,7 @@ from govoplan_mail.backend.config import ImapConfig
|
||||
from govoplan_mail.backend.sending.imap import (
|
||||
ImapAppendError,
|
||||
ImapConfigurationError,
|
||||
_detect_standard_folder_mappings,
|
||||
_detect_sent_folder,
|
||||
_extract_mailbox_name,
|
||||
_fetch_message_by_uid,
|
||||
@@ -18,6 +19,7 @@ from govoplan_mail.backend.sending.imap import (
|
||||
_select_readonly,
|
||||
_sequence_set,
|
||||
append_message_to_sent,
|
||||
list_imap_folders,
|
||||
list_imap_messages,
|
||||
list_imap_uids_since,
|
||||
)
|
||||
@@ -79,6 +81,45 @@ class ImapFolderParserTests(unittest.TestCase):
|
||||
"Gesendet",
|
||||
)
|
||||
|
||||
def test_detects_all_standard_folder_roles_by_flag_then_name(self):
|
||||
self.assertEqual(
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Gesendete Elemente",
|
||||
"drafts": "Entwürfe",
|
||||
"trash": "Deleted",
|
||||
"archive": "All Mail",
|
||||
"junk": "Spam",
|
||||
},
|
||||
_detect_standard_folder_mappings(
|
||||
[
|
||||
("INBOX", set()),
|
||||
("Gesendete Elemente", set()),
|
||||
("Entwürfe", set()),
|
||||
("Deleted", {"\\trash"}),
|
||||
("All Mail", {"\\all"}),
|
||||
("Spam", {"\\junk"}),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
def test_mock_folder_listing_exposes_detected_standard_mappings(self):
|
||||
result = list_imap_folders(
|
||||
imap_config=ImapConfig(host="mock.imap.local"), include_status=False
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent",
|
||||
"drafts": "Drafts",
|
||||
"trash": "Trash",
|
||||
"archive": "Archive",
|
||||
},
|
||||
result.detected_folder_mappings,
|
||||
)
|
||||
self.assertEqual("Sent", result.detected_sent_folder)
|
||||
|
||||
|
||||
class ImapMessagePaginationTests(unittest.TestCase):
|
||||
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
|
||||
|
||||
@@ -290,6 +290,51 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
||||
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
|
||||
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
|
||||
|
||||
def test_apply_transport_update_persists_standard_folder_mappings(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-folders",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_config={"host": "smtp.example.org"},
|
||||
smtp_username=None,
|
||||
smtp_password_encrypted=None,
|
||||
smtp_transport_revision="smtp-before",
|
||||
imap_config={"host": "imap.example.org", "sent_folder": "Legacy Sent"},
|
||||
imap_username=None,
|
||||
imap_password_encrypted=None,
|
||||
imap_transport_revision="imap-before",
|
||||
)
|
||||
session = SimpleNamespace(flush=lambda: None)
|
||||
|
||||
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index"):
|
||||
_apply_profile_transport_update(
|
||||
session, # type: ignore[arg-type]
|
||||
profile,
|
||||
user_id="user-1",
|
||||
api_key_id=None,
|
||||
smtp=None,
|
||||
imap=ImapConfig(
|
||||
host="imap.example.org",
|
||||
folder_mappings={
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent Items",
|
||||
"drafts": "Drafts",
|
||||
},
|
||||
),
|
||||
clear_imap=False,
|
||||
)
|
||||
|
||||
self.assertEqual(profile.imap_config["sent_folder"], "Sent Items")
|
||||
self.assertEqual(
|
||||
profile.imap_config["folder_mappings"],
|
||||
{
|
||||
"inbox": "INBOX",
|
||||
"sent": "Sent Items",
|
||||
"drafts": "Drafts",
|
||||
},
|
||||
)
|
||||
|
||||
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
|
||||
@@ -9,6 +9,15 @@ from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manife
|
||||
|
||||
|
||||
class MailManifestTests(unittest.TestCase):
|
||||
def test_mail_quick_access_can_return_exact_message_references(self) -> None:
|
||||
frontend = get_manifest().frontend
|
||||
self.assertIsNotNone(frontend)
|
||||
tool = next(
|
||||
item for item in frontend.quick_access_tools if item.id == "mail.messages" # type: ignore[union-attr]
|
||||
)
|
||||
self.assertIn("select", tool.modes)
|
||||
self.assertEqual(("mail.message",), tool.returned_reference_kinds)
|
||||
|
||||
def test_manifest_declares_optional_addresses_lookup(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
@@ -16,6 +25,7 @@ class MailManifestTests(unittest.TestCase):
|
||||
self.assertEqual(manifest.id, "mail")
|
||||
self.assertIn("addresses", manifest.optional_dependencies)
|
||||
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("addresses.contact_writer", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn(
|
||||
{
|
||||
"name": "campaigns.access",
|
||||
@@ -43,7 +53,22 @@ class MailManifestTests(unittest.TestCase):
|
||||
permissions = {permission.scope for permission in manifest.permissions}
|
||||
self.assertIn("mail:profile:write_own", permissions)
|
||||
self.assertIn("mail:secret:manage_own", permissions)
|
||||
self.assertTrue(
|
||||
{"mail:pop3:manage", "mail:pop3:import", "mail:pop3:delete"}.issubset(
|
||||
permissions
|
||||
)
|
||||
)
|
||||
roles = {template.slug: template for template in manifest.role_templates}
|
||||
self.assertIn("mail:pop3:delete", roles["mail_profile_admin"].permissions)
|
||||
self.assertEqual(
|
||||
set(roles["mail_legacy_import_operator"].permissions),
|
||||
{
|
||||
"mail:profile:read",
|
||||
"mail:profile:use",
|
||||
"mail:profile:test",
|
||||
"mail:pop3:import",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
set(roles["mail_profile_self_service"].permissions),
|
||||
{
|
||||
@@ -62,8 +87,28 @@ class MailManifestTests(unittest.TestCase):
|
||||
"mail.workflow.read-mailbox",
|
||||
"mail.reference.credentials-egress-retirement",
|
||||
"mail.reference.campaign-delivery-contract",
|
||||
"mail.address-book-integration",
|
||||
"mail.workflow.legacy-pop3-import",
|
||||
}.issubset(topics)
|
||||
)
|
||||
pop3_topic = topics["mail.workflow.legacy-pop3-import"]
|
||||
self.assertEqual(("user", "admin"), pop3_topic.documentation_types)
|
||||
self.assertIn("mail:pop3:import", pop3_topic.conditions[0].any_scopes)
|
||||
|
||||
pop3_provider = next(
|
||||
item
|
||||
for item in manifest.external_providers
|
||||
if item.id == "mail.pop3_legacy_import"
|
||||
)
|
||||
self.assertIn("delete", pop3_provider.operations)
|
||||
self.assertTrue(pop3_provider.behavior.outcome_unknown_supported)
|
||||
self.assertIn("mail.pop3.source_deletion", pop3_provider.behavior.audit_event_types)
|
||||
self.assertTrue(
|
||||
any(
|
||||
route.path == "/mail/legacy-import"
|
||||
for route in manifest.frontend.routes # type: ignore[union-attr]
|
||||
)
|
||||
)
|
||||
ownership = topics["mail.profile-ownership-and-consumers"]
|
||||
self.assertEqual(ownership.metadata["kind"], "reference")
|
||||
self.assertEqual(ownership.metadata["route"], "/settings?section=mail-profiles")
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from datetime import UTC, datetime
|
||||
import poplib
|
||||
import ssl
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, User
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.secrets import decrypt_secret
|
||||
from govoplan_mail.backend.config import Pop3Config, TransportSecurity
|
||||
from govoplan_mail.backend.db.models import (
|
||||
MailPop3Import,
|
||||
MailServerEndpoint,
|
||||
MailServerProfile,
|
||||
)
|
||||
from govoplan_mail.backend.pop3_imports import (
|
||||
Pop3ImportResult,
|
||||
create_pop3_imports,
|
||||
list_pop3_imports,
|
||||
)
|
||||
from govoplan_mail.backend.router import import_profile_pop3_messages
|
||||
from govoplan_mail.backend.schemas import MailPop3ImportRequest
|
||||
from govoplan_mail.backend.sending.pop3 import (
|
||||
Pop3ConfigurationError,
|
||||
Pop3DownloadedMessage,
|
||||
Pop3MessageSummary,
|
||||
Pop3ProviderError,
|
||||
_open_pop3,
|
||||
delete_pop3_messages,
|
||||
download_pop3_messages,
|
||||
preview_pop3_messages,
|
||||
)
|
||||
|
||||
|
||||
_RAW = (
|
||||
b"Subject: Legacy notice\r\n"
|
||||
b"From: Office <office@example.test>\r\n"
|
||||
b"To: Subject <subject@example.test>\r\n"
|
||||
b"Message-ID: <legacy-1@example.test>\r\n"
|
||||
b"\r\n"
|
||||
b"A bounded legacy message.\r\n"
|
||||
)
|
||||
|
||||
|
||||
class _Pop3Client:
|
||||
def __init__(self, *, quit_error: Exception | None = None) -> None:
|
||||
self.deletions: list[int] = []
|
||||
self.quit_calls = 0
|
||||
self.rset_calls = 0
|
||||
self.close_calls = 0
|
||||
self.quit_error = quit_error
|
||||
|
||||
def stat(self):
|
||||
return 1, len(_RAW)
|
||||
|
||||
def uidl(self):
|
||||
return b"+OK", [b"1 uid-1"], 1
|
||||
|
||||
def list(self):
|
||||
return b"+OK", [f"1 {len(_RAW)}".encode("ascii")], 1
|
||||
|
||||
def top(self, _number, _lines):
|
||||
return b"+OK", _RAW.rstrip(b"\r\n").split(b"\r\n"), len(_RAW)
|
||||
|
||||
def retr(self, _number):
|
||||
return b"+OK", _RAW.rstrip(b"\r\n").split(b"\r\n"), len(_RAW)
|
||||
|
||||
def dele(self, number):
|
||||
self.deletions.append(number)
|
||||
|
||||
def quit(self):
|
||||
self.quit_calls += 1
|
||||
if self.quit_error is not None:
|
||||
raise self.quit_error
|
||||
return b"+OK"
|
||||
|
||||
def rset(self):
|
||||
self.rset_calls += 1
|
||||
|
||||
def close(self):
|
||||
self.close_calls += 1
|
||||
|
||||
|
||||
def _config(**changes) -> Pop3Config:
|
||||
values = {
|
||||
"host": "pop3.example.test",
|
||||
"security": "tls",
|
||||
"username": "legacy-user",
|
||||
"password": "legacy-password",
|
||||
"legacy_import_enabled": True,
|
||||
}
|
||||
values.update(changes)
|
||||
return Pop3Config.model_validate(values)
|
||||
|
||||
|
||||
def _download(uidl: str = "uid-1") -> Pop3DownloadedMessage:
|
||||
summary = Pop3MessageSummary(
|
||||
message_number=1,
|
||||
uidl=uidl,
|
||||
subject="Legacy notice",
|
||||
from_header="Office <office@example.test>",
|
||||
to_header="Subject <subject@example.test>",
|
||||
date="Sat, 22 Aug 2026 10:00:00 +0200",
|
||||
message_id="<legacy-1@example.test>",
|
||||
size_bytes=len(_RAW),
|
||||
body_preview="A bounded legacy message.",
|
||||
)
|
||||
import hashlib
|
||||
|
||||
return Pop3DownloadedMessage(
|
||||
message_number=1,
|
||||
uidl=uidl,
|
||||
raw=_RAW,
|
||||
raw_sha256=hashlib.sha256(_RAW).hexdigest(),
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
class Pop3TransportTests(unittest.TestCase):
|
||||
def test_legacy_import_is_disabled_until_explicitly_enabled(self) -> None:
|
||||
with self.assertRaisesRegex(Pop3ConfigurationError, "disabled"):
|
||||
preview_pop3_messages(
|
||||
pop3_config=_config(legacy_import_enabled=False),
|
||||
limit=10,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "batch size limit"):
|
||||
_config(max_message_bytes=2 * 1024 * 1024, max_batch_bytes=1024 * 1024)
|
||||
|
||||
def test_preview_and_download_are_non_destructive(self) -> None:
|
||||
preview_client = _Pop3Client()
|
||||
with patch(
|
||||
"govoplan_mail.backend.sending.pop3._open_pop3",
|
||||
return_value=preview_client,
|
||||
):
|
||||
preview = preview_pop3_messages(pop3_config=_config(), limit=10)
|
||||
|
||||
self.assertEqual(["uid-1"], [item.uidl for item in preview.messages])
|
||||
self.assertEqual([], preview_client.deletions)
|
||||
self.assertEqual(1, preview_client.quit_calls)
|
||||
|
||||
download_client = _Pop3Client()
|
||||
with patch(
|
||||
"govoplan_mail.backend.sending.pop3._open_pop3",
|
||||
return_value=download_client,
|
||||
):
|
||||
downloaded = download_pop3_messages(
|
||||
pop3_config=_config(), uidls=("uid-1",)
|
||||
)
|
||||
|
||||
self.assertEqual(_RAW, downloaded[0].raw)
|
||||
self.assertEqual([], download_client.deletions)
|
||||
self.assertEqual(1, download_client.quit_calls)
|
||||
|
||||
def test_source_deletion_needs_policy_and_commits_with_quit(self) -> None:
|
||||
with self.assertRaisesRegex(Pop3ConfigurationError, "disabled"):
|
||||
delete_pop3_messages(pop3_config=_config(), uidls=("uid-1",))
|
||||
|
||||
client = _Pop3Client()
|
||||
with patch(
|
||||
"govoplan_mail.backend.sending.pop3._open_pop3", return_value=client
|
||||
):
|
||||
result = delete_pop3_messages(
|
||||
pop3_config=_config(allow_delete_after_import=True),
|
||||
uidls=("uid-1",),
|
||||
)
|
||||
|
||||
self.assertEqual(("uid-1",), result.deleted_uidls)
|
||||
self.assertEqual([1], client.deletions)
|
||||
self.assertEqual(1, client.quit_calls)
|
||||
|
||||
def test_quit_failure_marks_deletion_outcome_unknown(self) -> None:
|
||||
client = _Pop3Client(quit_error=poplib.error_proto("connection lost"))
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.sending.pop3._open_pop3",
|
||||
return_value=client,
|
||||
),
|
||||
self.assertRaises(Pop3ProviderError) as captured,
|
||||
):
|
||||
delete_pop3_messages(
|
||||
pop3_config=_config(allow_delete_after_import=True),
|
||||
uidls=("uid-1",),
|
||||
)
|
||||
|
||||
self.assertTrue(captured.exception.outcome_unknown)
|
||||
self.assertEqual([1], client.deletions)
|
||||
|
||||
def test_tls_and_authentication_failures_are_sanitized(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.sending.pop3.validate_outbound_host"
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.sending.pop3._OutboundPolicyPOP3SSL",
|
||||
side_effect=ssl.SSLError("private TLS detail"),
|
||||
),
|
||||
self.assertRaisesRegex(Pop3ProviderError, "TLS negotiation failed"),
|
||||
):
|
||||
_open_pop3(_config())
|
||||
|
||||
auth_client = _Pop3Client()
|
||||
auth_client.user = lambda _value: None # type: ignore[attr-defined]
|
||||
auth_client.pass_ = lambda _value: (_ for _ in ()).throw( # type: ignore[attr-defined]
|
||||
poplib.error_proto("private auth detail")
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.sending.pop3.validate_outbound_host"
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.sending.pop3._OutboundPolicyPOP3",
|
||||
return_value=auth_client,
|
||||
),
|
||||
self.assertRaisesRegex(Pop3ProviderError, "authentication failed"),
|
||||
):
|
||||
_open_pop3(_config(security=TransportSecurity.PLAIN))
|
||||
|
||||
|
||||
class Pop3PersistenceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=(
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
MailServerProfile.__table__,
|
||||
MailServerEndpoint.__table__,
|
||||
MailPop3Import.__table__,
|
||||
),
|
||||
)
|
||||
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
|
||||
self.profile = MailServerProfile(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Legacy source",
|
||||
slug="legacy-source",
|
||||
smtp_config={},
|
||||
)
|
||||
self.server = MailServerEndpoint(
|
||||
id="server-1",
|
||||
profile_id=self.profile.id,
|
||||
tenant_id="tenant-1",
|
||||
protocol="pop3",
|
||||
name="Legacy POP3",
|
||||
config={"legacy_import_enabled": True},
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
transport_revision="revision-1",
|
||||
)
|
||||
self.session.add_all((self.profile, self.server))
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_import_is_encrypted_and_duplicate_uidl_is_reused(self) -> None:
|
||||
first = create_pop3_imports(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
profile_id=self.profile.id,
|
||||
pop3_server_id=self.server.id,
|
||||
pop3_credential_id=None,
|
||||
transport_revision="revision-1",
|
||||
messages=(_download(),),
|
||||
user_id=None,
|
||||
deletion_requested=False,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(1, len(first.imported))
|
||||
encrypted = first.imported[0].raw_message_encrypted
|
||||
self.assertNotIn("Legacy notice", encrypted)
|
||||
self.assertEqual(
|
||||
_RAW,
|
||||
base64.b64decode(decrypt_secret(encrypted) or ""),
|
||||
)
|
||||
self.assertEqual("not_requested", first.imported[0].deletion_status)
|
||||
|
||||
repeated = create_pop3_imports(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
profile_id=self.profile.id,
|
||||
pop3_server_id=self.server.id,
|
||||
pop3_credential_id=None,
|
||||
transport_revision="revision-1",
|
||||
messages=(_download(),),
|
||||
user_id=None,
|
||||
deletion_requested=False,
|
||||
)
|
||||
|
||||
self.assertEqual((), repeated.imported)
|
||||
self.assertEqual((first.imported[0].id,), tuple(row.id for row in repeated.duplicates))
|
||||
self.assertEqual(
|
||||
(first.imported[0].id,),
|
||||
tuple(
|
||||
row.id
|
||||
for row in list_pop3_imports(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
profile_ids=(self.profile.id,),
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
(),
|
||||
list_pop3_imports(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
profile_ids=("unrelated-profile",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _RouteSession:
|
||||
def __init__(self, events: list[str]) -> None:
|
||||
self.events = events
|
||||
|
||||
def commit(self) -> None:
|
||||
self.events.append("commit")
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.events.append("rollback")
|
||||
|
||||
|
||||
class Pop3ImportRouteTests(unittest.TestCase):
|
||||
def test_local_import_and_audit_commit_before_source_deletion(self) -> None:
|
||||
events: list[str] = []
|
||||
now = datetime.now(UTC)
|
||||
row = SimpleNamespace(
|
||||
id="import-1",
|
||||
profile_id="profile-1",
|
||||
pop3_server_id="server-1",
|
||||
transport_revision="revision-1",
|
||||
provider_uidl="uid-1",
|
||||
message_id="<legacy-1@example.test>",
|
||||
subject="Legacy notice",
|
||||
from_header="office@example.test",
|
||||
to_header="subject@example.test",
|
||||
date="2026-08-22",
|
||||
body_preview="A bounded legacy message.",
|
||||
size_bytes=len(_RAW),
|
||||
raw_sha256=_download().raw_sha256,
|
||||
status="pending_review",
|
||||
imported_at=now,
|
||||
deletion_requested=True,
|
||||
deletion_status="pending",
|
||||
deletion_attempted_at=None,
|
||||
deletion_error=None,
|
||||
)
|
||||
resolved = SimpleNamespace(
|
||||
config=_config(allow_delete_after_import=True),
|
||||
server=SimpleNamespace(id="server-1"),
|
||||
credential=None,
|
||||
transport_revision="revision-1",
|
||||
)
|
||||
principal = ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"mail:profile:use",
|
||||
"mail:pop3:import",
|
||||
"mail:pop3:delete",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
payload = MailPop3ImportRequest(
|
||||
server_id="server-1",
|
||||
expected_transport_revision="revision-1",
|
||||
uidls=["uid-1"],
|
||||
delete_after_import=True,
|
||||
)
|
||||
|
||||
def audit(*_args, **_kwargs) -> None:
|
||||
events.append("audit")
|
||||
|
||||
def delete(**_kwargs):
|
||||
self.assertEqual(["audit", "commit"], events)
|
||||
events.append("delete")
|
||||
|
||||
def mark(*_args, **_kwargs):
|
||||
events.append("mark")
|
||||
row.deletion_status = "succeeded"
|
||||
row.deletion_attempted_at = now
|
||||
return (row,)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.router._resolve_profile_pop3_transport",
|
||||
return_value=(SimpleNamespace(id="profile-1"), resolved),
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.router.download_pop3_messages",
|
||||
return_value=(_download(),),
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.router.create_pop3_imports",
|
||||
return_value=Pop3ImportResult(imported=(row,), duplicates=()),
|
||||
),
|
||||
patch("govoplan_mail.backend.router.audit_event", side_effect=audit),
|
||||
patch("govoplan_mail.backend.router.delete_pop3_messages", side_effect=delete),
|
||||
patch(
|
||||
"govoplan_mail.backend.router.mark_pop3_deletion_result",
|
||||
side_effect=mark,
|
||||
),
|
||||
):
|
||||
result = import_profile_pop3_messages(
|
||||
"profile-1",
|
||||
payload,
|
||||
principal=principal,
|
||||
session=_RouteSession(events), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
self.assertEqual("succeeded", result.deletion_status)
|
||||
self.assertEqual(
|
||||
["audit", "commit", "delete", "mark", "commit", "audit", "commit"],
|
||||
events,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.mail import MailPostboxBridgeRequest
|
||||
from govoplan_core.core.postbox import PostboxTargetRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
from govoplan_mail.backend.postbox_bridge import MailPostboxBridge
|
||||
|
||||
|
||||
RAW_MESSAGE = b"""From: Ada Example <ada@example.test>
|
||||
To: Clerk <clerk@example.test>
|
||||
Cc: Archive <archive@example.test>
|
||||
Message-ID: <bridge-1@example.test>
|
||||
Subject: Submitted evidence
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary=bridge
|
||||
|
||||
--bridge
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Please process the attached evidence.
|
||||
--bridge
|
||||
Content-Type: application/pdf
|
||||
Content-Disposition: attachment; filename=evidence.pdf
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
UERG
|
||||
--bridge--
|
||||
"""
|
||||
|
||||
|
||||
class _DeliveryProvider:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def deliver(self, session, request):
|
||||
del session
|
||||
self.requests.append(request)
|
||||
return SimpleNamespace(
|
||||
postbox_id="postbox-1",
|
||||
message_id="message-1",
|
||||
delivery_id="delivery-1",
|
||||
duplicate=len(self.requests) > 1,
|
||||
)
|
||||
|
||||
|
||||
class MailPostboxBridgeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
access_users = Base.metadata.tables.get("access_users")
|
||||
if access_users is None:
|
||||
access_users = Table(
|
||||
"access_users",
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[access_users, MailServerProfile.__table__],
|
||||
)
|
||||
self.Session = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.Session() as session:
|
||||
session.add(
|
||||
MailServerProfile(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
name="Inbound",
|
||||
slug="inbound",
|
||||
smtp_config={},
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
self.addCleanup(self.engine.dispose)
|
||||
|
||||
def test_bridge_parses_bounded_content_and_delegates_idempotently(self) -> None:
|
||||
delivery = _DeliveryProvider()
|
||||
request = MailPostboxBridgeRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id="postbox-1"),
|
||||
profile_id="profile-1",
|
||||
folder="INBOX",
|
||||
uid="42",
|
||||
uidvalidity="20260807",
|
||||
raw_message=RAW_MESSAGE,
|
||||
)
|
||||
with self.Session() as session, patch(
|
||||
"govoplan_mail.backend.postbox_bridge.postbox_delivery_provider",
|
||||
return_value=delivery,
|
||||
):
|
||||
first = MailPostboxBridge().bridge_message(session, request)
|
||||
second = MailPostboxBridge().bridge_message(session, request)
|
||||
MailPostboxBridge().bridge_message(
|
||||
session,
|
||||
replace(request, uidvalidity="20260808"),
|
||||
)
|
||||
|
||||
self.assertFalse(first.duplicate)
|
||||
self.assertTrue(second.duplicate)
|
||||
self.assertEqual(first.source_digest, second.source_digest)
|
||||
self.assertEqual(
|
||||
delivery.requests[0].idempotency_key,
|
||||
delivery.requests[1].idempotency_key,
|
||||
)
|
||||
self.assertNotEqual(
|
||||
delivery.requests[0].idempotency_key,
|
||||
delivery.requests[2].idempotency_key,
|
||||
)
|
||||
bridged = delivery.requests[0]
|
||||
self.assertEqual("Submitted evidence", bridged.subject)
|
||||
self.assertIn("Please process", bridged.body_text)
|
||||
self.assertEqual(
|
||||
["sender", "to", "cc"],
|
||||
[participant.kind for participant in bridged.participants],
|
||||
)
|
||||
self.assertEqual(1, len(bridged.attachments))
|
||||
self.assertEqual("evidence.pdf", bridged.attachments[0].name)
|
||||
self.assertEqual("mail_attachment", bridged.attachments[0].reference_type)
|
||||
self.assertEqual("<bridge-1@example.test>", bridged.metadata["rfc_message_id"])
|
||||
self.assertEqual("20260807", bridged.metadata["mailbox_uidvalidity"])
|
||||
self.assertNotIn("raw_message", bridged.metadata)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,14 +14,17 @@ 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,
|
||||
POP3_PROVIDER_ID,
|
||||
SMTP_PROVIDER_ID,
|
||||
imap_provider_states,
|
||||
pop3_provider_states,
|
||||
smtp_provider_states,
|
||||
)
|
||||
|
||||
@@ -38,6 +41,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 +124,89 @@ class MailProviderStateTests(unittest.TestCase):
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID},
|
||||
{SMTP_PROVIDER_ID, IMAP_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, POP3_PROVIDER_ID},
|
||||
{
|
||||
item.provider_id
|
||||
for item in manifest.external_provider_state_providers
|
||||
},
|
||||
)
|
||||
|
||||
def test_pop3_state_is_disabled_by_default_and_projects_deletion_evidence(self) -> None:
|
||||
endpoint = MailServerEndpoint(
|
||||
id="pop3-server-1",
|
||||
profile_id=self.profile.id,
|
||||
tenant_id="tenant-1",
|
||||
protocol="pop3",
|
||||
name="Legacy POP3",
|
||||
config={"host": "pop3.example.test", "legacy_import_enabled": False},
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
is_active=True,
|
||||
)
|
||||
self.session.add(endpoint)
|
||||
self.session.flush()
|
||||
|
||||
disabled = pop3_provider_states(
|
||||
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
|
||||
)[0]
|
||||
self.assertFalse(disabled.active)
|
||||
self.assertEqual("inactive", disabled.health)
|
||||
|
||||
endpoint.config = {
|
||||
"host": "pop3.example.test",
|
||||
"legacy_import_enabled": True,
|
||||
}
|
||||
self.session.add(
|
||||
MailPop3Import(
|
||||
id="pop3-import-1",
|
||||
tenant_id="tenant-1",
|
||||
profile_id=self.profile.id,
|
||||
pop3_server_id=endpoint.id,
|
||||
transport_revision=endpoint.transport_revision,
|
||||
provider_uidl="uid-1",
|
||||
fingerprint="a" * 64,
|
||||
raw_sha256="b" * 64,
|
||||
raw_message_encrypted="ciphertext-do-not-project",
|
||||
size_bytes=42,
|
||||
imported_at=datetime.now(UTC),
|
||||
deletion_requested=True,
|
||||
deletion_status="outcome_unknown",
|
||||
)
|
||||
)
|
||||
self.session.add(
|
||||
MailPop3Import(
|
||||
id="pop3-import-tenant-2",
|
||||
tenant_id="tenant-2",
|
||||
profile_id=self.profile.id,
|
||||
pop3_server_id=endpoint.id,
|
||||
transport_revision=endpoint.transport_revision,
|
||||
provider_uidl="uid-tenant-2",
|
||||
fingerprint="c" * 64,
|
||||
raw_sha256="d" * 64,
|
||||
raw_message_encrypted="other-tenant-ciphertext",
|
||||
size_bytes=42,
|
||||
imported_at=datetime.now(UTC),
|
||||
deletion_requested=True,
|
||||
deletion_status="failed",
|
||||
)
|
||||
)
|
||||
self.session.flush()
|
||||
|
||||
state = pop3_provider_states(
|
||||
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
|
||||
)[0]
|
||||
self.assertTrue(state.active)
|
||||
self.assertEqual("warning", state.health)
|
||||
self.assertEqual("pending", state.conflict)
|
||||
self.assertEqual(1, state.metrics["outcome_unknown_deletions"])
|
||||
self.assertEqual(0, state.metrics["failed_deletions"])
|
||||
self.assertNotIn("pop3.example.test", str(state.to_dict()))
|
||||
self.assertNotIn("ciphertext-do-not-project", str(state.to_dict()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -7,6 +8,9 @@ from govoplan_core.security.outbound_http import OutboundHttpBlocked
|
||||
from govoplan_mail.backend.config import SmtpConfig
|
||||
from govoplan_mail.backend.sending.smtp import (
|
||||
SmtpConfigurationError,
|
||||
SmtpBatchPolicy,
|
||||
SmtpBatchSession,
|
||||
SmtpSendError,
|
||||
_open_smtp,
|
||||
_prepare_smtp_send,
|
||||
_smtp_send_result,
|
||||
@@ -70,6 +74,109 @@ class SmtpSendHelperTests(unittest.TestCase):
|
||||
self.assertEqual(result.accepted_count, 1)
|
||||
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
|
||||
|
||||
def test_batch_preflight_reuses_one_authenticated_connection(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
smtp = _FakeSmtp()
|
||||
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp) as opener:
|
||||
with SmtpBatchSession(config) as batch:
|
||||
first = batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
|
||||
second = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
|
||||
|
||||
opener.assert_called_once_with(config)
|
||||
self.assertFalse(first.session_reused)
|
||||
self.assertTrue(second.session_reused)
|
||||
self.assertEqual(1, second.connection_sequence)
|
||||
self.assertEqual([b"first", b"second"], smtp.messages)
|
||||
self.assertTrue(smtp.quit_called)
|
||||
|
||||
def test_batch_reconnects_before_next_message_when_health_check_fails(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
first_smtp = _FakeSmtp(noop_error_on_call=1)
|
||||
second_smtp = _FakeSmtp()
|
||||
policy = SmtpBatchPolicy(reconnect_attempts=1)
|
||||
with patch(
|
||||
"govoplan_mail.backend.sending.smtp._open_smtp",
|
||||
side_effect=[first_smtp, second_smtp],
|
||||
) as opener:
|
||||
with SmtpBatchSession(config, policy=policy) as batch:
|
||||
batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
|
||||
result = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
|
||||
|
||||
self.assertEqual(2, opener.call_count)
|
||||
self.assertEqual(2, result.connection_sequence)
|
||||
self.assertEqual(1, result.reconnect_count)
|
||||
self.assertEqual([b"first"], first_smtp.messages)
|
||||
self.assertEqual([b"second"], second_smtp.messages)
|
||||
|
||||
def test_preflight_retries_a_transient_connection_failure(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
smtp = _FakeSmtp()
|
||||
with patch(
|
||||
"govoplan_mail.backend.sending.smtp._open_smtp",
|
||||
side_effect=[OSError("temporary DNS failure"), smtp],
|
||||
) as opener:
|
||||
with SmtpBatchSession(config, policy=SmtpBatchPolicy(reconnect_attempts=1)) as batch:
|
||||
result = batch.send(b"message", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
|
||||
|
||||
self.assertEqual(2, opener.call_count)
|
||||
self.assertEqual(1, result.reconnect_count)
|
||||
|
||||
def test_connection_loss_after_send_starts_is_unknown_and_never_replayed(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
smtp = _FakeSmtp(send_error=smtplib.SMTPServerDisconnected("lost"))
|
||||
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp), self.assertRaises(SmtpSendError) as raised:
|
||||
with SmtpBatchSession(config) as batch:
|
||||
batch.send(b"one", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
|
||||
|
||||
self.assertTrue(raised.exception.outcome_unknown)
|
||||
self.assertTrue(raised.exception.systemic)
|
||||
self.assertEqual("smtp_connection_lost_after_transmission", raised.exception.reason_code)
|
||||
self.assertEqual(1, smtp.send_calls)
|
||||
|
||||
def test_authentication_preflight_is_systemic_and_blocks_batch(self):
|
||||
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
|
||||
error = smtplib.SMTPAuthenticationError(535, b"bad credentials")
|
||||
with patch("govoplan_mail.backend.sending.smtp._open_smtp", side_effect=error), self.assertRaises(SmtpSendError) as raised:
|
||||
SmtpBatchSession(config).preflight()
|
||||
|
||||
self.assertTrue(raised.exception.systemic)
|
||||
self.assertFalse(raised.exception.temporary)
|
||||
self.assertEqual("preflight", raised.exception.phase)
|
||||
self.assertEqual("smtp_authentication_failed", raised.exception.reason_code)
|
||||
|
||||
|
||||
class _FakeSmtp:
|
||||
def __init__(self, *, noop_error_on_call: int | None = None, send_error: BaseException | None = None):
|
||||
self.noop_error_on_call = noop_error_on_call
|
||||
self.send_error = send_error
|
||||
self.noop_calls = 0
|
||||
self.send_calls = 0
|
||||
self.messages: list[bytes] = []
|
||||
self.quit_called = False
|
||||
|
||||
def noop(self):
|
||||
self.noop_calls += 1
|
||||
if self.noop_error_on_call == self.noop_calls:
|
||||
raise smtplib.SMTPServerDisconnected("stale")
|
||||
return 250, b"ok"
|
||||
|
||||
def sendmail(self, _sender, _recipients, message):
|
||||
self.send_calls += 1
|
||||
if self.send_error is not None:
|
||||
raise self.send_error
|
||||
self.messages.append(message)
|
||||
return {}
|
||||
|
||||
def send_message(self, message, **_kwargs):
|
||||
return self.sendmail(None, None, message.as_bytes())
|
||||
|
||||
def quit(self):
|
||||
self.quit_called = True
|
||||
return 221, b"bye"
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node .mail-test-build/tests/mail-address-integration.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -9,6 +9,7 @@ function read(relativePath) {
|
||||
const profiles = read("../src/features/mail/MailProfileManagement.tsx");
|
||||
const mailbox = read("../src/features/mail/MailboxPage.tsx");
|
||||
const bounces = read("../src/features/mail/MailBouncePage.tsx");
|
||||
const legacyImport = read("../src/features/mail/MailLegacyImportPage.tsx");
|
||||
const moduleSource = read("../src/module.ts");
|
||||
const styles = read("../src/styles/mail-profiles.css");
|
||||
const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md");
|
||||
@@ -22,11 +23,17 @@ assert.match(profiles, /disabledReason: credentialMutationBlocker/);
|
||||
assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/);
|
||||
assert.match(profiles, /disabledReason=\{policySaveBlocker\}/);
|
||||
assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/);
|
||||
assert.match(profiles, /folder_mappings: draft\.imapFolderMappings/);
|
||||
assert.match(profiles, /listMailProfileImapFolders[\s\S]*listImapFolders/);
|
||||
assert.match(profiles, /onLookupImapFolders=\{\(\) => void runImapFolderLookup\(\)\}/);
|
||||
assert.match(profiles, /imapFolderLookupResult=\{imapFolderResult\}/);
|
||||
|
||||
assert.match(mailbox, /ActionBlockerHint/);
|
||||
assert.match(mailbox, /DocumentationHelpLink/);
|
||||
assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
|
||||
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
|
||||
assert.match(mailbox, /folder_mappings\?\.inbox/);
|
||||
assert.match(mailbox, /detected_folder_mappings\?\.inbox/);
|
||||
assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
|
||||
|
||||
assert.match(bounces, /DocumentationHelpLink/);
|
||||
@@ -34,11 +41,21 @@ 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, /<ConfirmDialog[\s\S]*tone="danger"[\s\S]*onConfirm=\{\(\) => void runImport\(\)\}/);
|
||||
assert.match(legacyImport, /legacy_import_enabled: false[\s\S]*is_active: false[\s\S]*createMailServerCredential[\s\S]*updateMailServerEndpoint/);
|
||||
assert.match(legacyImport, /expected_transport_revision: preview\.transport_revision/);
|
||||
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}`, /window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
|
||||
assert.match(moduleSource, /"mail\.profiles"/);
|
||||
assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/);
|
||||
assert.match(styles, /@media \(max-width: 1250px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
|
||||
assert.match(styles, /@media \(max-width: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
|
||||
assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-toolbar\.file-manager-toolbar[\s\S]*grid-template-columns: 1fr/);
|
||||
|
||||
for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) {
|
||||
|
||||
@@ -23,3 +23,15 @@ assert(
|
||||
!styles.includes(".mailbox-search-field button"),
|
||||
"Mail must not redefine the central icon-button appearance"
|
||||
);
|
||||
assert(
|
||||
source.includes("isMailboxMessageRead(message.flags)"),
|
||||
"mailbox rows must present provider-derived read/unread state"
|
||||
);
|
||||
assert(
|
||||
source.includes("mailboxSyncState(messageProvenance)"),
|
||||
"mailbox lists must present synchronization provenance"
|
||||
);
|
||||
assert(
|
||||
styles.includes(".mailbox-message-row.is-unread") && styles.includes(".mailbox-sync-provenance"),
|
||||
"read state and synchronization provenance must retain focused responsive styling"
|
||||
);
|
||||
|
||||
+178
-1
@@ -65,6 +65,41 @@ export type MailAddressLookupResponse = {
|
||||
candidates: MailAddressLookupCandidate[];
|
||||
};
|
||||
|
||||
export type MailAddressWriteTarget = {
|
||||
address_book_id: string;
|
||||
address_book_label?: string | null;
|
||||
operation: string;
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
message: string;
|
||||
scope_type?: string | null;
|
||||
scope_id?: string | null;
|
||||
source_kind?: string | null;
|
||||
read_only: boolean;
|
||||
required_scopes: string[];
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MailAddressWriteTargetResponse = {
|
||||
available: boolean;
|
||||
targets: MailAddressWriteTarget[];
|
||||
};
|
||||
|
||||
export type MailContactCreatePayload = {
|
||||
address_book_id: string;
|
||||
display_name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type MailContactCreateResponse = {
|
||||
contact_id: string;
|
||||
address_book_id: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
source_kind: string;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MailMailboxAttachment = {
|
||||
filename?: string | null;
|
||||
content_type: string;
|
||||
@@ -209,6 +244,20 @@ export async function lookupMailAddresses(settings: ApiSettings, query: string,
|
||||
return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
|
||||
}
|
||||
|
||||
export async function listMailAddressWriteTargets(settings: ApiSettings): Promise<MailAddressWriteTargetResponse> {
|
||||
return apiFetch<MailAddressWriteTargetResponse>(settings, "/api/v1/mail/address-write-targets");
|
||||
}
|
||||
|
||||
export async function createMailAddressContact(
|
||||
settings: ApiSettings,
|
||||
payload: MailContactCreatePayload
|
||||
): Promise<MailContactCreateResponse> {
|
||||
return apiFetch<MailContactCreateResponse>(settings, "/api/v1/mail/address-contacts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
|
||||
include_inactive: includeInactive ? true : undefined,
|
||||
@@ -247,7 +296,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" | "pop3";
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
inherit_to_lower_scopes?: boolean | null;
|
||||
@@ -255,6 +304,77 @@ export type MailServerEndpointPayload = {
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3ServerConfig = {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string;
|
||||
timeout_seconds?: number;
|
||||
max_message_bytes?: number;
|
||||
max_batch_bytes?: number;
|
||||
preview_body_lines?: number;
|
||||
legacy_import_enabled?: boolean;
|
||||
allow_delete_after_import?: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3ServerEndpoint = Omit<MailServerEndpoint, "protocol" | "config"> & {
|
||||
protocol: "pop3";
|
||||
config: MailPop3ServerConfig;
|
||||
};
|
||||
|
||||
export type MailPop3MessagePreview = {
|
||||
message_number: number;
|
||||
uidl: string;
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
date?: string | null;
|
||||
message_id?: string | null;
|
||||
size_bytes: number;
|
||||
body_preview?: string | null;
|
||||
already_imported: boolean;
|
||||
};
|
||||
|
||||
export type MailPop3PreviewResponse = {
|
||||
profile_id: string;
|
||||
server_id: string;
|
||||
transport_revision: string;
|
||||
host: string;
|
||||
port: number;
|
||||
security: string;
|
||||
message_count: number;
|
||||
mailbox_size_bytes: number;
|
||||
delete_after_import_allowed: boolean;
|
||||
messages: MailPop3MessagePreview[];
|
||||
};
|
||||
|
||||
export type MailPop3ImportRecord = {
|
||||
id: string;
|
||||
profile_id: string;
|
||||
pop3_server_id: string;
|
||||
transport_revision: string;
|
||||
provider_uidl: string;
|
||||
message_id?: string | null;
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
date?: string | null;
|
||||
body_preview?: string | null;
|
||||
size_bytes: number;
|
||||
raw_sha256: string;
|
||||
status: string;
|
||||
imported_at: string;
|
||||
deletion_requested: boolean;
|
||||
deletion_status: string;
|
||||
deletion_attempted_at?: string | null;
|
||||
deletion_error?: string | null;
|
||||
};
|
||||
|
||||
export type MailPop3ImportResponse = {
|
||||
imports: MailPop3ImportRecord[];
|
||||
duplicate_uidls: string[];
|
||||
deletion_status: string;
|
||||
};
|
||||
|
||||
export type MailCredentialCreatePayload = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
@@ -454,6 +574,63 @@ export async function testMailProfileImap(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
import { FormGrid, ContentGrid,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
PageScrollViewport,
|
||||
PageTitle,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
@@ -194,33 +192,37 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
];
|
||||
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
|
||||
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
|
||||
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label="Loading bounce processing">
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Watched mailboxes">
|
||||
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
||||
</Card>
|
||||
<Card title="Delivery-status observations">
|
||||
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
||||
</Card>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
<>
|
||||
<PageLayout
|
||||
archetype="collection"
|
||||
title="Bounce processing"
|
||||
description="Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands."
|
||||
actions={<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), disabled: Boolean(pageMutationBlocker), disabledReason: pageMutationBlocker }}
|
||||
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
|
||||
helpAction={<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />}
|
||||
createAction={<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>}
|
||||
/>}
|
||||
loading={loading}
|
||||
loadingLabel="Loading bounce processing"
|
||||
error={error}
|
||||
success={message}
|
||||
documentationType="admin"
|
||||
>
|
||||
<ContentGrid columns={2} collapseAt="workspace" className="">
|
||||
<Card title="Watched mailboxes">
|
||||
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
||||
</Card>
|
||||
<Card title="Delivery-status observations">
|
||||
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
||||
</Card>
|
||||
</ContentGrid>
|
||||
</PageLayout>
|
||||
|
||||
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
|
||||
<div className="form-grid">
|
||||
<FormGrid columns={1} collapseAt="standard" className="">
|
||||
{imapProfiles.length === 0 &&
|
||||
<ActionBlockerHint
|
||||
reason={{
|
||||
@@ -242,7 +244,7 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
<input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
|
||||
</FormField>
|
||||
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
|
||||
</div>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -254,6 +256,6 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
|
||||
busy={Boolean(busy)}
|
||||
onCancel={() => setDeleteSource(null)}
|
||||
onConfirm={() => void removeSource()} />
|
||||
</PageScrollViewport>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
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}`}
|
||||
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."
|
||||
actions={<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), 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 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." : "",
|
||||
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 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 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 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 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"}
|
||||
onClose={() => !busy && setSourceDialogOpen(false)}
|
||||
footer={<><Button onClick={() => setSourceDialogOpen(false)} disabled={Boolean(busy)}>Cancel</Button><Button 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.">
|
||||
<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"><input value={sourceDraft.name} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, name: event.target.value }))} /></FormField>
|
||||
<FormField label="POP3 host"><input value={sourceDraft.host} disabled={Boolean(busy)} onChange={(event) => setSourceDraft((draft) => ({ ...draft, host: event.target.value }))} placeholder="pop3.example.org" /></FormField>
|
||||
<FormField label="Port"><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">
|
||||
<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)"><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)"><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)"><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"><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"><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"}><input type="password" value={sourceDraft.password} disabled={Boolean(busy) || !canManageSecrets} onChange={(event) => setSourceDraft((draft) => ({ ...draft, password: event.target.value }))} autoComplete="new-password" /></FormField>
|
||||
<ToggleSwitch 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 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"
|
||||
tone="danger"
|
||||
busy={Boolean(busy)}
|
||||
onCancel={() => setDeleteConfirmationOpen(false)}
|
||||
onConfirm={() => void runImport()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function pop3Sources(profiles: MailServerProfile[]): Source[] {
|
||||
return profiles.flatMap((profile) =>
|
||||
((profile.servers ?? []) as unknown as MailPop3ServerEndpoint[])
|
||||
.filter((server) => server.protocol === "pop3")
|
||||
.map((server) => ({ profile, server }))
|
||||
);
|
||||
}
|
||||
|
||||
function defaultCredential(server: MailPop3ServerEndpoint | undefined): MailCredentialEnvelope | null {
|
||||
if (!server) return null;
|
||||
return server.credentials.find((credential) => credential.is_default)
|
||||
?? server.credentials.find((credential) => credential.is_active)
|
||||
?? server.credentials[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
function profileCanBeConfigured(auth: AuthInfo, profile: MailServerProfile): boolean {
|
||||
if (profile.scope_type === "system") return hasScope(auth, "system:settings:write");
|
||||
if (hasScope(auth, "mail:profile:write")) return true;
|
||||
return profile.scope_type === "user"
|
||||
&& profile.scope_id === auth.user.id
|
||||
&& hasScope(auth, "mail:profile:write_own");
|
||||
}
|
||||
|
||||
function sourceDraftFromSource(source: Source, credential: MailCredentialEnvelope | null): SourceDraft {
|
||||
const config = source.server.config;
|
||||
return {
|
||||
profileId: source.profile.id,
|
||||
name: source.server.name,
|
||||
host: String(config.host ?? ""),
|
||||
port: String(config.port ?? (config.security === "tls" ? 995 : 110)),
|
||||
security: config.security === "starttls" || config.security === "plain" ? config.security : "tls",
|
||||
timeoutSeconds: String(config.timeout_seconds ?? 30),
|
||||
maxMessageMiB: String(Math.max(1, Math.round(Number(config.max_message_bytes ?? 25 * 1024 * 1024) / 1024 / 1024))),
|
||||
maxBatchMiB: String(Math.max(1, Math.round(Number(config.max_batch_bytes ?? 100 * 1024 * 1024) / 1024 / 1024))),
|
||||
previewBodyLines: String(config.preview_body_lines ?? 20),
|
||||
username: String(credential?.public_data?.username ?? ""),
|
||||
password: "",
|
||||
enabled: Boolean(config.legacy_import_enabled),
|
||||
allowDeleteAfterImport: Boolean(config.allow_delete_after_import)
|
||||
};
|
||||
}
|
||||
|
||||
function sourceConfig(draft: SourceDraft): Record<string, unknown> {
|
||||
return {
|
||||
host: draft.host.trim(),
|
||||
port: Number(draft.port),
|
||||
security: draft.security,
|
||||
timeout_seconds: Number(draft.timeoutSeconds),
|
||||
max_message_bytes: Number(draft.maxMessageMiB) * 1024 * 1024,
|
||||
max_batch_bytes: Number(draft.maxBatchMiB) * 1024 * 1024,
|
||||
preview_body_lines: Number(draft.previewBodyLines),
|
||||
legacy_import_enabled: draft.enabled,
|
||||
allow_delete_after_import: draft.allowDeleteAfterImport
|
||||
};
|
||||
}
|
||||
|
||||
function credentialPayload(draft: SourceDraft, serverId: string) {
|
||||
return {
|
||||
name: `${draft.name.trim()} credential`,
|
||||
credential_kind: "username_password",
|
||||
username: draft.username.trim(),
|
||||
password: draft.password,
|
||||
allowed_modules: ["mail"],
|
||||
allowed_server_refs: [`mail:${serverId}`],
|
||||
is_default: true
|
||||
};
|
||||
}
|
||||
|
||||
function sourceSaveBlocker({
|
||||
draft,
|
||||
existingCredential,
|
||||
canManageSecrets
|
||||
}: {
|
||||
draft: SourceDraft;
|
||||
existingCredential: MailCredentialEnvelope | null;
|
||||
canManageSecrets: boolean;
|
||||
}): string {
|
||||
if (!draft.profileId) return "Select a Mail profile.";
|
||||
if (!draft.name.trim()) return "Enter a source name.";
|
||||
if (!draft.host.trim()) return "Enter the POP3 host.";
|
||||
if (!boundedInteger(draft.port, 1, 65535)) return "Enter a valid POP3 port.";
|
||||
if (!boundedInteger(draft.timeoutSeconds, 1, 300)) return "Enter a timeout from 1 to 300 seconds.";
|
||||
if (!boundedInteger(draft.maxMessageMiB, 1, 50)) return "Enter a message limit from 1 to 50 MiB.";
|
||||
if (!boundedInteger(draft.maxBatchMiB, 1, 500)) return "Enter a batch limit from 1 to 500 MiB.";
|
||||
if (Number(draft.maxBatchMiB) < Number(draft.maxMessageMiB)) return "The batch limit cannot be lower than the per-message limit.";
|
||||
if (!boundedInteger(draft.previewBodyLines, 0, 100)) return "Enter 0 to 100 preview body lines.";
|
||||
if (draft.allowDeleteAfterImport && !draft.enabled) return "Enable legacy import before permitting source deletion.";
|
||||
if (!existingCredential && !canManageSecrets) return "Managing the encrypted POP3 credential requires Mail secret authority.";
|
||||
if (!existingCredential && !draft.username.trim()) return "Enter the POP3 username.";
|
||||
if (!existingCredential && !draft.password) return "Enter the POP3 password.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function boundedInteger(value: string, minimum: number, maximum: number): boolean {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= minimum && parsed <= maximum;
|
||||
}
|
||||
|
||||
function deletionTone(status: string): "success" | "warning" | "error" | "inactive" {
|
||||
if (status === "succeeded") return "success";
|
||||
if (status === "failed" || status === "outcome_unknown") return "error";
|
||||
if (status === "pending") return "warning";
|
||||
return "inactive";
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||
import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
|
||||
import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
deactivateMailServerProfile,
|
||||
fetchMailSettingsDelta,
|
||||
getMailProfilePolicy,
|
||||
listImapFolders,
|
||||
listMailProfileImapFolders,
|
||||
mailProfilePatternKeys,
|
||||
mailProfilePolicyLimitKeys,
|
||||
listAvailableMailCredentials,
|
||||
@@ -102,6 +104,7 @@ type ProfileDraft = {
|
||||
imapUsername: string;
|
||||
imapPassword: string;
|
||||
imapSentFolder: string;
|
||||
imapFolderMappings: MailImapFolderMappings;
|
||||
imapTimeout: string;
|
||||
};
|
||||
|
||||
@@ -740,11 +743,11 @@ export function MailProfileScopeManager({
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<Dialog
|
||||
<Dialog variant="administration" size="wide"
|
||||
open={editing !== null}
|
||||
title={profileDialogTitle(editing, editingTarget)}
|
||||
onClose={() => !busy && closeProfileEditor()}
|
||||
className="admin-dialog admin-dialog-wide mail-profile-dialog"
|
||||
className="mail-profile-dialog"
|
||||
closeDisabled={busy}
|
||||
footer={editing === "new"
|
||||
? <>
|
||||
@@ -1020,13 +1023,13 @@ export function MailProfilePolicyEditor({
|
||||
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
||||
|
||||
<section className="mail-policy-section policy-section">
|
||||
<div className="subsection-heading split">
|
||||
<ActionToolbar surface="section-header" className="subsection-heading split">
|
||||
<h3>i18n:govoplan-mail.profile_allow_list.507dfe6c</h3>
|
||||
<div className="button-row compact-actions">
|
||||
{lowerLevelLimitToggle("allowed_profile_ids")}
|
||||
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || profileAllowListLocked || selectedProfileIds.size === 0}>i18n:govoplan-mail.clear_allow_list.f69c8c67</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ActionToolbar>
|
||||
<p className="muted small-note">{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}</p>
|
||||
<AdminSelectionList
|
||||
options={candidateProfiles.map((profile) => ({
|
||||
@@ -1163,7 +1166,8 @@ function ProfileForm({
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null);
|
||||
const [imapFolderResult, setImapFolderResult] = useState<MailImapFolderListResponse | null>(null);
|
||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||
|
||||
const disabled = busy || !canWrite;
|
||||
const credentialDisabled = disabled || !canManageCredentials;
|
||||
@@ -1256,6 +1260,7 @@ function ProfileForm({
|
||||
useEffect(() => {
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
setImapFolderResult(null);
|
||||
setMailActionState(null);
|
||||
}, [editing, editTarget]);
|
||||
|
||||
@@ -1276,6 +1281,7 @@ function ProfileForm({
|
||||
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
|
||||
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
|
||||
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
|
||||
imapFolderMappings: patch.folder_mappings !== undefined ? normalizeMailImapFolderMappings(patch.folder_mappings) : draft.imapFolderMappings,
|
||||
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
|
||||
});
|
||||
}
|
||||
@@ -1325,6 +1331,21 @@ function ProfileForm({
|
||||
}
|
||||
}
|
||||
|
||||
async function runImapFolderLookup() {
|
||||
if (!draftHasImap) return;
|
||||
setMailActionState("folders");
|
||||
setImapFolderResult(null);
|
||||
try {
|
||||
setImapFolderResult(useSavedImapTest && existingProfile ?
|
||||
await listMailProfileImapFolders(settings, existingProfile.id, selectedServerId, selectedCredentialId) :
|
||||
await listImapFolders(settings, rawImapPayload(draft, false)));
|
||||
} catch (err) {
|
||||
setImapFolderResult({ ok: false, protocol: "imap", message: errorMessage(err), folders: [] });
|
||||
} finally {
|
||||
setMailActionState(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mail-profile-form">
|
||||
{!canManageCredentials && (
|
||||
@@ -1343,7 +1364,7 @@ function ProfileForm({
|
||||
documentation={MAIL_PROFILE_DOCUMENTATION} />
|
||||
}
|
||||
{showProfileFields &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="i18n:govoplan-mail.name.709a2322" documentation={MAIL_PROFILE_DOCUMENTATION}><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
||||
<FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField>
|
||||
<FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField>
|
||||
@@ -1355,7 +1376,7 @@ function ProfileForm({
|
||||
onChange={(checked) => setDraft({ ...draft, inheritToLowerScopes: checked })}
|
||||
label="Visible to lower scopes" />
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{editTarget.kind === "profile" && existingProfile &&
|
||||
@@ -1363,7 +1384,7 @@ function ProfileForm({
|
||||
}
|
||||
|
||||
{editTarget.kind === "server" &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
<FormField label="Server name" documentation={MAIL_PROFILE_DOCUMENTATION}>
|
||||
<input value={draft.serverName} disabled={disabled} onChange={(event) => setDraft({ ...draft, serverName: event.target.value })} />
|
||||
</FormField>
|
||||
@@ -1387,11 +1408,11 @@ function ProfileForm({
|
||||
onChange={(checked) => setDraft({ ...draft, serverInheritToLowerScopes: checked })}
|
||||
label="Visible to lower scopes" />
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{editTarget.kind === "credentials" &&
|
||||
<div className="admin-form-grid two-columns">
|
||||
<FormGrid columns={2} gap="small" collapseAt="workspace" className="">
|
||||
{creatingCredential &&
|
||||
<FormField label="Reusable credential" documentation={MAIL_PROFILE_WORKFLOW_DOCUMENTATION}>
|
||||
<select value={reuseCredentialId} disabled={credentialDisabled} onChange={(event) => setReuseCredentialId(event.target.value)}>
|
||||
@@ -1455,7 +1476,7 @@ function ProfileForm({
|
||||
The selected credential remains independently managed and will be linked to this server.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</FormGrid>
|
||||
}
|
||||
|
||||
{policyMessages.length > 0 && (
|
||||
@@ -1472,7 +1493,7 @@ function ProfileForm({
|
||||
{showSettingsPanel && settingsPanelMode && (!creatingCredential || creatingNewCredential) &&
|
||||
<MailServerSettingsPanel
|
||||
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
|
||||
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
|
||||
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout }}
|
||||
smtpCredentials={{ username: draft.smtpUsername, password: draft.smtpPassword }}
|
||||
imapCredentials={{ username: draft.imapUsername, password: draft.imapPassword }}
|
||||
onSmtpChange={patchSmtpSettings}
|
||||
@@ -1501,8 +1522,10 @@ function ProfileForm({
|
||||
busyAction={mailActionState}
|
||||
onTestSmtp={() => void runSmtpTest()}
|
||||
onTestImap={() => void runImapTest()}
|
||||
onLookupImapFolders={() => void runImapFolderLookup()}
|
||||
smtpTestResult={smtpTestResult}
|
||||
imapTestResult={imapTestResult}
|
||||
imapFolderLookupResult={imapFolderResult}
|
||||
initialSection={initialSection}
|
||||
visibleSections={visibleSections}
|
||||
mode={settingsPanelMode} />
|
||||
@@ -1645,6 +1668,7 @@ function emptyProfileDraft(): ProfileDraft {
|
||||
imapUsername: "",
|
||||
imapPassword: "",
|
||||
imapSentFolder: "auto",
|
||||
imapFolderMappings: {},
|
||||
imapTimeout: "30"
|
||||
};
|
||||
}
|
||||
@@ -1696,7 +1720,11 @@ function profileToDraft(profile: MailServerProfile, target: MailProfileEditTarge
|
||||
? targetUsername
|
||||
: stringValue(profile.credentials?.imap?.username ?? profile.imap?.username),
|
||||
imapPassword: "",
|
||||
imapSentFolder: stringValue("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto"),
|
||||
imapSentFolder: stringValue(imapConfig?.folder_mappings?.sent || ("sent_folder" in (imapConfig ?? {}) ? imapConfig?.sent_folder || "auto" : "auto")),
|
||||
imapFolderMappings: normalizeMailImapFolderMappings({
|
||||
...(imapConfig?.folder_mappings ?? {}),
|
||||
...(!imapConfig?.folder_mappings?.sent && imapConfig?.sent_folder && imapConfig.sent_folder !== "auto" ? { sent: imapConfig.sent_folder } : {})
|
||||
}),
|
||||
imapTimeout: stringValue(imapConfig?.timeout_seconds ?? 30)
|
||||
};
|
||||
}
|
||||
@@ -1873,7 +1901,7 @@ function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
|
||||
|
||||
function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
|
||||
return mailImapSettingsPayload<MailSecurity>(
|
||||
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout },
|
||||
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, folder_mappings: draft.imapFolderMappings, timeout_seconds: draft.imapTimeout },
|
||||
{ fallbackSecurity: "tls", allowedSecurity: securityOptions }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ExternalLink, FilePenLine, Mail, Pencil, X } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
EmailAddressInput,
|
||||
LoadingFrame,
|
||||
quickAccessLaunchState,
|
||||
useDashboardWidgetData,
|
||||
type MailboxAddress,
|
||||
type QuickAccessToolRenderContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
bootstrapMailbox,
|
||||
listMailServerProfiles,
|
||||
lookupMailAddresses,
|
||||
type MailMailboxMessageSummary
|
||||
} from "../../api/mail";
|
||||
import { mailLookupSuggestions, mailtoHref } from "./mailAddressIntegration";
|
||||
import {
|
||||
mailboxDraftsLaunchPath,
|
||||
mailboxMessageLaunchPath
|
||||
} from "./mailboxLaunch";
|
||||
|
||||
|
||||
type MailQuickAccessData = {
|
||||
profileName?: string;
|
||||
profileId?: string;
|
||||
draftsFolder?: string | null;
|
||||
messages: MailMailboxMessageSummary[];
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
type Props = Pick<
|
||||
QuickAccessToolRenderContext,
|
||||
"settings" | "launchContext" | "complete" | "cancel" | "close"
|
||||
>;
|
||||
|
||||
export default function MailQuickAccess({
|
||||
settings,
|
||||
launchContext,
|
||||
complete,
|
||||
cancel,
|
||||
close
|
||||
}: Props) {
|
||||
const [composing, setComposing] = useState(false);
|
||||
const [recipients, setRecipients] = useState<MailboxAddress[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<MailboxAddress[]>([]);
|
||||
const [lookupAvailable, setLookupAvailable] = useState<boolean | null>(null);
|
||||
const [lookupError, setLookupError] = useState("");
|
||||
const lookupRequestRef = useRef(0);
|
||||
const load = useCallback(async (): Promise<MailQuickAccessData> => {
|
||||
const profiles = await listMailServerProfiles(settings);
|
||||
const profile = profiles.find((item) => item.is_active && item.imap);
|
||||
if (!profile) return { messages: [], available: false };
|
||||
const response = await bootstrapMailbox(settings, profile.id, "INBOX", 7, 0, false);
|
||||
return {
|
||||
profileName: profile.name,
|
||||
profileId: profile.id,
|
||||
draftsFolder: profile.imap?.folder_mappings?.drafts
|
||||
|| response.folders.detected_folder_mappings?.drafts
|
||||
|| null,
|
||||
messages: response.messages.messages ?? [],
|
||||
available: true
|
||||
};
|
||||
}, [settings]);
|
||||
const { data, loading, error } = useDashboardWidgetData(load, 0);
|
||||
const selectingForCase = launchContext.activeObject?.ownerModule === "cases"
|
||||
&& launchContext.activeObject.kind === "case";
|
||||
|
||||
const lookupRecipients = useCallback(async (query: string) => {
|
||||
const request = ++lookupRequestRef.current;
|
||||
const normalized = query.trim();
|
||||
if (!normalized) {
|
||||
setSuggestions([]);
|
||||
setLookupError("");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await lookupMailAddresses(settings, normalized, 12);
|
||||
if (request !== lookupRequestRef.current) return;
|
||||
setLookupAvailable(response.available);
|
||||
setSuggestions(mailLookupSuggestions(response.candidates));
|
||||
setLookupError("");
|
||||
} catch (lookupFailure) {
|
||||
if (request !== lookupRequestRef.current) return;
|
||||
setSuggestions([]);
|
||||
setLookupError(lookupFailure instanceof Error ? lookupFailure.message : String(lookupFailure));
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_messages.4294022c">
|
||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{selectingForCase ? (
|
||||
<p className="muted small-note">
|
||||
Select an authorized exact message for {launchContext.activeObject?.label}.
|
||||
Mail content remains in Mail and access is checked again when opened.
|
||||
</p>
|
||||
) : null}
|
||||
<DashboardWidgetList
|
||||
emptyText={data?.available ? "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d" : "i18n:govoplan-mail.select_an_imap_profile.5445648c"}
|
||||
items={(data?.messages ?? []).map((message) => ({
|
||||
id: `${message.folder}:${message.uid}`,
|
||||
title: message.subject || "i18n:govoplan-mail.no_subject.49b20da0",
|
||||
detail: message.from_header || data?.profileName,
|
||||
meta: formatMessageDate(message.date),
|
||||
leading: <Mail size={17} aria-hidden="true" />,
|
||||
to: selectingForCase
|
||||
? undefined
|
||||
: mailboxMessageLaunchPath(data!.profileId!, message),
|
||||
state: selectingForCase ? undefined : quickAccessLaunchState(launchContext),
|
||||
onClick: selectingForCase ? () => complete({
|
||||
contractVersion: "1",
|
||||
outcome: "completed",
|
||||
action: "selected",
|
||||
reference: {
|
||||
ownerModule: "mail",
|
||||
kind: "message",
|
||||
objectId: `${data!.profileId!}:${message.folder}:${message.uid}`,
|
||||
tenantId: launchContext.tenantId,
|
||||
label: message.subject || "Mail message",
|
||||
version: message.uid,
|
||||
path: mailboxMessageLaunchPath(data!.profileId!, message)
|
||||
}
|
||||
}) : close
|
||||
}))}
|
||||
/>
|
||||
{!selectingForCase && composing ? (
|
||||
<div className="mail-quick-compose" aria-label="i18n:govoplan-mail.compose">
|
||||
<label>i18n:govoplan-mail.recipients</label>
|
||||
<EmailAddressInput
|
||||
value={recipients}
|
||||
onChange={setRecipients}
|
||||
suggestions={suggestions}
|
||||
onSuggestionQueryChange={(query) => void lookupRecipients(query)}
|
||||
compact
|
||||
interfaceId="mail.quick-access.compose.recipients"
|
||||
helpModuleId="mail"
|
||||
helpTopicId="mail.address-book-integration"
|
||||
/>
|
||||
{lookupAvailable === false ? (
|
||||
<p className="form-help">i18n:govoplan-mail.address_suggestions_unavailable</p>
|
||||
) : null}
|
||||
{lookupError ? <DismissibleAlert tone="warning" resetKey={lookupError}>{lookupError}</DismissibleAlert> : null}
|
||||
<div className="button-row compact-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setComposing(false)}>
|
||||
i18n:govoplan-mail.cancel.77dfd213
|
||||
</button>
|
||||
<a className="btn btn-primary" href={mailtoHref(recipients)} onClick={close}>
|
||||
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.open_mail_application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{selectingForCase ? (
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Button onClick={() => cancel("user")}>
|
||||
<X size={15} aria-hidden="true" /> Cancel selection
|
||||
</Button>
|
||||
</div>
|
||||
) : <div className="dashboard-contribution-footer">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setComposing((current) => !current)} aria-expanded={composing}>
|
||||
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-mail.compose
|
||||
</button>
|
||||
{data?.profileId && data.draftsFolder ? (
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={mailboxDraftsLaunchPath(data.profileId, data.draftsFolder)}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<FilePenLine size={15} aria-hidden="true" /> i18n:govoplan-mail.drafts.22a31d86
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to="/mail"
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-mail.open_mail
|
||||
</Link>
|
||||
</div>}
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function formatMessageDate(value?: string | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(parsed);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react";
|
||||
import { useLocation } from "react-router";
|
||||
import { ToolbarGroup, ActionToolbar,
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
CountBadge,
|
||||
DataGridPaginationBar,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
@@ -19,15 +21,21 @@ import {
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
bootstrapMailbox,
|
||||
createMailAddressContact,
|
||||
getMailboxMessage,
|
||||
listMailAddressWriteTargets,
|
||||
listMailboxMessages,
|
||||
listMailServerProfiles,
|
||||
type MailAddressWriteTarget,
|
||||
type MailImapFolderResponse,
|
||||
type MailMailboxMessageDetail,
|
||||
type MailMailboxMessageSummary,
|
||||
type MailServerProfile } from
|
||||
"../../api/mail";
|
||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
||||
import { isMailboxMessageRead, mailboxSyncState, type MailboxSyncProvenance } from "./mailboxDisplay";
|
||||
import { mailboxLaunchFolder, parseMailboxLaunch, type MailboxLaunch } from "./mailboxLaunch";
|
||||
import { mailboxHeaderAddresses } from "./mailAddressIntegration";
|
||||
|
||||
const MAILBOX_DOCUMENTATION = {
|
||||
topicId: "mail.workflow.read-mailbox",
|
||||
@@ -36,6 +44,7 @@ const MAILBOX_DOCUMENTATION = {
|
||||
|
||||
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||
@@ -44,6 +53,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
||||
const [messageProvenance, setMessageProvenance] = useState<MailboxSyncProvenance | null>(null);
|
||||
const [messagePage, setMessagePage] = useState(1);
|
||||
const [messagePageSize, setMessagePageSize] = useState(10);
|
||||
const [messageQuery, setMessageQuery] = useState("");
|
||||
@@ -64,6 +74,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const messageDetailRequestRef = useRef(0);
|
||||
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
|
||||
const skipNextMessageLoadRef = useRef(false);
|
||||
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
|
||||
|
||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||
@@ -76,6 +87,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
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 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");
|
||||
@@ -95,6 +107,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
: "";
|
||||
|
||||
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
useEffect(() => {
|
||||
const request = parseMailboxLaunch(location.search);
|
||||
launchRequestRef.current = request;
|
||||
if (!profiles.length || (!request.profileId && !request.folder && !request.folderRole && !request.messageUid)) return;
|
||||
const usable = profiles.filter((profile) => profile.is_active && profile.imap);
|
||||
const targetProfileId = request.profileId && usable.some((profile) => profile.id === request.profileId)
|
||||
? request.profileId
|
||||
: selectedProfileId || usable[0]?.id || "";
|
||||
if (!targetProfileId) return;
|
||||
if (targetProfileId !== selectedProfileId) {
|
||||
selectProfile(targetProfileId);
|
||||
return;
|
||||
}
|
||||
void loadMailboxBootstrap(targetProfileId);
|
||||
}, [location.search]);
|
||||
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
|
||||
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
|
||||
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
|
||||
@@ -107,6 +134,19 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
|
||||
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
const request = launchRequestRef.current;
|
||||
if (!request || !foldersReady || loadingMessages) return;
|
||||
const targetProfileMatches = !request.profileId || request.profileId === selectedProfileId;
|
||||
const targetFolder = mailboxLaunchFolder(request, selectedProfile);
|
||||
if (!targetProfileMatches || (targetFolder && targetFolder !== selectedFolder)) return;
|
||||
const target = request.messageUid
|
||||
? messages.find((message) => message.uid === request.messageUid)
|
||||
: null;
|
||||
launchRequestRef.current = null;
|
||||
if (target) void openMessage(target);
|
||||
}, [foldersReady, loadingMessages, messages, selectedFolder, selectedProfile, selectedProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePreviewShortcut = (event: KeyboardEvent) => {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
@@ -154,13 +194,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
try {
|
||||
const loaded = await listMailServerProfiles(settings);
|
||||
const usable = loaded.filter((profile) => profile.is_active && profile.imap);
|
||||
const requestedProfileId = parseMailboxLaunch(location.search).profileId;
|
||||
setProfiles(loaded);
|
||||
setSelectedProfileId((current) => current && usable.some((profile) => profile.id === current) ? current : usable[0]?.id ?? "");
|
||||
setSelectedProfileId((current) =>
|
||||
requestedProfileId && usable.some((profile) => profile.id === requestedProfileId)
|
||||
? requestedProfileId
|
||||
: current && usable.some((profile) => profile.id === current)
|
||||
? current
|
||||
: usable[0]?.id ?? ""
|
||||
);
|
||||
if (usable.length === 0) {
|
||||
setFolders([]);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
@@ -176,6 +224,14 @@ 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 requestedLaunch = launchRequestRef.current;
|
||||
const requestedLaunchFolder = requestedLaunch && (!requestedLaunch.profileId || requestedLaunch.profileId === profileId)
|
||||
? mailboxLaunchFolder(requestedLaunch, profiles.find((profile) => profile.id === profileId) ?? null)
|
||||
: null;
|
||||
const requestedFolder = requestedLaunchFolder || (foldersLoadedForProfile === profileId && selectedFolder
|
||||
? selectedFolder
|
||||
: profileInbox || "INBOX");
|
||||
const folderRequestId = ++folderRequestRef.current;
|
||||
const messageRequestId = ++messageListRequestRef.current;
|
||||
messageDetailRequestRef.current += 1;
|
||||
@@ -183,6 +239,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setLoadingMessages(true);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
@@ -193,7 +250,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setDetailError("");
|
||||
setError("");
|
||||
try {
|
||||
const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
|
||||
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh);
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
|
||||
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
|
||||
@@ -201,7 +258,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const total = response.messages.total_count ?? loadedMessages.length;
|
||||
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
|
||||
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
|
||||
nextFolder = loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
||||
const detectedInbox = response.folders.detected_folder_mappings?.inbox;
|
||||
nextFolder = detectedInbox && loadedFolders.some((folder) => folder.name === detectedInbox)
|
||||
? detectedInbox
|
||||
: loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
||||
}
|
||||
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
|
||||
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);
|
||||
@@ -216,6 +276,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile(profileId);
|
||||
setMessages(loadedMessages);
|
||||
setMessageTotalCount(total);
|
||||
setMessageProvenance(mailboxProvenance(response.messages));
|
||||
} catch (err) {
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
const message = errorText(err);
|
||||
@@ -227,6 +288,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
selectedMessageKeyRef.current = "";
|
||||
@@ -244,6 +306,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
|
||||
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
|
||||
setLoadingMessages(true);
|
||||
setMessageProvenance(null);
|
||||
setMessageError("");
|
||||
setDetailError("");
|
||||
setError("");
|
||||
@@ -256,6 +319,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
|
||||
setMessages(loaded);
|
||||
setMessageTotalCount(total);
|
||||
setMessageProvenance(mailboxProvenance(response));
|
||||
setFolderMessageCount(folder, total);
|
||||
const rememberedKey = selectedMessageKeyRef.current;
|
||||
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
|
||||
@@ -270,6 +334,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setError(message);
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
@@ -316,6 +381,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessageProvenance(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
@@ -395,7 +461,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<span className="mailbox-tree-node-label">
|
||||
<span className="mailbox-tree-node-main">
|
||||
<span>{node.label}</span>
|
||||
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
|
||||
{showCount && <CountBadge tone="neutral" size="compact" className="mailbox-folder-count">{node.messageCount}</CountBadge>}
|
||||
</span>
|
||||
{flagText && <small>{flagText}</small>}
|
||||
</span>);
|
||||
@@ -407,7 +473,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
||||
<div className="file-list-sticky">
|
||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
||||
<ActionToolbar justify="between" className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
||||
<label className="mailbox-profile-field">
|
||||
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
|
||||
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||
@@ -415,8 +481,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
|
||||
<div className="mailbox-toolbar-actions">
|
||||
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</ToolbarGroup>
|
||||
<ToolbarGroup align="end" className="mailbox-toolbar-actions">
|
||||
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
|
||||
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
|
||||
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
|
||||
@@ -435,8 +501,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
i18n:govoplan-mail.messages.f1702b46
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
</ActionToolbar>
|
||||
|
||||
{noImapProfiles &&
|
||||
<ActionBlockerHint
|
||||
@@ -468,6 +534,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
<span>{messageCountLabel}</span>
|
||||
<span>{selectedFolder}</span>
|
||||
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
|
||||
{syncState &&
|
||||
<span className={`mailbox-sync-provenance is-${syncState}`} aria-live="polite">
|
||||
<Database size={13} aria-hidden="true" />
|
||||
{syncState === "refreshing" ? "i18n:govoplan-mail.cached_index_refreshing.75f18a6c" : syncState === "cached" ? "i18n:govoplan-mail.cached_mailbox_index.16fe75d1" : "i18n:govoplan-mail.live_provider_response.39c46538"}
|
||||
{messageProvenance?.indexedAt && <span> · {formatDateTime(messageProvenance.indexedAt, { fallback: "-" })}</span>}
|
||||
</span>
|
||||
}
|
||||
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
|
||||
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
|
||||
</div>
|
||||
@@ -486,10 +559,11 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
||||
const selected = key === selectedMessageKey;
|
||||
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
||||
const read = isMailboxMessageRead(message.flags);
|
||||
return (
|
||||
<div
|
||||
key={message.uid}
|
||||
className={`file-list-row file-row mailbox-message-row ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
||||
className={`file-list-row file-row mailbox-message-row ${read ? "is-read" : "is-unread"} ${selected ? "is-selected" : ""} ${loadingSelected ? "is-loading-message" : ""}`}
|
||||
role="row"
|
||||
tabIndex={0}
|
||||
onClick={() => void openMessage(message)}
|
||||
@@ -502,7 +576,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
<div className="file-list-name-cell">
|
||||
<div className="file-list-name">
|
||||
<Mail className="file-row-icon" size={20} aria-hidden="true" />
|
||||
{read ? <MailOpen className="file-row-icon" size={20} aria-hidden="true" /> : <Mail className="file-row-icon" size={20} aria-hidden="true" />}
|
||||
<span>
|
||||
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
|
||||
<small>{message.from_header || "-"}</small>
|
||||
@@ -511,6 +585,10 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
</div>
|
||||
<span className="mailbox-message-date">{formatDateTime(message.date, { fallback: "-" })}</span>
|
||||
<span className="file-row-tail mailbox-message-tail">
|
||||
<span className="mailbox-read-state" title={read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}>
|
||||
<span className="visually-hidden">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
||||
<span aria-hidden="true">{read ? "i18n:govoplan-mail.read.80ca1564" : "i18n:govoplan-mail.unread.66c78634"}</span>
|
||||
</span>
|
||||
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
|
||||
<span>{formatBytes(message.size_bytes)}</span>
|
||||
</span>
|
||||
@@ -554,6 +632,8 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
}))}
|
||||
emptyText={previewEmptyText} />
|
||||
|
||||
<MailboxContactActions settings={settings} message={selectedMessage} />
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -567,6 +647,146 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
|
||||
|
||||
}
|
||||
|
||||
function MailboxContactActions({
|
||||
settings,
|
||||
message
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
message: MailMailboxMessageDetail | null;
|
||||
}) {
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [targets, setTargets] = useState<MailAddressWriteTarget[]>([]);
|
||||
const [selectedTargetId, setSelectedTargetId] = useState("");
|
||||
const [creatingEmail, setCreatingEmail] = useState("");
|
||||
const [addedEmails, setAddedEmails] = useState<Set<string>>(() => new Set());
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoaded(false);
|
||||
void listMailAddressWriteTargets(settings)
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const writable = response.targets.filter((target) => target.allowed);
|
||||
setAvailable(response.available);
|
||||
setTargets(response.targets);
|
||||
setSelectedTargetId((current) => writable.some((target) => target.address_book_id === current)
|
||||
? current
|
||||
: writable[0]?.address_book_id || "");
|
||||
setError("");
|
||||
})
|
||||
.catch((loadError) => {
|
||||
if (!active) return;
|
||||
setAvailable(false);
|
||||
setTargets([]);
|
||||
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoaded(true);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
setAddedEmails(new Set());
|
||||
setError("");
|
||||
setSuccess("");
|
||||
}, [message?.folder, message?.uid]);
|
||||
|
||||
if (!message || !loaded || !available) return null;
|
||||
|
||||
const writableTargets = targets.filter((target) => target.allowed);
|
||||
const blockedTargets = targets.filter((target) => !target.allowed);
|
||||
const addresses = uniqueMailboxAddresses([
|
||||
...mailboxHeaderAddresses(message.from_header),
|
||||
...mailboxHeaderAddresses(message.to_header),
|
||||
...mailboxHeaderAddresses(message.cc_header)
|
||||
]);
|
||||
|
||||
async function addContact(address: { name?: string | null; email: string }) {
|
||||
if (!selectedTargetId || creatingEmail) return;
|
||||
setCreatingEmail(address.email);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const result = await createMailAddressContact(settings, {
|
||||
address_book_id: selectedTargetId,
|
||||
display_name: address.name || address.email,
|
||||
email: address.email
|
||||
});
|
||||
setAddedEmails((current) => new Set(current).add(address.email));
|
||||
setSuccess(i18nMessage("i18n:govoplan-mail.contact_added", { value0: result.display_name }));
|
||||
} catch (createError) {
|
||||
setError(createError instanceof Error ? createError.message : String(createError));
|
||||
} finally {
|
||||
setCreatingEmail("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mailbox-contact-actions" aria-label="i18n:govoplan-mail.address_book_actions">
|
||||
<h4>i18n:govoplan-mail.address_book_actions</h4>
|
||||
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
|
||||
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
|
||||
{writableTargets.length > 0 ? (
|
||||
<label className="mailbox-contact-target">
|
||||
<span>i18n:govoplan-mail.save_contacts_to</span>
|
||||
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)}>
|
||||
{writableTargets.map((target) => (
|
||||
<option key={target.address_book_id} value={target.address_book_id}>
|
||||
{target.address_book_label || target.address_book_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<p className="form-help">i18n:govoplan-mail.no_writable_address_book</p>
|
||||
)}
|
||||
<div className="mailbox-contact-candidates">
|
||||
{addresses.map((address) => {
|
||||
const added = addedEmails.has(address.email);
|
||||
return (
|
||||
<Button
|
||||
key={address.email}
|
||||
className="compact"
|
||||
disabled={!selectedTargetId || Boolean(creatingEmail) || added}
|
||||
disabledReason={!selectedTargetId ? blockedTargets[0]?.message || "i18n:govoplan-mail.no_writable_address_book" : undefined}
|
||||
onClick={() => void addContact(address)}
|
||||
>
|
||||
{added ? <Check size={15} aria-hidden="true" /> : <UserPlus size={15} aria-hidden="true" />}
|
||||
{added ? "i18n:govoplan-mail.contact_added_short" : i18nMessage("i18n:govoplan-mail.add_value_to_contacts", { value0: address.name || address.email })}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{blockedTargets.length > 0 ? (
|
||||
<details className="mailbox-contact-policy">
|
||||
<summary>i18n:govoplan-mail.unavailable_address_books</summary>
|
||||
<ul>
|
||||
{blockedTargets.map((target) => (
|
||||
<li key={target.address_book_id}>
|
||||
<strong>{target.address_book_label || target.address_book_id}</strong>: {target.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueMailboxAddresses<T extends { email: string }>(addresses: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
return addresses.filter((address) => {
|
||||
const email = address.email.toLocaleLowerCase();
|
||||
if (seen.has(email)) return false;
|
||||
seen.add(email);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function mailboxMessageKey(folder: string, uid: string): string {
|
||||
return `${folder || "INBOX"}::${uid}`;
|
||||
@@ -576,6 +796,14 @@ function mailboxCursorKey(profileId: string, folder: string, pageSize: number):
|
||||
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
|
||||
}
|
||||
|
||||
function mailboxProvenance(response: { from_cache?: boolean; refreshing?: boolean; indexed_at?: string | null }): MailboxSyncProvenance {
|
||||
return {
|
||||
fromCache: response.from_cache === true,
|
||||
refreshing: response.refreshing === true,
|
||||
indexedAt: response.indexed_at ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
if (!normalized) return messages;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
type MailAddressLookupCandidateLike = {
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export type MailAddressValue = {
|
||||
name?: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const EMAIL_PATTERN = /([^<>;,\s]+@[^<>;,\s]+)/g;
|
||||
|
||||
export function mailLookupSuggestions(candidates: readonly MailAddressLookupCandidateLike[]): MailAddressValue[] {
|
||||
const seen = new Set<string>();
|
||||
const suggestions: MailAddressValue[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const email = String(candidate.email ?? "").trim().toLocaleLowerCase();
|
||||
if (!email || seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
suggestions.push({ name: candidate.display_name || email, email });
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
export function mailboxHeaderAddresses(value?: string | null): MailAddressValue[] {
|
||||
const input = String(value ?? "").trim();
|
||||
if (!input) return [];
|
||||
const results: MailAddressValue[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const match of input.matchAll(EMAIL_PATTERN)) {
|
||||
const email = match[1]?.replace(/[)>]+$/, "").toLocaleLowerCase();
|
||||
if (!email || seen.has(email)) continue;
|
||||
seen.add(email);
|
||||
const prefix = input.slice(Math.max(0, input.lastIndexOf(",", match.index) + 1), match.index).trim();
|
||||
const name = prefix.replace(/[<"']/g, "").trim() || undefined;
|
||||
results.push({ name, email });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function mailtoHref(recipients: readonly MailAddressValue[]): string {
|
||||
const addresses = recipients.map((recipient) => recipient.email.trim()).filter(Boolean);
|
||||
return `mailto:${addresses.map(encodeURIComponent).join(",")}`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type MailboxSyncProvenance = {
|
||||
fromCache: boolean;
|
||||
refreshing: boolean;
|
||||
indexedAt: string | null;
|
||||
};
|
||||
|
||||
export type MailboxSyncState = "live" | "cached" | "refreshing";
|
||||
|
||||
export function isMailboxMessageRead(flags: readonly string[] | null | undefined): boolean {
|
||||
return (flags ?? []).some((flag) => flag.trim().replace(/^\\+/, "").toLocaleLowerCase() === "seen");
|
||||
}
|
||||
|
||||
export function mailboxSyncState(provenance: MailboxSyncProvenance | null): MailboxSyncState | null {
|
||||
if (!provenance) return null;
|
||||
if (provenance.refreshing) return "refreshing";
|
||||
return provenance.fromCache ? "cached" : "live";
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
type MessageReference = { folder: string; uid: string };
|
||||
type ProfileFolderMapping = {
|
||||
imap?: { folder_mappings?: { drafts?: string | null } | null } | null;
|
||||
};
|
||||
|
||||
export type MailboxLaunch = {
|
||||
profileId: string | null;
|
||||
folder: string | null;
|
||||
folderRole: "drafts" | null;
|
||||
messageUid: string | null;
|
||||
};
|
||||
|
||||
export function parseMailboxLaunch(search: string): MailboxLaunch {
|
||||
const params = new URLSearchParams(search);
|
||||
return {
|
||||
profileId: boundedValue(params.get("profile")),
|
||||
folder: boundedValue(params.get("folder")),
|
||||
folderRole: params.get("folderRole") === "drafts" ? "drafts" : null,
|
||||
messageUid: boundedValue(params.get("message"))
|
||||
};
|
||||
}
|
||||
|
||||
export function mailboxMessageLaunchPath(
|
||||
profileId: string,
|
||||
message: MessageReference
|
||||
): string {
|
||||
return mailPath({
|
||||
profile: profileId,
|
||||
folder: message.folder,
|
||||
message: message.uid
|
||||
});
|
||||
}
|
||||
|
||||
export function mailboxDraftsLaunchPath(profileId: string, folder?: string | null): string {
|
||||
return mailPath(folder
|
||||
? { profile: profileId, folder }
|
||||
: { profile: profileId, folderRole: "drafts" });
|
||||
}
|
||||
|
||||
export function mailboxLaunchFolder(
|
||||
launch: MailboxLaunch,
|
||||
profile: ProfileFolderMapping | null
|
||||
): string | null {
|
||||
if (launch.folder) return launch.folder;
|
||||
if (launch.folderRole === "drafts") {
|
||||
return boundedValue(profile?.imap?.folder_mappings?.drafts ?? null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mailPath(values: Record<string, string>): string {
|
||||
const params = new URLSearchParams(values);
|
||||
return `/mail?${params.toString()}`;
|
||||
}
|
||||
|
||||
function boundedValue(value: string | null): string | null {
|
||||
const clean = value?.trim() ?? "";
|
||||
return clean && clean.length <= 500 ? clean : null;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
||||
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
||||
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
||||
"i18n:govoplan-mail.cached_index_refreshing.75f18a6c": "Cached index refreshing",
|
||||
"i18n:govoplan-mail.cached_mailbox_index.16fe75d1": "Cached mailbox index",
|
||||
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "profiles scoped to campaigns",
|
||||
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles",
|
||||
"i18n:govoplan-mail.campaigns": "Campaigns",
|
||||
@@ -85,6 +87,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
|
||||
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
|
||||
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
|
||||
"i18n:govoplan-mail.live_provider_response.39c46538": "Live provider response",
|
||||
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
|
||||
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
|
||||
"i18n:govoplan-mail.local.dc99d54d": "Local",
|
||||
@@ -95,6 +98,19 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
|
||||
"i18n:govoplan-mail.mail.92379cbb": "Mail",
|
||||
"i18n:govoplan-mail.compose": "Compose",
|
||||
"i18n:govoplan-mail.recipients": "Recipients",
|
||||
"i18n:govoplan-mail.address_suggestions_unavailable": "Address-book suggestions are unavailable. You can still enter an email address manually.",
|
||||
"i18n:govoplan-mail.open_mail_application": "Open mail application",
|
||||
"i18n:govoplan-mail.address_book_actions": "Address-book actions",
|
||||
"i18n:govoplan-mail.save_contacts_to": "Save contacts to",
|
||||
"i18n:govoplan-mail.no_writable_address_book": "No writable address book is available for your account.",
|
||||
"i18n:govoplan-mail.contact_added": "{value0} was added to contacts.",
|
||||
"i18n:govoplan-mail.contact_added_short": "Added",
|
||||
"i18n:govoplan-mail.add_value_to_contacts": "Add {value0} to contacts",
|
||||
"i18n:govoplan-mail.unavailable_address_books": "Unavailable address books and policy reasons",
|
||||
"i18n:govoplan-mail.open_mail": "Open Mail",
|
||||
"i18n:govoplan-mail.quick_access_description": "Recent mailbox messages and mail actions.",
|
||||
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
||||
@@ -139,6 +155,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
|
||||
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
|
||||
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
|
||||
"i18n:govoplan-mail.read.80ca1564": "Read",
|
||||
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
|
||||
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
|
||||
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
|
||||
@@ -180,6 +197,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.to.ae79ea1e": "To",
|
||||
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
|
||||
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
|
||||
"i18n:govoplan-mail.unread.66c78634": "Unread",
|
||||
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
|
||||
"i18n:govoplan-mail.user.9f8a2389": "User",
|
||||
"i18n:govoplan-mail.users": "Users",
|
||||
@@ -214,6 +232,8 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
||||
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
||||
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
||||
"i18n:govoplan-mail.cached_index_refreshing.75f18a6c": "Zwischengespeicherter Index wird aktualisiert",
|
||||
"i18n:govoplan-mail.cached_mailbox_index.16fe75d1": "Zwischengespeicherter Postfachindex",
|
||||
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "kampagnenbezogene Profile",
|
||||
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Kampagnenbezogene Profile",
|
||||
"i18n:govoplan-mail.campaigns": "Kampagnen",
|
||||
@@ -282,6 +302,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
|
||||
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
|
||||
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
|
||||
"i18n:govoplan-mail.live_provider_response.39c46538": "Aktuelle Provider-Antwort",
|
||||
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
|
||||
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
|
||||
"i18n:govoplan-mail.local.dc99d54d": "Local",
|
||||
@@ -292,6 +313,19 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
|
||||
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
|
||||
"i18n:govoplan-mail.mail.92379cbb": "Mail",
|
||||
"i18n:govoplan-mail.compose": "Verfassen",
|
||||
"i18n:govoplan-mail.recipients": "Empfänger",
|
||||
"i18n:govoplan-mail.address_suggestions_unavailable": "Adressbuchvorschläge sind nicht verfügbar. Eine E-Mail-Adresse kann weiterhin manuell eingegeben werden.",
|
||||
"i18n:govoplan-mail.open_mail_application": "Mail-Anwendung öffnen",
|
||||
"i18n:govoplan-mail.address_book_actions": "Adressbuchaktionen",
|
||||
"i18n:govoplan-mail.save_contacts_to": "Kontakte speichern in",
|
||||
"i18n:govoplan-mail.no_writable_address_book": "Für dieses Konto ist kein beschreibbares Adressbuch verfügbar.",
|
||||
"i18n:govoplan-mail.contact_added": "{value0} wurde zu den Kontakten hinzugefügt.",
|
||||
"i18n:govoplan-mail.contact_added_short": "Hinzugefügt",
|
||||
"i18n:govoplan-mail.add_value_to_contacts": "{value0} zu Kontakten hinzufügen",
|
||||
"i18n:govoplan-mail.unavailable_address_books": "Nicht verfügbare Adressbücher und Richtliniengründe",
|
||||
"i18n:govoplan-mail.open_mail": "Mail öffnen",
|
||||
"i18n:govoplan-mail.quick_access_description": "Aktuelle Posteingangsnachrichten und Mail-Aktionen.",
|
||||
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
|
||||
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
|
||||
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
|
||||
@@ -336,6 +370,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
|
||||
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
|
||||
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
|
||||
"i18n:govoplan-mail.read.80ca1564": "Gelesen",
|
||||
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
|
||||
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
|
||||
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
|
||||
@@ -377,6 +412,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-mail.to.ae79ea1e": "To",
|
||||
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
|
||||
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
|
||||
"i18n:govoplan-mail.unread.66c78634": "Ungelesen",
|
||||
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
|
||||
"i18n:govoplan-mail.user.9f8a2389": "User",
|
||||
"i18n:govoplan-mail.users": "Benutzer",
|
||||
|
||||
+22
-5
@@ -1,19 +1,30 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule, QuickAccessToolsUiCapability } from "@govoplan/core-webui";
|
||||
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import MailQuickAccess from "./features/mail/MailQuickAccess";
|
||||
import "./styles/mail-profiles.css";
|
||||
|
||||
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||
const MailBouncePage = lazy(() => import("./features/mail/MailBouncePage"));
|
||||
const MailLegacyImportPage = lazy(() => import("./features/mail/MailLegacyImportPage"));
|
||||
const mailboxRead = ["mail:mailbox:read"];
|
||||
const bounceRead = ["mail:bounce:read", "mail:bounce:manage"];
|
||||
const legacyImportAccess = ["mail:pop3:import", "mail:pop3:manage"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
const mailQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||
tools: [
|
||||
{
|
||||
id: "mail.messages",
|
||||
render: (context) => createElement(MailQuickAccess, context)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const mailModule: PlatformWebModule = {
|
||||
id: "mail",
|
||||
@@ -27,16 +38,22 @@ export const mailModule: PlatformWebModule = {
|
||||
{ id: "mail.admin.tenant-servers", moduleId: "mail", kind: "section", label: "Tenant mail servers", order: 60 },
|
||||
{ id: "mail.admin.group-servers", moduleId: "mail", kind: "section", label: "Group mail servers", order: 20 },
|
||||
{ id: "mail.admin.user-servers", moduleId: "mail", kind: "section", label: "User mail servers", order: 20 },
|
||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 }
|
||||
{ id: "mail.settings.profiles", moduleId: "mail", kind: "section", label: "Personal mail profiles", order: 10 },
|
||||
{ id: "mail.quick_access.messages", moduleId: "mail", kind: "quick_access", label: "Mail Quick Access", order: 80 }
|
||||
],
|
||||
navItems: [
|
||||
{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 },
|
||||
{ to: "/mail/legacy-import", label: "Legacy POP3 import", iconName: "mail", anyOf: legacyImportAccess, order: 52 }
|
||||
],
|
||||
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||
routes: [
|
||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings, auth }) => createElement(MailboxPage, { settings, auth }) },
|
||||
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) }],
|
||||
{ path: "/mail/bounces", anyOf: bounceRead, order: 51, render: ({ settings }) => createElement(MailBouncePage, { settings }) },
|
||||
{ path: "/mail/legacy-import", anyOf: legacyImportAccess, order: 52, render: ({ settings, auth }) => createElement(MailLegacyImportPage, { settings, auth }) }],
|
||||
|
||||
uiCapabilities: {
|
||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
|
||||
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors
|
||||
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors,
|
||||
"quickAccess.tools": mailQuickAccessTools
|
||||
},
|
||||
runtimeUiCapabilities: {
|
||||
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
||||
|
||||
@@ -186,14 +186,6 @@
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mailbox-toolbar,
|
||||
.mailbox-message-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mailbox-toolbar label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
@@ -385,7 +377,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
@media (max-width: 1280px) {
|
||||
.mailbox-layout {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
@@ -396,8 +388,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.mailbox-toolbar,
|
||||
.mailbox-message-toolbar {
|
||||
.mailbox-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -458,13 +449,6 @@
|
||||
|
||||
.mailbox-folder-count {
|
||||
flex: 0 0 auto;
|
||||
min-width: 20px;
|
||||
padding: 1px 6px;
|
||||
border: var(--border-line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mailbox-toolbar.file-manager-toolbar {
|
||||
@@ -576,6 +560,14 @@
|
||||
opacity: .72;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-read .file-list-name strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-unread .file-list-name strong {
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.mailbox-message-row .file-list-name strong {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -599,6 +591,27 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mailbox-read-state,
|
||||
.mailbox-sync-provenance {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mailbox-message-row.is-unread .mailbox-read-state {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
.mailbox-sync-provenance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mailbox-sync-provenance.is-refreshing {
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.mailbox-preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -614,7 +627,57 @@
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.mail-quick-compose {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.mail-quick-compose > label,
|
||||
.mailbox-contact-target > span {
|
||||
color: var(--text-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mailbox-contact-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
|
||||
.mailbox-contact-actions h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mailbox-contact-target {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mailbox-contact-candidates {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mailbox-contact-policy {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mailbox-contact-policy ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.mailbox-shell.file-manager-shell {
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
mailboxHeaderAddresses,
|
||||
mailLookupSuggestions,
|
||||
mailtoHref
|
||||
} from "../src/features/mail/mailAddressIntegration";
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
|
||||
assertDeepEqual(
|
||||
mailLookupSuggestions([
|
||||
{
|
||||
display_name: "Ada Lovelace",
|
||||
email: "Ada@Example.Test"
|
||||
},
|
||||
{
|
||||
display_name: "Duplicate",
|
||||
email: "ada@example.test"
|
||||
},
|
||||
{
|
||||
display_name: "No email",
|
||||
email: null
|
||||
}
|
||||
]),
|
||||
[{ name: "Ada Lovelace", email: "ada@example.test" }]
|
||||
);
|
||||
|
||||
assertDeepEqual(
|
||||
mailboxHeaderAddresses('Ada Lovelace <ada@example.test>, "Grace Hopper" <grace@example.test>'),
|
||||
[
|
||||
{ name: "Ada Lovelace", email: "ada@example.test" },
|
||||
{ name: "Grace Hopper", email: "grace@example.test" }
|
||||
]
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
mailtoHref([
|
||||
{ name: "Ada Lovelace", email: "ada@example.test" },
|
||||
{ email: "grace@example.test" }
|
||||
]),
|
||||
"mailto:ada%40example.test,grace%40example.test"
|
||||
);
|
||||
|
||||
console.log("mail address integration tests passed");
|
||||
@@ -0,0 +1,19 @@
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
|
||||
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
import { isMailboxMessageRead, mailboxSyncState } from "../src/features/mail/mailboxDisplay";
|
||||
|
||||
assert(isMailboxMessageRead(["\\Seen"]), "standard IMAP Seen flag marks a message as read");
|
||||
assert(isMailboxMessageRead(["answered", "SEEN"]), "flag matching is case-insensitive and accepts normalized flags");
|
||||
assert(!isMailboxMessageRead(["\\Answered", "\\Flagged"]), "messages without Seen remain unread");
|
||||
assert(!isMailboxMessageRead(undefined), "missing flags fail safely to unread");
|
||||
|
||||
assertEqual(mailboxSyncState({ fromCache: false, refreshing: false, indexedAt: null }), "live");
|
||||
assertEqual(mailboxSyncState({ fromCache: true, refreshing: false, indexedAt: "2026-08-19T09:00:00Z" }), "cached");
|
||||
assertEqual(mailboxSyncState({ fromCache: true, refreshing: true, indexedAt: "2026-08-19T09:00:00Z" }), "refreshing");
|
||||
assertEqual(mailboxSyncState(null), null);
|
||||
@@ -0,0 +1,46 @@
|
||||
function assertEqual(actual: unknown, expected: unknown): void {
|
||||
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
import {
|
||||
mailboxDraftsLaunchPath,
|
||||
mailboxLaunchFolder,
|
||||
mailboxMessageLaunchPath,
|
||||
parseMailboxLaunch
|
||||
} from "../src/features/mail/mailboxLaunch";
|
||||
|
||||
assertDeepEqual(
|
||||
parseMailboxLaunch("?profile=profile-1&folder=INBOX%2FTeam&message=42"),
|
||||
{
|
||||
profileId: "profile-1",
|
||||
folder: "INBOX/Team",
|
||||
folderRole: null,
|
||||
messageUid: "42"
|
||||
}
|
||||
);
|
||||
|
||||
assertEqual(
|
||||
mailboxMessageLaunchPath("profile 1", { folder: "INBOX/Team", uid: "42" }),
|
||||
"/mail?profile=profile+1&folder=INBOX%2FTeam&message=42"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxDraftsLaunchPath("profile-1"),
|
||||
"/mail?profile=profile-1&folderRole=drafts"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxDraftsLaunchPath("profile-1", "Entwürfe"),
|
||||
"/mail?profile=profile-1&folder=Entw%C3%BCrfe"
|
||||
);
|
||||
assertEqual(
|
||||
mailboxLaunchFolder(parseMailboxLaunch("?folderRole=drafts"), {
|
||||
imap: { folder_mappings: { drafts: "Entwürfe" } }
|
||||
}),
|
||||
"Entwürfe"
|
||||
);
|
||||
|
||||
console.log("mailbox launch tests passed");
|
||||
@@ -17,11 +17,17 @@
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/mailbox-display.test.ts",
|
||||
"tests/mailbox-folders.test.ts",
|
||||
"tests/mailbox-launch.test.ts",
|
||||
"tests/mail-profile-editor-model.test.ts",
|
||||
"tests/mail-policy-validation.test.ts",
|
||||
"tests/mail-address-integration.test.ts",
|
||||
"src/features/mail/mailboxDisplay.ts",
|
||||
"src/features/mail/mailboxFolders.ts",
|
||||
"src/features/mail/mailboxLaunch.ts",
|
||||
"src/features/mail/mailProfileEditorModel.ts",
|
||||
"src/features/mail/mailPolicyValidation.ts"
|
||||
"src/features/mail/mailPolicyValidation.ts",
|
||||
"src/features/mail/mailAddressIntegration.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user