Compare commits

..
19 Commits
Author SHA1 Message Date
zemion 218fef11f1 feat(mail): add governed POP3 legacy import
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 04:52:25 +02:00
zemion 93ecedf607 fix(i18n): complete cached mailbox translations 2026-08-21 17:36:17 +02:00
zemion cfd3847524 feat(mail): return exact messages from Quick Access
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 16:03:29 +02:00
zemion 2fe56fca00 fix(mail): align package and manifest versions 2026-08-21 00:46:06 +02:00
zemion 34bd5be8d4 feat: add governed Mail DSAR coverage 2026-08-20 23:58:21 +02:00
zemion a044fee379 feat: add reusable SMTP batch sessions 2026-08-20 17:39:00 +02:00
zemion 169b81d9db feat: integrate governed address contacts 2026-08-20 11:10:18 +02:00
zemion 2ad122056e feat(mail): complete Quick Access message launches 2026-08-20 02:39:41 +02:00
zemion 9ac8847559 feat(mail): complete mailbox display states 2026-08-19 23:03:46 +02:00
zemion e0e00d7000 feat(mail): map standard IMAP folders per profile 2026-08-19 21:27:42 +02:00
zemion 393331574a refactor(webui): adopt semantic page actions 2026-08-19 18:47:45 +02:00
zemion bb8c60abb9 Adopt semantic mail action layout 2026-08-19 14:26:26 +02:00
zemion c59c67bd18 style: align mail with shared WebUI foundations 2026-08-18 21:32:50 +02:00
zemion 761dd76b9e Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 28f357e151 Adopt shared WebUI layout primitives 2026-08-18 10:42:52 +02:00
zemion cff428b475 feat: bridge observed messages to postboxes 2026-08-07 14:53:56 +02:00
zemion 77f44dfab5 feat: provision SMTP profiles from deployment receipts 2026-08-07 11:15:50 +02:00
zemion 2c5585ba99 Contribute Mail to Quick Access 2026-08-06 19:02:55 +02:00
zemion 62721d204d Release v0.1.18
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 21:07:45 +02:00
54 changed files with 8571 additions and 218 deletions
+32 -7
View File
@@ -4,7 +4,7 @@
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile management, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP 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.
+4 -1
View File
@@ -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
View File
@@ -23,12 +23,13 @@ See also [Mail protocol roadmap](MAIL_PROTOCOL_ROADMAP.md) and the Campaign
Mail owns:
- reusable SMTP/IMAP profile definitions and scope;
- encrypted SMTP/IMAP credentials and safe credential replacement;
- reusable SMTP/IMAP 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
+27 -11
View File
@@ -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.
+3 -3
View File
@@ -1,14 +1,14 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.17",
"version": "0.1.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.17",
"version": "0.1.20",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.17",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-mail"
version = "0.1.17"
version = "0.1.20"
description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.17",
"govoplan-core>=0.1.18",
"pydantic>=2,<3",
"redis>=5,<6",
"SQLAlchemy>=2,<3",
+143 -2
View File
@@ -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)
+43
View File
@@ -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
+69
View File
@@ -387,3 +387,72 @@ class MailBounceObservation(Base, TimestampMixin):
)
matched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class MailPop3Import(Base, TimestampMixin):
"""Governed local review record created from a legacy POP3 mailbox."""
__tablename__ = "mail_pop3_imports"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"profile_id",
"pop3_server_id",
"provider_uidl",
name="uq_mail_pop3_imports_source_uidl",
),
Index(
"ix_mail_pop3_imports_review",
"tenant_id",
"status",
"imported_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
profile_id: Mapped[str] = mapped_column(
ForeignKey("mail_server_profiles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
pop3_server_id: Mapped[str] = mapped_column(
ForeignKey("mail_server_endpoints.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
pop3_credential_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
transport_revision: Mapped[str] = mapped_column(String(120), nullable=False)
provider_uidl: Mapped[str] = mapped_column(String(500), nullable=False)
provider_message_number: Mapped[int | None] = mapped_column(Integer, nullable=True)
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
raw_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
raw_message_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
message_id: Mapped[str | None] = mapped_column(String(998), nullable=True, index=True)
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
from_header: Mapped[str | None] = mapped_column(Text, nullable=True)
to_header: Mapped[str | None] = mapped_column(Text, nullable=True)
date: Mapped[str | None] = mapped_column(String(255), nullable=True)
body_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
status: Mapped[str] = mapped_column(
String(40), default="pending_review", nullable=False, index=True
)
imported_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
imported_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
deletion_requested: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False
)
deletion_status: Mapped[str] = mapped_column(
String(40), default="not_requested", nullable=False, index=True
)
deletion_attempted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deletion_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
+639
View File
@@ -0,0 +1,639 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from email.utils import getaddresses
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_mail.backend.db.models import (
MailBounceObservation,
MailDeliveryAttempt,
MailDeliveryCommand,
MailDeliveryReconciliation,
MailMailboxMessageIndex,
MailPop3Import,
MailServerProfile,
)
MAIL_DSAR_CAPABILITY = dsar_capability_name("mail")
_MAX_RECORDS = 5_000
class MailDsarProvider:
provider_id = "mail"
module_id = "mail"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
email = _subject_email(subject)
membership_ids = _membership_ids(subject)
references = _mail_references(subject)
if email is None and not membership_ids and not references:
return ()
records: list[DsarRecordRef] = []
def append(record: DsarRecordRef) -> None:
if len(records) >= _MAX_RECORDS:
raise ValueError(
"Mail DSAR match limit exceeded; narrow the subject selectors."
)
records.append(record)
profiles = _matching_profiles(
db,
tenant_id=tenant_id,
membership_ids=membership_ids,
profile_id=references.get("profile"),
)
for profile in profiles:
append(
_record(
"mail_server_profile",
profile.id,
"mail_profile",
profile.name,
{
"match_fields": _profile_matching_fields(
profile, membership_ids
),
"name": profile.name,
"slug": profile.slug,
"description": profile.description,
"scope_type": profile.scope_type,
"is_active": profile.is_active,
"inherit_to_lower_scopes": profile.inherit_to_lower_scopes,
},
observed_at=profile.updated_at,
source_path="/settings?section=mail-profiles",
)
)
messages = _matching_messages(
db,
tenant_id=tenant_id,
email=email,
message_id=references.get("message_index"),
)
for message in messages:
matching_headers = _matching_headers(message, email)
append(
_record(
"mailbox_message_index",
message.id,
"mailbox_message",
message.subject or "Mailbox message",
{
"match_fields": list(matching_headers),
"subject": _bounded_text(message.subject),
"matching_headers": matching_headers,
"date": message.date,
"flags": tuple(
str(flag)[:100] for flag in (message.flags or ())[:32]
),
"size_bytes": message.size_bytes,
"body_preview": _bounded_text(message.body_preview),
"attachment_count": message.attachment_count,
"indexed_at": _iso(message.indexed_at),
},
observed_at=message.updated_at,
source_path="/mail",
)
)
pop3_imports = _matching_pop3_imports(
db,
tenant_id=tenant_id,
email=email,
import_id=references.get("pop3_import"),
)
for imported in pop3_imports:
matching_headers = _matching_pop3_headers(imported, email)
append(
_record(
"mail_pop3_import",
imported.id,
"mail_imported_message",
imported.subject or "Imported legacy message",
{
"match_fields": list(matching_headers),
"subject": _bounded_text(imported.subject),
"matching_headers": matching_headers,
"date": imported.date,
"message_id": _bounded_text(imported.message_id),
"body_preview": _bounded_text(imported.body_preview),
"size_bytes": imported.size_bytes,
"status": imported.status,
"imported_at": _iso(imported.imported_at),
"deletion_requested": imported.deletion_requested,
"deletion_status": imported.deletion_status,
},
observed_at=imported.updated_at,
source_path="/mail/legacy-import",
)
)
bounces = _matching_bounces(
db,
tenant_id=tenant_id,
email=email,
bounce_id=references.get("bounce"),
)
bounce_command_ids = {row.command_id for row in bounces if row.command_id}
commands = _matching_commands(
db,
tenant_id=tenant_id,
membership_ids=membership_ids,
command_id=references.get("command"),
related_command_ids=bounce_command_ids,
)
command_ids = {row.id for row in commands}
for command in commands:
append(
_record(
"mail_delivery_command",
command.id,
"mail_delivery_evidence",
f"Mail {command.command_type} command",
{
"match_fields": (
["created_by_user_id"]
if command.created_by_user_id in membership_ids
else (
["reference"]
if command.id == references.get("command")
else ["bounce"]
)
),
"command_type": command.command_type,
"source_module": command.source_module,
"source_resource_type": command.source_resource_type,
"message_sha256": command.message_sha256,
"rfc_message_id": command.rfc_message_id,
"message_size_bytes": command.message_size_bytes,
"recipient_count": command.recipient_count,
"status": command.status,
"attempt_count": command.attempt_count,
"effect_started_at": _iso(command.effect_started_at),
"completed_at": _iso(command.completed_at),
"accepted_count": command.accepted_count,
"refused_count": command.refused_count,
"failure_code": command.failure_code,
"payload_purged_at": _iso(command.payload_purged_at),
},
observed_at=command.updated_at,
immutable=True,
retention_reason="Mail delivery commands are immutable transport, retry, and outcome evidence; encrypted payload retention is governed separately.",
)
)
for attempt in _command_attempts(db, command_ids):
append(
_record(
"mail_delivery_attempt",
attempt.id,
"mail_delivery_evidence",
f"Mail delivery attempt {attempt.attempt_number}",
{
"command_id": attempt.command_id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"started_at": _iso(attempt.started_at),
"effect_started_at": _iso(attempt.effect_started_at),
"completed_at": _iso(attempt.completed_at),
"accepted_count": attempt.accepted_count,
"refused_count": attempt.refused_count,
"outcome_code": attempt.outcome_code,
},
observed_at=attempt.updated_at,
immutable=True,
retention_reason="Per-attempt Mail outcome state is immutable delivery and recovery evidence.",
)
)
for reconciliation in _command_reconciliations(db, command_ids):
append(
_record(
"mail_delivery_reconciliation",
reconciliation.id,
"mail_delivery_evidence",
"Mail delivery reconciliation",
{
"command_id": reconciliation.command_id,
"decision": reconciliation.decision,
},
observed_at=reconciliation.updated_at,
immutable=True,
retention_reason="Mail reconciliation decisions are immutable authorization and recovery evidence.",
)
)
for bounce in bounces:
append(
_record(
"mail_bounce_observation",
bounce.id,
"mail_bounce_evidence",
"Mail bounce observation",
{
"match_fields": (
["recipient"]
if email and _normalized_email(bounce.recipient) == email
else ["reference"]
),
"command_id": bounce.command_id
if bounce.command_id in command_ids
else None,
"recipient": email
if email and _normalized_email(bounce.recipient) == email
else None,
"action": bounce.action,
"status_code": bounce.status_code,
"permanent": bounce.permanent,
"observed_at": _iso(bounce.observed_at),
"matched": bounce.matched,
},
observed_at=bounce.updated_at,
immutable=True,
retention_reason="Bounce observations are immutable delivery-status and suppression evidence.",
source_path="/mail/bounces",
)
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del session, tenant_id, subject
actions = []
for record in records:
if (
record.provider_id != self.provider_id
or record.module_id != self.module_id
):
raise ValueError("Mail DSAR received a foreign provider record.")
actions.append(
DsarErasureActionRef(
action_id=f"mail:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}",
provider_id="mail",
module_id="mail",
kind="retain" if record.immutable_evidence else "manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}",
rationale=record.retention_reason
or "Mailbox indexes and personal profiles must be reviewed through Mail and the authoritative external mailbox lifecycle.",
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del session, tenant_id, subject, request_id
return tuple(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Mail erasure requires an authorized Mail/external-mailbox lifecycle action; the DSAR provider does not mutate it directly.",
)
for action in actions
)
def _matching_profiles(
session: Session,
*,
tenant_id: str,
membership_ids: set[str],
profile_id: str | None,
) -> list[MailServerProfile]:
conditions = []
if profile_id:
conditions.append(MailServerProfile.id == profile_id)
if membership_ids:
conditions.extend(
(
MailServerProfile.created_by_user_id.in_(membership_ids),
MailServerProfile.updated_by_user_id.in_(membership_ids),
(MailServerProfile.scope_type == "user")
& MailServerProfile.scope_id.in_(membership_ids),
)
)
if not conditions:
return []
return _bounded_rows(
session.query(MailServerProfile)
.filter(MailServerProfile.tenant_id == tenant_id, or_(*conditions))
.order_by(MailServerProfile.id)
)
def _profile_matching_fields(
row: MailServerProfile, membership_ids: set[str]
) -> list[str]:
fields = []
if row.scope_type == "user" and row.scope_id in membership_ids:
fields.append("scope_id")
for field in ("created_by_user_id", "updated_by_user_id"):
if getattr(row, field) in membership_ids:
fields.append(field)
return fields
def _matching_messages(
session: Session, *, tenant_id: str, email: str | None, message_id: str | None
) -> list[MailMailboxMessageIndex]:
conditions = []
if message_id:
conditions.append(MailMailboxMessageIndex.id == message_id)
if email:
pattern = f"%{_escape_like(email)}%"
conditions.extend(
func.lower(field).like(pattern, escape="\\")
for field in (
MailMailboxMessageIndex.from_header,
MailMailboxMessageIndex.to_header,
MailMailboxMessageIndex.cc_header,
)
)
if not conditions:
return []
candidates = _bounded_rows(
session.query(MailMailboxMessageIndex)
.filter(MailMailboxMessageIndex.tenant_id == tenant_id, or_(*conditions))
.order_by(MailMailboxMessageIndex.id)
)
return [
row
for row in candidates
if row.id == message_id or _matching_headers(row, email)
]
def _matching_headers(
row: MailMailboxMessageIndex, email: str | None
) -> dict[str, list[dict[str, str | None]]]:
if email is None:
return {}
result = {}
for role, value in (
("from", row.from_header),
("to", row.to_header),
("cc", row.cc_header),
):
matches = [
{"email": address.casefold(), "name": name or None}
for name, address in getaddresses([value or ""])
if address.casefold() == email
]
if matches:
result[role] = matches[:64]
return result
def _matching_bounces(
session: Session, *, tenant_id: str, email: str | None, bounce_id: str | None
) -> list[MailBounceObservation]:
conditions = []
if bounce_id:
conditions.append(MailBounceObservation.id == bounce_id)
if email:
conditions.append(func.lower(MailBounceObservation.recipient) == email)
if not conditions:
return []
return _bounded_rows(
session.query(MailBounceObservation)
.filter(MailBounceObservation.tenant_id == tenant_id, or_(*conditions))
.order_by(MailBounceObservation.id)
)
def _matching_pop3_imports(
session: Session,
*,
tenant_id: str,
email: str | None,
import_id: str | None,
) -> list[MailPop3Import]:
conditions = []
if import_id:
conditions.append(MailPop3Import.id == import_id)
if email:
pattern = f"%{_escape_like(email)}%"
conditions.extend(
func.lower(field).like(pattern, escape="\\")
for field in (
MailPop3Import.from_header,
MailPop3Import.to_header,
)
)
if not conditions:
return []
candidates = _bounded_rows(
session.query(MailPop3Import)
.filter(MailPop3Import.tenant_id == tenant_id, or_(*conditions))
.order_by(MailPop3Import.id)
)
return [
row
for row in candidates
if row.id == import_id or _matching_pop3_headers(row, email)
]
def _matching_pop3_headers(
row: MailPop3Import,
email: str | None,
) -> dict[str, list[dict[str, str | None]]]:
if email is None:
return {}
result = {}
for role, value in (("from", row.from_header), ("to", row.to_header)):
matches = [
{"email": address.casefold(), "name": name or None}
for name, address in getaddresses([value or ""])
if address.casefold() == email
]
if matches:
result[role] = matches[:64]
return result
def _matching_commands(
session: Session,
*,
tenant_id: str,
membership_ids: set[str],
command_id: str | None,
related_command_ids: set[str],
) -> list[MailDeliveryCommand]:
conditions = []
if command_id:
conditions.append(MailDeliveryCommand.id == command_id)
if related_command_ids:
conditions.append(MailDeliveryCommand.id.in_(related_command_ids))
if membership_ids:
conditions.append(MailDeliveryCommand.created_by_user_id.in_(membership_ids))
if not conditions:
return []
return _bounded_rows(
session.query(MailDeliveryCommand)
.filter(MailDeliveryCommand.tenant_id == tenant_id, or_(*conditions))
.order_by(MailDeliveryCommand.id)
)
def _command_attempts(
session: Session, command_ids: set[str]
) -> list[MailDeliveryAttempt]:
if not command_ids:
return []
return _bounded_rows(
session.query(MailDeliveryAttempt)
.filter(MailDeliveryAttempt.command_id.in_(command_ids))
.order_by(MailDeliveryAttempt.id)
)
def _command_reconciliations(
session: Session, command_ids: set[str]
) -> list[MailDeliveryReconciliation]:
if not command_ids:
return []
return _bounded_rows(
session.query(MailDeliveryReconciliation)
.filter(MailDeliveryReconciliation.command_id.in_(command_ids))
.order_by(MailDeliveryReconciliation.id)
)
def _mail_references(subject: DsarSubjectRef) -> dict[str, str]:
aliases = {
"mail.profile": "profile",
"mail.message_index": "message_index",
"mail.delivery_command": "command",
"mail.bounce_observation": "bounce",
"mail.pop3_import": "pop3_import",
}
return {
target: value
for key, target in aliases.items()
if (value := str(subject.external_references.get(key) or "").strip())
}
def _membership_ids(subject: DsarSubjectRef) -> set[str]:
values = [subject.membership_id]
values.extend(
subject.external_references.get(key)
for key in (
"mail.user",
"mail.membership",
"access.membership",
"membership_id",
)
)
return {value for item in values if (value := str(item or "").strip())}
def _subject_email(subject: DsarSubjectRef) -> str | None:
values = [subject.email, subject.external_references.get("mail.email")]
normalized = {email for value in values if (email := _normalized_email(value))}
return normalized.pop() if len(normalized) == 1 else None
def _normalized_email(value: object) -> str | None:
if not isinstance(value, str):
return None
value = value.strip().casefold()
return value or None
def _record(
resource_type: str,
resource_id: str,
category: str,
title: str,
data: Mapping[str, object],
*,
observed_at: datetime | None = None,
immutable: bool = False,
retention_reason: str | None = None,
source_path: str | None = None,
) -> DsarRecordRef:
return DsarRecordRef(
provider_id="mail",
module_id="mail",
resource_type=resource_type,
resource_id=resource_id,
category=category,
title=title,
data=data,
observed_at=observed_at,
immutable_evidence=immutable,
retention_reason=retention_reason,
source_path=source_path,
)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Mail DSAR provider requires a SQLAlchemy session.")
return value
def _bounded_rows(query: object) -> list[object]:
rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined]
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Mail DSAR match limit exceeded; narrow the subject selectors."
)
return rows
def _bounded_text(value: str | None) -> str | None:
return value[:2_000] if value else None
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
__all__ = ["MAIL_DSAR_CAPABILITY", "MailDsarProvider"]
+1 -1
View File
@@ -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)
+487 -21
View File
@@ -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.17",
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")
+246
View File
@@ -0,0 +1,246 @@
from __future__ import annotations
import base64
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from govoplan_core.security.secrets import encrypt_secret
from govoplan_mail.backend.db.models import MailPop3Import, MailServerEndpoint
from govoplan_mail.backend.sending.pop3 import Pop3DownloadedMessage
class Pop3ImportError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class Pop3ImportResult:
imported: tuple[MailPop3Import, ...]
duplicates: tuple[MailPop3Import, ...]
def create_pop3_imports(
session: Session,
*,
tenant_id: str,
profile_id: str,
pop3_server_id: str,
pop3_credential_id: str | None,
transport_revision: str,
messages: Iterable[Pop3DownloadedMessage],
user_id: str | None,
deletion_requested: bool,
) -> Pop3ImportResult:
downloaded = tuple(messages)
if not downloaded:
raise Pop3ImportError("No POP3 messages were downloaded for import")
uidls = [item.uidl for item in downloaded]
if len(uidls) != len(set(uidls)):
raise Pop3ImportError("The POP3 download contained duplicate UIDL identifiers")
# Serialize imports per source before checking UIDLs. The database unique
# constraint remains the last line of defense, while this lock lets a
# concurrent request observe the first request's committed rows and report
# them as duplicates instead of surfacing an integrity error.
source = session.scalar(
select(MailServerEndpoint)
.where(
MailServerEndpoint.id == pop3_server_id,
MailServerEndpoint.profile_id == profile_id,
or_(
MailServerEndpoint.tenant_id == tenant_id,
MailServerEndpoint.tenant_id.is_(None),
),
MailServerEndpoint.protocol == "pop3",
)
.with_for_update()
)
if source is None:
raise Pop3ImportError("The selected POP3 source is unavailable")
existing = {
row.provider_uidl: row
for row in session.scalars(
select(MailPop3Import).where(
MailPop3Import.tenant_id == tenant_id,
MailPop3Import.profile_id == profile_id,
MailPop3Import.pop3_server_id == pop3_server_id,
MailPop3Import.provider_uidl.in_(uidls),
)
)
}
imported: list[MailPop3Import] = []
duplicates: list[MailPop3Import] = []
now = datetime.now(timezone.utc)
for message in downloaded:
duplicate = existing.get(message.uidl)
if duplicate is not None:
duplicates.append(duplicate)
continue
encrypted = encrypt_secret(base64.b64encode(message.raw).decode("ascii"))
if not encrypted:
raise Pop3ImportError("The downloaded POP3 message could not be encrypted")
summary = message.summary
row = MailPop3Import(
tenant_id=tenant_id,
profile_id=profile_id,
pop3_server_id=pop3_server_id,
pop3_credential_id=pop3_credential_id,
transport_revision=_required_revision(transport_revision),
provider_uidl=message.uidl,
provider_message_number=message.message_number,
fingerprint=_fingerprint(
tenant_id=tenant_id,
profile_id=profile_id,
pop3_server_id=pop3_server_id,
uidl=message.uidl,
raw_sha256=message.raw_sha256,
),
raw_sha256=message.raw_sha256,
raw_message_encrypted=encrypted,
message_id=summary.message_id,
subject=summary.subject,
from_header=summary.from_header,
to_header=summary.to_header,
date=summary.date,
body_preview=summary.body_preview,
size_bytes=len(message.raw),
status="pending_review",
imported_at=now,
imported_by_user_id=user_id,
deletion_requested=bool(deletion_requested),
deletion_status=("pending" if deletion_requested else "not_requested"),
)
session.add(row)
imported.append(row)
session.flush()
return Pop3ImportResult(imported=tuple(imported), duplicates=tuple(duplicates))
def list_pop3_imports(
session: Session,
*,
tenant_id: str,
profile_id: str | None = None,
profile_ids: Iterable[str] | None = None,
limit: int = 100,
) -> tuple[MailPop3Import, ...]:
statement = select(MailPop3Import).where(
MailPop3Import.tenant_id == tenant_id
)
if profile_id:
statement = statement.where(MailPop3Import.profile_id == profile_id)
elif profile_ids is not None:
allowed = tuple(dict.fromkeys(str(value) for value in profile_ids if value))
if not allowed:
return ()
statement = statement.where(MailPop3Import.profile_id.in_(allowed))
rows = session.scalars(
statement.order_by(
MailPop3Import.imported_at.desc(),
MailPop3Import.id.desc(),
).limit(max(1, min(int(limit), 500)))
)
return tuple(rows)
def mark_pop3_deletion_result(
session: Session,
*,
tenant_id: str,
import_ids: Iterable[str],
status: str,
error: str | None = None,
) -> tuple[MailPop3Import, ...]:
clean_status = str(status or "").strip().casefold()
if clean_status not in {"succeeded", "failed", "outcome_unknown"}:
raise Pop3ImportError("Unsupported POP3 deletion result")
ids = tuple(dict.fromkeys(str(value).strip() for value in import_ids if str(value).strip()))
if not ids:
return ()
rows = tuple(
session.scalars(
select(MailPop3Import)
.where(
MailPop3Import.tenant_id == tenant_id,
MailPop3Import.id.in_(ids),
MailPop3Import.deletion_requested.is_(True),
)
.with_for_update()
)
)
if len(rows) != len(ids):
raise Pop3ImportError("One or more POP3 import records are unavailable")
now = datetime.now(timezone.utc)
safe_error = _bounded_error(error)
for row in rows:
row.deletion_status = clean_status
row.deletion_attempted_at = now
row.deletion_error = safe_error
session.flush()
return rows
def pop3_import_payload(row: MailPop3Import) -> dict[str, object]:
return {
"id": row.id,
"profile_id": row.profile_id,
"pop3_server_id": row.pop3_server_id,
"transport_revision": row.transport_revision,
"provider_uidl": row.provider_uidl,
"message_id": row.message_id,
"subject": row.subject,
"from_header": row.from_header,
"to_header": row.to_header,
"date": row.date,
"body_preview": row.body_preview,
"size_bytes": row.size_bytes,
"raw_sha256": row.raw_sha256,
"status": row.status,
"imported_at": row.imported_at,
"deletion_requested": row.deletion_requested,
"deletion_status": row.deletion_status,
"deletion_attempted_at": row.deletion_attempted_at,
"deletion_error": row.deletion_error,
}
def _fingerprint(
*,
tenant_id: str,
profile_id: str,
pop3_server_id: str,
uidl: str,
raw_sha256: str,
) -> str:
material = "\x1f".join(
(tenant_id, profile_id, pop3_server_id, uidl, raw_sha256)
).encode("utf-8")
return hashlib.sha256(material).hexdigest()
def _required_revision(value: object) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > 120:
raise Pop3ImportError("A valid POP3 transport revision is required")
return clean
def _bounded_error(value: str | None) -> str | None:
clean = " ".join(str(value or "").split())
return clean[:500] or None
__all__ = [
"Pop3ImportError",
"Pop3ImportResult",
"create_pop3_imports",
"list_pop3_imports",
"mark_pop3_deletion_result",
"pop3_import_payload",
]
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
import hashlib
from email import policy
from email.message import Message
from email.parser import BytesParser
from email.utils import getaddresses
from sqlalchemy.orm import Session
from govoplan_core.core.mail import (
MailPostboxBridgeProvider,
MailPostboxBridgeRequest,
MailPostboxBridgeResult,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.postbox import (
PostboxAttachmentRef,
PostboxDeliveryRequest,
PostboxParticipantRef,
PostboxTargetRef,
postbox_delivery_provider,
)
from govoplan_mail.backend.db.models import MailServerProfile
from govoplan_mail.backend.runtime import configure_runtime, get_registry
MAX_BRIDGE_MESSAGE_BYTES = 50 * 1024 * 1024
MAX_POSTBOX_BODY_CHARS = 500_000
class MailPostboxBridgeError(RuntimeError):
pass
class MailPostboxBridge(MailPostboxBridgeProvider):
"""Translate immutable IMAP observations into native Postbox delivery."""
def bridge_message(
self,
session: object,
request: MailPostboxBridgeRequest,
) -> MailPostboxBridgeResult:
if not isinstance(session, Session):
raise TypeError("Mail Postbox bridging requires a SQLAlchemy session.")
if not isinstance(request.target, PostboxTargetRef):
raise MailPostboxBridgeError("A typed Postbox target is required.")
if len(request.raw_message) > MAX_BRIDGE_MESSAGE_BYTES:
raise MailPostboxBridgeError("Mail message exceeds the Postbox bridge limit.")
profile = session.get(MailServerProfile, request.profile_id)
if profile is None or profile.tenant_id != request.tenant_id:
raise MailPostboxBridgeError("Mail profile not found.")
folder = request.folder.strip()
uid = request.uid.strip()
uidvalidity = request.uidvalidity.strip()
if not folder or not uid or not uidvalidity:
raise MailPostboxBridgeError(
"Mail folder, UIDVALIDITY, and immutable UID are required."
)
try:
message = BytesParser(policy=policy.default).parsebytes(request.raw_message)
except Exception as exc:
raise MailPostboxBridgeError("Mail message could not be parsed.") from exc
provider = postbox_delivery_provider(get_registry())
if provider is None:
raise MailPostboxBridgeError("Postbox delivery is not available.")
source_digest = hashlib.sha256(request.raw_message).hexdigest()
source_key = hashlib.sha256(
f"{request.tenant_id}\0{request.profile_id}\0{folder}\0{uidvalidity}\0{uid}".encode("utf-8")
).hexdigest()
result = provider.deliver(
session,
PostboxDeliveryRequest(
tenant_id=request.tenant_id,
target=request.target,
producer_module="mail",
producer_resource_type="imap_message",
producer_resource_id=source_key,
idempotency_key=f"mail-postbox:{source_key}:{source_digest}",
subject=_header(message, "Subject") or "(No subject)",
body_text=_plain_text_body(message),
sender_label=_header(message, "From"),
classification=request.classification,
participants=_participants(message),
attachments=_attachments(
message,
profile_id=request.profile_id,
folder=folder,
uidvalidity=uidvalidity,
uid=uid,
),
metadata={
**dict(request.metadata),
"transport": "mail-imap",
"mail_profile_id": request.profile_id,
"mailbox_folder": folder,
"mailbox_uidvalidity": uidvalidity,
"mailbox_uid": uid,
"rfc_message_id": _header(message, "Message-ID"),
"raw_sha256": source_digest,
},
),
)
return MailPostboxBridgeResult(
postbox_id=result.postbox_id,
message_id=result.message_id,
delivery_id=result.delivery_id,
duplicate=result.duplicate,
source_digest=source_digest,
)
def _header(message: Message, name: str) -> str | None:
value = " ".join(str(message.get(name) or "").split())
return value[:1000] or None
def _plain_text_body(message: Message) -> str | None:
candidates = message.walk() if message.is_multipart() else (message,)
for part in candidates:
if part.get_content_type() != "text/plain":
continue
if part.get_content_disposition() == "attachment":
continue
try:
value = part.get_content()
except Exception:
payload = part.get_payload(decode=True) or b""
value = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
if isinstance(value, bytes):
value = value.decode(part.get_content_charset() or "utf-8", errors="replace")
normalized = str(value).strip()
if normalized:
return normalized[:MAX_POSTBOX_BODY_CHARS]
return None
def _participants(message: Message) -> tuple[PostboxParticipantRef, ...]:
result: list[PostboxParticipantRef] = []
for kind, headers in (
("sender", ("From",)),
("to", ("To",)),
("cc", ("Cc",)),
("bcc", ("Bcc",)),
):
for name, address in getaddresses(
[str(value) for header in headers for value in message.get_all(header, [])]
):
clean_address = address.strip()[:500]
if not clean_address:
continue
result.append(
PostboxParticipantRef(
kind=kind,
reference_type="external_email",
label=name.strip()[:500] or None,
address=clean_address,
)
)
return tuple(result)
def _attachments(
message: Message,
*,
profile_id: str,
folder: str,
uidvalidity: str,
uid: str,
) -> tuple[PostboxAttachmentRef, ...]:
result: list[PostboxAttachmentRef] = []
for index, part in enumerate(message.walk()):
filename = part.get_filename()
if part.get_content_disposition() != "attachment" and not filename:
continue
payload = part.get_payload(decode=True) or b""
digest = hashlib.sha256(payload).hexdigest()
reference_id = hashlib.sha256(
f"{profile_id}\0{folder}\0{uidvalidity}\0{uid}\0{index}\0{digest}".encode("utf-8")
).hexdigest()
result.append(
PostboxAttachmentRef(
reference_type="mail_attachment",
reference_id=reference_id,
name=str(filename or f"attachment-{index + 1}")[:1000],
media_type=part.get_content_type(),
size_bytes=len(payload),
digest=digest,
metadata={
"mail_profile_id": profile_id,
"mailbox_folder": folder,
"mailbox_uidvalidity": uidvalidity,
"mailbox_uid": uid,
"mime_part_index": index,
},
)
)
return tuple(result)
def create_postbox_bridge(context: ModuleContext) -> MailPostboxBridge:
configure_runtime(registry=context.registry, settings=context.settings)
return MailPostboxBridge()
__all__ = [
"MailPostboxBridge",
"MailPostboxBridgeError",
"create_postbox_bridge",
]
+135
View File
@@ -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",
]
+475 -4
View File
@@ -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(
+117 -3
View File
@@ -197,7 +197,7 @@ class MailServerEndpointResponse(BaseModel):
id: str
profile_id: str
tenant_id: str | None = None
protocol: Literal["smtp", "imap"]
protocol: Literal["smtp", "imap", "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
+51 -20
View File
@@ -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,
)
+492
View File
@@ -0,0 +1,492 @@
from __future__ import annotations
import hashlib
import poplib
import socket
import ssl
from dataclasses import dataclass
from email import policy
from email.message import Message
from email.parser import BytesParser
from typing import Iterable
from govoplan_core.security.outbound_http import (
OutboundHttpError,
create_outbound_connection,
validate_outbound_host,
)
from govoplan_mail.backend.config import Pop3Config, TransportSecurity
class _OutboundPolicyPOP3(poplib.POP3):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
return create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="POP3 legacy import",
)
class _OutboundPolicyPOP3SSL(poplib.POP3_SSL):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
sock = create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="POP3 legacy import",
)
try:
return self.context.wrap_socket(sock, server_hostname=self.host)
except Exception:
sock.close()
raise
class Pop3ConfigurationError(ValueError):
pass
class Pop3ProviderError(RuntimeError):
def __init__(self, message: str, *, outcome_unknown: bool = False):
super().__init__(message)
self.outcome_unknown = outcome_unknown
@dataclass(frozen=True, slots=True)
class Pop3LoginTestResult:
host: str
port: int
security: str
authenticated: bool
message_count: int
mailbox_size_bytes: int
@dataclass(frozen=True, slots=True)
class Pop3MessageSummary:
message_number: int
uidl: str
subject: str | None
from_header: str | None
to_header: str | None
date: str | None
message_id: str | None
size_bytes: int
body_preview: str | None
@dataclass(frozen=True, slots=True)
class Pop3PreviewResult:
host: str
port: int
security: str
message_count: int
mailbox_size_bytes: int
messages: tuple[Pop3MessageSummary, ...]
@dataclass(frozen=True, slots=True)
class Pop3DownloadedMessage:
message_number: int
uidl: str
raw: bytes
raw_sha256: str
summary: Pop3MessageSummary
@dataclass(frozen=True, slots=True)
class Pop3DeletionResult:
deleted_uidls: tuple[str, ...]
def _require_pop3_config(config: Pop3Config) -> tuple[str, int]:
if not config.legacy_import_enabled:
raise Pop3ConfigurationError(
"POP3 legacy import is disabled for the selected server"
)
if not config.host:
raise Pop3ConfigurationError("POP3 host is required")
if not config.port:
raise Pop3ConfigurationError("POP3 port is required")
if not config.username or not config.password:
raise Pop3ConfigurationError("POP3 username and password are required")
return config.host, config.port
def _open_pop3(config: Pop3Config) -> poplib.POP3:
host, port = _require_pop3_config(config)
try:
validate_outbound_host(host, port=port, label="POP3 legacy import")
except OutboundHttpError as exc:
raise Pop3ConfigurationError(str(exc)) from exc
context = ssl.create_default_context()
client: poplib.POP3 | None = None
try:
if config.security == TransportSecurity.TLS:
client = _OutboundPolicyPOP3SSL(
host=host,
port=port,
timeout=config.timeout_seconds,
context=context,
)
else:
client = _OutboundPolicyPOP3(
host=host,
port=port,
timeout=config.timeout_seconds,
)
if config.security == TransportSecurity.STARTTLS:
client.stls(context=context)
client.user(config.username)
client.pass_(config.password)
return client
except ssl.SSLError as exc:
_close_without_commit(client)
raise Pop3ProviderError("POP3 TLS negotiation failed") from exc
except poplib.error_proto as exc:
_close_without_commit(client)
raise Pop3ProviderError("POP3 authentication failed") from exc
except (OSError, socket.error) as exc:
_close_without_commit(client)
raise Pop3ProviderError("POP3 connection failed") from exc
except Exception:
_close_without_commit(client)
raise
def test_pop3_login(*, pop3_config: Pop3Config) -> Pop3LoginTestResult:
client = _open_pop3(pop3_config)
try:
message_count, mailbox_size = client.stat()
return Pop3LoginTestResult(
host=str(pop3_config.host),
port=int(pop3_config.port or 0),
security=pop3_config.security.value,
authenticated=True,
message_count=int(message_count),
mailbox_size_bytes=int(mailbox_size),
)
except poplib.error_proto as exc:
raise Pop3ProviderError("POP3 mailbox statistics are unavailable") from exc
finally:
_quit_without_deletions(client)
def preview_pop3_messages(
*,
pop3_config: Pop3Config,
limit: int = 50,
) -> Pop3PreviewResult:
clean_limit = max(1, min(int(limit), 100))
client = _open_pop3(pop3_config)
try:
message_count, mailbox_size = client.stat()
uidls = _uidl_map(client)
sizes = _size_map(client)
selected_numbers = sorted(uidls, reverse=True)[:clean_limit]
messages = tuple(
_preview_message(
client,
message_number=number,
uidl=uidls[number],
size_bytes=sizes.get(number, 0),
body_lines=pop3_config.preview_body_lines,
max_message_bytes=pop3_config.max_message_bytes,
)
for number in selected_numbers
)
return Pop3PreviewResult(
host=str(pop3_config.host),
port=int(pop3_config.port or 0),
security=pop3_config.security.value,
message_count=int(message_count),
mailbox_size_bytes=int(mailbox_size),
messages=messages,
)
except poplib.error_proto as exc:
raise Pop3ProviderError("POP3 message preview failed") from exc
finally:
_quit_without_deletions(client)
def download_pop3_messages(
*,
pop3_config: Pop3Config,
uidls: Iterable[str],
) -> tuple[Pop3DownloadedMessage, ...]:
selected_uidls = tuple(dict.fromkeys(_required_uidl(value) for value in uidls))
if not selected_uidls:
raise Pop3ConfigurationError("Select at least one POP3 message to import")
if len(selected_uidls) > 100:
raise Pop3ConfigurationError("At most 100 POP3 messages can be imported at once")
client = _open_pop3(pop3_config)
try:
uidl_by_number = _uidl_map(client)
number_by_uidl = {uidl: number for number, uidl in uidl_by_number.items()}
missing = [uidl for uidl in selected_uidls if uidl not in number_by_uidl]
if missing:
raise Pop3ProviderError(
"One or more previewed POP3 messages are no longer available; refresh the preview"
)
sizes = _size_map(client)
advertised_batch_size = sum(
max(0, int(sizes.get(number_by_uidl[uidl], 0)))
for uidl in selected_uidls
)
if advertised_batch_size > pop3_config.max_batch_bytes:
raise Pop3ProviderError(
"The selected POP3 messages exceed the configured batch size limit"
)
downloaded: list[Pop3DownloadedMessage] = []
downloaded_bytes = 0
for uidl in selected_uidls:
number = number_by_uidl[uidl]
advertised_size = sizes.get(number, 0)
if advertised_size > pop3_config.max_message_bytes:
raise Pop3ProviderError(
f"POP3 message {uidl} exceeds the configured import size limit"
)
_response, lines, _octets = client.retr(number)
raw = _message_bytes(lines)
if len(raw) > pop3_config.max_message_bytes:
raise Pop3ProviderError(
f"POP3 message {uidl} exceeds the configured import size limit"
)
downloaded_bytes += len(raw)
if downloaded_bytes > pop3_config.max_batch_bytes:
raise Pop3ProviderError(
"The selected POP3 messages exceed the configured batch size limit"
)
summary = _message_summary(
raw,
message_number=number,
uidl=uidl,
size_bytes=len(raw),
)
downloaded.append(
Pop3DownloadedMessage(
message_number=number,
uidl=uidl,
raw=raw,
raw_sha256=hashlib.sha256(raw).hexdigest(),
summary=summary,
)
)
return tuple(downloaded)
except poplib.error_proto as exc:
raise Pop3ProviderError("POP3 message download failed") from exc
finally:
_quit_without_deletions(client)
def delete_pop3_messages(
*,
pop3_config: Pop3Config,
uidls: Iterable[str],
) -> Pop3DeletionResult:
selected_uidls = tuple(dict.fromkeys(_required_uidl(value) for value in uidls))
if not selected_uidls:
return Pop3DeletionResult(deleted_uidls=())
if not pop3_config.allow_delete_after_import:
raise Pop3ConfigurationError(
"POP3 delete-after-import is disabled for the selected server"
)
client = _open_pop3(pop3_config)
quit_started = False
try:
number_by_uidl = {
uidl: number for number, uidl in _uidl_map(client).items()
}
missing = [uidl for uidl in selected_uidls if uidl not in number_by_uidl]
if missing:
raise Pop3ProviderError(
"One or more imported POP3 messages are no longer available for deletion"
)
for uidl in selected_uidls:
client.dele(number_by_uidl[uidl])
quit_started = True
client.quit()
return Pop3DeletionResult(deleted_uidls=selected_uidls)
except Pop3ProviderError:
_close_without_commit(client)
raise
except poplib.error_proto as exc:
_close_without_commit(client)
raise Pop3ProviderError(
"POP3 deletion outcome is unknown" if quit_started else "POP3 deletion was rejected",
outcome_unknown=quit_started,
) from exc
except Exception as exc:
_close_without_commit(client)
raise Pop3ProviderError(
"POP3 deletion outcome is unknown" if quit_started else "POP3 deletion failed",
outcome_unknown=quit_started,
) from exc
def _uidl_map(client: poplib.POP3) -> dict[int, str]:
_response, lines, _octets = client.uidl()
result: dict[int, str] = {}
for raw_line in lines:
parts = bytes(raw_line).decode("utf-8", errors="replace").split(maxsplit=1)
if len(parts) != 2 or not parts[0].isdigit():
continue
uidl = _required_uidl(parts[1])
result[int(parts[0])] = uidl
if not result:
raise Pop3ProviderError(
"The POP3 server does not provide stable UIDL identifiers; safe import is unavailable"
)
return result
def _size_map(client: poplib.POP3) -> dict[int, int]:
_response, lines, _octets = client.list()
result: dict[int, int] = {}
for raw_line in lines:
parts = bytes(raw_line).decode("ascii", errors="ignore").split(maxsplit=1)
if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit():
result[int(parts[0])] = int(parts[1])
return result
def _preview_message(
client: poplib.POP3,
*,
message_number: int,
uidl: str,
size_bytes: int,
body_lines: int,
max_message_bytes: int,
) -> Pop3MessageSummary:
raw: bytes | None = None
try:
_response, lines, _octets = client.top(message_number, body_lines)
raw = _message_bytes(lines)
except (poplib.error_proto, AttributeError):
# TOP is optional. Never use RETR as a preview fallback when the
# advertised message already exceeds the configured download bound.
if size_bytes > max_message_bytes:
return Pop3MessageSummary(
message_number=message_number,
uidl=uidl,
subject=None,
from_header=None,
to_header=None,
date=None,
message_id=None,
size_bytes=size_bytes,
body_preview=None,
)
_response, lines, _octets = client.retr(message_number)
raw = _message_bytes(lines)
if len(raw) > min(max_message_bytes, 256 * 1024):
return Pop3MessageSummary(
message_number=message_number,
uidl=uidl,
subject=None,
from_header=None,
to_header=None,
date=None,
message_id=None,
size_bytes=size_bytes,
body_preview=None,
)
return _message_summary(
raw,
message_number=message_number,
uidl=uidl,
size_bytes=size_bytes,
)
def _message_summary(
raw: bytes,
*,
message_number: int,
uidl: str,
size_bytes: int,
) -> Pop3MessageSummary:
message = BytesParser(policy=policy.default).parsebytes(raw)
return Pop3MessageSummary(
message_number=message_number,
uidl=uidl,
subject=_header(message, "Subject"),
from_header=_header(message, "From"),
to_header=_header(message, "To"),
date=_header(message, "Date"),
message_id=_header(message, "Message-ID"),
size_bytes=max(0, int(size_bytes)),
body_preview=_body_preview(message),
)
def _header(message: Message, name: str) -> str | None:
value = message.get(name)
if value is None:
return None
text = str(value).strip()
return text[:2_000] or None
def _body_preview(message: Message) -> str | None:
body = message.get_body(preferencelist=("plain",)) if message.is_multipart() else message
if body is None:
return None
try:
text = body.get_content()
except Exception:
payload = body.get_payload(decode=True)
text = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else str(payload or "")
normalized = " ".join(str(text).split())
return normalized[:500] or None
def _message_bytes(lines: Iterable[bytes]) -> bytes:
return b"\r\n".join(bytes(line) for line in lines) + b"\r\n"
def _required_uidl(value: object) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > 500 or any(char.isspace() for char in clean):
raise Pop3ConfigurationError("POP3 UIDL must be a non-empty token")
return clean
def _quit_without_deletions(client: poplib.POP3) -> None:
try:
client.quit()
except Exception:
_close_without_commit(client)
def _close_without_commit(client: poplib.POP3 | None) -> None:
if client is None:
return
try:
client.rset()
except Exception:
pass
try:
client.close()
except Exception:
pass
__all__ = [
"Pop3ConfigurationError",
"Pop3DeletionResult",
"Pop3DownloadedMessage",
"Pop3LoginTestResult",
"Pop3MessageSummary",
"Pop3PreviewResult",
"Pop3ProviderError",
"delete_pop3_messages",
"download_pop3_messages",
"preview_pop3_messages",
"test_pop3_login",
]
+348 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import copy
import logging
import os
import smtplib
import ssl
from dataclasses import dataclass
@@ -64,10 +65,22 @@ class SmtpSendError(RuntimeError):
started, so automatic retry is intentionally forbidden.
"""
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False):
def __init__(
self,
message: str,
*,
temporary: bool = False,
outcome_unknown: bool = False,
systemic: bool = False,
reason_code: str | None = None,
phase: str = "send",
):
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
self.systemic = systemic
self.reason_code = reason_code
self.phase = phase
@dataclass(frozen=True, slots=True)
@@ -86,12 +99,315 @@ class SmtpSendResult:
envelope_from: str
envelope_recipients: list[str]
refused_recipients: dict[str, tuple[int, bytes | str]]
connection_sequence: int = 1
session_reused: bool = False
reconnect_count: int = 0
@property
def accepted_count(self) -> int:
return len(self.envelope_recipients) - len(self.refused_recipients)
@dataclass(frozen=True, slots=True)
class SmtpBatchPolicy:
reuse_connections: bool = True
max_messages_per_connection: int = 100
reconnect_attempts: int = 1
health_check_before_reuse: bool = True
@classmethod
def from_environment(cls) -> "SmtpBatchPolicy":
return cls(
reuse_connections=_environment_bool("GOVOPLAN_SMTP_BATCH_REUSE", True),
max_messages_per_connection=_environment_int(
"GOVOPLAN_SMTP_BATCH_MAX_MESSAGES",
default=100,
minimum=1,
maximum=10_000,
),
reconnect_attempts=_environment_int(
"GOVOPLAN_SMTP_BATCH_RECONNECT_ATTEMPTS",
default=1,
minimum=0,
maximum=5,
),
health_check_before_reuse=_environment_bool(
"GOVOPLAN_SMTP_BATCH_HEALTH_CHECK",
True,
),
)
@dataclass(frozen=True, slots=True)
class SmtpBatchPreflightResult:
ready: bool
authenticated: bool
connection_sequence: int
reconnect_count: int
class SmtpBatchSession:
"""Bounded reusable SMTP connection for one already-authorized batch."""
def __init__(
self,
smtp_config: SmtpConfig,
*,
policy: SmtpBatchPolicy | None = None,
) -> None:
self.smtp_config = smtp_config
self.policy = policy or SmtpBatchPolicy.from_environment()
self._smtp: smtplib.SMTP | None = None
self._connection_sequence = 0
self._reconnect_count = 0
self._messages_on_connection = 0
self._closed = False
@property
def connection_count(self) -> int:
return self._connection_sequence
@property
def reconnect_count(self) -> int:
return self._reconnect_count
def preflight(self) -> SmtpBatchPreflightResult:
"""Validate DNS/egress/connectivity/TLS/auth before a provider effect."""
if self._closed:
raise SmtpSendError(
"SMTP batch session is closed.",
systemic=True,
reason_code="batch_session_closed",
phase="preflight",
)
_require_smtp_config(self.smtp_config)
if is_mock_smtp_host(self.smtp_config.host):
if self._connection_sequence == 0:
self._connection_sequence = 1
return self._preflight_result()
if self._smtp is None:
self._connect_with_retries()
return self._preflight_result()
def send(
self,
message: EmailMessage | bytes,
*,
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
host, port, recipients = _prepare_smtp_send(
smtp_config=self.smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
if is_mock_smtp_host(self.smtp_config.host):
preflight = self.preflight()
_accepted, refused = _send_mock_smtp_payload(
message,
smtp_config=self.smtp_config,
envelope_from=envelope_from,
envelope_recipients=recipients,
)
self._messages_on_connection += 1
return _smtp_send_result(
smtp_config=self.smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
connection_sequence=preflight.connection_sequence,
session_reused=self._messages_on_connection > 1,
reconnect_count=preflight.reconnect_count,
)
reused = self._prepare_connection_for_send()
smtp = self._smtp
if smtp is None: # Defensive: preflight either opens or raises.
raise SmtpSendError(
"SMTP preflight did not establish a connection.",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
)
try:
if isinstance(message, bytes):
refused = smtp.sendmail(envelope_from, recipients, message)
else:
refused = smtp.send_message(
message,
from_addr=envelope_from,
to_addrs=recipients,
)
except smtplib.SMTPRecipientsRefused as exc:
raise SmtpSendError(
f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}",
temporary=False,
reason_code="smtp_recipients_refused",
) from exc
except smtplib.SMTPSenderRefused as exc:
self._discard_connection()
raise SmtpSendError(
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_sender_refused",
) from exc
except smtplib.SMTPResponseException as exc:
disconnected = int(exc.smtp_code) == 421
if disconnected:
self._discard_connection()
raise SmtpSendError(
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=disconnected,
reason_code="smtp_connection_closed" if disconnected else "smtp_message_rejected",
) from exc
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
self._discard_connection()
raise SmtpSendError(
f"SMTP outcome is unknown after transmission started: {exc}",
outcome_unknown=True,
systemic=True,
reason_code="smtp_connection_lost_after_transmission",
) from exc
self._messages_on_connection += 1
result = _smtp_send_result(
smtp_config=self.smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
connection_sequence=self._connection_sequence,
session_reused=reused,
reconnect_count=self._reconnect_count,
)
if not self.policy.reuse_connections:
self._discard_connection()
return result
def close(self) -> None:
self._closed = True
self._discard_connection()
def __enter__(self) -> "SmtpBatchSession":
self.preflight()
return self
def __exit__(self, _exc_type, _exc, _traceback) -> None:
self.close()
def _preflight_result(self) -> SmtpBatchPreflightResult:
return SmtpBatchPreflightResult(
ready=True,
authenticated=bool(self.smtp_config.username and self.smtp_config.password),
connection_sequence=self._connection_sequence,
reconnect_count=self._reconnect_count,
)
def _prepare_connection_for_send(self) -> bool:
reused = self._smtp is not None and self._messages_on_connection > 0
if self._smtp is not None and self._messages_on_connection >= self.policy.max_messages_per_connection:
self._discard_connection()
reused = False
elif reused and self.policy.health_check_before_reuse:
try:
code, _message = self._smtp.noop()
if int(code) >= 400:
raise smtplib.SMTPServerDisconnected(f"SMTP NOOP returned {code}")
except (OSError, smtplib.SMTPException):
self._discard_connection()
reused = False
self.preflight()
return reused and self._smtp is not None
def _connect_with_retries(self) -> None:
last_error: BaseException | None = None
for attempt in range(self.policy.reconnect_attempts + 1):
try:
smtp = _open_smtp(self.smtp_config)
except smtplib.SMTPAuthenticationError as exc:
raise SmtpSendError(
"SMTP authentication failed during batch preflight.",
systemic=True,
reason_code="smtp_authentication_failed",
phase="preflight",
) from exc
except SmtpConfigurationError:
raise
except smtplib.SMTPResponseException as exc:
temporary = 400 <= int(exc.smtp_code) < 500
last_error = exc
if not temporary or attempt >= self.policy.reconnect_attempts:
raise SmtpSendError(
"SMTP server rejected batch preflight.",
temporary=temporary,
systemic=True,
reason_code="smtp_preflight_rejected",
phase="preflight",
) from exc
continue
except (OSError, smtplib.SMTPException) as exc:
last_error = exc
if attempt >= self.policy.reconnect_attempts:
raise SmtpSendError(
"SMTP connectivity is unavailable during batch preflight.",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
) from exc
continue
self._smtp = smtp
if attempt > 0 or self._connection_sequence > 0:
self._reconnect_count += 1
self._connection_sequence += 1
self._messages_on_connection = 0
return
raise SmtpSendError(
f"SMTP batch preflight failed: {last_error}",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
)
def _discard_connection(self) -> None:
smtp, self._smtp = self._smtp, None
self._messages_on_connection = 0
if smtp is None:
return
try:
smtp.quit()
except Exception as quit_exc:
_log_smtp_cleanup_failure("closing batch connection", quit_exc)
try:
smtp.close()
except Exception as close_exc:
_log_smtp_cleanup_failure("closing batch socket", close_exc)
def _environment_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().casefold() in {"1", "true", "yes", "on"}
def _environment_int(name: str, *, default: int, minimum: int, maximum: int) -> int:
value = os.getenv(name)
try:
parsed = int(value) if value is not None else default
except ValueError:
parsed = default
return max(minimum, min(maximum, parsed))
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
@@ -324,15 +640,27 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}",
temporary=False,
systemic=True,
reason_code="smtp_authentication_failed",
phase="preflight",
) from exc
except smtplib.SMTPResponseException as exc:
raise SmtpSendError(
f"SMTP connection error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_preflight_rejected",
phase="preflight",
) from exc
except (OSError, smtplib.SMTPException) as exc:
# No message transmission has begun yet; a later explicit retry is safe.
raise SmtpSendError(f"SMTP connection failed: {exc}", temporary=True) from exc
raise SmtpSendError(
f"SMTP connection failed: {exc}",
temporary=True,
systemic=True,
reason_code="smtp_connectivity_unavailable",
phase="preflight",
) from exc
try:
if isinstance(message, bytes):
@@ -352,6 +680,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=True,
reason_code="smtp_sender_refused",
) from exc
except smtplib.SMTPResponseException as exc:
# An explicit SMTP response means the server rejected the transaction;
@@ -359,6 +689,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}",
temporary=400 <= int(exc.smtp_code) < 500,
systemic=int(exc.smtp_code) == 421,
reason_code="smtp_connection_closed" if int(exc.smtp_code) == 421 else "smtp_message_rejected",
) from exc
except (OSError, smtplib.SMTPServerDisconnected, smtplib.SMTPException) as exc:
# A connection loss after DATA began can happen after the server accepted
@@ -366,6 +698,8 @@ def _send_network_smtp_payload(
raise SmtpSendError(
f"SMTP outcome is unknown after transmission started: {exc}",
outcome_unknown=True,
systemic=True,
reason_code="smtp_connection_lost_after_transmission",
) from exc
finally:
try:
@@ -387,6 +721,9 @@ def _smtp_send_result(
envelope_from: str,
envelope_recipients: list[str],
refused: dict[str, tuple[int, bytes]],
connection_sequence: int = 1,
session_reused: bool = False,
reconnect_count: int = 0,
) -> SmtpSendResult:
return SmtpSendResult(
host=host,
@@ -395,6 +732,9 @@ def _smtp_send_result(
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
connection_sequence=connection_sequence,
session_reused=session_reused,
reconnect_count=reconnect_count,
)
@@ -404,15 +744,19 @@ def send_email_bytes(
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
batch_session: SmtpBatchSession | None = None,
) -> SmtpSendResult:
"""Send exact RFC 5322 bytes through SMTP without reserializing the message."""
return _send_smtp_payload(
if batch_session is not None:
if batch_session.smtp_config != smtp_config:
raise SmtpConfigurationError("SMTP batch session does not match the resolved transport.")
return batch_session.send(
message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
return _send_smtp_payload(message_bytes, smtp_config=smtp_config, envelope_from=envelope_from, envelope_recipients=envelope_recipients)
def send_email_message(
+32 -16
View File
@@ -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
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from pydantic import ValidationError
from govoplan_mail.backend.router import (
create_mail_address_contact,
list_mail_address_write_targets,
lookup_mail_addresses,
)
from govoplan_mail.backend.schemas import MailContactCreateRequest
class _Session:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
self.rollbacks += 1
class _Writer:
def __init__(self, *, allowed: bool = True, read_only: bool = False) -> None:
self.allowed = allowed
self.read_only = read_only
self.created_payload = None
self.created_provenance = None
def list_write_targets(self, _session, _principal, *, operation):
return (
SimpleNamespace(
address_book_id="book-1",
address_book_label="Personal contacts",
operation=operation,
allowed=self.allowed,
reason="allowed" if self.allowed else "read_only_source",
message="Contact can be added." if self.allowed else "This source is read-only.",
scope_type="user",
scope_id="user-1",
source_kind="local" if self.allowed else "ldap",
read_only=self.read_only,
required_scopes=("addresses:contacts:write",),
provenance={"policy": "addresses"},
),
)
def can_write_to_address_book(self, _session, _principal, *, address_book_id, operation):
return self.list_write_targets(_session, _principal, operation=operation)[0]
def create_contact(self, _session, _principal, *, address_book_id, payload, provenance):
self.created_payload = payload
self.created_provenance = provenance
return SimpleNamespace(
contact_id="contact-1",
address_book_id=address_book_id,
display_name=payload["display_name"],
email=payload["emails"][0]["email"],
source_kind="local",
provenance=provenance,
)
def _principal():
return SimpleNamespace(has=lambda scope: scope == "mail:profile:use")
class MailAddressIntegrationTests(unittest.TestCase):
def test_optional_capabilities_fail_open_for_mail(self) -> None:
with patch("govoplan_mail.backend.router._registry_capability", return_value=None):
lookup = lookup_mail_addresses(query="ada", limit=25, session=_Session(), principal=_principal())
targets = list_mail_address_write_targets(session=_Session(), principal=_principal())
self.assertFalse(lookup.available)
self.assertEqual(lookup.candidates, [])
self.assertFalse(targets.available)
self.assertEqual(targets.targets, [])
def test_write_target_preserves_read_only_decision(self) -> None:
writer = _Writer(allowed=False, read_only=True)
with patch("govoplan_mail.backend.router._registry_capability", return_value=writer):
response = list_mail_address_write_targets(session=_Session(), principal=_principal())
self.assertTrue(response.available)
self.assertFalse(response.targets[0].allowed)
self.assertTrue(response.targets[0].read_only)
self.assertEqual(response.targets[0].reason, "read_only_source")
def test_blocked_target_cannot_be_bypassed_by_create(self) -> None:
session = _Session()
writer = _Writer(allowed=False, read_only=True)
with (
patch("govoplan_mail.backend.router._registry_capability", return_value=writer),
self.assertRaises(HTTPException) as raised,
):
create_mail_address_contact(
MailContactCreateRequest(
address_book_id="book-1",
display_name="Ada Lovelace",
email="ada@example.test",
),
session=session,
principal=_principal(),
)
self.assertEqual(raised.exception.status_code, 422)
self.assertEqual(session.commits, 0)
def test_allowed_create_uses_writer_and_records_consumer_provenance(self) -> None:
session = _Session()
writer = _Writer()
with patch("govoplan_mail.backend.router._registry_capability", return_value=writer):
result = create_mail_address_contact(
MailContactCreateRequest(
address_book_id="book-1",
display_name="Ada Lovelace",
email="ada@example.test",
),
session=session,
principal=_principal(),
)
self.assertEqual(result.contact_id, "contact-1")
self.assertEqual(writer.created_payload["emails"][0]["email"], "ada@example.test")
self.assertEqual(writer.created_provenance["consumer_module"], "mail")
self.assertEqual(session.commits, 1)
def test_proxy_rejects_invalid_email_before_calling_writer(self) -> None:
with self.assertRaises(ValidationError):
MailContactCreateRequest(address_book_id="book-1", email="not-an-email")
if __name__ == "__main__":
unittest.main()
+388
View File
@@ -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()
+419
View File
@@ -0,0 +1,419 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
plan_data_subject_erasure,
search_data_subject_request,
)
from govoplan_mail.backend.db.models import (
MailBounceObservation,
MailDeliveryAttempt,
MailDeliveryCommand,
MailDeliveryReconciliation,
MailMailboxMessageIndex,
MailPop3Import,
MailServerEndpoint,
MailServerProfile,
)
from govoplan_mail.backend.dsar_provider import MAIL_DSAR_CAPABILITY, MailDsarProvider
from govoplan_mail.backend.manifest import manifest
class _Registry:
def __init__(self, provider, active=True):
self.provider = provider
self.active = active
def capability_names(self):
return (MAIL_DSAR_CAPABILITY,)
def capability_owner(self, name):
assert name == MAIL_DSAR_CAPABILITY
return "mail"
def tenant_entitlement_resolver(self):
active = self.active
class Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State", (), {"effective_modules": ("mail",) if active else ()}
)()
return Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
assert name == MAIL_DSAR_CAPABILITY
return self.provider
class MailDsarProviderTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
ChangeSequenceEntry.__table__,
DataSubjectRequest.__table__,
MailServerProfile.__table__,
MailServerEndpoint.__table__,
MailMailboxMessageIndex.__table__,
MailPop3Import.__table__,
MailDeliveryCommand.__table__,
MailDeliveryAttempt.__table__,
MailDeliveryReconciliation.__table__,
MailBounceObservation.__table__,
],
)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
account = Account(
id="account-1",
email="subject@example.test",
normalized_email="subject@example.test",
display_name="Subject",
)
user = User(
id="membership-1",
tenant_id="tenant-1",
account_id=account.id,
email="subject@example.test",
display_name="Subject",
)
profile = MailServerProfile(
id="profile-subject",
tenant_id="tenant-1",
scope_type="user",
scope_id=user.id,
name="Personal mail",
slug="personal",
smtp_config={"host": "smtp-secret-do-not-export"},
smtp_username="smtp-user-do-not-export",
smtp_password_encrypted="smtp-cipher-do-not-export",
imap_config={"host": "imap-secret-do-not-export"},
imap_username="imap-user-do-not-export",
imap_password_encrypted="imap-cipher-do-not-export",
created_by_user_id=user.id,
)
message = MailMailboxMessageIndex(
id="message-subject",
tenant_id="tenant-1",
profile_id=profile.id,
folder="INBOX-secret-do-not-export",
uid="uid-secret-do-not-export",
uid_int=1,
sort_position=1,
subject="Subject notice",
from_header="Office <office@example.test>",
to_header="Subject Person <Subject@Example.Test>",
cc_header="Unrelated Person <other@example.test>",
date="2026-08-20",
message_id="message-locator-do-not-export",
flags=["\\Seen"],
size_bytes=42,
body_preview="Message preview for the subject",
attachment_count=1,
indexed_at=now,
)
unrelated = MailMailboxMessageIndex(
id="message-other",
tenant_id="tenant-1",
profile_id=profile.id,
folder="INBOX",
uid="2",
uid_int=2,
sort_position=2,
subject="Unrelated message do not export",
from_header="other@example.test",
to_header="someone@example.test",
indexed_at=now,
)
tenant_two_profile = MailServerProfile(
id="profile-tenant-2",
tenant_id="tenant-2",
name="Tenant two",
slug="tenant-two",
smtp_config={},
)
tenant_two = MailMailboxMessageIndex(
id="message-tenant-2",
tenant_id="tenant-2",
profile_id=tenant_two_profile.id,
folder="INBOX",
uid="1",
uid_int=1,
sort_position=1,
subject="Tenant two message do not export",
to_header="subject@example.test",
indexed_at=now,
)
pop3_server = MailServerEndpoint(
id="pop3-server-subject",
profile_id=profile.id,
tenant_id="tenant-1",
protocol="pop3",
name="Legacy POP3",
config={"host": "pop3-secret-do-not-export"},
scope_type="tenant",
scope_id="tenant-1",
)
pop3_import = MailPop3Import(
id="pop3-import-subject",
tenant_id="tenant-1",
profile_id=profile.id,
pop3_server_id=pop3_server.id,
pop3_credential_id="credential-secret-do-not-export",
transport_revision="pop3-revision-secret-do-not-export",
provider_uidl="provider-uidl-secret-do-not-export",
fingerprint="e" * 64,
raw_sha256="f" * 64,
raw_message_encrypted="pop3-message-cipher-do-not-export",
message_id="pop3-message-id",
subject="Imported subject notice",
from_header="Legacy office <legacy@example.test>",
to_header="Subject Person <subject@example.test>",
date="2026-08-19",
body_preview="Imported message preview for the subject",
size_bytes=84,
status="pending_review",
imported_at=now,
deletion_requested=False,
deletion_status="not_requested",
)
command = MailDeliveryCommand(
id="command-subject",
tenant_id="tenant-1",
command_type="send",
source_module="notifications",
source_resource_type="notification",
idempotency_key="idempotency-do-not-export",
canonical_request_hash="a" * 64,
profile_id=profile.id,
expected_smtp_transport_revision="revision-secret",
envelope_recipients_encrypted="recipient-cipher-do-not-export",
message_encrypted="message-cipher-do-not-export",
message_sha256="b" * 64,
rfc_message_id="rfc-message-id",
message_size_bytes=100,
recipient_count=1,
status="succeeded",
attempt_count=1,
accepted_count=1,
created_by_user_id=user.id,
completed_at=now,
expires_at=now + timedelta(days=30),
)
attempt = MailDeliveryAttempt(
id="attempt-subject",
command_id=command.id,
attempt_number=1,
worker_id="worker-do-not-export",
status="succeeded",
started_at=now,
completed_at=now,
accepted_count=1,
diagnostic_summary="diagnostic-do-not-export",
)
reconciliation = MailDeliveryReconciliation(
id="reconciliation-subject",
command_id=command.id,
decision="confirmed_sent",
evidence_reference="private-reference-do-not-export",
note_encrypted="private-note-do-not-export",
created_by_user_id=user.id,
)
bounce = MailBounceObservation(
id="bounce-subject",
tenant_id="tenant-1",
profile_id=profile.id,
folder="bounce-folder-do-not-export",
uid="bounce-uid-do-not-export",
fingerprint="c" * 64,
raw_sha256="d" * 64,
original_message_id="original-id-do-not-export",
command_id=command.id,
recipient="subject@example.test",
action="failed",
status_code="5.1.1",
diagnostic="bounce-diagnostic-do-not-export",
permanent=True,
observed_at=now,
matched=True,
evidence={"secret": "bounce-evidence-do-not-export"},
)
self.session.add_all(
[
account,
user,
profile,
message,
unrelated,
tenant_two_profile,
tenant_two,
pop3_server,
pop3_import,
command,
attempt,
reconciliation,
bounce,
]
)
self.session.commit()
self.provider = MailDsarProvider()
self.subject = DsarSubjectRef(
membership_id=user.id, email="subject@example.test"
)
def tearDown(self):
self.session.close()
self.engine.dispose()
def test_manifest_and_minimized_tenant_scoped_search(self):
self.assertIn(
MAIL_DSAR_CAPABILITY, {item.name for item in manifest.provides_interfaces}
)
self.assertIsInstance(
manifest.capability_factories[MAIL_DSAR_CAPABILITY](None), DsarProvider
)
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self.subject
)
self.assertTrue(
{
"mail_server_profile",
"mailbox_message_index",
"mail_delivery_command",
"mail_delivery_attempt",
"mail_delivery_reconciliation",
"mail_bounce_observation",
"mail_pop3_import",
}.issubset({r.resource_type for r in records})
)
serialized = repr([record.to_dict() for record in records])
for hidden in (
"other@example.test",
"Unrelated Person",
"message-other",
"Unrelated message do not export",
"message-tenant-2",
"Tenant two message do not export",
"smtp-secret-do-not-export",
"smtp-user-do-not-export",
"smtp-cipher-do-not-export",
"imap-secret-do-not-export",
"imap-user-do-not-export",
"imap-cipher-do-not-export",
"INBOX-secret-do-not-export",
"uid-secret-do-not-export",
"message-locator-do-not-export",
"idempotency-do-not-export",
"recipient-cipher-do-not-export",
"message-cipher-do-not-export",
"worker-do-not-export",
"diagnostic-do-not-export",
"private-reference-do-not-export",
"private-note-do-not-export",
"bounce-folder-do-not-export",
"bounce-uid-do-not-export",
"original-id-do-not-export",
"bounce-diagnostic-do-not-export",
"bounce-evidence-do-not-export",
"pop3-secret-do-not-export",
"credential-secret-do-not-export",
"pop3-revision-secret-do-not-export",
"provider-uidl-secret-do-not-export",
"pop3-message-cipher-do-not-export",
):
self.assertNotIn(hidden, serialized)
def test_conflicting_email_fails_closed(self):
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"mail.email": "other@example.test"},
),
)
self.assertEqual((), records)
def test_plan_preserves_evidence_and_executes_nothing(self):
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self.subject
)
actions = self.provider.plan_erasure(
self.session, tenant_id="tenant-1", subject=self.subject, records=records
)
self.assertTrue({"retain", "manual_review"}.issubset({a.kind for a in actions}))
self.assertFalse(any(action.executable for action in actions))
def test_core_workflow_discovers_active_and_skips_disabled_provider(self):
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-MAIL-1",
request_kind="access_and_erasure",
subject=self.subject,
purpose="Authorized request",
legal_basis="GDPR",
due_at=None,
requested_by_account_id="officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=1,
)
self.assertEqual(["mail"], request.coverage["covered_modules"])
plan_data_subject_erasure(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=2,
)
self.assertFalse(
any(action["executable"] for action in request.erasure_plan["actions"])
)
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-MAIL-OFF",
request_kind="access",
subject=self.subject,
purpose="Coverage",
legal_basis=None,
due_at=None,
requested_by_account_id="officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(
[MAIL_DSAR_CAPABILITY], disabled.coverage["inactive_provider_capabilities"]
)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -8,6 +8,7 @@ from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.sending.imap import (
ImapAppendError,
ImapConfigurationError,
_detect_standard_folder_mappings,
_detect_sent_folder,
_extract_mailbox_name,
_fetch_message_by_uid,
@@ -18,6 +19,7 @@ from govoplan_mail.backend.sending.imap import (
_select_readonly,
_sequence_set,
append_message_to_sent,
list_imap_folders,
list_imap_messages,
list_imap_uids_since,
)
@@ -79,6 +81,45 @@ class ImapFolderParserTests(unittest.TestCase):
"Gesendet",
)
def test_detects_all_standard_folder_roles_by_flag_then_name(self):
self.assertEqual(
{
"inbox": "INBOX",
"sent": "Gesendete Elemente",
"drafts": "Entwürfe",
"trash": "Deleted",
"archive": "All Mail",
"junk": "Spam",
},
_detect_standard_folder_mappings(
[
("INBOX", set()),
("Gesendete Elemente", set()),
("Entwürfe", set()),
("Deleted", {"\\trash"}),
("All Mail", {"\\all"}),
("Spam", {"\\junk"}),
]
),
)
def test_mock_folder_listing_exposes_detected_standard_mappings(self):
result = list_imap_folders(
imap_config=ImapConfig(host="mock.imap.local"), include_status=False
)
self.assertEqual(
{
"inbox": "INBOX",
"sent": "Sent",
"drafts": "Drafts",
"trash": "Trash",
"archive": "Archive",
},
result.detected_folder_mappings,
)
self.assertEqual("Sent", result.detected_sent_folder)
class ImapMessagePaginationTests(unittest.TestCase):
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
+45
View File
@@ -290,6 +290,51 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
def test_apply_transport_update_persists_standard_folder_mappings(self):
profile = SimpleNamespace(
id="profile-folders",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org"},
smtp_username=None,
smtp_password_encrypted=None,
smtp_transport_revision="smtp-before",
imap_config={"host": "imap.example.org", "sent_folder": "Legacy Sent"},
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index"):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=None,
imap=ImapConfig(
host="imap.example.org",
folder_mappings={
"inbox": "INBOX",
"sent": "Sent Items",
"drafts": "Drafts",
},
),
clear_imap=False,
)
self.assertEqual(profile.imap_config["sent_folder"], "Sent Items")
self.assertEqual(
profile.imap_config["folder_mappings"],
{
"inbox": "INBOX",
"sent": "Sent Items",
"drafts": "Drafts",
},
)
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
profile = SimpleNamespace(
id="profile-1",
+45
View File
@@ -9,6 +9,15 @@ from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manife
class MailManifestTests(unittest.TestCase):
def test_mail_quick_access_can_return_exact_message_references(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
tool = next(
item for item in frontend.quick_access_tools if item.id == "mail.messages" # type: ignore[union-attr]
)
self.assertIn("select", tool.modes)
self.assertEqual(("mail.message",), tool.returned_reference_kinds)
def test_manifest_declares_optional_addresses_lookup(self) -> None:
manifest = get_manifest()
@@ -16,6 +25,7 @@ class MailManifestTests(unittest.TestCase):
self.assertEqual(manifest.id, "mail")
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.contact_writer", {interface.name for interface in manifest.requires_interfaces})
self.assertIn(
{
"name": "campaigns.access",
@@ -43,7 +53,22 @@ class MailManifestTests(unittest.TestCase):
permissions = {permission.scope for permission in manifest.permissions}
self.assertIn("mail:profile:write_own", permissions)
self.assertIn("mail:secret:manage_own", permissions)
self.assertTrue(
{"mail:pop3:manage", "mail:pop3:import", "mail:pop3:delete"}.issubset(
permissions
)
)
roles = {template.slug: template for template in manifest.role_templates}
self.assertIn("mail:pop3:delete", roles["mail_profile_admin"].permissions)
self.assertEqual(
set(roles["mail_legacy_import_operator"].permissions),
{
"mail:profile:read",
"mail:profile:use",
"mail:profile:test",
"mail:pop3:import",
},
)
self.assertEqual(
set(roles["mail_profile_self_service"].permissions),
{
@@ -62,8 +87,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")
+440
View File
@@ -0,0 +1,440 @@
from __future__ import annotations
import base64
from datetime import UTC, datetime
import poplib
import ssl
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, User
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.base import Base
from govoplan_core.security.secrets import decrypt_secret
from govoplan_mail.backend.config import Pop3Config, TransportSecurity
from govoplan_mail.backend.db.models import (
MailPop3Import,
MailServerEndpoint,
MailServerProfile,
)
from govoplan_mail.backend.pop3_imports import (
Pop3ImportResult,
create_pop3_imports,
list_pop3_imports,
)
from govoplan_mail.backend.router import import_profile_pop3_messages
from govoplan_mail.backend.schemas import MailPop3ImportRequest
from govoplan_mail.backend.sending.pop3 import (
Pop3ConfigurationError,
Pop3DownloadedMessage,
Pop3MessageSummary,
Pop3ProviderError,
_open_pop3,
delete_pop3_messages,
download_pop3_messages,
preview_pop3_messages,
)
_RAW = (
b"Subject: Legacy notice\r\n"
b"From: Office <office@example.test>\r\n"
b"To: Subject <subject@example.test>\r\n"
b"Message-ID: <legacy-1@example.test>\r\n"
b"\r\n"
b"A bounded legacy message.\r\n"
)
class _Pop3Client:
def __init__(self, *, quit_error: Exception | None = None) -> None:
self.deletions: list[int] = []
self.quit_calls = 0
self.rset_calls = 0
self.close_calls = 0
self.quit_error = quit_error
def stat(self):
return 1, len(_RAW)
def uidl(self):
return b"+OK", [b"1 uid-1"], 1
def list(self):
return b"+OK", [f"1 {len(_RAW)}".encode("ascii")], 1
def top(self, _number, _lines):
return b"+OK", _RAW.rstrip(b"\r\n").split(b"\r\n"), len(_RAW)
def retr(self, _number):
return b"+OK", _RAW.rstrip(b"\r\n").split(b"\r\n"), len(_RAW)
def dele(self, number):
self.deletions.append(number)
def quit(self):
self.quit_calls += 1
if self.quit_error is not None:
raise self.quit_error
return b"+OK"
def rset(self):
self.rset_calls += 1
def close(self):
self.close_calls += 1
def _config(**changes) -> Pop3Config:
values = {
"host": "pop3.example.test",
"security": "tls",
"username": "legacy-user",
"password": "legacy-password",
"legacy_import_enabled": True,
}
values.update(changes)
return Pop3Config.model_validate(values)
def _download(uidl: str = "uid-1") -> Pop3DownloadedMessage:
summary = Pop3MessageSummary(
message_number=1,
uidl=uidl,
subject="Legacy notice",
from_header="Office <office@example.test>",
to_header="Subject <subject@example.test>",
date="Sat, 22 Aug 2026 10:00:00 +0200",
message_id="<legacy-1@example.test>",
size_bytes=len(_RAW),
body_preview="A bounded legacy message.",
)
import hashlib
return Pop3DownloadedMessage(
message_number=1,
uidl=uidl,
raw=_RAW,
raw_sha256=hashlib.sha256(_RAW).hexdigest(),
summary=summary,
)
class Pop3TransportTests(unittest.TestCase):
def test_legacy_import_is_disabled_until_explicitly_enabled(self) -> None:
with self.assertRaisesRegex(Pop3ConfigurationError, "disabled"):
preview_pop3_messages(
pop3_config=_config(legacy_import_enabled=False),
limit=10,
)
with self.assertRaisesRegex(ValueError, "batch size limit"):
_config(max_message_bytes=2 * 1024 * 1024, max_batch_bytes=1024 * 1024)
def test_preview_and_download_are_non_destructive(self) -> None:
preview_client = _Pop3Client()
with patch(
"govoplan_mail.backend.sending.pop3._open_pop3",
return_value=preview_client,
):
preview = preview_pop3_messages(pop3_config=_config(), limit=10)
self.assertEqual(["uid-1"], [item.uidl for item in preview.messages])
self.assertEqual([], preview_client.deletions)
self.assertEqual(1, preview_client.quit_calls)
download_client = _Pop3Client()
with patch(
"govoplan_mail.backend.sending.pop3._open_pop3",
return_value=download_client,
):
downloaded = download_pop3_messages(
pop3_config=_config(), uidls=("uid-1",)
)
self.assertEqual(_RAW, downloaded[0].raw)
self.assertEqual([], download_client.deletions)
self.assertEqual(1, download_client.quit_calls)
def test_source_deletion_needs_policy_and_commits_with_quit(self) -> None:
with self.assertRaisesRegex(Pop3ConfigurationError, "disabled"):
delete_pop3_messages(pop3_config=_config(), uidls=("uid-1",))
client = _Pop3Client()
with patch(
"govoplan_mail.backend.sending.pop3._open_pop3", return_value=client
):
result = delete_pop3_messages(
pop3_config=_config(allow_delete_after_import=True),
uidls=("uid-1",),
)
self.assertEqual(("uid-1",), result.deleted_uidls)
self.assertEqual([1], client.deletions)
self.assertEqual(1, client.quit_calls)
def test_quit_failure_marks_deletion_outcome_unknown(self) -> None:
client = _Pop3Client(quit_error=poplib.error_proto("connection lost"))
with (
patch(
"govoplan_mail.backend.sending.pop3._open_pop3",
return_value=client,
),
self.assertRaises(Pop3ProviderError) as captured,
):
delete_pop3_messages(
pop3_config=_config(allow_delete_after_import=True),
uidls=("uid-1",),
)
self.assertTrue(captured.exception.outcome_unknown)
self.assertEqual([1], client.deletions)
def test_tls_and_authentication_failures_are_sanitized(self) -> None:
with (
patch(
"govoplan_mail.backend.sending.pop3.validate_outbound_host"
),
patch(
"govoplan_mail.backend.sending.pop3._OutboundPolicyPOP3SSL",
side_effect=ssl.SSLError("private TLS detail"),
),
self.assertRaisesRegex(Pop3ProviderError, "TLS negotiation failed"),
):
_open_pop3(_config())
auth_client = _Pop3Client()
auth_client.user = lambda _value: None # type: ignore[attr-defined]
auth_client.pass_ = lambda _value: (_ for _ in ()).throw( # type: ignore[attr-defined]
poplib.error_proto("private auth detail")
)
with (
patch(
"govoplan_mail.backend.sending.pop3.validate_outbound_host"
),
patch(
"govoplan_mail.backend.sending.pop3._OutboundPolicyPOP3",
return_value=auth_client,
),
self.assertRaisesRegex(Pop3ProviderError, "authentication failed"),
):
_open_pop3(_config(security=TransportSecurity.PLAIN))
class Pop3PersistenceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=(
Account.__table__,
User.__table__,
MailServerProfile.__table__,
MailServerEndpoint.__table__,
MailPop3Import.__table__,
),
)
self.session = sessionmaker(bind=self.engine, expire_on_commit=False)()
self.profile = MailServerProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Legacy source",
slug="legacy-source",
smtp_config={},
)
self.server = MailServerEndpoint(
id="server-1",
profile_id=self.profile.id,
tenant_id="tenant-1",
protocol="pop3",
name="Legacy POP3",
config={"legacy_import_enabled": True},
scope_type="tenant",
scope_id="tenant-1",
transport_revision="revision-1",
)
self.session.add_all((self.profile, self.server))
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_import_is_encrypted_and_duplicate_uidl_is_reused(self) -> None:
first = create_pop3_imports(
self.session,
tenant_id="tenant-1",
profile_id=self.profile.id,
pop3_server_id=self.server.id,
pop3_credential_id=None,
transport_revision="revision-1",
messages=(_download(),),
user_id=None,
deletion_requested=False,
)
self.session.commit()
self.assertEqual(1, len(first.imported))
encrypted = first.imported[0].raw_message_encrypted
self.assertNotIn("Legacy notice", encrypted)
self.assertEqual(
_RAW,
base64.b64decode(decrypt_secret(encrypted) or ""),
)
self.assertEqual("not_requested", first.imported[0].deletion_status)
repeated = create_pop3_imports(
self.session,
tenant_id="tenant-1",
profile_id=self.profile.id,
pop3_server_id=self.server.id,
pop3_credential_id=None,
transport_revision="revision-1",
messages=(_download(),),
user_id=None,
deletion_requested=False,
)
self.assertEqual((), repeated.imported)
self.assertEqual((first.imported[0].id,), tuple(row.id for row in repeated.duplicates))
self.assertEqual(
(first.imported[0].id,),
tuple(
row.id
for row in list_pop3_imports(
self.session,
tenant_id="tenant-1",
profile_ids=(self.profile.id,),
)
),
)
self.assertEqual(
(),
list_pop3_imports(
self.session,
tenant_id="tenant-1",
profile_ids=("unrelated-profile",),
),
)
class _RouteSession:
def __init__(self, events: list[str]) -> None:
self.events = events
def commit(self) -> None:
self.events.append("commit")
def rollback(self) -> None:
self.events.append("rollback")
class Pop3ImportRouteTests(unittest.TestCase):
def test_local_import_and_audit_commit_before_source_deletion(self) -> None:
events: list[str] = []
now = datetime.now(UTC)
row = SimpleNamespace(
id="import-1",
profile_id="profile-1",
pop3_server_id="server-1",
transport_revision="revision-1",
provider_uidl="uid-1",
message_id="<legacy-1@example.test>",
subject="Legacy notice",
from_header="office@example.test",
to_header="subject@example.test",
date="2026-08-22",
body_preview="A bounded legacy message.",
size_bytes=len(_RAW),
raw_sha256=_download().raw_sha256,
status="pending_review",
imported_at=now,
deletion_requested=True,
deletion_status="pending",
deletion_attempted_at=None,
deletion_error=None,
)
resolved = SimpleNamespace(
config=_config(allow_delete_after_import=True),
server=SimpleNamespace(id="server-1"),
credential=None,
transport_revision="revision-1",
)
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
scopes=frozenset(
{
"mail:profile:use",
"mail:pop3:import",
"mail:pop3:delete",
}
),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
payload = MailPop3ImportRequest(
server_id="server-1",
expected_transport_revision="revision-1",
uidls=["uid-1"],
delete_after_import=True,
)
def audit(*_args, **_kwargs) -> None:
events.append("audit")
def delete(**_kwargs):
self.assertEqual(["audit", "commit"], events)
events.append("delete")
def mark(*_args, **_kwargs):
events.append("mark")
row.deletion_status = "succeeded"
row.deletion_attempted_at = now
return (row,)
with (
patch(
"govoplan_mail.backend.router._resolve_profile_pop3_transport",
return_value=(SimpleNamespace(id="profile-1"), resolved),
),
patch(
"govoplan_mail.backend.router.download_pop3_messages",
return_value=(_download(),),
),
patch(
"govoplan_mail.backend.router.create_pop3_imports",
return_value=Pop3ImportResult(imported=(row,), duplicates=()),
),
patch("govoplan_mail.backend.router.audit_event", side_effect=audit),
patch("govoplan_mail.backend.router.delete_pop3_messages", side_effect=delete),
patch(
"govoplan_mail.backend.router.mark_pop3_deletion_result",
side_effect=mark,
),
):
result = import_profile_pop3_messages(
"profile-1",
payload,
principal=principal,
session=_RouteSession(events), # type: ignore[arg-type]
)
self.assertEqual("succeeded", result.deletion_status)
self.assertEqual(
["audit", "commit", "delete", "mark", "commit", "audit", "commit"],
events,
)
if __name__ == "__main__":
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
import unittest
from dataclasses import replace
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.core.mail import MailPostboxBridgeRequest
from govoplan_core.core.postbox import PostboxTargetRef
from govoplan_core.db.base import Base
from govoplan_mail.backend.db.models import MailServerProfile
from govoplan_mail.backend.postbox_bridge import MailPostboxBridge
RAW_MESSAGE = b"""From: Ada Example <ada@example.test>
To: Clerk <clerk@example.test>
Cc: Archive <archive@example.test>
Message-ID: <bridge-1@example.test>
Subject: Submitted evidence
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=bridge
--bridge
Content-Type: text/plain; charset=utf-8
Please process the attached evidence.
--bridge
Content-Type: application/pdf
Content-Disposition: attachment; filename=evidence.pdf
Content-Transfer-Encoding: base64
UERG
--bridge--
"""
class _DeliveryProvider:
def __init__(self) -> None:
self.requests = []
def deliver(self, session, request):
del session
self.requests.append(request)
return SimpleNamespace(
postbox_id="postbox-1",
message_id="message-1",
delivery_id="delivery-1",
duplicate=len(self.requests) > 1,
)
class MailPostboxBridgeTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
access_users = Base.metadata.tables.get("access_users")
if access_users is None:
access_users = Table(
"access_users",
Base.metadata,
Column("id", String(36), primary_key=True),
)
Base.metadata.create_all(
self.engine,
tables=[access_users, MailServerProfile.__table__],
)
self.Session = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.Session() as session:
session.add(
MailServerProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Inbound",
slug="inbound",
smtp_config={},
)
)
session.commit()
self.addCleanup(self.engine.dispose)
def test_bridge_parses_bounded_content_and_delegates_idempotently(self) -> None:
delivery = _DeliveryProvider()
request = MailPostboxBridgeRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id="postbox-1"),
profile_id="profile-1",
folder="INBOX",
uid="42",
uidvalidity="20260807",
raw_message=RAW_MESSAGE,
)
with self.Session() as session, patch(
"govoplan_mail.backend.postbox_bridge.postbox_delivery_provider",
return_value=delivery,
):
first = MailPostboxBridge().bridge_message(session, request)
second = MailPostboxBridge().bridge_message(session, request)
MailPostboxBridge().bridge_message(
session,
replace(request, uidvalidity="20260808"),
)
self.assertFalse(first.duplicate)
self.assertTrue(second.duplicate)
self.assertEqual(first.source_digest, second.source_digest)
self.assertEqual(
delivery.requests[0].idempotency_key,
delivery.requests[1].idempotency_key,
)
self.assertNotEqual(
delivery.requests[0].idempotency_key,
delivery.requests[2].idempotency_key,
)
bridged = delivery.requests[0]
self.assertEqual("Submitted evidence", bridged.subject)
self.assertIn("Please process", bridged.body_text)
self.assertEqual(
["sender", "to", "cc"],
[participant.kind for participant in bridged.participants],
)
self.assertEqual(1, len(bridged.attachments))
self.assertEqual("evidence.pdf", bridged.attachments[0].name)
self.assertEqual("mail_attachment", bridged.attachments[0].reference_type)
self.assertEqual("<bridge-1@example.test>", bridged.metadata["rfc_message_id"])
self.assertEqual("20260807", bridged.metadata["mailbox_uidvalidity"])
self.assertNotIn("raw_message", bridged.metadata)
if __name__ == "__main__":
unittest.main()
+78 -2
View File
@@ -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()
+107
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import smtplib
import unittest
from unittest.mock import patch
@@ -7,6 +8,9 @@ from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import SmtpConfig
from govoplan_mail.backend.sending.smtp import (
SmtpConfigurationError,
SmtpBatchPolicy,
SmtpBatchSession,
SmtpSendError,
_open_smtp,
_prepare_smtp_send,
_smtp_send_result,
@@ -70,6 +74,109 @@ class SmtpSendHelperTests(unittest.TestCase):
self.assertEqual(result.accepted_count, 1)
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
def test_batch_preflight_reuses_one_authenticated_connection(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp()
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp) as opener:
with SmtpBatchSession(config) as batch:
first = batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
second = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
opener.assert_called_once_with(config)
self.assertFalse(first.session_reused)
self.assertTrue(second.session_reused)
self.assertEqual(1, second.connection_sequence)
self.assertEqual([b"first", b"second"], smtp.messages)
self.assertTrue(smtp.quit_called)
def test_batch_reconnects_before_next_message_when_health_check_fails(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
first_smtp = _FakeSmtp(noop_error_on_call=1)
second_smtp = _FakeSmtp()
policy = SmtpBatchPolicy(reconnect_attempts=1)
with patch(
"govoplan_mail.backend.sending.smtp._open_smtp",
side_effect=[first_smtp, second_smtp],
) as opener:
with SmtpBatchSession(config, policy=policy) as batch:
batch.send(b"first", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
result = batch.send(b"second", envelope_from="sender@example.org", envelope_recipients=["two@example.org"])
self.assertEqual(2, opener.call_count)
self.assertEqual(2, result.connection_sequence)
self.assertEqual(1, result.reconnect_count)
self.assertEqual([b"first"], first_smtp.messages)
self.assertEqual([b"second"], second_smtp.messages)
def test_preflight_retries_a_transient_connection_failure(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp()
with patch(
"govoplan_mail.backend.sending.smtp._open_smtp",
side_effect=[OSError("temporary DNS failure"), smtp],
) as opener:
with SmtpBatchSession(config, policy=SmtpBatchPolicy(reconnect_attempts=1)) as batch:
result = batch.send(b"message", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
self.assertEqual(2, opener.call_count)
self.assertEqual(1, result.reconnect_count)
def test_connection_loss_after_send_starts_is_unknown_and_never_replayed(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
smtp = _FakeSmtp(send_error=smtplib.SMTPServerDisconnected("lost"))
with patch("govoplan_mail.backend.sending.smtp._open_smtp", return_value=smtp), self.assertRaises(SmtpSendError) as raised:
with SmtpBatchSession(config) as batch:
batch.send(b"one", envelope_from="sender@example.org", envelope_recipients=["one@example.org"])
self.assertTrue(raised.exception.outcome_unknown)
self.assertTrue(raised.exception.systemic)
self.assertEqual("smtp_connection_lost_after_transmission", raised.exception.reason_code)
self.assertEqual(1, smtp.send_calls)
def test_authentication_preflight_is_systemic_and_blocks_batch(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
error = smtplib.SMTPAuthenticationError(535, b"bad credentials")
with patch("govoplan_mail.backend.sending.smtp._open_smtp", side_effect=error), self.assertRaises(SmtpSendError) as raised:
SmtpBatchSession(config).preflight()
self.assertTrue(raised.exception.systemic)
self.assertFalse(raised.exception.temporary)
self.assertEqual("preflight", raised.exception.phase)
self.assertEqual("smtp_authentication_failed", raised.exception.reason_code)
class _FakeSmtp:
def __init__(self, *, noop_error_on_call: int | None = None, send_error: BaseException | None = None):
self.noop_error_on_call = noop_error_on_call
self.send_error = send_error
self.noop_calls = 0
self.send_calls = 0
self.messages: list[bytes] = []
self.quit_called = False
def noop(self):
self.noop_calls += 1
if self.noop_error_on_call == self.noop_calls:
raise smtplib.SMTPServerDisconnected("stale")
return 250, b"ok"
def sendmail(self, _sender, _recipients, message):
self.send_calls += 1
if self.send_error is not None:
raise self.send_error
self.messages.append(message)
return {}
def send_message(self, message, **_kwargs):
return self.sendmail(None, None, message.as_bytes())
def quit(self):
self.quit_called = True
return 221, b"bye"
def close(self):
return None
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -1,17 +1,17 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.17",
"version": "0.1.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.17",
"version": "0.1.20",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.17",
"version": "0.1.20",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
@@ -26,7 +26,7 @@
}
},
"scripts": {
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-display.test.js && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mailbox-launch.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node .mail-test-build/tests/mail-address-integration.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
},
"devDependencies": {
"typescript": "^5.7.2"
@@ -9,6 +9,7 @@ function read(relativePath) {
const profiles = read("../src/features/mail/MailProfileManagement.tsx");
const mailbox = read("../src/features/mail/MailboxPage.tsx");
const bounces = read("../src/features/mail/MailBouncePage.tsx");
const legacyImport = read("../src/features/mail/MailLegacyImportPage.tsx");
const moduleSource = read("../src/module.ts");
const styles = read("../src/styles/mail-profiles.css");
const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md");
@@ -22,11 +23,17 @@ assert.match(profiles, /disabledReason: credentialMutationBlocker/);
assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/);
assert.match(profiles, /disabledReason=\{policySaveBlocker\}/);
assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/);
assert.match(profiles, /folder_mappings: draft\.imapFolderMappings/);
assert.match(profiles, /listMailProfileImapFolders[\s\S]*listImapFolders/);
assert.match(profiles, /onLookupImapFolders=\{\(\) => void runImapFolderLookup\(\)\}/);
assert.match(profiles, /imapFolderLookupResult=\{imapFolderResult\}/);
assert.match(mailbox, /ActionBlockerHint/);
assert.match(mailbox, /DocumentationHelpLink/);
assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
assert.match(mailbox, /folder_mappings\?\.inbox/);
assert.match(mailbox, /detected_folder_mappings\?\.inbox/);
assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
assert.match(bounces, /DocumentationHelpLink/);
@@ -34,11 +41,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
View File
@@ -65,6 +65,41 @@ export type MailAddressLookupResponse = {
candidates: MailAddressLookupCandidate[];
};
export type MailAddressWriteTarget = {
address_book_id: string;
address_book_label?: string | null;
operation: string;
allowed: boolean;
reason: string;
message: string;
scope_type?: string | null;
scope_id?: string | null;
source_kind?: string | null;
read_only: boolean;
required_scopes: string[];
provenance: Record<string, unknown>;
};
export type MailAddressWriteTargetResponse = {
available: boolean;
targets: MailAddressWriteTarget[];
};
export type MailContactCreatePayload = {
address_book_id: string;
display_name?: string | null;
email: string;
};
export type MailContactCreateResponse = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
source_kind: string;
provenance: Record<string, unknown>;
};
export type MailMailboxAttachment = {
filename?: string | null;
content_type: string;
@@ -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,
+28 -26
View File
@@ -1,18 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
import {
import { FormGrid, ContentGrid,
ActionBlockerHint,
Button,
Card,
ConfirmDialog,
DataGrid,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
LoadingFrame,
PageScrollViewport,
PageTitle,
PageActionBar,
PageLayout,
StatusBadge,
TableActionGroup,
ToggleSwitch,
@@ -194,33 +192,37 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
];
return (
<PageScrollViewport>
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
<div className="button-row compact-actions">
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
<Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading bounce processing">
<div className="dashboard-grid">
<>
<PageLayout
archetype="collection"
title="Bounce processing"
description="Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands."
actions={<PageActionBar
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), disabled: Boolean(pageMutationBlocker), disabledReason: pageMutationBlocker }}
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
helpAction={<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />}
createAction={<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>}
/>}
loading={loading}
loadingLabel="Loading bounce processing"
error={error}
success={message}
documentationType="admin"
>
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="Watched mailboxes">
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
</Card>
<Card title="Delivery-status observations">
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
</Card>
</div>
</LoadingFrame>
</div>
</ContentGrid>
</PageLayout>
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
<div className="form-grid">
<FormGrid columns={1} collapseAt="standard" className="">
{imapProfiles.length === 0 &&
<ActionBlockerHint
reason={{
@@ -242,7 +244,7 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
<input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
</FormField>
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
</div>
</FormGrid>
</Dialog>
<ConfirmDialog
@@ -254,6 +256,6 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
busy={Boolean(busy)}
onCancel={() => setDeleteSource(null)}
onConfirm={() => void removeSource()} />
</PageScrollViewport>
</>
);
}
@@ -0,0 +1,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 }
);
}
+202
View File
@@ -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);
}
+241 -13
View File
@@ -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(",")}`;
}
+17
View File
@@ -0,0 +1,17 @@
export type MailboxSyncProvenance = {
fromCache: boolean;
refreshing: boolean;
indexedAt: string | null;
};
export type MailboxSyncState = "live" | "cached" | "refreshing";
export function isMailboxMessageRead(flags: readonly string[] | null | undefined): boolean {
return (flags ?? []).some((flag) => flag.trim().replace(/^\\+/, "").toLocaleLowerCase() === "seen");
}
export function mailboxSyncState(provenance: MailboxSyncProvenance | null): MailboxSyncState | null {
if (!provenance) return null;
if (provenance.refreshing) return "refreshing";
return provenance.fromCache ? "cached" : "live";
}
+59
View File
@@ -0,0 +1,59 @@
type MessageReference = { folder: string; uid: string };
type ProfileFolderMapping = {
imap?: { folder_mappings?: { drafts?: string | null } | null } | null;
};
export type MailboxLaunch = {
profileId: string | null;
folder: string | null;
folderRole: "drafts" | null;
messageUid: string | null;
};
export function parseMailboxLaunch(search: string): MailboxLaunch {
const params = new URLSearchParams(search);
return {
profileId: boundedValue(params.get("profile")),
folder: boundedValue(params.get("folder")),
folderRole: params.get("folderRole") === "drafts" ? "drafts" : null,
messageUid: boundedValue(params.get("message"))
};
}
export function mailboxMessageLaunchPath(
profileId: string,
message: MessageReference
): string {
return mailPath({
profile: profileId,
folder: message.folder,
message: message.uid
});
}
export function mailboxDraftsLaunchPath(profileId: string, folder?: string | null): string {
return mailPath(folder
? { profile: profileId, folder }
: { profile: profileId, folderRole: "drafts" });
}
export function mailboxLaunchFolder(
launch: MailboxLaunch,
profile: ProfileFolderMapping | null
): string | null {
if (launch.folder) return launch.folder;
if (launch.folderRole === "drafts") {
return boundedValue(profile?.imap?.folder_mappings?.drafts ?? null);
}
return null;
}
function mailPath(values: Record<string, string>): string {
const params = new URLSearchParams(values);
return `/mail?${params.toString()}`;
}
function boundedValue(value: string | null): string | null {
const clean = value?.trim() ?? "";
return clean && clean.length <= 500 ? clean : null;
}
+36
View File
@@ -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
View File
@@ -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
+82 -19
View File
@@ -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");
+19
View File
@@ -0,0 +1,19 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
import { isMailboxMessageRead, mailboxSyncState } from "../src/features/mail/mailboxDisplay";
assert(isMailboxMessageRead(["\\Seen"]), "standard IMAP Seen flag marks a message as read");
assert(isMailboxMessageRead(["answered", "SEEN"]), "flag matching is case-insensitive and accepts normalized flags");
assert(!isMailboxMessageRead(["\\Answered", "\\Flagged"]), "messages without Seen remain unread");
assert(!isMailboxMessageRead(undefined), "missing flags fail safely to unread");
assertEqual(mailboxSyncState({ fromCache: false, refreshing: false, indexedAt: null }), "live");
assertEqual(mailboxSyncState({ fromCache: true, refreshing: false, indexedAt: "2026-08-19T09:00:00Z" }), "cached");
assertEqual(mailboxSyncState({ fromCache: true, refreshing: true, indexedAt: "2026-08-19T09:00:00Z" }), "refreshing");
assertEqual(mailboxSyncState(null), null);
+46
View File
@@ -0,0 +1,46 @@
function assertEqual(actual: unknown, expected: unknown): void {
if (actual !== expected) throw new Error(`expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`expected ${expectedJson}, got ${actualJson}`);
}
import {
mailboxDraftsLaunchPath,
mailboxLaunchFolder,
mailboxMessageLaunchPath,
parseMailboxLaunch
} from "../src/features/mail/mailboxLaunch";
assertDeepEqual(
parseMailboxLaunch("?profile=profile-1&folder=INBOX%2FTeam&message=42"),
{
profileId: "profile-1",
folder: "INBOX/Team",
folderRole: null,
messageUid: "42"
}
);
assertEqual(
mailboxMessageLaunchPath("profile 1", { folder: "INBOX/Team", uid: "42" }),
"/mail?profile=profile+1&folder=INBOX%2FTeam&message=42"
);
assertEqual(
mailboxDraftsLaunchPath("profile-1"),
"/mail?profile=profile-1&folderRole=drafts"
);
assertEqual(
mailboxDraftsLaunchPath("profile-1", "Entwürfe"),
"/mail?profile=profile-1&folder=Entw%C3%BCrfe"
);
assertEqual(
mailboxLaunchFolder(parseMailboxLaunch("?folderRole=drafts"), {
imap: { folder_mappings: { drafts: "Entwürfe" } }
}),
"Entwürfe"
);
console.log("mailbox launch tests passed");
+7 -1
View File
@@ -17,11 +17,17 @@
"rootDir": "."
},
"include": [
"tests/mailbox-display.test.ts",
"tests/mailbox-folders.test.ts",
"tests/mailbox-launch.test.ts",
"tests/mail-profile-editor-model.test.ts",
"tests/mail-policy-validation.test.ts",
"tests/mail-address-integration.test.ts",
"src/features/mail/mailboxDisplay.ts",
"src/features/mail/mailboxFolders.ts",
"src/features/mail/mailboxLaunch.ts",
"src/features/mail/mailProfileEditorModel.ts",
"src/features/mail/mailPolicyValidation.ts"
"src/features/mail/mailPolicyValidation.ts",
"src/features/mail/mailAddressIntegration.ts"
]
}