Compare commits

...
39 Commits
Author SHA1 Message Date
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
zemion e41d05914a Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:04 +02:00
zemion a040de4fe5 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:52:11 +02:00
zemion 622f63b4b5 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:22:07 +02:00
zemion b65431f437 Make package publication retries hash-safe 2026-08-04 14:32:19 +02:00
zemion c42a7816fd Define governed secure mail profiles 2026-08-04 14:02:21 +02:00
zemion a63d541f0e Harden module package publication 2026-08-04 14:02:10 +02:00
zemion 7c8cb21144 Partition mail retention by tenant 2026-08-04 09:29:36 +02:00
zemion d7dc4f6b04 Add protected package release workflow 2026-08-04 04:14:05 +02:00
zemion 72d6f8432c Index authorized mailbox cache changes in Search 2026-08-04 03:03:26 +02:00
zemion 7844d9cab4 Migrate Mail surfaces to interface patterns 2026-08-03 10:06:13 +02:00
zemion 5b5077cde7 Fence Mail provider and mailbox recovery effects 2026-08-03 05:00:19 +02:00
zemion ff3aa066ae Reconcile iCalendar replies from mail 2026-08-02 16:38:45 +02:00
zemion 5a3b13e26e feat: declare governed external provider state 2026-08-01 17:48:37 +02:00
zemion fe309dbff5 Add bounce mailbox processing 2026-07-31 22:48:07 +02:00
zemion 2721ee6542 Refine reusable mail profile credential editing 2026-07-31 02:48:56 +02:00
zemion 0cb6719b91 feat: provide governed notification email delivery 2026-07-30 17:42:07 +02:00
zemion b8029c24d6 feat: add durable mail delivery outbox 2026-07-30 14:26:53 +02:00
zemion dec5a4e350 feat: add reusable mail reference selectors 2026-07-29 14:16:29 +02:00
zemion 47c98b3957 Declare mail workspace View surfaces 2026-07-28 21:04:55 +02:00
zemion 3735027b3d Add hierarchical mail servers and credentials 2026-07-28 19:32:56 +02:00
zemion 2813c671c0 docs: update GovOPlaN repository links 2026-07-27 15:46:50 +02:00
zemion 2bccb78960 fix(mail-webui): clarify campaign profile wording 2026-07-22 04:06:25 +02:00
74 changed files with 17242 additions and 480 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Mail Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Mail internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope
This repository owns the `mail` module: SMTP/IMAP profiles, mail profile policy, encrypted mail credentials, SMTP sending, IMAP append and mailbox access, mock mail infrastructure, backend module manifest, and `@govoplan/mail-webui`.
+46 -5
View File
@@ -19,6 +19,15 @@ This repository owns:
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
Mail publishes `privacy.dsar.mail` for Core's governed data-subject-request
workflow. It isolates matching mailbox header parties and returns bounded index,
personal-profile, delivery, reconciliation, and bounce metadata. SMTP/IMAP
configuration and credentials, encrypted messages and envelopes, folder/UID
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
@@ -33,15 +42,42 @@ revision comparison, credential resolution, policy checks, and SMTP/IMAP
effects inside Mail. Consumer-visible outcomes are sanitized: provider banners,
raw response bytes, hosts, account identities, and credentials are not returned.
A remaining observability slice, tracked in [govoplan-mail#17](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17), is a Mail-owned durable outbox and transport-attempt store with
restricted diagnostic access, retention controls, and correlation identifiers.
Until that exists, Campaign retains only sanitized delivery evidence; raw
provider diagnostics must not be copied into consumer records.
Mail also owns a durable delivery-command outbox for effects that do not
already have a consumer-owned job ledger, including Campaign report messages.
It persists the command and attempt before SMTP, binds idempotency keys to
canonical request hashes, distinguishes partial refusal and unknown outcome,
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,
preventing silent duplicate Sent copies while an operator inspects the mailbox.
Every current outbox, Campaign SMTP, and Campaign Sent-folder attempt also
starts a Mail-owned Core recovery operation under a stable per-attempt effect
identifier before contacting the provider. Evidence contains only message,
address, and folder digests plus bounded outcome counts. A completed effect is
never replayed to repair caller state; unknown outcomes require explicit
provider-backed reconciliation.
Read-only mailbox folder/message indexing and bounce/calendar-reply scans use
distributed recovery fences. Cache rows or source cursors commit before an
independent verification closes the operation. A failed read rolls back and is
safe to repeat because these paths never move, delete, flag, or otherwise
mutate provider messages.
The existing SMTP/IMAP credential-inheritance policy remains part of the Mail
policy model for compatibility. Campaign delivery requires effective
@@ -110,11 +146,16 @@ 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.
only for explicit legacy-download requirements. The same roadmap records the
approved S/MIME-first, OpenPGP-additional message-protection profile and its
no-silent-downgrade requirement.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
The [Mail handbook](docs/MAIL_HANDBOOK.md) provides the adaptive user,
governance, technical, security, and operations perspectives.
The [Mail interface pattern inventory](docs/INTERFACE_PATTERN_MIGRATION.md)
records the route, administration, state, accessibility, consequence, and
privacy contracts for the Mail WebUI.
## Release packaging
+71
View File
@@ -0,0 +1,71 @@
# Mail Interface Pattern Migration
This inventory records the Mail-owned part of the GovOPlaN interface pattern
language. Core owns the shell and shared components; Mail owns the transport,
mailbox, policy, and delivery-evidence consequences described here.
## Surface inventory
| Surface | Primary task | Archetype | Consequence | Pattern evidence |
| --- | --- | --- | --- | --- |
| `/mail` folder, message, and preview panes | Browse and inspect an authorized mailbox without changing provider state | Directory/explorer | Medium because message metadata and content are private, although navigation is read-only | Full-height three-pane workspace, bounded paging, stable keyboard selection, contextual Help Center link, explicit no-profile blocker |
| `/mail` toolbar, page filter, and pagination | Select a profile, refresh bounded indexes, and find a message on the current page | Explorer actions and local filtering | Low for refresh; medium for provider access | Shared actions expose loading/profile/folder blockers; profile transport summary is non-secret; loading and errors use Core components |
| 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 `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 |
## State and consequence contract
- 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.
- Profile, server, credential, policy, mailbox refresh, connection-test, and
bounce actions remain visible when structurally relevant. Missing authority,
required input, or an in-progress operation is exposed through a focusable
disabled-action explanation.
- Connection tests never save the draft and explicitly require the relevant
hostname. Save remains the committing action. Policy save is unavailable
until a local change exists.
- Effective policy keeps inherited source/provenance visible. A locked parent
limit or governed workflow cannot be represented as an editable local value.
- Removing a bounce watcher retains observations and delivery evidence.
Profile/server deactivation and credential unlinking use confirmations whose
copy distinguishes retained reusable credentials from scrubbed owned secrets.
- Mailbox browsing is read-only. Listing or previewing must not mark messages
read, move, delete, reply, or expose unbounded content.
## Accessibility, responsive, and privacy evidence
Shared `Dialog` owns focus entry, Escape handling, focus containment, and focus
return. Toolbar and form DOM order is keyboard order; mailbox rows support
Enter and Space, arrow navigation is bounded to the visible page, and disabled
reasons are keyboard-focusable. Status always has text in addition to color.
Contextual links identify their destination to assistive technology.
Profile/policy grids collapse to one column below 900 px. The mailbox changes
from three panes to two below 1250 px and to a single-column toolbar and message
rows below 760 px while preserving source order and independent scroll regions.
Long identities and transport summaries wrap or ellipsize inside stable bounds.
Profile and mailbox APIs return non-secret transport metadata and bounded
message content only. Passwords are write-only and rendered only as a saved
state marker. Bounce observations contain bounded sanitized diagnostics and a
raw digest, not the raw provider message. Optional Campaign, Addresses, Audit,
Calendar, and Notifications integrations remain capability-driven; Mail WebUI
does not import their private packages.
The focused structural test guards shared components, contextual help,
actionable blockers, confirmation, optional-module boundaries, responsive
rules, and the absence of browser-native confirmation. Mail backend tests,
Core component tests, manifest-shape checks, module permutations, structural
localization audit, theme contract, and bundle budgets provide integration
evidence.
+120 -16
View File
@@ -17,7 +17,7 @@ campaign definition, contact database, or general records store.
| Release reviewer | [Acceptance checklist](#acceptance-checklist) |
See also [Mail protocol roadmap](MAIL_PROTOCOL_ROADMAP.md) and the Campaign
[Mail profile boundary](https://git.add-ideas.de/add-ideas/govoplan-campaign/src/branch/main/docs/MAIL_PROFILE_BOUNDARY.md).
[Mail profile boundary](https://git.add-ideas.de/GovOPlaN/govoplan-campaign/src/branch/main/docs/MAIL_PROFILE_BOUNDARY.md).
## Domain ownership
@@ -43,6 +43,47 @@ 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.
## Interface patterns and unavailable actions
Mail uses the platform's shared explorer, connection tree, adaptive form,
effective-policy, dialog, status, alert, and confirmation components. The
module-owned surface inventory and consequence classification are recorded in
[Mail interface pattern migration](INTERFACE_PATTERN_MIGRATION.md).
An unavailable action remains visible when it belongs to the current task. Its
hover/focus explanation identifies the missing field, active operation, or
permission. Larger blockers state what must change, who can change it, and
where to go. Contextual Help Center links resolve to the configured Docs module
when installed and otherwise use the public documentation fallback.
Mailbox reads, profile reloads, connection tests, policy saves, bounce scans,
and destructive actions each expose their current loading or blocked state.
Removing a bounce watcher and deactivating profile hierarchy items use shared
confirmation dialogs. Removing a watcher retains existing observations;
deactivating a profile may scrub Mail-owned credentials as described below.
## Concepts
### Profile
@@ -52,6 +93,13 @@ 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.
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
that further. A visible profile is not automatically authorized for every
@@ -82,10 +130,20 @@ effect.
### Provider outcomes
SMTP acceptance, recipient refusal, temporary/permanent failure, connection
loss, and unknown outcome are distinct. IMAP append is a separate operation and
outcome. Mail returns a sanitized transport result; the consuming domain owns
the durable business job and its retry/reconciliation semantics.
SMTP acceptance, partial or complete recipient refusal, temporary/permanent
failure, connection loss, and unknown outcome are distinct. IMAP append is a
separate operation and outcome. Ordinary Campaign recipient delivery retains
its Campaign-owned job ledger. Mail owns an encrypted durable command and
attempt ledger for report delivery and other effects that do not have such a
consumer ledger.
The Mail worker commits an attempt before it starts SMTP and then commits an
effect-start marker before opening the provider effect. Worker redelivery may
recover a stale pre-effect claim, but an accepted, partially accepted,
in-progress-after-effect, or unknown command is never sent automatically.
Unknown outcomes require a separately authorized reconciliation with an
external evidence reference. A deliberate resend creates a new command with a
new idempotency key and links it to the prior command.
## User tasks
@@ -127,7 +185,17 @@ 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
@@ -176,8 +244,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.
@@ -270,6 +340,20 @@ implemented.
### SMTP/IMAP incidents
Current SMTP delivery and Sent-folder APPEND paths start a Mail-owned Core
recovery operation before the network effect. Outbox attempts use the durable
command and attempt number; Campaign jobs and single-message actions pass their
own stable attempt identifiers through the versioned capability. A matching
completed identifier is never sent or appended again merely to reconstruct
caller state. Provider acceptance, definitive rejection, and outcome-unknown
states are recorded independently of the consuming transaction.
Mailbox folder/message indexing and configured bounce/calendar-reply scans are
read-only provider operations. They acquire distributed per-profile/folder or
per-source fences, commit bounded projection state, and verify that state in an
independent session. A failed read is rolled back and can be repeated; it is not
treated as an unknown provider mutation.
1. Stop new consumer work if duplicate effects or credential compromise are
possible.
2. Preserve safe Mail, consumer-job, worker, and provider evidence.
@@ -281,6 +365,21 @@ implemented.
merely to recreate a Sent copy.
6. Record the incident/reconciliation reference in the consuming domain's audit
trail without copying raw provider secrets or message content unnecessarily.
7. For an outbox `outcome_unknown`, reconcile the Mail command with provider
evidence. Confirmed acceptance closes the provider operation as succeeded;
confirmed absence records verified recovery and permits only a new,
deliberate attempt identifier.
### Delivery-status and calendar-reply sources
An authorized Mail bounce source scans a bounded IMAP UID range without
changing mailbox flags. It correlates DSN reports with durable Mail commands
and, when the optional Calendar invitation capability is active, forwards
`text/calendar` or `.ics` `METHOD:REPLY` parts to Calendar. Calendar remains
owner of attendee state; Mail records only a raw digest, mailbox coordinates,
Message-ID, and audit linkage. Ordinary or malformed calendar messages do not
block DSN progress. A repeated UID/message digest produces no second Calendar
state transition or outbound synchronization effect.
### Backup, restore, and retirement
@@ -354,11 +453,11 @@ Security invariants:
Credential replacement/deletion emits canonical non-secret Mail audit events.
Profile/policy create/update/deactivate currently emits change-feed evidence,
but connection tests and the complete administrative lifecycle do not yet have
equivalent canonical audit events. Consumer send/retry/reconciliation evidence
belongs primarily to the consuming module today. A Mail-owned restricted
provider-attempt ledger with correlation, retention, and unknown-outcome
reconciliation remains part of the durable outbox work in
[`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17).
equivalent canonical audit events. Mail delivery commands emit request,
terminal-outcome, reconciliation, and deliberate-resend events without
addresses or raw provider responses. MIME, envelope addresses, detailed
refusals, and reconciliation notes are encrypted and minimized after their
retention deadline while hashes, counts, attempts, and decisions remain.
## Acceptance checklist
@@ -387,9 +486,6 @@ Before claiming a Mail composition is production-ready:
## Explicitly planned, not yet claimed
- Durable, idempotent Campaign report delivery with Mail-owned attempts,
unknown-outcome reconciliation, and partial-refusal evidence
([`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17)).
- Canonical audit events for profile tests and the remaining profile/policy
administration lifecycle, plus an operator-visible Redis-throttling
degradation signal.
@@ -401,5 +497,13 @@ Before claiming a Mail composition is production-ready:
is stable.
- POP3 except for a future explicit legacy download/import requirement.
- 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
is unavailable; it deliberately does not.
+22
View File
@@ -13,6 +13,28 @@ and JMAP are deferred until the IMAP mailbox MVP is stable.
This baseline matches the first production use case: send campaign mail, append
sent copies when configured, and inspect mailboxes read-only.
## Message Protection Profiles
The product and security profile approved on 2026-08-04 makes S/MIME the first
institutional signing/encryption profile and OpenPGP an additional explicit
profile. Neither profile is implemented by treating protection as a local Mail
toggle:
- private-key custody belongs to an Encryption/KMS provider and usable private
keys are never persisted by Mail;
- recipient certificates and keys initially come from administered directory
or LDAP sources; opportunistic Internet discovery is deferred;
- required signing or encryption fails closed when material is missing,
expired, revoked, unverifiable, or its provider is unavailable;
- plaintext fallback is permitted only by an explicit, audited policy and is
never inferred from provider failure; and
- delivery evidence pins the signing identity, trust/revocation evidence,
algorithm suite, key version, and any explicit downgrade decision.
Provider-neutral S/MIME custody and interoperability fixtures are the first
implementation slice. OpenPGP uses the same no-silent-downgrade boundary after
the S/MIME profile is stable.
## JMAP
JMAP is the preferred future sync/search protocol where target mail servers
+93
View File
@@ -0,0 +1,93 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.18",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/cookie-es": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
"license": "MIT",
"peer": true
},
"node_modules/lucide-react": {
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
"license": "ISC",
"peer": true,
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.8"
}
},
"node_modules/react-router": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
"integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cookie-es": "^3.1.1"
},
"engines": {
"node": ">=22.22.0"
},
"peerDependencies": {
"react": ">=19.2.7",
"react-dom": ">=19.2.7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
}
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.10",
"version": "0.1.18",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,11 +19,11 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.10",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-mail"
version = "0.1.10"
version = "0.1.19"
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.9",
"govoplan-core>=0.1.18",
"pydantic>=2,<3",
"redis>=5,<6",
"SQLAlchemy>=2,<3",
@@ -0,0 +1,761 @@
from __future__ import annotations
import hashlib
from collections import defaultdict
from collections.abc import Mapping
from datetime import datetime, timezone
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from email.utils import parsedate_to_datetime
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.core.calendar import (
CalendarCapabilityError,
calendar_invitation_provider,
)
from govoplan_core.core.mail import (
MailBounceObservationRef,
MailBounceProcessingProvider,
)
from govoplan_mail.backend.db.models import (
MailBounceObservation,
MailBounceSource,
MailDeliveryCommand,
MailServerProfile,
)
from govoplan_mail.backend.sending.imap import (
get_imap_raw_message,
list_imap_uids_since,
)
from govoplan_mail.backend.server_hierarchy import (
MailServerHierarchyError,
hierarchy_context_for_profile,
resolve_mail_transport,
)
from govoplan_mail.backend.runtime import get_registry
from govoplan_mail.backend.recovery import (
MailRecoveryError,
begin_bounce_scan_recovery,
)
class MailBounceError(RuntimeError):
pass
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def normalize_message_id(value: object | None) -> str | None:
normalized = " ".join(str(value or "").split())
return normalized[:998] or None
def _calendar_reply_parts(raw_message: bytes) -> tuple[tuple[str, str | None], ...]:
try:
message = BytesParser(policy=policy.default).parsebytes(raw_message)
except Exception as exc:
raise MailBounceError("Mail message could not be parsed.") from exc
parts: list[tuple[str, str | None]] = []
for part in message.walk():
filename = part.get_filename()
if part.get_content_type() != "text/calendar" and not str(
filename or ""
).casefold().endswith(".ics"):
continue
try:
content = part.get_content()
except Exception:
payload = part.get_payload(decode=True) or b""
content = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
if isinstance(content, bytes):
content = content.decode(
part.get_content_charset() or "utf-8",
errors="replace",
)
text = str(content).strip()
if text:
parts.append((text, filename))
return tuple(parts)
def _reconcile_calendar_replies(
session: Session,
*,
tenant_id: str,
profile_id: str,
folder: str,
uid: str,
raw_message: bytes,
) -> int:
provider = calendar_invitation_provider(get_registry())
if provider is None:
return 0
raw_sha256 = hashlib.sha256(raw_message).hexdigest()
try:
message = BytesParser(policy=policy.default).parsebytes(raw_message)
message_id = normalize_message_id(message.get("Message-ID"))
except Exception:
message_id = None
recorded_count = 0
for icalendar, filename in _calendar_reply_parts(raw_message):
try:
recorded = provider.record_icalendar_reply(
session,
tenant_id=tenant_id,
icalendar=icalendar,
received_at=utcnow(),
evidence={
"transport": "mail-imap",
"profile_id": profile_id,
"folder": folder,
"mailbox_uid": uid,
"message_id": message_id,
"filename": filename,
"raw_sha256": raw_sha256,
},
)
except CalendarCapabilityError:
# Mailboxes routinely contain unrelated or malformed invitations.
# They are not allowed to block DSN progress for the entire source.
continue
for invitation in recorded:
audit_event(
session,
tenant_id=tenant_id,
user_id=None,
action="mail.calendar_reply.reconciled",
object_type="calendar_invitation",
object_id=invitation.event_id,
details={
"profile_id": profile_id,
"folder": folder,
"mailbox_uid": uid,
"message_id": message_id,
"calendar_uid": invitation.uid,
"correlation_id": invitation.correlation_id,
"raw_sha256": raw_sha256,
},
)
recorded_count += 1
return recorded_count
def configure_bounce_source(
session: Session,
*,
tenant_id: str,
profile_id: str,
folder: str = "INBOX",
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
is_active: bool = True,
created_by_user_id: str | None = None,
) -> MailBounceSource:
profile = _profile(session, tenant_id=tenant_id, profile_id=profile_id)
clean_folder = folder.strip() or "INBOX"
resolved = _resolve_imap(
session,
profile=profile,
server_id=imap_server_id,
credential_id=imap_credential_id,
)
source = session.scalar(
select(MailBounceSource).where(
MailBounceSource.profile_id == profile.id,
MailBounceSource.folder == clean_folder,
)
)
if source is None:
source = MailBounceSource(
tenant_id=tenant_id,
profile_id=profile.id,
folder=clean_folder,
expected_imap_transport_revision=resolved.transport_revision,
created_by_user_id=created_by_user_id,
)
session.add(source)
source.imap_server_id = resolved.server.id if resolved.server else None
source.imap_credential_id = (
resolved.credential.id if resolved.credential else None
)
source.expected_imap_transport_revision = resolved.transport_revision
source.is_active = is_active
source.last_error = None
session.flush()
return source
def list_bounce_sources(
session: Session,
*,
tenant_id: str,
) -> tuple[MailBounceSource, ...]:
return tuple(
session.scalars(
select(MailBounceSource)
.where(MailBounceSource.tenant_id == tenant_id)
.order_by(MailBounceSource.created_at, MailBounceSource.id)
)
)
def delete_bounce_source(
session: Session,
*,
tenant_id: str,
source_id: str,
) -> None:
source = session.get(MailBounceSource, source_id)
if source is None or source.tenant_id != tenant_id:
raise MailBounceError("Bounce source not found.")
session.delete(source)
session.flush()
def list_bounce_observations(
session: Session,
*,
tenant_id: str,
command_id: str | None = None,
limit: int = 100,
) -> tuple[MailBounceObservationRef, ...]:
statement = select(MailBounceObservation).where(
MailBounceObservation.tenant_id == tenant_id
)
if command_id:
statement = statement.where(MailBounceObservation.command_id == command_id)
rows = session.scalars(
statement.order_by(
MailBounceObservation.observed_at.desc(),
MailBounceObservation.id,
).limit(max(1, min(int(limit), 500)))
)
return tuple(_observation_ref(item) for item in rows)
class SqlMailBounceProcessingProvider(MailBounceProcessingProvider):
def scan_source(
self,
session: object,
*,
tenant_id: str,
source_id: str,
limit: int = 100,
) -> Mapping[str, object]:
db = _session(session)
source = db.get(MailBounceSource, source_id)
if source is None or source.tenant_id != tenant_id:
raise MailBounceError("Bounce source not found.")
processed, observations, calendar_replies = self._scan_source(
db,
source,
limit=max(1, min(int(limit), 1_000)),
)
return {
"sources": 1,
"processed_messages": processed,
"observations": observations,
"calendar_replies": calendar_replies,
"failures": [],
}
def process_raw_message(
self,
session: object,
*,
tenant_id: str,
profile_id: str,
folder: str,
uid: str,
raw_message: bytes,
reconcile_calendar: bool = True,
) -> tuple[MailBounceObservationRef, ...]:
db = _session(session)
if reconcile_calendar:
_reconcile_calendar_replies(
db,
tenant_id=tenant_id,
profile_id=profile_id,
folder=folder,
uid=uid,
raw_message=raw_message,
)
raw_sha256 = hashlib.sha256(raw_message).hexdigest()
reports = parse_delivery_status(raw_message)
observations: list[MailBounceObservationRef] = []
for report in reports:
original_message_id = normalize_message_id(
report.get("original_message_id")
)
command = _correlated_command(
db,
tenant_id=tenant_id,
original_message_id=original_message_id,
command_id=str(report.get("command_id") or "") or None,
)
fingerprint = _observation_fingerprint(
tenant_id=tenant_id,
profile_id=profile_id,
folder=folder,
uid=uid,
raw_sha256=raw_sha256,
recipient=report.get("recipient"),
action=report.get("action"),
status_code=report.get("status_code"),
)
existing = db.scalar(
select(MailBounceObservation).where(
MailBounceObservation.tenant_id == tenant_id,
MailBounceObservation.fingerprint == fingerprint,
)
)
if existing is not None:
observations.append(_observation_ref(existing))
continue
action = str(report.get("action") or "unknown").casefold()[:40]
status_code = _bounded(report.get("status_code"), 80)
item = MailBounceObservation(
tenant_id=tenant_id,
profile_id=profile_id,
folder=folder[:255],
uid=uid[:255],
fingerprint=fingerprint,
raw_sha256=raw_sha256,
original_message_id=original_message_id,
command_id=command.id if command else None,
recipient=_bounded(report.get("recipient"), 998),
action=action,
status_code=status_code,
diagnostic=_bounded(report.get("diagnostic"), 500),
permanent=action == "failed" or bool(status_code and status_code.startswith("5")),
observed_at=report.get("observed_at") or utcnow(),
matched=command is not None,
evidence={
"reporting_mta": report.get("reporting_mta"),
"remote_mta": report.get("remote_mta"),
"diagnostic_type": report.get("diagnostic_type"),
},
)
try:
with db.begin_nested():
db.add(item)
db.flush()
except IntegrityError:
item = db.scalar(
select(MailBounceObservation).where(
MailBounceObservation.tenant_id == tenant_id,
MailBounceObservation.fingerprint == fingerprint,
)
)
if item is None:
raise
audit_event(
db,
tenant_id=tenant_id,
user_id=None,
action="mail.bounce.observed",
object_type="mail_bounce_observation",
object_id=item.id,
details={
"profile_id": profile_id,
"folder": folder,
"uid": uid,
"command_id": item.command_id,
"action": item.action,
"status_code": item.status_code,
"matched": item.matched,
"raw_sha256": raw_sha256,
},
)
observations.append(_observation_ref(item))
return tuple(observations)
def scan_due(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 100,
) -> Mapping[str, object]:
db = _session(session)
remaining = max(1, min(int(limit), 1_000))
statement = select(MailBounceSource).where(
MailBounceSource.is_active.is_(True)
)
if tenant_id:
statement = statement.where(MailBounceSource.tenant_id == tenant_id)
sources = tuple(
db.scalars(statement.order_by(MailBounceSource.last_scanned_at, MailBounceSource.id))
)
processed = 0
observations = 0
calendar_replies = 0
failures: list[dict[str, str]] = []
for source in sources:
if remaining <= 0:
break
try:
count, found, replies = self._scan_source(
db,
source,
limit=remaining,
)
processed += count
observations += found
calendar_replies += replies
remaining -= count
except Exception as exc:
source.last_scanned_at = utcnow()
source.last_error = _bounded(exc, 500)
db.flush()
failures.append({"source_id": source.id, "error": source.last_error or "Scan failed"})
return {
"sources": len(sources),
"processed_messages": processed,
"observations": observations,
"calendar_replies": calendar_replies,
"failures": failures,
}
def observations_for_commands(
self,
session: object,
*,
tenant_id: str,
command_ids: tuple[str, ...],
) -> Mapping[str, tuple[MailBounceObservationRef, ...]]:
if not command_ids:
return {}
rows = _session(session).scalars(
select(MailBounceObservation)
.where(
MailBounceObservation.tenant_id == tenant_id,
MailBounceObservation.command_id.in_(tuple(set(command_ids))),
)
.order_by(MailBounceObservation.observed_at, MailBounceObservation.id)
)
grouped: defaultdict[str, list[MailBounceObservationRef]] = defaultdict(list)
for row in rows:
if row.command_id:
grouped[row.command_id].append(_observation_ref(row))
return {key: tuple(value) for key, value in grouped.items()}
def _scan_source(
self,
session: Session,
source: MailBounceSource,
*,
limit: int,
) -> tuple[int, int, int]:
profile = _profile(
session,
tenant_id=source.tenant_id,
profile_id=source.profile_id,
)
resolved = _resolve_imap(
session,
profile=profile,
server_id=source.imap_server_id,
credential_id=source.imap_credential_id,
)
if resolved.transport_revision != source.expected_imap_transport_revision:
raise MailBounceError(
"Bounce-source IMAP settings changed; review and save the source before scanning."
)
try:
recovery = begin_bounce_scan_recovery(
tenant_id=source.tenant_id,
profile_id=source.profile_id,
source_id=source.id,
folder=source.folder,
)
except MailRecoveryError as exc:
raise MailBounceError(str(exc)) from exc
try:
page = list_imap_uids_since(
imap_config=resolved.config,
folder=source.folder,
highest_uid=source.highest_processed_uid,
expected_uidvalidity=source.uidvalidity,
limit=limit,
)
found = 0
calendar_replies = 0
highest = 0 if page.cursor_reset else source.highest_processed_uid
for uid in page.uids:
raw = get_imap_raw_message(
imap_config=resolved.config,
folder=source.folder,
uid=uid,
)
calendar_replies += _reconcile_calendar_replies(
session,
tenant_id=source.tenant_id,
profile_id=source.profile_id,
folder=source.folder,
uid=uid,
raw_message=raw.raw,
)
found += len(
self.process_raw_message(
session,
tenant_id=source.tenant_id,
profile_id=source.profile_id,
folder=source.folder,
uid=uid,
raw_message=raw.raw,
reconcile_calendar=False,
)
)
highest = max(highest, int(uid))
now = utcnow()
source.uidvalidity = page.uidvalidity
source.highest_processed_uid = highest
source.last_scanned_at = now
source.last_success_at = now
source.last_error = None
session.flush()
session.commit()
except Exception as exc:
session.rollback()
if not recovery.operation.closed:
recovery.reject(code=exc.__class__.__name__)
raise
recovery.complete(highest_uid=highest, uidvalidity=page.uidvalidity)
return len(page.uids), found, calendar_replies
def parse_delivery_status(raw_message: bytes) -> tuple[Mapping[str, object], ...]:
try:
message = BytesParser(policy=policy.default).parsebytes(raw_message)
except Exception as exc:
raise MailBounceError("Bounce message could not be parsed.") from exc
original_message_id = normalize_message_id(message.get("Original-Message-ID"))
command_id = _bounded(message.get("X-GovOPlaN-Delivery-ID"), 36)
reporting_mta = None
reports: list[dict[str, object]] = []
is_report = message.get_content_type() == "multipart/report"
for part in message.walk():
content_type = part.get_content_type()
if content_type == "message/delivery-status":
payload = part.get_payload()
blocks = payload if isinstance(payload, list) else []
for position, block in enumerate(blocks):
if not isinstance(block, EmailMessage):
continue
if position == 0:
reporting_mta = block.get("Reporting-MTA") or reporting_mta
original_message_id = normalize_message_id(
block.get("Original-Message-ID") or original_message_id
)
continue
reports.append(
_delivery_status_block(
block,
original_message_id=original_message_id,
command_id=command_id,
reporting_mta=reporting_mta,
fallback_date=message.get("Date"),
)
)
elif content_type == "message/rfc822":
payload = part.get_payload()
if isinstance(payload, list) and payload and isinstance(payload[0], EmailMessage):
original_message_id = normalize_message_id(
payload[0].get("Message-ID") or original_message_id
)
command_id = _bounded(
payload[0].get("X-GovOPlaN-Delivery-ID") or command_id,
36,
)
elif content_type == "text/rfc822-headers":
try:
headers = BytesParser(policy=policy.default).parsebytes(
part.get_payload(decode=True) or b"",
headersonly=True,
)
except Exception:
continue
original_message_id = normalize_message_id(
headers.get("Message-ID") or original_message_id
)
command_id = _bounded(
headers.get("X-GovOPlaN-Delivery-ID") or command_id,
36,
)
if reports:
for report in reports:
report["original_message_id"] = (
report.get("original_message_id") or original_message_id
)
report["command_id"] = report.get("command_id") or command_id
return tuple(reports)
failed = message.get_all("X-Failed-Recipients", [])
recipients = [value.strip() for header in failed for value in str(header).split(",") if value.strip()]
if not recipients and not is_report:
return ()
return tuple(
{
"original_message_id": original_message_id,
"command_id": command_id,
"recipient": recipient or None,
"action": "failed" if recipient else "unknown",
"status_code": None,
"diagnostic": "Unstructured delivery-status report",
"observed_at": _parsed_date(message.get("Date")) or utcnow(),
"reporting_mta": reporting_mta,
}
for recipient in (recipients or [""])
)
def _delivery_status_block(
block: EmailMessage,
*,
original_message_id: str | None,
command_id: str | None,
reporting_mta: object | None,
fallback_date: object | None,
) -> dict[str, object]:
diagnostic = str(block.get("Diagnostic-Code") or "")
diagnostic_type, _, diagnostic_text = diagnostic.partition(";")
return {
"original_message_id": normalize_message_id(
block.get("Original-Message-ID") or original_message_id
),
"command_id": command_id,
"recipient": _dsn_address(
block.get("Final-Recipient") or block.get("Original-Recipient")
),
"action": str(block.get("Action") or "unknown").casefold(),
"status_code": _bounded(block.get("Status"), 80),
"diagnostic": diagnostic_text.strip() or diagnostic_type.strip() or None,
"diagnostic_type": diagnostic_type.strip() or None,
"reporting_mta": str(reporting_mta or "") or None,
"remote_mta": _dsn_address(block.get("Remote-MTA")),
"observed_at": _parsed_date(
block.get("Last-Attempt-Date") or fallback_date
) or utcnow(),
}
def _correlated_command(
session: Session,
*,
tenant_id: str,
original_message_id: str | None,
command_id: str | None,
) -> MailDeliveryCommand | None:
if command_id:
command = session.get(MailDeliveryCommand, command_id)
if command is not None and command.tenant_id == tenant_id:
return command
if not original_message_id:
return None
return session.scalar(
select(MailDeliveryCommand).where(
MailDeliveryCommand.tenant_id == tenant_id,
MailDeliveryCommand.rfc_message_id == original_message_id,
)
)
def _observation_fingerprint(**values: object) -> str:
canonical = "\x1f".join(str(values[key] or "") for key in sorted(values))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _observation_ref(item: MailBounceObservation) -> MailBounceObservationRef:
return MailBounceObservationRef(
id=item.id,
tenant_id=item.tenant_id,
profile_id=item.profile_id,
folder=item.folder,
uid=item.uid,
original_message_id=item.original_message_id,
command_id=item.command_id,
recipient=item.recipient,
action=item.action,
status_code=item.status_code,
diagnostic=item.diagnostic,
permanent=item.permanent,
observed_at=item.observed_at,
matched=item.matched,
evidence=dict(item.evidence or {}),
)
def _profile(session: Session, *, tenant_id: str, profile_id: str) -> MailServerProfile:
profile = session.get(MailServerProfile, profile_id)
if profile is None or profile.tenant_id not in {None, tenant_id} or not profile.is_active:
raise MailBounceError("Active Mail profile not found.")
return profile
def _resolve_imap(
session: Session,
*,
profile: MailServerProfile,
server_id: str | None,
credential_id: str | None,
):
try:
resolved = resolve_mail_transport(
session,
profile=profile,
protocol="imap",
context=hierarchy_context_for_profile(profile, administrative=True),
server_id=server_id,
credential_id=credential_id,
)
except MailServerHierarchyError as exc:
raise MailBounceError(str(exc)) from exc
return resolved
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise MailBounceError("Bounce processing requires a SQLAlchemy Session.")
return value
def _bounded(value: object | None, limit: int) -> str | None:
text = " ".join(str(value or "").split())
return text[:limit] or None
def _dsn_address(value: object | None) -> str | None:
text = str(value or "")
_, separator, address = text.partition(";")
return _bounded(address if separator else text, 998)
def _parsed_date(value: object | None) -> datetime | None:
if not value:
return None
try:
parsed = parsedate_to_datetime(str(value))
except (TypeError, ValueError, OverflowError):
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
__all__ = [
"MailBounceError",
"SqlMailBounceProcessingProvider",
"configure_bounce_source",
"delete_bounce_source",
"list_bounce_sources",
"list_bounce_observations",
"normalize_message_id",
"parse_delivery_status",
]
+597 -25
View File
@@ -1,16 +1,22 @@
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from typing import Any, Iterator
from sqlalchemy.orm import Session
from govoplan_core.core.mail import NotificationMailDeliveryRequest
from govoplan_core.core.modules import ModuleContext
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
_assert_campaign_inherits_profile_credentials,
assert_campaign_mail_policy_allows_json,
assert_mail_policy_allows_send,
campaign_mail_owner_context,
campaign_profile_transport_revisions,
effective_mail_profile_policy,
ensure_mail_profile_allowed_for_campaign,
@@ -19,20 +25,44 @@ from govoplan_mail.backend.mail_profiles import (
mail_profile_id_from_campaign_json,
smtp_config_from_profile,
)
from govoplan_mail.backend.server_hierarchy import (
MailHierarchyContext,
MailServerHierarchyError,
resolve_mail_transport,
select_mail_transport,
)
from govoplan_mail.backend.runtime import configure_runtime
from govoplan_mail.backend.recovery import (
MailRecoveryError,
begin_provider_effect_recovery,
)
from govoplan_mail.backend.sending.imap import (
ImapAppendError,
ImapConfigurationError,
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:
@@ -44,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]]:
@@ -81,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,
)
@@ -104,6 +154,7 @@ def _authorized_campaign_profile(
tenant_id: str,
campaign_id: str,
profile_id: str,
selection: dict[str, str | None] | None = None,
):
profile = ensure_mail_profile_allowed_for_campaign(
session,
@@ -113,10 +164,55 @@ def _authorized_campaign_profile(
require_active=True,
)
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
_assert_campaign_inherits_profile_credentials(profile, policy)
_assert_campaign_inherits_profile_credentials(profile, policy, selection)
return profile
def _campaign_hierarchy_context(
session: Session,
*,
tenant_id: str,
campaign_id: str,
) -> MailHierarchyContext:
campaign = campaign_mail_owner_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
return MailHierarchyContext(
tenant_id=tenant_id,
user_id=campaign.owner_user_id,
group_ids=(
frozenset({campaign.owner_group_id})
if campaign.owner_group_id
else frozenset()
),
target_scope_type="campaign",
target_scope_id=campaign.id,
)
def _selection_payload(
*,
profile_id: str,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
) -> dict[str, str | None]:
return {
"mail_profile_id": profile_id,
"smtp_server_id": smtp_server_id,
"smtp_credential_id": smtp_credential_id,
"imap_server_id": imap_server_id,
"imap_credential_id": imap_credential_id,
}
def _supports_hierarchy(session: object) -> bool:
return callable(getattr(session, "execute", None))
def campaign_profile_delivery_summary(
session: Session,
*,
@@ -125,21 +221,33 @@ def campaign_profile_delivery_summary(
campaign_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
) -> dict[str, Any]:
"""Return only non-secret capabilities and opaque drift evidence."""
selection = _selection_payload(
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
imap_server_id=imap_server_id,
imap_credential_id=imap_credential_id,
)
if campaign_id:
profile = _authorized_campaign_profile(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
profile_id=profile_id,
selection=selection,
)
else:
assert_campaign_mail_policy_allows_json(
session,
tenant_id=tenant_id,
raw_json={"server": {"mail_profile_id": profile_id}},
raw_json={"server": {key: value for key, value in selection.items() if value}},
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
@@ -149,18 +257,164 @@ def campaign_profile_delivery_summary(
profile_id=profile_id,
require_active=True,
)
revisions = campaign_profile_transport_revisions(profile)
smtp = profile.smtp_config or {}
imap = profile.imap_config or {}
if campaign_id and _supports_hierarchy(session):
context = _campaign_hierarchy_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
try:
smtp = select_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
)
imap = select_mail_transport(
session,
profile=profile,
protocol="imap",
context=context,
server_id=imap_server_id,
credential_id=imap_credential_id,
)
except MailServerHierarchyError as exc:
raise MailProfileError(str(exc)) from exc
smtp_available = smtp.available
imap_available = imap.available
smtp_revision = smtp.transport_revision
imap_revision = imap.transport_revision if imap.available else None
resolved_smtp_server_id = smtp.server.id if smtp.server else None
resolved_smtp_credential_id = smtp.credential.id if smtp.credential else None
resolved_imap_server_id = imap.server.id if imap.server else None
resolved_imap_credential_id = imap.credential.id if imap.credential else None
else:
revisions = campaign_profile_transport_revisions(profile)
smtp_config = profile.smtp_config or {}
imap_config = profile.imap_config or {}
smtp_available = bool(smtp_config.get("host") and smtp_config.get("port"))
imap_available = bool(imap_config.get("host") and imap_config.get("port"))
smtp_revision = revisions["smtp"]
imap_revision = revisions["imap"]
resolved_smtp_server_id = smtp_server_id
resolved_smtp_credential_id = smtp_credential_id
resolved_imap_server_id = imap_server_id
resolved_imap_credential_id = imap_credential_id
return {
"mail_profile_id": profile_id,
"smtp_available": bool(smtp.get("host") and smtp.get("port")),
"imap_available": bool(imap.get("host") and imap.get("port")),
"smtp_transport_revision": revisions["smtp"],
"imap_transport_revision": revisions["imap"],
"smtp_server_id": resolved_smtp_server_id,
"smtp_credential_id": resolved_smtp_credential_id,
"imap_server_id": resolved_imap_server_id,
"imap_credential_id": resolved_imap_credential_id,
"smtp_available": smtp_available,
"imap_available": imap_available,
"smtp_transport_revision": smtp_revision,
"imap_transport_revision": imap_revision,
}
@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,
*,
@@ -172,26 +426,68 @@ def send_campaign_email_bytes(
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,
recovery_effect_id: str | None = None,
recovery_resource_type: str | None = None,
recovery_resource_id: str | None = None,
) -> CampaignSmtpDeliveryResult:
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
revisions = campaign_profile_transport_revisions(profile)
if revisions["smtp"] != expected_smtp_transport_revision:
resolved_smtp = 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_smtp_revision = selected_smtp.transport_revision
else:
current_smtp_revision = campaign_profile_transport_revisions(profile)["smtp"]
if current_smtp_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 = smtp_config_from_profile(profile)
if _supports_hierarchy(session):
resolved_smtp = resolve_mail_transport(
session,
profile=profile,
protocol="smtp",
context=context,
server_id=smtp_server_id,
credential_id=smtp_credential_id,
)
smtp = resolved_smtp.config
else:
smtp = smtp_config_from_profile(profile)
except MailProfileError:
raise
except Exception:
@@ -202,33 +498,81 @@ def send_campaign_email_bytes(
tenant_id=tenant_id,
campaign_id=campaign_id,
smtp=smtp,
imap=profile.imap_config or None,
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
try:
recovery = begin_provider_effect_recovery(
kind="smtp-delivery",
effect_id=recovery_effect_id,
tenant_id=tenant_id,
profile_id=profile_id,
message_bytes=message_bytes,
expected_transport_revision=expected_smtp_transport_revision,
recipient_count=len(envelope_recipients),
resource_type=recovery_resource_type,
resource_id=recovery_resource_id,
)
except MailRecoveryError as exc:
raise SmtpConfigurationError(str(exc)) from None
if recovery is not None and recovery.replayed:
raise SmtpSendError(
"The matching SMTP effect already succeeded; reconcile caller state without resending.",
outcome_unknown=True,
)
try:
result = send_email_bytes(
message_bytes,
smtp_config=smtp,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
batch_session=_ACTIVE_SMTP_BATCH.get(),
)
except SmtpSendError as exc:
raise _sanitized_smtp_error(exc) from None
except SmtpConfigurationError:
sanitized = _sanitized_smtp_error(exc)
if recovery is not None:
if sanitized.outcome_unknown:
recovery.unknown(code="smtp_outcome_unknown", summary=str(sanitized))
else:
recovery.reject(code="smtp_rejected", summary=str(sanitized))
raise sanitized from None
except SmtpConfigurationError as exc:
if recovery is not None:
recovery.reject(code="smtp_configuration", summary=str(exc))
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
except Exception:
if recovery is not None:
recovery.unknown(
code="unexpected_provider_error",
summary="SMTP outcome is unknown after an unexpected provider failure",
)
raise SmtpSendError(
"Mail delivery outcome is unknown after the provider effect started.",
outcome_unknown=True,
) from None
return CampaignSmtpDeliveryResult(
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:
recovery.succeed_smtp(
accepted_count=sanitized_result.accepted_count,
refused_recipients=sanitized_result.refused_recipients,
)
except Exception:
raise SmtpSendError(
"SMTP returned an outcome, but durable recovery evidence could not be finalized.",
outcome_unknown=True,
) from None
return sanitized_result
def append_campaign_message_to_sent(
@@ -241,31 +585,86 @@ def append_campaign_message_to_sent(
folder: str | None,
expected_smtp_transport_revision: str,
expected_imap_transport_revision: str | None,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
imap_server_id: str | None = None,
imap_credential_id: str | None = None,
recovery_effect_id: str | None = None,
recovery_resource_type: str | None = None,
recovery_resource_id: str | None = None,
) -> CampaignImapAppendResult:
selection = _selection_payload(
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
imap_server_id=imap_server_id,
imap_credential_id=imap_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 ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
revisions = campaign_profile_transport_revisions(profile)
if revisions["smtp"] != expected_smtp_transport_revision:
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,
)
selected_imap = select_mail_transport(
session,
profile=profile,
protocol="imap",
context=context,
server_id=imap_server_id,
credential_id=imap_credential_id,
)
except MailServerHierarchyError as exc:
raise MailProfileError(str(exc)) from exc
smtp_revision = selected_smtp.transport_revision
imap_revision = selected_imap.transport_revision
else:
revisions = campaign_profile_transport_revisions(profile)
smtp_revision = revisions["smtp"]
imap_revision = revisions["imap"]
if smtp_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 append-to-Sent delivery."
)
if revisions["imap"] != expected_imap_transport_revision:
if imap_revision != expected_imap_transport_revision:
raise MailProfileError(
"The selected Mail profile's IMAP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before append-to-Sent delivery."
)
try:
imap = imap_config_from_profile(profile)
if _supports_hierarchy(session):
imap = resolve_mail_transport(
session,
profile=profile,
protocol="imap",
context=context,
server_id=imap_server_id,
credential_id=imap_credential_id,
).config
else:
imap = imap_config_from_profile(profile)
except MailProfileError:
raise
except Exception:
@@ -277,22 +676,62 @@ def append_campaign_message_to_sent(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
smtp=profile.smtp_config or None,
smtp=None,
imap=imap,
)
except MailProfileError:
raise MailProfileError("Appending to Sent is blocked by the effective Mail policy.") from None
try:
recovery = begin_provider_effect_recovery(
kind="imap-append",
effect_id=recovery_effect_id,
tenant_id=tenant_id,
profile_id=profile_id,
message_bytes=message_bytes,
expected_transport_revision=expected_imap_transport_revision,
folder=folder,
resource_type=recovery_resource_type,
resource_id=recovery_resource_id,
)
except MailRecoveryError as exc:
raise ImapConfigurationError(str(exc)) from None
if recovery is not None and recovery.replayed:
raise ImapAppendError(
"The matching IMAP append already succeeded; reconcile caller state without appending again.",
outcome_unknown=True,
)
try:
result = append_message_to_sent(message_bytes, imap_config=imap, folder=folder)
except ImapAppendError as exc:
raise _sanitized_imap_error(exc) from None
except ImapConfigurationError:
sanitized = _sanitized_imap_error(exc)
if recovery is not None:
if sanitized.outcome_unknown:
recovery.unknown(code="imap_outcome_unknown", summary=str(sanitized))
else:
recovery.reject(code="imap_rejected", summary=str(sanitized))
raise sanitized from None
except ImapConfigurationError as exc:
if recovery is not None:
recovery.reject(code="imap_configuration", summary=str(exc))
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
except Exception:
if recovery is not None:
recovery.unknown(
code="unexpected_provider_error",
summary="IMAP APPEND outcome is unknown after an unexpected provider failure",
)
raise ImapAppendError(
"The Sent-folder append outcome is unknown; inspect the mailbox before retrying.",
outcome_unknown=True,
) from None
if recovery is not None:
try:
recovery.succeed_imap(folder=result.folder)
except Exception:
raise ImapAppendError(
"IMAP APPEND returned success, but durable recovery evidence could not be finalized.",
outcome_unknown=True,
) from None
return CampaignImapAppendResult(folder=result.folder)
@@ -305,10 +744,37 @@ 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)
@staticmethod
def submit_delivery_command(session: Session, **kwargs: Any) -> dict[str, object]:
from govoplan_mail.backend.delivery_outbox import submit_delivery_command
return submit_delivery_command(session, **kwargs)
@staticmethod
def delivery_command_summary(
session: Session,
*,
tenant_id: str,
command_id: str,
) -> dict[str, object]:
from govoplan_mail.backend.delivery_outbox import (
delivery_command_summary,
get_delivery_command,
)
return delivery_command_summary(
get_delivery_command(
session,
tenant_id=tenant_id,
command_id=command_id,
)
)
@staticmethod
def mock_mailbox():
from govoplan_mail.backend.dev import mock_mailbox
@@ -319,3 +785,109 @@ class MailCampaignCapability:
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
configure_runtime(settings=context.settings)
return MailCampaignCapability()
def delivery_outbox_capability(context: ModuleContext):
from govoplan_mail.backend.delivery_outbox import MailDeliveryOutboxCapability
configure_runtime(settings=context.settings)
return MailDeliveryOutboxCapability()
class MailNotificationDeliveryCapability:
"""Submit notification email to the Mail-owned durable outbox."""
def submit_notification_mail(
self,
session: Session,
request: NotificationMailDeliveryRequest,
) -> dict[str, object]:
profile_id = str(request.mail_profile_id or "").strip()
from_address = str(request.from_address or "").strip()
if not profile_id or not from_address:
return {
"status": "paused",
"provider": "mail.notificationDelivery",
"error": (
"Notification email requires a Mail profile and sender "
"selected by tenant policy."
),
}
try:
transport = campaign_profile_delivery_summary(
session,
tenant_id=request.tenant_id,
profile_id=profile_id,
smtp_server_id=request.smtp_server_id,
smtp_credential_id=request.smtp_credential_id,
)
except MailProfileError:
return {
"status": "paused",
"provider": "mail.notificationDelivery",
"error": (
"The selected notification Mail profile is unavailable or "
"blocked by effective policy."
),
}
if not transport.get("smtp_available"):
return {
"status": "paused",
"provider": "mail.notificationDelivery",
"error": "The selected notification Mail profile has no usable SMTP transport.",
}
message = EmailMessage()
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid()
message["Subject"] = request.subject
message["From"] = from_address
message["To"] = request.recipient
message.set_content(request.body_text)
if request.body_html:
message.add_alternative(request.body_html, subtype="html")
from govoplan_mail.backend.delivery_outbox import submit_delivery_command
command = submit_delivery_command(
session,
tenant_id=request.tenant_id,
command_type="notification",
source_module="notifications",
source_resource_type="notification",
source_resource_id=request.notification_id,
source_version_id=None,
idempotency_key=f"notification:{request.notification_id}",
profile_id=profile_id,
smtp_server_id=(
str(transport.get("smtp_server_id"))
if transport.get("smtp_server_id")
else request.smtp_server_id
),
smtp_credential_id=(
str(transport.get("smtp_credential_id"))
if transport.get("smtp_credential_id")
else request.smtp_credential_id
),
message_bytes=bytes(message),
envelope_from=from_address,
envelope_recipients=[request.recipient],
from_header=from_address,
expected_smtp_transport_revision=str(
transport["smtp_transport_revision"]
),
)
return {
"status": "accepted",
"provider": "mail.delivery_outbox",
"external_message_id": command["id"],
"delivery_status": command["status"],
"duplicate": bool(command.get("duplicate")),
}
def notification_delivery_capability(
context: ModuleContext,
) -> MailNotificationDeliveryCapability:
configure_runtime(settings=context.settings)
return MailNotificationDeliveryCapability()
+2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from govoplan_core.mail.config import (
ImapConfig,
ImapFolderMappings,
ImapServerConfig,
SmtpConfig,
SmtpServerConfig,
@@ -13,6 +14,7 @@ from govoplan_core.mail.config import (
__all__ = [
"ImapConfig",
"ImapFolderMappings",
"ImapServerConfig",
"SmtpConfig",
"SmtpServerConfig",
File diff suppressed because it is too large Load Diff
+287
View File
@@ -9,6 +9,7 @@ from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
def new_uuid() -> str:
@@ -30,6 +31,7 @@ class MailServerProfile(Base, TimestampMixin):
slug: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
smtp_username: Mapped[str | None] = mapped_column(String(320))
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
@@ -42,6 +44,56 @@ class MailServerProfile(Base, TimestampMixin):
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
class MailServerEndpoint(Base, TimestampMixin):
__tablename__ = "mail_server_endpoints"
__table_args__ = (
UniqueConstraint("profile_id", "protocol", "name", name="uq_mail_server_endpoints_profile_protocol_name"),
Index("ix_mail_server_endpoints_profile_protocol", "profile_id", "protocol", "is_active"),
Index("ix_mail_server_endpoints_scope", "tenant_id", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
profile_id: Mapped[str] = mapped_column(
ForeignKey("mail_server_profiles.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
protocol: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
transport_revision: Mapped[str] = mapped_column(String(36), default=new_uuid, nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
class MailServerCredentialBinding(Base, TimestampMixin):
__tablename__ = "mail_server_credential_bindings"
__table_args__ = (
UniqueConstraint("server_id", "credential_id", name="uq_mail_server_credential_bindings_server_credential"),
Index("ix_mail_server_credential_bindings_default", "server_id", "is_default"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
server_id: Mapped[str] = mapped_column(
ForeignKey("mail_server_endpoints.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
credential_id: Mapped[str] = mapped_column(
ForeignKey("core_credential_envelopes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
class MailProfilePolicy(Base, TimestampMixin):
__tablename__ = "mail_profile_policies"
__table_args__ = (
@@ -100,3 +152,238 @@ class MailMailboxMessageIndex(Base, TimestampMixin):
body_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
attachment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
indexed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
class MailDeliveryCommand(Base, TimestampMixin):
__tablename__ = "mail_delivery_commands"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"command_type",
"idempotency_key",
name="uq_mail_delivery_commands_idempotency",
),
Index(
"ix_mail_delivery_commands_dispatch",
"status",
"next_attempt_at",
"created_at",
),
Index(
"ix_mail_delivery_commands_source",
"tenant_id",
"source_module",
"source_resource_type",
"source_resource_id",
),
)
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)
command_type: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
source_module: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
source_resource_type: Mapped[str] = mapped_column(String(80), nullable=False)
source_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
source_version_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
profile_id: Mapped[str] = mapped_column(
ForeignKey("mail_server_profiles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
smtp_server_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
smtp_credential_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
expected_smtp_transport_revision: Mapped[str] = mapped_column(String(120), nullable=False)
envelope_from_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
envelope_recipients_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
from_header_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
message_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
message_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
rfc_message_id: Mapped[str | None] = mapped_column(
String(998), nullable=True, index=True
)
message_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
recipient_count: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
claimed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
effect_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
refused_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
refusal_summary: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
refusal_details_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
failure_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
failure_summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
supersedes_command_id: Mapped[str | None] = mapped_column(
ForeignKey("mail_delivery_commands.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
payload_purged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class MailDeliveryAttempt(Base, TimestampMixin):
__tablename__ = "mail_delivery_attempts"
__table_args__ = (
UniqueConstraint(
"command_id",
"attempt_number",
name="uq_mail_delivery_attempts_number",
),
Index("ix_mail_delivery_attempts_command_started", "command_id", "started_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
command_id: Mapped[str] = mapped_column(
ForeignKey("mail_delivery_commands.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
worker_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
effect_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
accepted_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
refused_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
outcome_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
diagnostic_summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
class MailDeliveryReconciliation(Base, TimestampMixin):
__tablename__ = "mail_delivery_reconciliations"
__table_args__ = (
Index(
"ix_mail_delivery_reconciliations_command_created",
"command_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
command_id: Mapped[str] = mapped_column(
ForeignKey("mail_delivery_commands.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
decision: Mapped[str] = mapped_column(String(40), nullable=False)
evidence_reference: Mapped[str] = mapped_column(String(500), nullable=False)
note_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
class MailBounceSource(Base, TimestampMixin):
__tablename__ = "mail_bounce_sources"
__table_args__ = (
UniqueConstraint(
"profile_id",
"folder",
name="uq_mail_bounce_sources_profile_folder",
),
Index(
"ix_mail_bounce_sources_scan",
"is_active",
"last_scanned_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,
)
folder: Mapped[str] = mapped_column(
String(255), default="INBOX", nullable=False
)
imap_server_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
imap_credential_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
expected_imap_transport_revision: Mapped[str] = mapped_column(
String(120), nullable=False
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False, index=True
)
uidvalidity: Mapped[str | None] = mapped_column(String(255), nullable=True)
highest_processed_uid: Mapped[int] = mapped_column(
BigInteger, default=0, nullable=False
)
last_scanned_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
last_success_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("access_users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
class MailBounceObservation(Base, TimestampMixin):
__tablename__ = "mail_bounce_observations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"fingerprint",
name="uq_mail_bounce_observations_fingerprint",
),
Index(
"ix_mail_bounce_observations_command",
"tenant_id",
"command_id",
"observed_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,
)
folder: Mapped[str] = mapped_column(String(255), nullable=False)
uid: Mapped[str] = mapped_column(String(255), nullable=False)
fingerprint: Mapped[str] = mapped_column(String(64), nullable=False)
raw_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
original_message_id: Mapped[str | None] = mapped_column(
String(998), nullable=True, index=True
)
command_id: Mapped[str | None] = mapped_column(
ForeignKey("mail_delivery_commands.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
recipient: Mapped[str | None] = mapped_column(String(998), nullable=True)
action: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
status_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
diagnostic: Mapped[str | None] = mapped_column(String(500), nullable=True)
permanent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
observed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
matched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
@@ -0,0 +1,919 @@
from __future__ import annotations
import base64
import hashlib
import json
from collections import Counter
from datetime import datetime, timedelta, timezone
from email import policy
from email.parser import BytesParser
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_mail.backend.capabilities import send_campaign_email_bytes
from govoplan_mail.backend.db.models import (
MailDeliveryAttempt,
MailDeliveryCommand,
MailDeliveryReconciliation,
)
from govoplan_mail.backend.mail_profiles import MailProfileError
from govoplan_mail.backend.recovery import (
MailRecoveryError,
reconcile_outbox_provider_effect,
)
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError
DISPATCHABLE_STATUSES = frozenset({"pending", "temporary_failure", "reconciled_not_accepted"})
TERMINAL_STATUSES = frozenset(
{
"accepted",
"partially_refused",
"permanent_failure",
"outcome_unknown",
"reconciled_accepted",
"cancelled",
}
)
NON_RETRYABLE_STATUSES = TERMINAL_STATUSES | frozenset({"claimed", "in_progress"})
DEFAULT_PAYLOAD_RETENTION_DAYS = 30
STALE_CLAIM_AFTER = timedelta(minutes=10)
class MailDeliveryError(RuntimeError):
pass
class MailDeliveryIdempotencyConflict(MailDeliveryError):
pass
class MailDeliveryNotFound(MailDeliveryError):
pass
class MailDeliveryStateError(MailDeliveryError):
pass
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def _bounded_text(value: object | None, *, limit: int = 500) -> str | None:
if value is None:
return None
candidate = " ".join(str(value).split())
return candidate[:limit] or None
def _canonical_hash(payload: dict[str, object]) -> str:
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _encrypt_json(value: object) -> str:
encrypted = encrypt_secret(
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
)
if not encrypted:
raise MailDeliveryError("Mail delivery evidence could not be encrypted")
return encrypted
def _decrypt_json(value: str | None) -> Any:
plaintext = decrypt_secret(value)
if plaintext is None:
return None
return json.loads(plaintext)
def _message_bytes(command: MailDeliveryCommand) -> bytes:
encoded = decrypt_secret(command.message_encrypted)
if encoded is None:
raise MailDeliveryStateError("Mail delivery payload is no longer available")
try:
message = base64.b64decode(encoded.encode("ascii"), validate=True)
except (ValueError, TypeError) as exc:
raise MailDeliveryStateError("Mail delivery payload is invalid") from exc
if hashlib.sha256(message).hexdigest() != command.message_sha256:
raise MailDeliveryStateError("Mail delivery payload integrity check failed")
return message
def _rfc_message_id(message: bytes) -> str | None:
try:
value = BytesParser(policy=policy.default).parsebytes(
message,
headersonly=True,
).get("Message-ID")
except Exception:
return None
normalized = " ".join(str(value or "").split())
return normalized[:998] or None
def _delivery_payload(
*,
command_type: str,
source_module: str,
source_resource_type: str,
source_resource_id: str | None,
source_version_id: str | None,
profile_id: str,
smtp_server_id: str | None,
smtp_credential_id: str | None,
expected_smtp_transport_revision: str,
envelope_from: str,
envelope_recipients: list[str],
from_header: str | None,
message_sha256: str,
) -> dict[str, object]:
return {
"command_type": command_type,
"source_module": source_module,
"source_resource_type": source_resource_type,
"source_resource_id": source_resource_id,
"source_version_id": source_version_id,
"profile_id": profile_id,
"smtp_server_id": smtp_server_id,
"smtp_credential_id": smtp_credential_id,
"expected_smtp_transport_revision": expected_smtp_transport_revision,
"envelope_from": envelope_from,
"envelope_recipients": envelope_recipients,
"from_header": from_header,
"message_sha256": message_sha256,
}
def submit_delivery_command(
session: Session,
*,
tenant_id: str,
command_type: str,
source_module: str,
source_resource_type: str,
source_resource_id: str | None,
source_version_id: str | None,
idempotency_key: str,
profile_id: str,
message_bytes: bytes,
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,
created_by_user_id: str | None = None,
retention_days: int = DEFAULT_PAYLOAD_RETENTION_DAYS,
supersedes_command_id: str | None = None,
) -> dict[str, object]:
clean_key = idempotency_key.strip()
clean_recipients = [str(value).strip() for value in envelope_recipients if str(value).strip()]
if not clean_key or len(clean_key) > 200:
raise MailDeliveryError("A bounded idempotency key is required")
if not clean_recipients:
raise MailDeliveryError("At least one envelope recipient is required")
if retention_days < 1:
raise MailDeliveryError("Mail payload retention must be at least one day")
digest = hashlib.sha256(message_bytes).hexdigest()
rfc_message_id = _rfc_message_id(message_bytes)
request_hash = _canonical_hash(
_delivery_payload(
command_type=command_type,
source_module=source_module,
source_resource_type=source_resource_type,
source_resource_id=source_resource_id,
source_version_id=source_version_id,
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
expected_smtp_transport_revision=expected_smtp_transport_revision,
envelope_from=envelope_from,
envelope_recipients=clean_recipients,
from_header=from_header,
message_sha256=digest,
)
)
existing = session.scalar(
select(MailDeliveryCommand).where(
MailDeliveryCommand.tenant_id == tenant_id,
MailDeliveryCommand.command_type == command_type,
MailDeliveryCommand.idempotency_key == clean_key,
)
)
if existing is not None:
if existing.canonical_request_hash != request_hash:
raise MailDeliveryIdempotencyConflict(
"The idempotency key is already bound to a different mail command"
)
return delivery_command_summary(existing, duplicate=True)
now = utcnow()
command = MailDeliveryCommand(
tenant_id=tenant_id,
command_type=command_type,
source_module=source_module,
source_resource_type=source_resource_type,
source_resource_id=source_resource_id,
source_version_id=source_version_id,
idempotency_key=clean_key,
canonical_request_hash=request_hash,
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
expected_smtp_transport_revision=expected_smtp_transport_revision,
envelope_from_encrypted=encrypt_secret(envelope_from),
envelope_recipients_encrypted=_encrypt_json(clean_recipients),
from_header_encrypted=encrypt_secret(from_header),
message_encrypted=encrypt_secret(base64.b64encode(message_bytes).decode("ascii")),
message_sha256=digest,
rfc_message_id=rfc_message_id,
message_size_bytes=len(message_bytes),
recipient_count=len(clean_recipients),
status="pending",
next_attempt_at=now,
created_by_user_id=created_by_user_id,
supersedes_command_id=supersedes_command_id,
expires_at=now + timedelta(days=retention_days),
)
try:
with session.begin_nested():
session.add(command)
session.flush()
except IntegrityError:
existing = session.scalar(
select(MailDeliveryCommand).where(
MailDeliveryCommand.tenant_id == tenant_id,
MailDeliveryCommand.command_type == command_type,
MailDeliveryCommand.idempotency_key == clean_key,
)
)
if existing is None or existing.canonical_request_hash != request_hash:
raise MailDeliveryIdempotencyConflict(
"The idempotency key is already bound to a different mail command"
) from None
return delivery_command_summary(existing, duplicate=True)
audit_event(
session,
tenant_id=tenant_id,
user_id=created_by_user_id,
action="mail.delivery_requested",
object_type="mail_delivery_command",
object_id=command.id,
details={
"command_type": command_type,
"source_module": source_module,
"source_resource_type": source_resource_type,
"source_resource_id": source_resource_id,
"recipient_count": len(clean_recipients),
"message_sha256": digest,
"supersedes_command_id": supersedes_command_id,
},
)
return delivery_command_summary(command)
def get_delivery_command(
session: Session,
*,
tenant_id: str,
command_id: str,
) -> MailDeliveryCommand:
command = session.get(MailDeliveryCommand, command_id)
if command is None or command.tenant_id != tenant_id:
raise MailDeliveryNotFound("Mail delivery command not found")
return command
def delivery_command_summary(
command: MailDeliveryCommand,
*,
duplicate: bool = False,
) -> dict[str, object]:
return {
"id": command.id,
"tenant_id": command.tenant_id,
"command_type": command.command_type,
"source_module": command.source_module,
"source_resource_type": command.source_resource_type,
"source_resource_id": command.source_resource_id,
"source_version_id": command.source_version_id,
"status": command.status,
"recipient_count": command.recipient_count,
"accepted_count": command.accepted_count,
"refused_count": command.refused_count,
"refusal_summary": dict(command.refusal_summary or {}),
"attempt_count": command.attempt_count,
"failure_code": command.failure_code,
"failure_summary": command.failure_summary,
"message_sha256": command.message_sha256,
"rfc_message_id": command.rfc_message_id,
"message_size_bytes": command.message_size_bytes,
"created_at": command.created_at,
"completed_at": command.completed_at,
"expires_at": command.expires_at,
"payload_purged_at": command.payload_purged_at,
"safe_to_retry": command.status in DISPATCHABLE_STATUSES,
"outcome_known": command.status
not in {"claimed", "in_progress", "outcome_unknown"},
"duplicate": duplicate,
}
def delivery_command_diagnostics(
session: Session,
*,
tenant_id: str,
command_id: str,
) -> dict[str, object]:
command = get_delivery_command(session, tenant_id=tenant_id, command_id=command_id)
attempts = session.scalars(
select(MailDeliveryAttempt)
.where(MailDeliveryAttempt.command_id == command.id)
.order_by(MailDeliveryAttempt.attempt_number)
).all()
reconciliations = session.scalars(
select(MailDeliveryReconciliation)
.where(MailDeliveryReconciliation.command_id == command.id)
.order_by(MailDeliveryReconciliation.created_at)
).all()
refusals = _decrypt_json(command.refusal_details_encrypted) or {}
return {
**delivery_command_summary(command),
"refused_recipients": refusals,
"attempts": [
{
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"worker_id": attempt.worker_id,
"status": attempt.status,
"started_at": attempt.started_at,
"effect_started_at": attempt.effect_started_at,
"completed_at": attempt.completed_at,
"accepted_count": attempt.accepted_count,
"refused_count": attempt.refused_count,
"outcome_code": attempt.outcome_code,
"diagnostic_summary": attempt.diagnostic_summary,
}
for attempt in attempts
],
"reconciliations": [
{
"id": item.id,
"decision": item.decision,
"evidence_reference": item.evidence_reference,
"note": decrypt_secret(item.note_encrypted),
"created_by_user_id": item.created_by_user_id,
"created_at": item.created_at,
}
for item in reconciliations
],
}
def _current_attempt(
session: Session,
command: MailDeliveryCommand,
) -> MailDeliveryAttempt | None:
return session.scalar(
select(MailDeliveryAttempt)
.where(
MailDeliveryAttempt.command_id == command.id,
MailDeliveryAttempt.attempt_number == command.attempt_count,
)
.limit(1)
)
def _recover_stale_commands(
session: Session,
*,
now: datetime,
tenant_id: str | None,
) -> tuple[int, int]:
cutoff = now - STALE_CLAIM_AFTER
clauses = [
MailDeliveryCommand.status.in_(("claimed", "in_progress")),
MailDeliveryCommand.claimed_at <= cutoff,
]
if tenant_id:
clauses.append(MailDeliveryCommand.tenant_id == tenant_id)
commands = session.scalars(
select(MailDeliveryCommand).where(*clauses).order_by(MailDeliveryCommand.claimed_at)
).all()
recovered = 0
unknown = 0
for command in commands:
attempt = _current_attempt(session, command)
effect_started = command.effect_started_at or (
attempt.effect_started_at if attempt else None
)
if effect_started is None:
command.status = "pending"
command.next_attempt_at = now
command.claimed_at = None
command.claimed_by = None
if attempt is not None:
attempt.status = "claim_abandoned"
attempt.completed_at = now
attempt.outcome_code = "worker_lost_before_effect"
recovered += 1
continue
command.status = "outcome_unknown"
command.completed_at = now
command.next_attempt_at = None
command.failure_code = "worker_lost_after_effect_start"
command.failure_summary = (
"Delivery outcome is unknown because the worker stopped after transmission began."
)
if attempt is not None:
attempt.status = "outcome_unknown"
attempt.completed_at = now
attempt.outcome_code = command.failure_code
attempt.diagnostic_summary = command.failure_summary
unknown += 1
if commands:
session.commit()
return recovered, unknown
def _claim_command(
session: Session,
*,
command_id: str,
now: datetime,
worker_id: str | None,
) -> tuple[MailDeliveryCommand, MailDeliveryAttempt] | None:
command = session.scalar(
select(MailDeliveryCommand)
.where(
MailDeliveryCommand.id == command_id,
MailDeliveryCommand.status.in_(DISPATCHABLE_STATUSES),
or_(
MailDeliveryCommand.next_attempt_at.is_(None),
MailDeliveryCommand.next_attempt_at <= now,
),
MailDeliveryCommand.payload_purged_at.is_(None),
)
.with_for_update(skip_locked=True)
)
if command is None:
session.rollback()
return None
command.attempt_count += 1
command.status = "claimed"
command.claimed_by = _bounded_text(worker_id, limit=255)
command.claimed_at = now
command.effect_started_at = None
command.next_attempt_at = None
command.failure_code = None
command.failure_summary = None
attempt = MailDeliveryAttempt(
command_id=command.id,
attempt_number=command.attempt_count,
worker_id=command.claimed_by,
status="claimed",
started_at=now,
)
session.add(attempt)
session.commit()
return command, attempt
def _mark_effect_started(
session: Session,
command: MailDeliveryCommand,
attempt: MailDeliveryAttempt,
) -> None:
now = utcnow()
command.status = "in_progress"
command.effect_started_at = now
attempt.status = "in_progress"
attempt.effect_started_at = now
session.commit()
def _refusal_summary(refusals: dict[str, dict[str, int | str]]) -> dict[str, int]:
classifications = Counter(
str(item.get("classification") or "unknown")
for item in refusals.values()
)
return dict(sorted(classifications.items()))
def _record_outcome(
session: Session,
*,
command: MailDeliveryCommand,
attempt: MailDeliveryAttempt,
status: str,
accepted_count: int = 0,
refusals: dict[str, dict[str, int | str]] | None = None,
failure_code: str | None = None,
failure_summary: str | None = None,
) -> None:
now = utcnow()
refusal_map = refusals or {}
command.status = status
command.accepted_count = accepted_count
command.refused_count = len(refusal_map)
command.refusal_summary = _refusal_summary(refusal_map)
command.refusal_details_encrypted = (
_encrypt_json(refusal_map) if refusal_map else None
)
command.failure_code = failure_code
command.failure_summary = _bounded_text(failure_summary)
command.claimed_by = None
command.claimed_at = None
command.next_attempt_at = (
now + timedelta(minutes=min(60, 2 ** min(command.attempt_count, 5)))
if status == "temporary_failure"
else None
)
if status != "temporary_failure":
command.completed_at = now
attempt.status = status
attempt.completed_at = now
attempt.accepted_count = accepted_count
attempt.refused_count = len(refusal_map)
attempt.outcome_code = failure_code or status
attempt.diagnostic_summary = _bounded_text(failure_summary)
session.commit()
audit_event(
session,
tenant_id=command.tenant_id,
user_id=command.created_by_user_id,
action="mail.delivery_completed",
object_type="mail_delivery_command",
object_id=command.id,
details={
"status": status,
"attempt_number": attempt.attempt_number,
"accepted_count": accepted_count,
"refused_count": len(refusal_map),
"refusal_summary": command.refusal_summary,
"failure_code": failure_code,
"source_module": command.source_module,
"source_resource_id": command.source_resource_id,
},
)
session.commit()
def _process_claimed(
session: Session,
command: MailDeliveryCommand,
attempt: MailDeliveryAttempt,
) -> str:
try:
message = _message_bytes(command)
envelope_from = decrypt_secret(command.envelope_from_encrypted)
recipients = _decrypt_json(command.envelope_recipients_encrypted)
from_header = decrypt_secret(command.from_header_encrypted)
if not envelope_from or not isinstance(recipients, list) or not recipients:
raise MailDeliveryStateError("Mail delivery envelope is unavailable")
except MailDeliveryStateError as exc:
_record_outcome(
session,
command=command,
attempt=attempt,
status="permanent_failure",
failure_code="payload_unavailable",
failure_summary=str(exc),
)
return "permanent_failure"
_mark_effect_started(session, command, attempt)
try:
result = send_campaign_email_bytes(
session,
tenant_id=command.tenant_id,
campaign_id=str(command.source_resource_id or ""),
profile_id=command.profile_id,
message_bytes=message,
envelope_from=envelope_from,
envelope_recipients=[str(item) for item in recipients],
from_header=from_header,
expected_smtp_transport_revision=command.expected_smtp_transport_revision,
smtp_server_id=command.smtp_server_id,
smtp_credential_id=command.smtp_credential_id,
recovery_effect_id=(
f"outbox:{command.id}:smtp-attempt:{attempt.attempt_number}"
),
recovery_resource_type="mail_delivery_command",
recovery_resource_id=command.id,
)
except SmtpSendError as exc:
if exc.outcome_unknown:
status = "outcome_unknown"
code = "smtp_outcome_unknown"
elif exc.temporary:
status = "temporary_failure"
code = "smtp_temporary_failure"
else:
status = "permanent_failure"
code = "smtp_permanent_failure"
_record_outcome(
session,
command=command,
attempt=attempt,
status=status,
failure_code=code,
failure_summary=str(exc),
)
return status
except (MailProfileError, SmtpConfigurationError) as exc:
_record_outcome(
session,
command=command,
attempt=attempt,
status="permanent_failure",
failure_code="authorization_or_configuration_changed",
failure_summary=str(exc),
)
return "permanent_failure"
except Exception:
_record_outcome(
session,
command=command,
attempt=attempt,
status="outcome_unknown",
failure_code="unexpected_error_after_effect_start",
failure_summary="Mail delivery outcome is unknown after transmission began.",
)
return "outcome_unknown"
refusals = dict(result.refused_recipients)
accepted_count = result.accepted_count
if not refusals:
status = "accepted"
elif accepted_count > 0:
status = "partially_refused"
elif all(
item.get("classification") == "temporary" for item in refusals.values()
):
status = "temporary_failure"
elif any(
item.get("classification") == "unknown" for item in refusals.values()
):
status = "outcome_unknown"
else:
status = "permanent_failure"
_record_outcome(
session,
command=command,
attempt=attempt,
status=status,
accepted_count=accepted_count,
refusals=refusals,
failure_code=None if status == "accepted" else f"smtp_{status}",
failure_summary=None if status == "accepted" else "One or more recipients were refused.",
)
return status
def dispatch_due(
session: Session,
*,
tenant_id: str | None = None,
limit: int = 25,
worker_id: str | None = None,
) -> dict[str, object]:
bounded_limit = max(1, min(int(limit), 100))
now = utcnow()
recovered, recovered_unknown = _recover_stale_commands(
session,
now=now,
tenant_id=tenant_id,
)
clauses = [
MailDeliveryCommand.status.in_(DISPATCHABLE_STATUSES),
MailDeliveryCommand.payload_purged_at.is_(None),
or_(
MailDeliveryCommand.next_attempt_at.is_(None),
MailDeliveryCommand.next_attempt_at <= now,
),
]
if tenant_id:
clauses.append(MailDeliveryCommand.tenant_id == tenant_id)
command_ids = list(
session.scalars(
select(MailDeliveryCommand.id)
.where(*clauses)
.order_by(MailDeliveryCommand.next_attempt_at, MailDeliveryCommand.created_at)
.limit(bounded_limit)
).all()
)
counters: Counter[str] = Counter()
processed_ids: list[str] = []
for command_id in command_ids:
claimed = _claim_command(
session,
command_id=command_id,
now=utcnow(),
worker_id=worker_id,
)
if claimed is None:
continue
command, attempt = claimed
outcome = _process_claimed(session, command, attempt)
counters[outcome] += 1
processed_ids.append(command.id)
return {
"selected": len(processed_ids),
"accepted": counters["accepted"],
"partially_refused": counters["partially_refused"],
"retrying": counters["temporary_failure"],
"failed": counters["permanent_failure"],
"outcome_unknown": counters["outcome_unknown"] + recovered_unknown,
"recovered_before_effect": recovered,
"command_ids": processed_ids,
}
def reconcile_delivery_command(
session: Session,
*,
tenant_id: str,
command_id: str,
decision: str,
evidence_reference: str,
note: str | None,
user_id: str,
) -> dict[str, object]:
command = get_delivery_command(session, tenant_id=tenant_id, command_id=command_id)
if command.status not in {"outcome_unknown", "in_progress"}:
raise MailDeliveryStateError(
"Only a delivery with an unknown outcome can be reconciled"
)
clean_decision = decision.strip().casefold()
if clean_decision not in {"accepted", "not_accepted"}:
raise MailDeliveryStateError(
"Reconciliation decision must be accepted or not_accepted"
)
clean_evidence = _bounded_text(evidence_reference)
if not clean_evidence:
raise MailDeliveryStateError("An evidence reference is required")
try:
reconcile_outbox_provider_effect(
command_id=command.id,
effect_occurred=clean_decision == "accepted",
evidence_reference=clean_evidence,
user_id=user_id,
)
except MailRecoveryError as exc:
raise MailDeliveryStateError(str(exc)) from exc
item = MailDeliveryReconciliation(
command_id=command.id,
decision=clean_decision,
evidence_reference=clean_evidence,
note_encrypted=encrypt_secret(note),
created_by_user_id=user_id,
)
session.add(item)
command.status = (
"reconciled_accepted"
if clean_decision == "accepted"
else "reconciled_not_accepted"
)
command.completed_at = utcnow() if clean_decision == "accepted" else None
command.next_attempt_at = None
command.failure_code = f"reconciled_{clean_decision}"
command.failure_summary = "Delivery outcome was reconciled from external evidence."
audit_event(
session,
tenant_id=tenant_id,
user_id=user_id,
action="mail.delivery_reconciled",
object_type="mail_delivery_command",
object_id=command.id,
details={
"decision": clean_decision,
"evidence_reference": clean_evidence,
},
)
session.commit()
return delivery_command_summary(command)
def resend_delivery_command(
session: Session,
*,
tenant_id: str,
command_id: str,
idempotency_key: str,
user_id: str,
) -> dict[str, object]:
command = get_delivery_command(session, tenant_id=tenant_id, command_id=command_id)
if command.status not in {
"outcome_unknown",
"reconciled_not_accepted",
"permanent_failure",
"partially_refused",
}:
raise MailDeliveryStateError(
"A deliberate resend is only available after a terminal or reconciled failure"
)
if command.payload_purged_at is not None:
raise MailDeliveryStateError("The retained delivery payload is no longer available")
message = _message_bytes(command)
recipients = _decrypt_json(command.envelope_recipients_encrypted)
envelope_from = decrypt_secret(command.envelope_from_encrypted)
if not envelope_from or not isinstance(recipients, list):
raise MailDeliveryStateError("The retained delivery envelope is unavailable")
result = submit_delivery_command(
session,
tenant_id=tenant_id,
command_type=command.command_type,
source_module=command.source_module,
source_resource_type=command.source_resource_type,
source_resource_id=command.source_resource_id,
source_version_id=command.source_version_id,
idempotency_key=idempotency_key,
profile_id=command.profile_id,
message_bytes=message,
envelope_from=envelope_from,
envelope_recipients=[str(item) for item in recipients],
from_header=decrypt_secret(command.from_header_encrypted),
expected_smtp_transport_revision=command.expected_smtp_transport_revision,
smtp_server_id=command.smtp_server_id,
smtp_credential_id=command.smtp_credential_id,
created_by_user_id=user_id,
retention_days=max(
1,
(command.expires_at - utcnow()).days,
),
supersedes_command_id=command.id,
)
audit_event(
session,
tenant_id=tenant_id,
user_id=user_id,
action="mail.delivery_resend_requested",
object_type="mail_delivery_command",
object_id=str(result["id"]),
details={"supersedes_command_id": command.id},
)
session.commit()
return result
def purge_expired(
session: Session,
*,
tenant_id: str | None = None,
limit: int = 250,
) -> dict[str, object]:
now = utcnow()
clauses = [
MailDeliveryCommand.expires_at <= now,
MailDeliveryCommand.payload_purged_at.is_(None),
]
if tenant_id:
clauses.append(MailDeliveryCommand.tenant_id == tenant_id)
commands = session.scalars(
select(MailDeliveryCommand)
.where(*clauses)
.order_by(MailDeliveryCommand.expires_at)
.limit(max(1, min(int(limit), 1000)))
).all()
for command in commands:
command.envelope_from_encrypted = None
command.envelope_recipients_encrypted = None
command.from_header_encrypted = None
command.message_encrypted = None
command.refusal_details_encrypted = None
command.payload_purged_at = now
reconciliations = session.scalars(
select(MailDeliveryReconciliation)
.join(
MailDeliveryCommand,
MailDeliveryCommand.id == MailDeliveryReconciliation.command_id,
)
.where(
MailDeliveryCommand.payload_purged_at == now,
MailDeliveryReconciliation.note_encrypted.is_not(None),
)
).all()
for reconciliation in reconciliations:
reconciliation.note_encrypted = None
session.commit()
return {"purged": len(commands)}
class MailDeliveryOutboxCapability:
dispatch_due = staticmethod(dispatch_due)
purge_expired = staticmethod(purge_expired)
+61 -1
View File
@@ -4,7 +4,13 @@ from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationConfigurationDecision,
DocumentationContext,
DocumentationLink,
DocumentationTopic,
)
from govoplan_mail.backend.mail_profiles import (
EffectiveMailProfilePolicy,
MailProfileError,
@@ -35,6 +41,60 @@ def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTo
return tuple(topics)
def documentation_configuration_states(
context: DocumentationContext,
keys: tuple[str, ...],
) -> dict[str, DocumentationConfigurationDecision]:
if "mail_profile_policy" not in keys:
return {}
principal = context.principal
tenant_id = str(getattr(principal, "tenant_id", "") or "")
session = context.session
if not tenant_id or not isinstance(session, Session):
return {
"mail_profile_policy": DocumentationConfigurationDecision(
key="mail_profile_policy",
state="unavailable",
reason="The effective tenant policy cannot be evaluated in this context.",
)
}
try:
policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=tenant_id,
scope_type="tenant",
)
except MailProfileError:
return {
"mail_profile_policy": DocumentationConfigurationDecision(
key="mail_profile_policy",
state="unavailable",
reason="The effective tenant policy could not be evaluated.",
)
}
tenant_sources = [
source
for source in policy.source_policies
if source.get("scope_type") == "tenant"
]
explicitly_configured = any(
bool(source.get("applied_fields"))
for source in tenant_sources
)
return {
"mail_profile_policy": DocumentationConfigurationDecision(
key="mail_profile_policy",
state="enabled" if explicitly_configured else "inherited",
source="tenant" if explicitly_configured else "system default",
reason=(
"A tenant policy is configured."
if explicitly_configured
else "The tenant uses the inherited system policy."
),
)
}
def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTopic | None:
principal = context.principal
tenant_id = str(getattr(principal, "tenant_id", "") or "")
+554
View File
@@ -0,0 +1,554 @@
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,
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",
)
)
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_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",
}
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"]
+86 -12
View File
@@ -4,7 +4,7 @@ import fnmatch
import json
import re
from dataclasses import dataclass, field
from typing import Any, Iterable
from typing import Any, Iterable, Mapping
from sqlalchemy import and_, or_, select, text
from sqlalchemy.orm import Session
@@ -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)
@@ -919,6 +919,19 @@ def campaign_mail_context_visible_to_actor(
)
def campaign_mail_owner_context(
session: Session,
*,
tenant_id: str,
campaign_id: str,
) -> CampaignMailPolicyContext:
return _campaign_policy_context(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
)
def mail_profile_scope_visible_to_actor(
session: Session,
*,
@@ -1335,7 +1348,15 @@ def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool:
return bool(profile.imap_config)
_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset({"mail_profile_id"})
_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset(
{
"mail_profile_id",
"smtp_server_id",
"smtp_credential_id",
"imap_server_id",
"imap_credential_id",
}
)
def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
@@ -1343,8 +1364,8 @@ def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
if unexpected:
paths = ", ".join(f"server.{key}" for key in unexpected)
raise MailProfileError(
"Campaign JSON may only reference a Mail-owned profile through server.mail_profile_id; "
f"remove campaign-local SMTP/IMAP settings ({paths}), select a Mail profile, and save a new campaign version."
"Campaign JSON may only reference Mail-owned profile, server, and credential identifiers; "
f"remove campaign-local SMTP/IMAP settings ({paths}), select Mail resources, and save a new campaign version."
)
value = server.get("mail_profile_id")
if value is None:
@@ -1354,18 +1375,63 @@ def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
return value.strip()
def campaign_mail_selection_from_json(
raw_json: dict[str, Any] | None,
) -> dict[str, str | None]:
data = raw_json if isinstance(raw_json, dict) else {}
server = data.get("server") if isinstance(data.get("server"), dict) else {}
profile_id = _campaign_mail_profile_reference_id(server)
selection: dict[str, str | None] = {"mail_profile_id": profile_id}
for key in (
"smtp_server_id",
"smtp_credential_id",
"imap_server_id",
"imap_credential_id",
):
value = server.get(key)
if value is None:
selection[key] = None
continue
if not isinstance(value, str) or not value.strip():
raise MailProfileError(f"server.{key} must be a non-empty Mail identifier")
selection[key] = value.strip()
if profile_id is None and any(
selection[key]
for key in selection
if key != "mail_profile_id"
):
raise MailProfileError(
"Mail server or credential selections require server.mail_profile_id"
)
for protocol in ("smtp", "imap"):
if (
selection[f"{protocol}_credential_id"]
and not selection[f"{protocol}_server_id"]
):
raise MailProfileError(
f"server.{protocol}_credential_id requires server.{protocol}_server_id"
)
return selection
def _assert_campaign_inherits_profile_credentials(
profile: MailServerProfile,
policy: EffectiveMailProfilePolicy,
selection: Mapping[str, str | None] | None = None,
) -> None:
for protocol in ("smtp", "imap"):
if not _profile_has_transport(profile, protocol):
continue
if not _credential_policy_for_protocol(policy, protocol).inherit:
explicit_credential = (
selection or {}
).get(f"{protocol}_credential_id")
if (
not _credential_policy_for_protocol(policy, protocol).inherit
and not explicit_credential
):
raise MailProfileError(
f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} "
"credential policy requires campaign-local credentials. Store the credentials on a Mail profile "
"and change the policy to inherit them."
"credential policy requires an explicit credential selection for this campaign."
)
@@ -1379,8 +1445,8 @@ def assert_campaign_mail_policy_allows_json(
owner_group_id: str | None = None,
) -> None:
data = raw_json if isinstance(raw_json, dict) else {}
server = data.get("server") if isinstance(data.get("server"), dict) else {}
profile_id = _campaign_mail_profile_reference_id(server)
selection = campaign_mail_selection_from_json(data)
profile_id = selection["mail_profile_id"]
if profile_id:
if campaign_id:
profile = ensure_mail_profile_allowed_for_campaign(
@@ -1391,7 +1457,7 @@ def assert_campaign_mail_policy_allows_json(
require_active=True,
)
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
_assert_campaign_inherits_profile_credentials(profile, policy)
_assert_campaign_inherits_profile_credentials(profile, policy, selection)
return
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
policy = effective_mail_profile_policy(
@@ -1408,7 +1474,7 @@ def assert_campaign_mail_policy_allows_json(
owner_group_id=owner_group_id,
):
raise MailProfileError("Mail-server profile is not allowed by the effective policy")
_assert_campaign_inherits_profile_credentials(profile, policy)
_assert_campaign_inherits_profile_credentials(profile, policy, selection)
return
return
@@ -1424,6 +1490,7 @@ def create_mail_server_profile(
smtp: SmtpConfig,
imap: ImapConfig | None,
is_active: bool = True,
inherit_to_lower_scopes: bool = True,
scope_type: str = "tenant",
scope_id: str | None = None,
) -> MailServerProfile:
@@ -1466,6 +1533,7 @@ def create_mail_server_profile(
slug=clean_slug,
description=description,
is_active=is_active,
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
smtp_config=smtp_payload,
smtp_username=smtp_username,
smtp_password_encrypted=encrypt_secret(smtp_password),
@@ -1491,6 +1559,7 @@ def update_mail_server_profile(
slug: str | None = None,
description: str | None = None,
is_active: bool | None = None,
inherit_to_lower_scopes: bool | None = None,
smtp: SmtpConfig | None = None,
imap: ImapConfig | None = None,
clear_imap: bool = False,
@@ -1538,6 +1607,8 @@ def update_mail_server_profile(
profile.description = description
if is_active is not None:
profile.is_active = is_active
if inherit_to_lower_scopes is not None:
profile.inherit_to_lower_scopes = bool(inherit_to_lower_scopes)
next_smtp, next_imap = _next_profile_transport_state(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
_assert_profile_transport_allowed(session, tenant_id=tenant_id, profile=profile, smtp=next_smtp, imap=next_imap)
@@ -1883,6 +1954,9 @@ def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]:
"slug": profile.slug,
"description": profile.description,
"is_active": profile.is_active,
"inherit_to_lower_scopes": bool(
getattr(profile, "inherit_to_lower_scopes", True)
),
"smtp": _server_config_payload(profile.smtp_config),
"imap": _server_config_payload(profile.imap_config) if profile.imap_config else None,
"credentials": {
+102 -1
View File
@@ -7,11 +7,17 @@ from threading import Lock
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.change_sequence import record_change
from govoplan_core.security.time import ensure_aware_utc, utc_now
from govoplan_mail.backend.db.models import MailMailboxFolderIndex, MailMailboxMessageIndex
from govoplan_mail.backend.db.models import (
MailMailboxFolderIndex,
MailMailboxMessageIndex,
new_uuid,
)
from govoplan_mail.backend.sending.imap import ImapFolderListResult, ImapMailboxInfo, ImapMailboxMessageListResult, ImapMailboxMessageSummary
MAILBOX_INDEX_TTL_SECONDS = 30
MAILBOX_MESSAGES_COLLECTION = "mail.mailbox_messages"
_refresh_lock = Lock()
_refreshing_keys: set[tuple[str, str, str]] = set()
@@ -44,6 +50,13 @@ def clear_mailbox_index(session: Session, *, profile_id: str) -> tuple[int, int]
transport change. Message rows are removed before their folder metadata.
"""
messages = (
session.query(MailMailboxMessageIndex)
.filter(MailMailboxMessageIndex.profile_id == profile_id)
.all()
)
for message in messages:
_record_message_change(session, message, operation="deleted")
deleted_messages = (
session.query(MailMailboxMessageIndex)
.filter(MailMailboxMessageIndex.profile_id == profile_id)
@@ -86,6 +99,31 @@ def cache_mailbox_folders(
.filter(MailMailboxFolderIndex.tenant_id == tenant_id, MailMailboxFolderIndex.profile_id == profile_id)
.all()
}
expected_names = {folder.name for folder in result.folders}
removed_names = set(existing) - expected_names
if removed_names:
removed_messages = (
session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder.in_(removed_names),
)
.all()
)
for message in removed_messages:
_record_message_change(session, message, operation="deleted")
(
session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder.in_(removed_names),
)
.delete(synchronize_session=False)
)
for name in removed_names:
session.delete(existing[name])
for folder in result.folders:
row = existing.get(folder.name)
if row is None:
@@ -125,6 +163,17 @@ def cache_mailbox_messages(
uids = [message.uid for message in result.messages]
if result.total_count <= 0:
removed_messages = (
session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == result.folder,
)
.all()
)
for message in removed_messages:
_record_message_change(session, message, operation="deleted")
(
session.query(MailMailboxMessageIndex)
.filter(
@@ -147,6 +196,7 @@ def cache_mailbox_messages(
if uids:
stale_query = stale_query.filter(MailMailboxMessageIndex.uid.notin_(uids))
for row in stale_query.all():
_record_message_change(session, row, operation="deleted")
session.delete(row)
existing = {}
if uids:
@@ -163,13 +213,16 @@ def cache_mailbox_messages(
}
for index, message in enumerate(result.messages):
row = existing.get(message.uid)
created = row is None
if row is None:
row = MailMailboxMessageIndex(
id=new_uuid(),
tenant_id=tenant_id,
profile_id=profile_id,
folder=result.folder,
uid=message.uid,
)
previous = None if created else _message_state(row)
row.uid_int = _uid_int(message.uid)
row.sort_position = result.offset + index
row.subject = message.subject
@@ -184,6 +237,12 @@ def cache_mailbox_messages(
row.attachment_count = message.attachment_count
row.indexed_at = indexed_at
session.add(row)
if created or previous != _message_state(row):
_record_message_change(
session,
row,
operation="created" if created else "updated",
)
def cached_mailbox_folders(
@@ -294,6 +353,48 @@ def _message_from_index(row: MailMailboxMessageIndex) -> ImapMailboxMessageSumma
)
def _message_state(row: MailMailboxMessageIndex) -> tuple[object, ...]:
return (
row.folder,
row.uid,
row.sort_position,
row.subject,
row.from_header,
row.to_header,
row.cc_header,
row.date,
row.message_id,
tuple(row.flags or ()),
row.size_bytes,
row.body_preview,
row.attachment_count,
)
def _record_message_change(
session: Session,
row: MailMailboxMessageIndex,
*,
operation: str,
) -> None:
record_change(
session,
module_id="mail",
collection=MAILBOX_MESSAGES_COLLECTION,
resource_type="mailbox_message",
resource_id=row.id,
operation=operation,
tenant_id=row.tenant_id,
actor_type="system",
payload={
"profile_id": row.profile_id,
"folder": row.folder,
"uid": row.uid,
"message_id": row.message_id,
},
)
def _uid_int(uid: str) -> int:
try:
return int(str(uid))
+642 -12
View File
@@ -6,12 +6,22 @@ from pathlib import Path
from sqlalchemy import inspect
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
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,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
@@ -19,18 +29,48 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
QuickAccessTool,
RoleTemplate,
)
from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration,
ProviderBehaviorDeclaration,
ProviderObjectDeclaration,
declared_module_architecture,
)
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.documentation import documentation_topics
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,
SMTP_PROVIDER_ID,
imap_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
_mail_table_retirement_provider = drop_table_retirement_provider(
mail_models.MailServerCredentialBinding,
mail_models.MailServerEndpoint,
mail_models.MailServerProfile,
mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex,
mail_models.MailDeliveryReconciliation,
mail_models.MailDeliveryAttempt,
mail_models.MailDeliveryCommand,
mail_models.MailBounceObservation,
mail_models.MailBounceSource,
label="Mail",
)
@@ -60,6 +100,20 @@ def _mail_retirement_provider(session: object | None, module_id: str):
)
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(
@@ -86,6 +140,26 @@ PERMISSIONS = (
"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:delivery:diagnostic",
"Inspect mail delivery diagnostics",
"Inspect bounded recipient-level refusal and attempt evidence for durable Mail commands.",
),
_permission(
"mail:delivery:reconcile",
"Reconcile mail delivery outcomes",
"Reconcile unknown delivery outcomes and explicitly authorize deliberate resend commands.",
),
_permission(
"mail:bounce:read",
"View bounce processing",
"Inspect bounded delivery-status observations and their correlation state.",
),
_permission(
"mail:bounce:manage",
"Manage bounce processing",
"Configure and run IMAP delivery-status watchers.",
),
_permission(
"mail:secret:manage_own",
"Manage own mail secrets",
@@ -105,6 +179,10 @@ ROLE_TEMPLATES = (
"mail:mailbox:read",
"mail:profile:write",
"mail:secret:manage",
"mail:delivery:diagnostic",
"mail:delivery:reconcile",
"mail:bounce:read",
"mail:bounce:manage",
),
),
RoleTemplate(
@@ -146,16 +224,121 @@ def _mail_router(context: ModuleContext):
return aggregate
SMTP_PROVIDER = ExternalProviderDeclaration(
id=SMTP_PROVIDER_ID,
module_id="mail",
label="SMTP message delivery",
maturity="publish",
operations=("discover", "read", "publish", "preview", "dry_run"),
objects=(
ProviderObjectDeclaration(
object_type="mail_server_endpoint",
field_groups=("identity", "transport", "policy", "revision"),
authority_modes=("external_authoritative", "governance_overlay"),
default_authority_mode="governance_overlay",
),
ProviderObjectDeclaration(
object_type="outbound_message",
field_groups=("envelope", "content_digest", "delivery_state", "evidence"),
authority_modes=("governance_overlay",),
default_authority_mode="governance_overlay",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="Every command pins the selected SMTP endpoint, credential, and random transport revision.",
concurrency="The expected transport revision is checked before credentials are decrypted or an effect starts.",
freshness="Delivery outcomes and completion times are retained; transport freshness is not periodic state.",
health="Successful deliveries and unresolved outcome-unknown commands are projected without exposing server details.",
max_read_items=5000,
idempotency="Tenant, command type, and idempotency key bind one canonical encrypted delivery request.",
retry="Only classified pre-acceptance temporary failures are retried with bounded scheduling.",
timeout_seconds=60,
conflicts="A reused idempotency key with different content is rejected before delivery.",
outcome_unknown="A connection failure after effect start becomes outcome_unknown and is never blindly retried.",
outcome_unknown_supported=True,
evidence="Encrypted command, attempt, acceptance/refusal summary, effect-start marker, and reconciliation are retained.",
audit_event_types=(
"mail.delivery_requested",
"mail.delivery_completed",
"mail.delivery_reconciled",
),
correction="A reconciled new command is a separate auditable effect and does not rewrite the original outcome.",
rollback="SMTP acceptance cannot be rolled back.",
compensation="A follow-up message or domain correction is the only safe compensation after acceptance.",
reconciliation="Operators record provider evidence and choose accepted or not-accepted before any resend.",
outage="Pending commands remain durable; accepted or outcome-unknown commands are not redelivered automatically.",
classifications=("confidential", "personal", "special_category"),
purposes=("governed message delivery", "notification delivery"),
retention="Payload, delivery evidence, and audit records follow separate configured retention policies.",
secret_handling="Credentials are decrypted only inside Mail after authorization and revision validation.",
),
interface_names=("mail.campaign_delivery", "mail.delivery_commands", "mail.delivery_outbox"),
documentation_topic_ids=("mail.reference.campaign-delivery-contract",),
)
IMAP_PROVIDER = ExternalProviderDeclaration(
id=IMAP_PROVIDER_ID,
module_id="mail",
label="IMAP mailbox projection",
maturity="read",
operations=("discover", "search", "read", "preview"),
objects=(
ProviderObjectDeclaration(
object_type="mailbox_folder",
field_groups=("identity", "flags", "counts", "revision"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
ProviderObjectDeclaration(
object_type="mailbox_message",
field_groups=("identity", "headers", "body_preview", "flags", "source_metadata"),
authority_modes=("external_authoritative", "external_mirror"),
default_authority_mode="external_mirror",
),
),
behavior=ProviderBehaviorDeclaration(
revision_tokens="UIDVALIDITY, UID, folder, flags, and transport revision identify mailbox observations.",
concurrency="Bounded reads pin the profile transport revision and never mutate message flags.",
freshness="Folder and message index timestamps state when the external mailbox was last observed.",
health="Index state and bounce-source errors are projected independently of secret profile fields.",
max_read_items=500,
evidence="Mailbox index rows and bounce observations retain bounded source identities and observation times.",
audit_event_types=("mail.mailbox.read", "mail.bounce.observed"),
correction="A later mailbox refresh replaces the derived projection while source-owned history remains external.",
reconciliation="UIDVALIDITY changes invalidate the affected derived index before a bounded refresh.",
outage="The last derived index remains readable with stale or unknown freshness where policy permits.",
classifications=("confidential", "personal", "special_category"),
purposes=("mailbox access", "delivery-status processing"),
retention="Derived mailbox indexes and bounce evidence follow Mail retention policy.",
secret_handling="IMAP credentials remain encrypted and are never returned through mailbox or provider-state APIs.",
),
documentation_topic_ids=("mail.workflow.read-mailbox",),
)
manifest = ModuleManifest(
id="mail",
name="Mail",
version="0.1.10",
version="0.1.19",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses"),
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(
name="calendar.invitations",
version_min="0.2.0",
version_max_exclusive="0.3.0",
optional=True,
),
ModuleInterfaceRequirement(
name="campaigns.access",
version_min="0.1.0",
@@ -174,15 +357,90 @@ 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",
version_max_exclusive="2.0.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_mail_router,
role_templates=ROLE_TEMPLATES,
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),),
search_sources=(
SearchSourceProviderRegistration(
id="mail.mailbox_messages",
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),),
frontend=FrontendModule(
module_id="mail",
package_name="@govoplan/mail-webui",
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),),
routes=(
FrontendRoute(
path="/mail",
component="MailboxPage",
required_any=("mail:mailbox:read",),
order=50,
),
FrontendRoute(
path="/mail/bounces",
component="MailBouncePage",
required_any=("mail:bounce:read", "mail:bounce:manage"),
order=51,
surface_id="mail.bounce-processing",
),
),
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(
module_id="mail",
@@ -194,17 +452,275 @@ manifest = ModuleManifest(
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
mail_models.MailServerCredentialBinding,
mail_models.MailServerEndpoint,
mail_models.MailServerProfile,
mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex,
mail_models.MailDeliveryReconciliation,
mail_models.MailDeliveryAttempt,
mail_models.MailDeliveryCommand,
mail_models.MailBounceObservation,
mail_models.MailBounceSource,
label="Mail",
),
),
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",
fromlist=["delivery_outbox_capability"],
).delivery_outbox_capability(context),
CAPABILITY_MAIL_NOTIFICATION_DELIVERY: lambda context: __import__(
"govoplan_mail.backend.capabilities",
fromlist=["notification_delivery_capability"],
).notification_delivery_capability(context),
CAPABILITY_MAIL_BOUNCE_PROCESSING: lambda context: __import__(
"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, personal-profile, delivery, reconciliation, and bounce metadata without exposing transport secrets.",
contract_version="0.1.0",
documentation_types=("admin",),
audience=("privacy_officer", "mail_admin", "records_manager"),
),
},
documentation=(
DocumentationTopic(
id="mail.privacy.data-subject-requests",
title="Review Mail data in a data-subject request",
summary="Collect tenant-scoped Mail metadata while preserving transport evidence and external-mailbox authority.",
body=(
"Mail's DSAR provider searches the effective tenant by normalized header or bounce-recipient email, direct membership references, and namespaced Mail profile, message-index, delivery-command, or bounce references. It isolates only matching From, To, and Cc parties and returns bounded message-index content, safe personal-profile metadata, and delivery, attempt, reconciliation, and bounce outcomes. It excludes SMTP/IMAP configuration, usernames and credentials, encrypted messages and envelopes, refusal detail, mailbox folders and UIDs, endpoint and credential identifiers, idempotency and worker claims, diagnostics, error text, and opaque evidence. "
"Delivery attempts, reconciliation decisions, and bounce observations remain retained as immutable transport and recovery evidence. Mailbox indexes and personal profiles require manual review through Mail and the authoritative external mailbox. The provider performs no direct erasure because deleting a derived index alone would not delete its external source, while changing a profile or delivery command can affect credentials, other users, and preserved evidence."
),
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, 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",
summary="Expose bounded cached mailbox message metadata and previews to permission-aware platform Search.",
body=(
"When Search is installed, Mail contributes its read-only mailbox cache, never credentials or "
"unbounded raw messages. Results require both mailbox-read and profile-use authority and recheck "
"the current profile scope, policy, activity, tenant, user, group, or Campaign visibility before "
"returning a result. Mailbox refreshes publish durable index changes; a rebuild remains available "
"for reconciliation."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("mail_user", "mail_admin", "administrator"),
related_modules=("search",),
order=39,
),
DocumentationTopic(
id="mail.profiles-and-policy",
title="Mail profiles and policy hierarchy",
@@ -236,12 +752,87 @@ manifest = ModuleManifest(
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles and policy",
"section": "Effective profile policy",
"help_contexts": ["mail.profiles", "mail.admin.profiles"],
"related_topic_ids": [
"mail.profile-ownership-and-consumers",
"campaigns.mail-profile-governance",
],
},
),
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",
summary="Watch an authorized IMAP folder and correlate DSN outcomes with durable Mail commands.",
body=(
"Mail stores the outgoing RFC Message-ID with its durable command, parses "
"bounded message/delivery-status reports without changing mailbox flags, "
"and records idempotent per-recipient observations. SMTP acceptance remains "
"separate from a later bounce. Unmatched reports remain visible for review; "
"Mail stores only bounded diagnostics and a raw digest, not the raw bounce body. "
"When Calendar is active, the same bounded source scan forwards METHOD:REPLY "
"iCalendar parts to Calendar for idempotent attendee reconciliation without "
"classifying the message as a bounce."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("mail_admin", "campaign_manager", "release_reviewer"),
order=43,
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:bounce:read", "mail:bounce:manage"),
),
),
related_modules=("campaigns", "calendar", "audit"),
metadata={
"kind": "workflow",
"route": "/mail/bounces",
"screen": "Bounce processing",
"help_contexts": ["mail.bounces", "mail.bounce-processing"],
"verification": (
"Send a message with a unique Message-ID, ingest a DSN twice, and "
"verify one correlated observation while the SMTP acceptance remains intact."
),
},
),
DocumentationTopic(
id="mail.profile-ownership-and-consumers",
title="Mail owns transport profiles and credentials",
@@ -329,8 +920,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",),
@@ -350,15 +941,16 @@ manifest = ModuleManifest(
"kind": "workflow",
"route": "/mail",
"screen": "Mail",
"help_contexts": ["mail.list"],
"help_contexts": ["mail.list", "mail.mailbox"],
"prerequisites": [
"An active visible profile has IMAP configured.",
"You may both use that profile and read its mailbox.",
],
"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.",
@@ -406,7 +998,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 durable recipient jobs, retry, and reconciliation. Live report emailing remains disabled until the planned Mail-owned idempotent outbox and attempt ledger are implemented.",
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"),
@@ -429,7 +1021,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",
@@ -439,6 +1031,44 @@ manifest = ModuleManifest(
),
),
documentation_providers=(documentation_topics,),
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=("mail_profile_policy",),
resolve=documentation_configuration_states,
),
),
external_providers=(SMTP_PROVIDER, IMAP_PROVIDER),
external_provider_state_providers=(
ExternalProviderStateProviderRegistration(
module_id="mail",
provider_id=SMTP_PROVIDER_ID,
provider=smtp_provider_states,
),
ExternalProviderStateProviderRegistration(
module_id="mail",
provider_id=IMAP_PROVIDER_ID,
provider=imap_provider_states,
),
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="integration",
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.",),
supported_authority_modes=(
"external_authoritative",
"external_mirror",
"governance_overlay",
),
owned_concepts=("mail profile", "mail delivery command", "delivery attempt", "mailbox projection"),
non_owned_concepts=("campaign", "notification", "recipient address directory", "external mailbox"),
target_tested_providers=(SMTP_PROVIDER_ID, IMAP_PROVIDER_ID),
recovery_docs=("docs/MAIL_HANDBOOK.md",),
security_docs=("docs/MAIL_HANDBOOK.md",),
operations_docs=("docs/MAIL_HANDBOOK.md",),
),
)
@@ -0,0 +1,22 @@
"""split mail envelopes into servers and reusable credential bindings
Revision ID: 7192a3bcdef0
Revises: 608192abcdef
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
hierarchy = import_module(
"govoplan_mail.backend.migrations.versions.7192a3bcdef0_mail_server_hierarchy"
)
revision = hierarchy.revision
down_revision = hierarchy.down_revision
branch_labels = hierarchy.branch_labels
depends_on = hierarchy.depends_on
upgrade = hierarchy.upgrade
downgrade = hierarchy.downgrade
@@ -0,0 +1,22 @@
"""add durable mail delivery outbox
Revision ID: 82a3b4c5d6e7
Revises: 7192a3bcdef0
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from importlib import import_module
delivery_outbox = import_module(
"govoplan_mail.backend.migrations.versions.82a3b4c5d6e7_mail_delivery_outbox"
)
revision = delivery_outbox.revision
down_revision = delivery_outbox.down_revision
branch_labels = delivery_outbox.branch_labels
depends_on = delivery_outbox.depends_on
upgrade = delivery_outbox.upgrade
downgrade = delivery_outbox.downgrade
@@ -0,0 +1,265 @@
"""split mail envelopes into servers and reusable credential bindings
Revision ID: 7192a3bcdef0
Revises: 608192abcdef
Create Date: 2026-07-23 00:00:00.000000
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from alembic import op
import sqlalchemy as sa
revision = "7192a3bcdef0"
down_revision = "608192abcdef"
branch_labels = None
depends_on = "c91f0a72be34"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "mail_server_profiles" not in tables:
return
profile_columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
if "inherit_to_lower_scopes" not in profile_columns:
with op.batch_alter_table("mail_server_profiles") as batch:
batch.add_column(
sa.Column(
"inherit_to_lower_scopes",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
)
)
if "mail_server_endpoints" not in tables:
op.create_table(
"mail_server_endpoints",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("protocol", sa.String(length=20), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("config", sa.JSON(), nullable=False),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("transport_revision", sa.String(length=36), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("updated_by_user_id", sa.String(length=36), 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"],
name=op.f("fk_mail_server_endpoints_profile_id_mail_server_profiles"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f("fk_mail_server_endpoints_tenant_id_core_scopes"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["access_users.id"],
name=op.f("fk_mail_server_endpoints_created_by_user_id_access_users"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["updated_by_user_id"],
["access_users.id"],
name=op.f("fk_mail_server_endpoints_updated_by_user_id_access_users"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_server_endpoints")),
sa.UniqueConstraint(
"profile_id",
"protocol",
"name",
name="uq_mail_server_endpoints_profile_protocol_name",
),
)
_create_endpoint_indexes()
inspector = sa.inspect(bind)
if "mail_server_credential_bindings" not in inspector.get_table_names():
op.create_table(
"mail_server_credential_bindings",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("server_id", sa.String(length=36), nullable=False),
sa.Column("credential_id", sa.String(length=36), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["server_id"],
["mail_server_endpoints.id"],
name=op.f("fk_mail_server_credential_bindings_server_id_mail_server_endpoints"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["credential_id"],
["core_credential_envelopes.id"],
name=op.f("fk_mail_server_credential_bindings_credential_id_core_credential_envelopes"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["access_users.id"],
name=op.f("fk_mail_server_credential_bindings_created_by_user_id_access_users"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_server_credential_bindings")),
sa.UniqueConstraint(
"server_id",
"credential_id",
name="uq_mail_server_credential_bindings_server_credential",
),
)
op.create_index(
"ix_mail_server_credential_bindings_default",
"mail_server_credential_bindings",
["server_id", "is_default"],
unique=False,
)
for column in ("server_id", "credential_id", "is_default", "created_by_user_id"):
op.create_index(
op.f(f"ix_mail_server_credential_bindings_{column}"),
"mail_server_credential_bindings",
[column],
unique=False,
)
_seed_legacy_endpoints(bind)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_server_credential_bindings" in tables:
op.drop_table("mail_server_credential_bindings")
if "mail_server_endpoints" in tables:
op.drop_table("mail_server_endpoints")
if "mail_server_profiles" in tables:
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
if "inherit_to_lower_scopes" in columns:
with op.batch_alter_table("mail_server_profiles") as batch:
batch.drop_column("inherit_to_lower_scopes")
def _create_endpoint_indexes() -> None:
op.create_index(
"ix_mail_server_endpoints_profile_protocol",
"mail_server_endpoints",
["profile_id", "protocol", "is_active"],
unique=False,
)
op.create_index(
"ix_mail_server_endpoints_scope",
"mail_server_endpoints",
["tenant_id", "scope_type", "scope_id"],
unique=False,
)
for column in (
"profile_id",
"tenant_id",
"protocol",
"scope_type",
"scope_id",
"is_default",
"is_active",
"created_by_user_id",
"updated_by_user_id",
):
op.create_index(
op.f(f"ix_mail_server_endpoints_{column}"),
"mail_server_endpoints",
[column],
unique=False,
)
def _seed_legacy_endpoints(bind) -> None:
existing = {
(row.profile_id, row.protocol)
for row in bind.execute(
sa.text("SELECT profile_id, protocol FROM mail_server_endpoints WHERE is_default = :is_default"),
{"is_default": True},
)
}
rows = bind.execute(
sa.text(
"SELECT id, tenant_id, scope_type, scope_id, smtp_config, imap_config, "
"smtp_transport_revision, imap_transport_revision, created_by_user_id, updated_by_user_id "
"FROM mail_server_profiles"
)
).mappings()
now = datetime.now(timezone.utc)
table = sa.table(
"mail_server_endpoints",
sa.column("id", sa.String),
sa.column("profile_id", sa.String),
sa.column("tenant_id", sa.String),
sa.column("protocol", sa.String),
sa.column("name", sa.String),
sa.column("config", sa.JSON),
sa.column("scope_type", sa.String),
sa.column("scope_id", sa.String),
sa.column("inherit_to_lower_scopes", sa.Boolean),
sa.column("is_default", sa.Boolean),
sa.column("is_active", sa.Boolean),
sa.column("transport_revision", sa.String),
sa.column("created_by_user_id", sa.String),
sa.column("updated_by_user_id", sa.String),
sa.column("created_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
)
for row in rows:
for protocol, config_key, revision_key in (
("smtp", "smtp_config", "smtp_transport_revision"),
("imap", "imap_config", "imap_transport_revision"),
):
config = _json_object(row[config_key])
if not config or (row["id"], protocol) in existing:
continue
bind.execute(
table.insert().values(
id=str(uuid.uuid4()),
profile_id=row["id"],
tenant_id=row["tenant_id"],
protocol=protocol,
name=protocol.upper(),
config=config,
scope_type=row["scope_type"] or "tenant",
scope_id=row["scope_id"],
inherit_to_lower_scopes=True,
is_default=True,
is_active=True,
transport_revision=row[revision_key] or str(uuid.uuid4()),
created_by_user_id=row["created_by_user_id"],
updated_by_user_id=row["updated_by_user_id"],
created_at=now,
updated_at=now,
)
)
def _json_object(value):
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
return {}
@@ -0,0 +1,243 @@
"""add durable mail delivery outbox
Revision ID: 82a3b4c5d6e7
Revises: 7192a3bcdef0
Create Date: 2026-07-30 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "82a3b4c5d6e7"
down_revision = "7192a3bcdef0"
branch_labels = None
depends_on = "c91f0a72be34"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_delivery_commands" not in tables:
op.create_table(
"mail_delivery_commands",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("command_type", sa.String(length=60), nullable=False),
sa.Column("source_module", sa.String(length=80), nullable=False),
sa.Column("source_resource_type", sa.String(length=80), nullable=False),
sa.Column("source_resource_id", sa.String(length=120), nullable=True),
sa.Column("source_version_id", sa.String(length=120), nullable=True),
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
sa.Column("profile_id", sa.String(length=36), nullable=False),
sa.Column("smtp_server_id", sa.String(length=36), nullable=True),
sa.Column("smtp_credential_id", sa.String(length=36), nullable=True),
sa.Column(
"expected_smtp_transport_revision",
sa.String(length=120),
nullable=False,
),
sa.Column("envelope_from_encrypted", sa.Text(), nullable=True),
sa.Column("envelope_recipients_encrypted", sa.Text(), nullable=True),
sa.Column("from_header_encrypted", sa.Text(), nullable=True),
sa.Column("message_encrypted", sa.Text(), nullable=True),
sa.Column("message_sha256", sa.String(length=64), nullable=False),
sa.Column("message_size_bytes", sa.BigInteger(), nullable=False),
sa.Column("recipient_count", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("attempt_count", sa.Integer(), nullable=False),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("claimed_by", sa.String(length=255), nullable=True),
sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("accepted_count", sa.Integer(), nullable=False),
sa.Column("refused_count", sa.Integer(), nullable=False),
sa.Column("refusal_summary", sa.JSON(), nullable=False),
sa.Column("refusal_details_encrypted", sa.Text(), nullable=True),
sa.Column("failure_code", sa.String(length=80), nullable=True),
sa.Column("failure_summary", sa.String(length=500), nullable=True),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("supersedes_command_id", sa.String(length=36), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("payload_purged_at", sa.DateTime(timezone=True), 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"],
name=op.f(
"fk_mail_delivery_commands_profile_id_mail_server_profiles"
),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["access_users.id"],
name=op.f(
"fk_mail_delivery_commands_created_by_user_id_access_users"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["supersedes_command_id"],
["mail_delivery_commands.id"],
name=op.f(
"fk_mail_delivery_commands_supersedes_command_id_mail_delivery_commands"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_mail_delivery_commands"),
),
sa.UniqueConstraint(
"tenant_id",
"command_type",
"idempotency_key",
name="uq_mail_delivery_commands_idempotency",
),
)
op.create_index(
"ix_mail_delivery_commands_dispatch",
"mail_delivery_commands",
["status", "next_attempt_at", "created_at"],
unique=False,
)
op.create_index(
"ix_mail_delivery_commands_source",
"mail_delivery_commands",
[
"tenant_id",
"source_module",
"source_resource_type",
"source_resource_id",
],
unique=False,
)
for column in (
"tenant_id",
"command_type",
"source_module",
"profile_id",
"status",
"next_attempt_at",
"created_by_user_id",
"supersedes_command_id",
"expires_at",
):
op.create_index(
op.f(f"ix_mail_delivery_commands_{column}"),
"mail_delivery_commands",
[column],
unique=False,
)
inspector = sa.inspect(op.get_bind())
if "mail_delivery_attempts" not in inspector.get_table_names():
op.create_table(
"mail_delivery_attempts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("command_id", sa.String(length=36), nullable=False),
sa.Column("attempt_number", sa.Integer(), nullable=False),
sa.Column("worker_id", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("accepted_count", sa.Integer(), nullable=False),
sa.Column("refused_count", sa.Integer(), nullable=False),
sa.Column("outcome_code", sa.String(length=80), nullable=True),
sa.Column("diagnostic_summary", 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(
["command_id"],
["mail_delivery_commands.id"],
name=op.f(
"fk_mail_delivery_attempts_command_id_mail_delivery_commands"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_mail_delivery_attempts"),
),
sa.UniqueConstraint(
"command_id",
"attempt_number",
name="uq_mail_delivery_attempts_number",
),
)
op.create_index(
"ix_mail_delivery_attempts_command_started",
"mail_delivery_attempts",
["command_id", "started_at"],
unique=False,
)
for column in ("command_id", "status"):
op.create_index(
op.f(f"ix_mail_delivery_attempts_{column}"),
"mail_delivery_attempts",
[column],
unique=False,
)
inspector = sa.inspect(op.get_bind())
if "mail_delivery_reconciliations" not in inspector.get_table_names():
op.create_table(
"mail_delivery_reconciliations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("command_id", sa.String(length=36), nullable=False),
sa.Column("decision", sa.String(length=40), nullable=False),
sa.Column("evidence_reference", sa.String(length=500), nullable=False),
sa.Column("note_encrypted", sa.Text(), nullable=True),
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["command_id"],
["mail_delivery_commands.id"],
name=op.f(
"fk_mail_delivery_reconciliations_command_id_mail_delivery_commands"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_user_id"],
["access_users.id"],
name=op.f(
"fk_mail_delivery_reconciliations_created_by_user_id_access_users"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_mail_delivery_reconciliations"),
),
)
op.create_index(
"ix_mail_delivery_reconciliations_command_created",
"mail_delivery_reconciliations",
["command_id", "created_at"],
unique=False,
)
for column in ("command_id", "created_by_user_id"):
op.create_index(
op.f(f"ix_mail_delivery_reconciliations_{column}"),
"mail_delivery_reconciliations",
[column],
unique=False,
)
def downgrade() -> None:
tables = set(sa.inspect(op.get_bind()).get_table_names())
if "mail_delivery_reconciliations" in tables:
op.drop_table("mail_delivery_reconciliations")
if "mail_delivery_attempts" in tables:
op.drop_table("mail_delivery_attempts")
if "mail_delivery_commands" in tables:
op.drop_table("mail_delivery_commands")
@@ -0,0 +1,133 @@
"""add mail bounce processing
Revision ID: 93b4c5d6e7f8
Revises: 82a3b4c5d6e7
Create Date: 2026-07-31 19:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "93b4c5d6e7f8"
down_revision = "82a3b4c5d6e7"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("mail_delivery_commands") as batch_op:
batch_op.add_column(
sa.Column("rfc_message_id", sa.String(length=998), nullable=True)
)
batch_op.create_index(
"ix_mail_delivery_commands_rfc_message_id",
("rfc_message_id",),
)
op.create_table(
"mail_bounce_sources",
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("folder", sa.String(length=255), nullable=False),
sa.Column("imap_server_id", sa.String(length=36), nullable=True),
sa.Column("imap_credential_id", sa.String(length=36), nullable=True),
sa.Column(
"expected_imap_transport_revision",
sa.String(length=120),
nullable=False,
),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("uidvalidity", sa.String(length=255), nullable=True),
sa.Column("highest_processed_uid", sa.BigInteger(), nullable=False),
sa.Column("last_scanned_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.String(length=500), nullable=True),
sa.Column("created_by_user_id", sa.String(length=36), 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(
["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"profile_id", "folder", name="uq_mail_bounce_sources_profile_folder"
),
)
op.create_index("ix_mail_bounce_sources_tenant_id", "mail_bounce_sources", ["tenant_id"])
op.create_index("ix_mail_bounce_sources_profile_id", "mail_bounce_sources", ["profile_id"])
op.create_index("ix_mail_bounce_sources_is_active", "mail_bounce_sources", ["is_active"])
op.create_index("ix_mail_bounce_sources_last_scanned_at", "mail_bounce_sources", ["last_scanned_at"])
op.create_index("ix_mail_bounce_sources_created_by_user_id", "mail_bounce_sources", ["created_by_user_id"])
op.create_index(
"ix_mail_bounce_sources_scan",
"mail_bounce_sources",
["is_active", "last_scanned_at"],
)
op.create_table(
"mail_bounce_observations",
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("folder", sa.String(length=255), nullable=False),
sa.Column("uid", sa.String(length=255), nullable=False),
sa.Column("fingerprint", sa.String(length=64), nullable=False),
sa.Column("raw_sha256", sa.String(length=64), nullable=False),
sa.Column("original_message_id", sa.String(length=998), nullable=True),
sa.Column("command_id", sa.String(length=36), nullable=True),
sa.Column("recipient", sa.String(length=998), nullable=True),
sa.Column("action", sa.String(length=40), nullable=False),
sa.Column("status_code", sa.String(length=80), nullable=True),
sa.Column("diagnostic", sa.String(length=500), nullable=True),
sa.Column("permanent", sa.Boolean(), nullable=False),
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("matched", sa.Boolean(), nullable=False),
sa.Column("evidence", sa.JSON(), nullable=False),
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(
["command_id"], ["mail_delivery_commands.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"fingerprint",
name="uq_mail_bounce_observations_fingerprint",
),
)
for column in (
"tenant_id",
"profile_id",
"original_message_id",
"command_id",
"action",
"observed_at",
"matched",
):
op.create_index(
f"ix_mail_bounce_observations_{column}",
"mail_bounce_observations",
[column],
)
op.create_index(
"ix_mail_bounce_observations_command",
"mail_bounce_observations",
["tenant_id", "command_id", "observed_at"],
)
def downgrade() -> None:
op.drop_table("mail_bounce_observations")
op.drop_table("mail_bounce_sources")
with op.batch_alter_table("mail_delivery_commands") as batch_op:
batch_op.drop_index("ix_mail_delivery_commands_rfc_message_id")
batch_op.drop_column("rfc_message_id")
+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",
]
+342
View File
@@ -0,0 +1,342 @@
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from govoplan_core.core.provider_governance import (
ExternalProviderRuntimeState,
ExternalProviderStateContext,
)
from govoplan_mail.backend.db.models import (
MailBounceSource,
MailDeliveryCommand,
MailMailboxFolderIndex,
MailMailboxMessageIndex,
MailServerEndpoint,
MailServerProfile,
)
SMTP_PROVIDER_ID = "mail.smtp_delivery"
IMAP_PROVIDER_ID = "mail.imap_mailbox"
_CURRENT_INDEX_WINDOW = timedelta(minutes=30)
def smtp_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _mail_provider_states(context, protocol="smtp")
def imap_provider_states(
context: ExternalProviderStateContext,
) -> tuple[ExternalProviderRuntimeState, ...]:
return _mail_provider_states(context, protocol="imap")
def _mail_provider_states(
context: ExternalProviderStateContext,
*,
protocol: str,
) -> 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=protocol)
endpoints_by_profile: dict[str, list[MailServerEndpoint]] = defaultdict(list)
for endpoint in endpoints:
endpoints_by_profile[endpoint.profile_id].append(endpoint)
observed_at = datetime.now(UTC)
if protocol == "smtp":
metrics = _smtp_metrics(context.session, profile_ids=profile_ids)
return tuple(
_smtp_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) or _legacy_configured(profile, "smtp")
)
metrics = _imap_metrics(context.session, profile_ids=profile_ids)
return tuple(
_imap_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) or _legacy_configured(profile, "imap")
)
def _profiles(context: ExternalProviderStateContext) -> tuple[MailServerProfile, ...]:
statement = select(MailServerProfile)
if context.tenant_id is not None:
statement = statement.where(
or_(
MailServerProfile.tenant_id.is_(None),
MailServerProfile.tenant_id == context.tenant_id,
)
)
return tuple(
context.session.scalars(
statement.order_by(
MailServerProfile.tenant_id,
MailServerProfile.id,
).limit(context.max_items + 1)
)
)
def _endpoints(
session: Session,
*,
profile_ids: tuple[str, ...],
protocol: str,
) -> tuple[MailServerEndpoint, ...]:
return tuple(
session.scalars(
select(MailServerEndpoint).where(
MailServerEndpoint.profile_id.in_(profile_ids),
MailServerEndpoint.protocol == protocol,
)
)
)
def _smtp_metrics(
session: Session,
*,
profile_ids: tuple[str, ...],
) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = defaultdict(dict)
rows = session.execute(
select(
MailDeliveryCommand.profile_id,
MailDeliveryCommand.status,
func.count(MailDeliveryCommand.id),
func.max(MailDeliveryCommand.completed_at),
)
.where(MailDeliveryCommand.profile_id.in_(profile_ids))
.group_by(MailDeliveryCommand.profile_id, MailDeliveryCommand.status)
)
for profile_id, status, count, last_completed_at in rows:
item = result[str(profile_id)]
item[str(status)] = int(count)
if status in {"accepted", "reconciled_accepted", "partially_refused"}:
current = _aware(item.get("last_success_at"))
candidate = _aware(last_completed_at)
if candidate is not None and (current is None or candidate > current):
item["last_success_at"] = candidate
return result
def _imap_metrics(
session: Session,
*,
profile_ids: tuple[str, ...],
) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = defaultdict(dict)
folder_rows = session.execute(
select(
MailMailboxFolderIndex.profile_id,
func.count(MailMailboxFolderIndex.id),
func.max(MailMailboxFolderIndex.indexed_at),
)
.where(MailMailboxFolderIndex.profile_id.in_(profile_ids))
.group_by(MailMailboxFolderIndex.profile_id)
)
for profile_id, count, indexed_at in folder_rows:
result[str(profile_id)]["indexed_folders"] = int(count)
result[str(profile_id)]["last_indexed_at"] = _aware(indexed_at)
message_rows = session.execute(
select(
MailMailboxMessageIndex.profile_id,
func.count(MailMailboxMessageIndex.id),
)
.where(MailMailboxMessageIndex.profile_id.in_(profile_ids))
.group_by(MailMailboxMessageIndex.profile_id)
)
for profile_id, count in message_rows:
result[str(profile_id)]["indexed_messages"] = int(count)
bounce_rows = session.execute(
select(
MailBounceSource.profile_id,
func.count(MailBounceSource.id),
func.sum(
case((MailBounceSource.last_error.is_not(None), 1), else_=0)
),
func.max(MailBounceSource.last_success_at),
)
.where(
MailBounceSource.profile_id.in_(profile_ids),
MailBounceSource.is_active.is_(True),
)
.group_by(MailBounceSource.profile_id)
)
for profile_id, count, errors, last_success_at in bounce_rows:
item = result[str(profile_id)]
item["active_bounce_sources"] = int(count)
item["bounce_source_errors"] = int(errors or 0)
item["last_bounce_success_at"] = _aware(last_success_at)
return result
def _smtp_state(
profile: MailServerProfile,
*,
endpoints: list[MailServerEndpoint],
metrics: dict[str, Any],
observed_at: datetime,
) -> ExternalProviderRuntimeState:
active = bool(profile.is_active) and (
any(item.is_active for item in endpoints)
or (not endpoints and _legacy_configured(profile, "smtp"))
)
outcome_unknown = int(metrics.get("outcome_unknown", 0))
last_success = _aware(metrics.get("last_success_at"))
health = (
"inactive"
if not active
else "warning"
if outcome_unknown
else "healthy"
if last_success is not None
else "unknown"
)
return ExternalProviderRuntimeState(
provider_id=SMTP_PROVIDER_ID,
binding_ref=f"mail:profile:{profile.id}:smtp",
authority_mode="governance_overlay",
observed_at=observed_at,
configured=True,
active=active,
health=health,
freshness="not_applicable",
conflict="pending" if outcome_unknown else "clear",
recovery=(
"not_applicable"
if not active
else "attention"
if outcome_unknown or last_success is None
else "ready"
),
last_success_at=last_success,
detail=(
"SMTP delivery is disabled."
if not active
else "SMTP outcomes require reconciliation."
if outcome_unknown
else "SMTP delivery has no retained successful observation yet."
if last_success is None
else "SMTP delivery evidence is available."
),
metrics={
"pending_commands": int(metrics.get("pending", 0)),
"temporary_failures": int(metrics.get("temporary_failure", 0)),
"permanent_failures": int(metrics.get("permanent_failure", 0)),
"outcome_unknown_commands": outcome_unknown,
"active_endpoints": sum(1 for item in endpoints if item.is_active),
},
)
def _imap_state(
profile: MailServerProfile,
*,
endpoints: list[MailServerEndpoint],
metrics: dict[str, Any],
observed_at: datetime,
) -> ExternalProviderRuntimeState:
active = bool(profile.is_active) and (
any(item.is_active for item in endpoints)
or (not endpoints and _legacy_configured(profile, "imap"))
)
indexed_at = _aware(metrics.get("last_indexed_at"))
errors = int(metrics.get("bounce_source_errors", 0))
freshness = (
"not_applicable"
if not active
else "unknown"
if indexed_at is None
else "current"
if observed_at - indexed_at <= _CURRENT_INDEX_WINDOW
else "stale"
)
health = (
"inactive"
if not active
else "error"
if errors
else "healthy"
if indexed_at is not None
else "unknown"
)
return ExternalProviderRuntimeState(
provider_id=IMAP_PROVIDER_ID,
binding_ref=f"mail:profile:{profile.id}:imap",
authority_mode="external_mirror",
observed_at=observed_at,
configured=True,
active=active,
health=health,
freshness=freshness,
conflict="not_applicable",
recovery=(
"not_applicable"
if not active
else "ready"
if health == "healthy" and freshness == "current"
else "attention"
),
last_success_at=indexed_at or _aware(metrics.get("last_bounce_success_at")),
detail=(
"IMAP mailbox access is disabled."
if not active
else "IMAP mailbox or bounce-source errors require attention."
if errors
else "IMAP mailbox state has not been indexed yet."
if indexed_at is None
else "IMAP mailbox index state is available."
),
metrics={
"indexed_folders": int(metrics.get("indexed_folders", 0)),
"indexed_messages": int(metrics.get("indexed_messages", 0)),
"active_bounce_sources": int(metrics.get("active_bounce_sources", 0)),
"bounce_source_errors": errors,
"active_endpoints": sum(1 for item in endpoints if item.is_active),
},
)
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)
def _aware(value: object | None) -> datetime | None:
if not isinstance(value, datetime):
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
__all__ = [
"IMAP_PROVIDER_ID",
"SMTP_PROVIDER_ID",
"imap_provider_states",
"smtp_provider_states",
]
+679
View File
@@ -0,0 +1,679 @@
from __future__ import annotations
from dataclasses import dataclass
import hashlib
from threading import Lock
from uuid import uuid4
from sqlalchemy import select
from govoplan_core.core.recovery import (
RecoveryGuaranteeError,
RecoveryMode,
RecoveryOperation,
RecoveryPlan,
RecoveryStatus,
)
from govoplan_core.core.recovery_runtime import (
DurableRecoveryOperation,
RecoveryOperationBusy,
RecoveryOperationStateConflict,
begin_durable_recovery_operation,
claim_durable_recovery_operation,
)
from govoplan_core.core.runtime_coordination import process_runtime_identity
from govoplan_core.db.session import get_database
from govoplan_mail.backend.db.models import (
MailBounceSource,
MailMailboxFolderIndex,
MailMailboxMessageIndex,
)
from govoplan_mail.backend.sending.imap import (
ImapFolderListResult,
ImapMailboxMessageListResult,
)
class MailRecoveryError(RuntimeError):
pass
class MailboxRefreshBusy(MailRecoveryError):
pass
_local_refresh_lock = Lock()
_local_refreshes: set[str] = set()
def _claim_local_refresh(key: str) -> bool:
with _local_refresh_lock:
if key in _local_refreshes:
return False
_local_refreshes.add(key)
return True
def _release_local_refresh(key: str) -> None:
with _local_refresh_lock:
_local_refreshes.discard(key)
def _digest(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _bounded_idempotency(kind: str, effect_id: str) -> str:
return f"mail-{kind}:{_digest(effect_id)}"
@dataclass(slots=True)
class ProviderEffectRecovery:
operation: DurableRecoveryOperation | None
operation_id: str
replayed: bool
kind: str
def reject(self, *, code: str, summary: str) -> None:
if self.operation is None:
return
self.operation.reject(
summary=summary,
evidence={
"verified": True,
"checks": {"provider_rejection": code},
"effect_kind": self.kind,
},
)
def unknown(self, *, code: str, summary: str) -> None:
if self.operation is None:
return
self.operation.unresolved(
status=RecoveryStatus.OUTCOME_UNKNOWN,
summary=summary,
evidence={"effect_started": True, "failure_code": code},
failure_summary=(
"Inspect the provider and reconcile the outcome before any retry"
),
)
def succeed_smtp(
self,
*,
accepted_count: int,
refused_recipients: dict[str, dict[str, int | str]],
) -> None:
if self.operation is None:
return
refused = [
{
"recipient_sha256": _digest(address.casefold()),
"classification": str(item.get("classification") or "unknown"),
"status_code": int(item.get("status_code") or 0),
}
for address, item in sorted(refused_recipients.items())
]
evidence = {
"verified": True,
"checks": {
"provider_returned": True,
"accepted_count": accepted_count,
"refused_count": len(refused),
},
"accepted_count": accepted_count,
"refused": refused,
}
if accepted_count > 0:
self.operation.succeed(evidence=evidence)
else:
self.operation.reject(
summary="SMTP definitively accepted no recipients",
evidence=evidence,
)
def succeed_imap(self, *, folder: str) -> None:
if self.operation is None:
return
self.operation.succeed(
evidence={
"verified": True,
"checks": {"provider_append_returned": True},
"folder_sha256": _digest(folder),
}
)
def begin_provider_effect_recovery(
*,
kind: str,
effect_id: str | None,
tenant_id: str,
profile_id: str,
message_bytes: bytes,
expected_transport_revision: str | None,
recipient_count: int | None = None,
folder: str | None = None,
resource_type: str | None = None,
resource_id: str | None = None,
) -> ProviderEffectRecovery | None:
"""Fence a Mail-owned provider mutation before any network effect.
``effect_id`` is optional only for compatibility with callers predating the
recovery contract. Current Mail and Campaign paths always supply one.
"""
clean_effect_id = str(effect_id or "").strip()
if not clean_effect_id:
return None
if kind not in {"smtp-delivery", "imap-append"}:
raise ValueError("Unsupported Mail provider recovery kind")
message_sha256 = hashlib.sha256(message_bytes).hexdigest()
request = {
"tenant_id": tenant_id,
"profile_id": profile_id,
"message_sha256": message_sha256,
"message_size_bytes": len(message_bytes),
"transport_revision": expected_transport_revision,
"recipient_count": recipient_count,
"folder_sha256": _digest(folder) if folder else None,
"effect_id_sha256": _digest(clean_effect_id),
}
try:
started = begin_durable_recovery_operation(
get_database().SessionLocal,
identity=process_runtime_identity(),
module_id="mail",
operation_type=kind,
idempotency_key=_bounded_idempotency(kind, clean_effect_id),
request=request,
recovery_plan=RecoveryPlan(
mode=RecoveryMode.FORWARD_RECOVERY,
preconditions=(
"Mail authorized the profile and effective transport policy",
"the caller supplied a stable effect identifier",
"the request records message and address digests, never content or credentials",
),
forward_recovery_steps=(
"inspect provider evidence without repeating the effect",
"record whether the provider accepted the effect",
"retry only under a new deliberate attempt identifier when absence is proven",
),
verification_steps=(
"compare the provider outcome with the message digest and effect identifier",
"verify the caller's durable attempt state independently",
),
),
precondition_evidence=request,
lease_resource_key=f"mail:{kind}:{tenant_id}:{_digest(clean_effect_id)[:40]}",
lease_ttl_seconds=15 * 60,
resource_type=resource_type,
resource_id=resource_id,
metadata={"resources": ["postgresql", kind.split("-", 1)[0]]},
)
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
raise MailRecoveryError(
"This Mail provider effect already has an active or unresolved recovery record"
) from exc
except (RecoveryGuaranteeError, RuntimeError) as exc:
raise MailRecoveryError(
"The Mail recovery ledger is unavailable; no provider effect was started"
) from exc
return ProviderEffectRecovery(
operation=started.operation,
operation_id=started.operation_id,
replayed=started.replayed,
kind=kind,
)
def reconcile_outbox_provider_effect(
*,
command_id: str,
effect_occurred: bool,
evidence_reference: str,
user_id: str,
) -> bool:
"""Resolve the newest unknown SMTP effect for a durable outbox command."""
factory = get_database().SessionLocal
with factory() as evidence_session:
operation = evidence_session.scalar(
select(RecoveryOperation)
.where(
RecoveryOperation.module_id == "mail",
RecoveryOperation.operation_type == "smtp-delivery",
RecoveryOperation.resource_type == "mail_delivery_command",
RecoveryOperation.resource_id == command_id,
)
.order_by(RecoveryOperation.created_at.desc(), RecoveryOperation.id.desc())
.limit(1)
)
if operation is None:
return False
if effect_occurred and operation.status == RecoveryStatus.SUCCEEDED.value:
return True
if not effect_occurred and operation.status == RecoveryStatus.RECOVERED.value:
return True
if operation.status != RecoveryStatus.OUTCOME_UNKNOWN.value:
raise MailRecoveryError(
f"The Mail recovery record is already {operation.status}"
)
operation_id = operation.id
try:
recovery = claim_durable_recovery_operation(
factory,
identity=process_runtime_identity(),
operation_id=operation_id,
lease_ttl_seconds=15 * 60,
)
recovery.resolve_unknown(
effect_occurred=effect_occurred,
summary="An operator reconciled the SMTP provider outcome",
evidence={
"verified": True,
"checks": {
"provider_evidence_sha256": _digest(evidence_reference),
"reconciled_by_user_id": user_id,
},
"effect_occurred": effect_occurred,
},
)
except (RecoveryGuaranteeError, RecoveryOperationBusy) as exc:
raise MailRecoveryError(
"The Mail provider recovery record could not be reconciled"
) from exc
return True
@dataclass(slots=True)
class MailboxRefreshRecovery:
operation: DurableRecoveryOperation
tenant_id: str
profile_id: str
folder: str
local_key: str
def complete_folders(self, result: ImapFolderListResult) -> None:
expected = {item.name for item in result.folders}
with get_database().SessionLocal() as session:
rows = session.scalars(
select(MailMailboxFolderIndex).where(
MailMailboxFolderIndex.tenant_id == self.tenant_id,
MailMailboxFolderIndex.profile_id == self.profile_id,
)
).all()
present = {row.folder for row in rows}
verified = present == expected
evidence = {
"verified": verified,
"checks": {
"expected_folder_count": len(expected),
"indexed_folder_count": len(present),
"expected_folders_sha256": _digest("\n".join(sorted(expected))),
"indexed_folders_sha256": _digest("\n".join(sorted(present))),
},
}
try:
if verified:
self.operation.succeed(evidence=evidence)
else:
self.operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="The IMAP folder read committed an incomplete mailbox index",
evidence=evidence,
failure_summary="Repeat the read-only index refresh under a new fence",
)
finally:
_release_local_refresh(self.local_key)
def complete_messages(self, result: ImapMailboxMessageListResult) -> None:
expected = {item.uid for item in result.messages}
with get_database().SessionLocal() as session:
rows = session.scalars(
select(MailMailboxMessageIndex).where(
MailMailboxMessageIndex.tenant_id == self.tenant_id,
MailMailboxMessageIndex.profile_id == self.profile_id,
MailMailboxMessageIndex.folder == result.folder,
MailMailboxMessageIndex.uid.in_(expected or {""}),
)
).all()
folder = session.scalar(
select(MailMailboxFolderIndex).where(
MailMailboxFolderIndex.tenant_id == self.tenant_id,
MailMailboxFolderIndex.profile_id == self.profile_id,
MailMailboxFolderIndex.folder == result.folder,
)
)
present = {row.uid for row in rows}
verified = (
present == expected
and folder is not None
and folder.uidvalidity == result.uidvalidity
and folder.message_count == result.total_count
)
evidence = {
"verified": verified,
"checks": {
"expected_message_count": len(expected),
"indexed_message_count": len(present),
"uids_sha256": _digest("\n".join(sorted(expected))),
"uidvalidity_matches": bool(
folder is not None and folder.uidvalidity == result.uidvalidity
),
"total_count_matches": bool(
folder is not None and folder.message_count == result.total_count
),
},
}
try:
if verified:
self.operation.succeed(evidence=evidence)
else:
self.operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="The IMAP message read committed an incomplete mailbox index",
evidence=evidence,
failure_summary="Repeat the read-only index refresh under a new fence",
)
finally:
_release_local_refresh(self.local_key)
def complete_bootstrap(
self,
folders: ImapFolderListResult,
messages: ImapMailboxMessageListResult,
) -> None:
expected_folders = {item.name for item in folders.folders}
expected_uids = {item.uid for item in messages.messages}
with get_database().SessionLocal() as session:
folder_rows = session.scalars(
select(MailMailboxFolderIndex).where(
MailMailboxFolderIndex.tenant_id == self.tenant_id,
MailMailboxFolderIndex.profile_id == self.profile_id,
)
).all()
message_rows = session.scalars(
select(MailMailboxMessageIndex).where(
MailMailboxMessageIndex.tenant_id == self.tenant_id,
MailMailboxMessageIndex.profile_id == self.profile_id,
MailMailboxMessageIndex.folder == messages.folder,
MailMailboxMessageIndex.uid.in_(expected_uids or {""}),
)
).all()
selected_folder = session.scalar(
select(MailMailboxFolderIndex).where(
MailMailboxFolderIndex.tenant_id == self.tenant_id,
MailMailboxFolderIndex.profile_id == self.profile_id,
MailMailboxFolderIndex.folder == messages.folder,
)
)
present_folders = {row.folder for row in folder_rows}
present_uids = {row.uid for row in message_rows}
verified = (
present_folders == expected_folders
and present_uids == expected_uids
and selected_folder is not None
and selected_folder.uidvalidity == messages.uidvalidity
and selected_folder.message_count == messages.total_count
)
evidence = {
"verified": verified,
"checks": {
"expected_folder_count": len(expected_folders),
"indexed_folder_count": len(present_folders),
"expected_message_count": len(expected_uids),
"indexed_message_count": len(present_uids),
"folder_set_matches": present_folders == expected_folders,
"message_window_matches": present_uids == expected_uids,
"uidvalidity_matches": bool(
selected_folder is not None
and selected_folder.uidvalidity == messages.uidvalidity
),
"total_count_matches": bool(
selected_folder is not None
and selected_folder.message_count == messages.total_count
),
},
}
try:
if verified:
self.operation.succeed(evidence=evidence)
else:
self.operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="The IMAP bootstrap committed an incomplete mailbox index",
evidence=evidence,
failure_summary="Repeat the read-only index refresh under a new fence",
)
finally:
_release_local_refresh(self.local_key)
def reject(self, *, summary: str, code: str) -> None:
try:
self.operation.reject(
summary=summary,
evidence={
"verified": True,
"checks": {
"provider_mutation": False,
"business_transaction_rolled_back": True,
"failure_code": code,
},
},
)
finally:
_release_local_refresh(self.local_key)
@dataclass(slots=True)
class BounceScanRecovery:
operation: DurableRecoveryOperation
source_id: str
local_key: str
def complete(self, *, highest_uid: int, uidvalidity: str | None) -> None:
with get_database().SessionLocal() as session:
source = session.get(MailBounceSource, self.source_id)
verified = bool(
source is not None
and source.highest_processed_uid == highest_uid
and source.uidvalidity == uidvalidity
and source.last_success_at is not None
)
evidence = {
"verified": verified,
"checks": {
"source_present": source is not None,
"cursor_matches": bool(
source is not None
and source.highest_processed_uid == highest_uid
),
"uidvalidity_matches": bool(
source is not None and source.uidvalidity == uidvalidity
),
"success_recorded": bool(
source is not None and source.last_success_at is not None
),
},
}
try:
if verified:
self.operation.succeed(evidence=evidence)
else:
self.operation.unresolved(
status=RecoveryStatus.RECOVERY_REQUIRED,
summary="The read-only bounce scan did not persist its verified cursor",
evidence=evidence,
failure_summary="Repeat the source scan under a new distributed fence",
)
finally:
_release_local_refresh(self.local_key)
def reject(self, *, code: str) -> None:
try:
self.operation.reject(
summary="The read-only bounce scan failed before its cursor committed",
evidence={
"verified": True,
"checks": {
"provider_mutation": False,
"business_transaction_rolled_back": True,
"failure_code": code,
},
},
)
finally:
_release_local_refresh(self.local_key)
def begin_mailbox_refresh_recovery(
*,
tenant_id: str,
profile_id: str,
folder: str,
purpose: str,
) -> MailboxRefreshRecovery:
folder_sha256 = _digest(folder)
refresh_id = str(uuid4())
local_key = f"mailbox:{tenant_id}:{profile_id}:{folder_sha256}"
if not _claim_local_refresh(local_key):
raise MailboxRefreshBusy(
"Another request in this runtime is already refreshing this mailbox index"
)
try:
started = begin_durable_recovery_operation(
get_database().SessionLocal,
identity=process_runtime_identity(),
module_id="mail",
operation_type="mailbox-index-refresh",
idempotency_key=f"mailbox-refresh:{refresh_id}",
request={
"tenant_id": tenant_id,
"profile_id": profile_id,
"folder_sha256": folder_sha256,
"purpose": purpose,
},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.ATOMIC,
preconditions=(
"the actor is authorized to read the selected Mail profile",
"the IMAP operation is read-only",
),
verification_steps=(
"reload the bounded mailbox index through an independent session",
"compare provider UID and folder metadata with committed cache rows",
),
),
precondition_evidence={
"profile_id": profile_id,
"folder_sha256": folder_sha256,
"provider_mutation": False,
},
lease_resource_key=f"mail:mailbox-refresh:{tenant_id}:{profile_id}:{folder_sha256[:24]}",
lease_ttl_seconds=5 * 60,
resource_type="mail_profile",
resource_id=profile_id,
metadata={"resources": ["postgresql", "imap"], "purpose": purpose},
)
except RecoveryOperationBusy as exc:
_release_local_refresh(local_key)
raise MailboxRefreshBusy(
"Another runtime is already refreshing this mailbox index"
) from exc
except (RecoveryOperationStateConflict, RecoveryGuaranteeError, RuntimeError) as exc:
_release_local_refresh(local_key)
raise MailRecoveryError(
"The mailbox recovery fence is unavailable; IMAP was not read"
) from exc
if started.operation is None:
_release_local_refresh(local_key)
raise MailRecoveryError("A mailbox refresh cannot replay a completed read")
return MailboxRefreshRecovery(
operation=started.operation,
tenant_id=tenant_id,
profile_id=profile_id,
folder=folder,
local_key=local_key,
)
def begin_bounce_scan_recovery(
*,
tenant_id: str,
profile_id: str,
source_id: str,
folder: str,
) -> BounceScanRecovery:
folder_sha256 = _digest(folder)
local_key = f"bounce:{tenant_id}:{source_id}"
if not _claim_local_refresh(local_key):
raise MailboxRefreshBusy(
"Another request in this runtime is already scanning this bounce source"
)
try:
started = begin_durable_recovery_operation(
get_database().SessionLocal,
identity=process_runtime_identity(),
module_id="mail",
operation_type="bounce-source-scan",
idempotency_key=f"bounce-scan:{uuid4()}",
request={
"tenant_id": tenant_id,
"profile_id": profile_id,
"source_id": source_id,
"folder_sha256": folder_sha256,
},
recovery_plan=RecoveryPlan(
mode=RecoveryMode.ATOMIC,
preconditions=(
"the configured source transport revision still matches",
"the IMAP scan does not alter provider mailbox state",
),
verification_steps=(
"reload the bounce source cursor independently",
"verify UIDVALIDITY, highest UID, and success timestamp",
),
),
precondition_evidence={
"source_id": source_id,
"folder_sha256": folder_sha256,
"provider_mutation": False,
},
lease_resource_key=f"mail:bounce-scan:{tenant_id}:{source_id}",
lease_ttl_seconds=15 * 60,
resource_type="mail_bounce_source",
resource_id=source_id,
metadata={"resources": ["postgresql", "imap"]},
)
except RecoveryOperationBusy as exc:
_release_local_refresh(local_key)
raise MailboxRefreshBusy(
"Another runtime is already scanning this bounce source"
) from exc
except (RecoveryOperationStateConflict, RecoveryGuaranteeError, RuntimeError) as exc:
_release_local_refresh(local_key)
raise MailRecoveryError(
"The bounce-source recovery fence is unavailable; IMAP was not read"
) from exc
if started.operation is None:
_release_local_refresh(local_key)
raise MailRecoveryError("A bounce-source scan cannot replay a completed read")
return BounceScanRecovery(
operation=started.operation,
source_id=source_id,
local_key=local_key,
)
__all__ = [
"MailRecoveryError",
"BounceScanRecovery",
"MailboxRefreshBusy",
"MailboxRefreshRecovery",
"ProviderEffectRecovery",
"begin_mailbox_refresh_recovery",
"begin_bounce_scan_recovery",
"begin_provider_effect_recovery",
"reconcile_outbox_provider_effect",
]
File diff suppressed because it is too large Load Diff
+241 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_mail.backend.config import (
@@ -103,6 +103,7 @@ class MailServerProfileCreateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool = True
inherit_to_lower_scopes: bool = True
scope_type: MailProfileScope = "tenant"
scope_id: str | None = None
smtp: SmtpServerConfig
@@ -137,6 +138,7 @@ class MailServerProfileUpdateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100)
description: str | None = None
is_active: bool | None = None
inherit_to_lower_scopes: bool | None = None
smtp: SmtpServerConfig | None = None
imap: ImapServerConfig | None = None
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
@@ -167,6 +169,127 @@ class MailServerProfileUpdateRequest(BaseModel):
)
class MailCredentialEnvelopeResponse(BaseModel):
id: str
binding_id: str | None = None
server_id: str | None = None
tenant_id: str | None = None
scope_type: MailProfileScope
scope_id: str | None = None
name: str
description: str | None = None
credential_kind: str
public_data: dict[str, Any] = Field(default_factory=dict)
secret_keys: list[str] = Field(default_factory=list)
secret_configured: bool = False
allowed_modules: list[str] = Field(default_factory=list)
allowed_server_refs: list[str] = Field(default_factory=list)
inherit_to_lower_scopes: bool = False
is_default: bool = False
is_active: bool = True
revision: str
created_at: datetime
updated_at: datetime
deleted_at: datetime | None = None
class MailServerEndpointResponse(BaseModel):
id: str
profile_id: str
tenant_id: str | None = None
protocol: Literal["smtp", "imap"]
name: str
config: dict[str, Any] = Field(default_factory=dict)
scope_type: MailProfileScope
scope_id: str | None = None
inherit_to_lower_scopes: bool = True
is_default: bool = False
is_active: bool = True
transport_revision: str
credentials: list[MailCredentialEnvelopeResponse] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class MailServerEndpointCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
protocol: Literal["smtp", "imap"]
name: str = Field(min_length=1, max_length=255)
config: dict[str, Any] = Field(default_factory=dict)
inherit_to_lower_scopes: bool | None = None
is_default: bool = False
is_active: bool = True
class MailServerEndpointUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, max_length=255)
config: dict[str, Any] | None = None
inherit_to_lower_scopes: bool | None = None
is_default: bool | None = None
is_active: bool | None = None
class MailCredentialCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = None
credential_kind: str = "username_password"
username: str | None = None
password: str | None = None
public_data: dict[str, Any] = Field(default_factory=dict)
secret_data: dict[str, Any] = Field(default_factory=dict)
inherit_to_lower_scopes: bool | None = None
allowed_modules: list[str] = Field(default_factory=lambda: ["mail"])
allowed_server_refs: list[str] = Field(default_factory=list)
is_default: bool = False
class MailCampaignCredentialCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
username: str = Field(min_length=1, max_length=320)
password: SecretStr
server_ids: list[str] = Field(min_length=1)
class MailCredentialBindRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
credential_id: str = Field(min_length=1)
is_default: bool = False
class MailCredentialUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, max_length=255)
description: str | None = None
username: str | None = None
password: str | None = None
public_data: dict[str, Any] | None = None
secret_data: dict[str, Any] | None = None
inherit_to_lower_scopes: bool | None = None
allowed_modules: list[str] | None = None
allowed_server_refs: list[str] | None = None
is_default: bool | None = None
is_active: bool | None = None
class MailCredentialUnlinkRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
retire_if_unused: bool = False
class MailCredentialListResponse(BaseModel):
credentials: list[MailCredentialEnvelopeResponse] = Field(default_factory=list)
class MailServerProfileResponse(BaseModel):
id: str
tenant_id: str | None = None
@@ -176,11 +299,13 @@ class MailServerProfileResponse(BaseModel):
slug: str
description: str | None = None
is_active: bool
inherit_to_lower_scopes: bool = True
smtp: dict[str, Any]
imap: dict[str, Any] | None = None
credentials: dict[str, Any] = Field(default_factory=dict)
smtp_password_configured: bool = False
imap_password_configured: bool = False
servers: list[MailServerEndpointResponse] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
@@ -219,6 +344,43 @@ 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"]
@@ -245,6 +407,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
@@ -311,3 +474,80 @@ class MailMailboxMessageResponse(BaseModel):
port: int | None = None
security: str | None = None
message: MailMailboxMessageDetailResponse
class MailDeliveryCommandResponse(BaseModel):
result: dict[str, Any]
class MailDeliveryReconcileRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
decision: Literal["accepted", "not_accepted"]
evidence_reference: str = Field(min_length=1, max_length=500)
note: str | None = Field(default=None, max_length=4000)
class MailDeliveryResendRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
idempotency_key: str = Field(min_length=1, max_length=200)
class MailBounceSourceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
profile_id: str = Field(min_length=1, max_length=36)
folder: str = Field(default="INBOX", min_length=1, max_length=255)
imap_server_id: str | None = Field(default=None, max_length=36)
imap_credential_id: str | None = Field(default=None, max_length=36)
is_active: bool = True
class MailBounceSourceResponse(BaseModel):
id: str
tenant_id: str
profile_id: str
folder: str
imap_server_id: str | None = None
imap_credential_id: str | None = None
expected_imap_transport_revision: str
is_active: bool
uidvalidity: str | None = None
highest_processed_uid: int
last_scanned_at: datetime | None = None
last_success_at: datetime | None = None
last_error: str | None = None
class MailBounceSourceListResponse(BaseModel):
sources: list[MailBounceSourceResponse]
class MailBounceObservationResponse(BaseModel):
id: str
tenant_id: str
profile_id: str
folder: str
uid: str
original_message_id: str | None = None
command_id: str | None = None
recipient: str | None = None
action: str
status_code: str | None = None
diagnostic: str | None = None
permanent: bool
observed_at: datetime
matched: bool
evidence: dict[str, Any] = Field(default_factory=dict)
class MailBounceObservationListResponse(BaseModel):
observations: list[MailBounceObservationResponse]
class MailBounceScanResponse(BaseModel):
sources: int
processed_messages: int
observations: int
failures: list[dict[str, str]] = Field(default_factory=list)
+292
View File
@@ -0,0 +1,292 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_mail.backend.db.models import (
MailMailboxMessageIndex,
MailServerProfile,
)
from govoplan_mail.backend.mail_profiles import mail_profile_visible_to_actor
PROVIDER_ID = "mail.mailbox_messages"
RESOURCE_TYPE = "mailbox_message"
READ_SCOPE = "mail:mailbox:read"
USE_SCOPE = "mail:profile:use"
class MailSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="mail",
resource_type=RESOURCE_TYPE,
label="Mailbox messages",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
statement = (
select(MailMailboxMessageIndex, MailServerProfile)
.join(
MailServerProfile,
MailServerProfile.id == MailMailboxMessageIndex.profile_id,
)
.where(MailMailboxMessageIndex.tenant_id == request.tenant_id)
)
if request.cursor:
statement = statement.where(
MailMailboxMessageIndex.id > request.cursor
)
rows = list(
db.execute(
statement.order_by(MailMailboxMessageIndex.id).limit(
request.limit + 1
)
).all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
high_watermark = db.scalar(
select(func.max(MailMailboxMessageIndex.indexed_at)).where(
MailMailboxMessageIndex.tenant_id == request.tenant_id
)
)
return SearchBackfillPage(
documents=tuple(
_document(message, profile=profile)
for message, profile in selected
),
next_cursor=(
selected[-1][0].id if has_more and selected else None
),
complete=not has_more,
high_watermark=(
high_watermark.isoformat()
if high_watermark is not None
else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal) or not (
principal.has(READ_SCOPE) and principal.has(USE_SCOPE)
):
return decisions
valid = tuple(
item
for item in requests
if item.reference.tenant_id == principal.tenant_id
and item.reference.module_id == "mail"
and item.reference.resource_type == RESOURCE_TYPE
)
if not valid:
return decisions
db = _session(session)
ids = {item.reference.resource_id for item in valid}
messages = {
message.id: (message, profile)
for message, profile in db.execute(
select(MailMailboxMessageIndex, MailServerProfile)
.join(
MailServerProfile,
MailServerProfile.id
== MailMailboxMessageIndex.profile_id,
)
.where(
MailMailboxMessageIndex.id.in_(ids),
MailMailboxMessageIndex.tenant_id == principal.tenant_id,
)
).all()
}
user_id = str(
getattr(principal.user, "id", "") or principal.membership_id or ""
)
for item in valid:
match = messages.get(item.reference.resource_id)
if match is None:
continue
_message, profile = match
try:
allowed = mail_profile_visible_to_actor(
db,
profile=profile,
tenant_id=principal.tenant_id,
user_id=user_id,
group_ids=principal.group_ids,
tenant_admin=principal.has("tenant:*"),
require_active=True,
)
except (RuntimeError, ValueError):
allowed = False
decisions[item.reference.key] = allowed
return decisions
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "mail"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
row = db.get(MailMailboxMessageIndex, event.resource.id)
profile = (
db.get(MailServerProfile, row.profile_id)
if row is not None and row.tenant_id == event.tenant.id
else None
)
deleted = (
row is None
or row.tenant_id != event.tenant.id
or profile is None
)
cursor = event.event_id
document = (
None
if deleted
else _document(row, profile=profile, change_cursor=cursor)
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="mail",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="delete" if deleted else "upsert",
reference=reference,
source_revision=(
document.source_revision if document is not None else cursor
),
cursor=cursor,
document=document,
occurred_at=event.occurred_at,
),
)
def create_mail_search_source(_context: ModuleContext) -> MailSearchSource:
return MailSearchSource()
def _document(
message: MailMailboxMessageIndex,
*,
profile: MailServerProfile,
change_cursor: str | None = None,
) -> SearchDocument:
tokens = [f"scope:{READ_SCOPE}", f"scope:{USE_SCOPE}"]
scope_type = str(profile.scope_type or "tenant")
if scope_type == "user" and profile.scope_id:
tokens.append(f"membership:{profile.scope_id}")
elif scope_type == "group" and profile.scope_id:
tokens.append(f"group:{profile.scope_id}")
title = (message.subject or "(No subject)")[:500]
summary = " | ".join(
value for value in (message.from_header, message.date) if value
)[:4000]
body = " ".join(
value
for value in (
message.from_header,
message.to_header,
message.cc_header,
message.body_preview,
)
if value
)[:200_000]
updated_at = message.updated_at or message.indexed_at
return SearchDocument(
tenant_id=message.tenant_id,
module_id="mail",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=message.id,
title=title,
url=(
"/mail?"
f"profileId={quote(message.profile_id, safe='')}"
f"&folder={quote(message.folder, safe='')}"
f"&uid={quote(message.uid, safe='')}"
),
summary=summary or None,
body=body or None,
keywords=(message.folder[:200], profile.name[:200]),
visibility="restricted",
acl_tokens=tuple(dict.fromkeys(tokens)),
metadata={
"profile_id": message.profile_id,
"profile_name": profile.name,
"folder": message.folder,
"uid": message.uid,
"date": message.date,
"attachment_count": message.attachment_count,
},
source_revision=updated_at.isoformat(),
change_cursor=change_cursor,
source_updated_at=updated_at,
requires_authorization_recheck=True,
)
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Mail search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Mail search requires a SQLAlchemy session.")
return value
__all__ = [
"MailSearchSource",
"PROVIDER_ID",
"RESOURCE_TYPE",
"create_mail_search_source",
]
+194 -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)
@@ -180,6 +181,29 @@ class ImapMailboxMessageResult:
message: ImapMailboxMessageDetail
@dataclass(frozen=True, slots=True)
class ImapRawMessageResult:
host: str
port: int
security: str
folder: str
uid: str
flags: list[str]
size_bytes: int
raw: bytes
@dataclass(frozen=True, slots=True)
class ImapUidListResult:
host: str
port: int
security: str
folder: str
uids: list[str]
uidvalidity: str | None
cursor_reset: bool = False
@dataclass(frozen=True, slots=True)
class ImapAppendResult:
host: str
@@ -294,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:
@@ -379,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,
)
@@ -415,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,
)
@@ -1079,6 +1133,126 @@ def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapM
_log_imap_cleanup_failure("reading message", cleanup_exc)
def get_imap_raw_message(
*,
imap_config: ImapConfig,
folder: str,
uid: str,
) -> ImapRawMessageResult:
"""Fetch bounded raw MIME without changing mailbox flags."""
host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX"
uid = str(uid).strip()
if not uid:
raise ImapConfigurationError("Message UID is required")
if is_mock_imap_host(imap_config.host):
record = get_record(uid, include_raw=True)
if not record or not _mock_folder_matches(record, folder):
raise ImapAppendError(
f"Mock mailbox message {uid!r} was not found in folder {folder!r}",
temporary=False,
)
raw = _mock_raw_bytes(record)
return ImapRawMessageResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
uid=uid,
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=len(raw),
raw=raw,
)
client = _open_imap(imap_config)
try:
_select_readonly(client, folder)
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
return ImapRawMessageResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
uid=fetched_uid,
flags=flags,
size_bytes=size_bytes if size_bytes is not None else len(raw),
raw=raw,
)
finally:
try:
client.logout()
except Exception as cleanup_exc:
_log_imap_cleanup_failure("reading raw message", cleanup_exc)
def list_imap_uids_since(
*,
imap_config: ImapConfig,
folder: str,
highest_uid: int = 0,
expected_uidvalidity: str | None = None,
limit: int = 100,
) -> ImapUidListResult:
"""Return new UIDs oldest-first so a bounded watcher cannot skip a burst."""
host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX"
bounded_limit = max(1, min(int(limit), 1_000))
if is_mock_imap_host(imap_config.host):
records = [
record
for record in list_records(limit=5_000)
if _mock_folder_matches(record, folder)
]
cursor_reset = bool(
expected_uidvalidity and expected_uidvalidity != "mock-v1"
)
effective_highest = 0 if cursor_reset else highest_uid
numeric = sorted(
int(value)
for record in records
for value in (str(record.get("id") or ""),)
if value.isdigit() and int(value) > effective_highest
)
return ImapUidListResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
uids=[str(value) for value in numeric[:bounded_limit]],
uidvalidity="mock-v1",
cursor_reset=cursor_reset,
)
client = _open_imap(imap_config)
try:
_total, uidvalidity = _select_readonly(client, folder)
cursor_reset = bool(
expected_uidvalidity and uidvalidity != expected_uidvalidity
)
effective_highest = 0 if cursor_reset else highest_uid
numeric = sorted(
int(uid)
for uid in _search_message_uids(client)
if uid.isdigit() and int(uid) > effective_highest
)
return ImapUidListResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
uids=[str(value) for value in numeric[:bounded_limit]],
uidvalidity=uidvalidity,
cursor_reset=cursor_reset,
)
finally:
try:
client.logout()
except Exception as cleanup_exc:
_log_imap_cleanup_failure("listing watcher UIDs", cleanup_exc)
def append_message_to_sent(
message_bytes: bytes,
*,
+352 -8
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(
message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
if batch_session is not None:
if batch_session.smtp_config != smtp_config:
raise SmtpConfigurationError("SMTP batch session does not match the resolved transport.")
return batch_session.send(
message_bytes,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
return _send_smtp_payload(message_bytes, smtp_config=smtp_config, envelope_from=envelope_from, envelope_recipients=envelope_recipients)
def send_email_message(
File diff suppressed because it is too large Load Diff
+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()
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_core.db.base import Base
from govoplan_core.core.calendar import CalendarInvitationRef
from govoplan_mail.backend.bounce_processing import (
SqlMailBounceProcessingProvider,
parse_delivery_status,
)
from govoplan_mail.backend.db.models import (
MailBounceObservation,
MailBounceSource,
MailDeliveryCommand,
MailServerProfile,
)
from govoplan_mail.backend.delivery_outbox import submit_delivery_command
DSN = b"""From: Mail Delivery Subsystem <mailer-daemon@example.test>
To: sender@example.test
Date: Fri, 31 Jul 2026 12:00:00 +0000
Subject: Delivery Status Notification (Failure)
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status; boundary="dsn"
--dsn
Content-Type: text/plain; charset=utf-8
Delivery failed.
--dsn
Content-Type: message/delivery-status
Reporting-MTA: dns; mx.example.test
Original-Message-ID: <outgoing-1@example.test>
Final-Recipient: rfc822; recipient@example.test
Action: failed
Status: 5.1.1
Remote-MTA: dns; destination.example.test
Diagnostic-Code: smtp; 550 mailbox unavailable
Last-Attempt-Date: Fri, 31 Jul 2026 11:59:00 +0000
--dsn
Content-Type: message/rfc822
Message-ID: <outgoing-1@example.test>
From: sender@example.test
To: recipient@example.test
Subject: Original
Body
--dsn--
"""
CALENDAR_REPLY = b"""From: Ada <ada@example.test>
To: organizer@example.test
Message-ID: <calendar-reply-1@example.test>
Subject: Accepted: Planning
MIME-Version: 1.0
Content-Type: text/calendar; method=REPLY; charset=utf-8
BEGIN:VCALENDAR
VERSION:2.0
METHOD:REPLY
BEGIN:VEVENT
UID:invitation-1@govoplan.local
DTSTART:20260805T090000Z
ATTENDEE;PARTSTAT=ACCEPTED:mailto:ada@example.test
END:VEVENT
END:VCALENDAR
"""
class MailBounceProcessingTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
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__,
MailDeliveryCommand.__table__,
MailBounceSource.__table__,
MailBounceObservation.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
session.add(
MailServerProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Delivery",
slug="delivery",
smtp_config={"host": "smtp.example.test", "port": 25},
)
)
session.commit()
self.audit_delivery = patch(
"govoplan_mail.backend.delivery_outbox.audit_event"
)
self.audit_bounce = patch(
"govoplan_mail.backend.bounce_processing.audit_event"
)
self.audit_delivery.start()
self.audit_bounce.start()
self.addCleanup(self.audit_delivery.stop)
self.addCleanup(self.audit_bounce.stop)
self.addCleanup(self.engine.dispose)
def test_parser_extracts_structured_recipient_outcome(self) -> None:
reports = parse_delivery_status(DSN)
self.assertEqual(1, len(reports))
self.assertEqual("recipient@example.test", reports[0]["recipient"])
self.assertEqual("failed", reports[0]["action"])
self.assertEqual("5.1.1", reports[0]["status_code"])
self.assertEqual(
"<outgoing-1@example.test>",
reports[0]["original_message_id"],
)
def test_processing_correlates_and_is_idempotent(self) -> None:
provider = SqlMailBounceProcessingProvider()
with self.SessionLocal() as session:
command = submit_delivery_command(
session,
tenant_id="tenant-1",
command_type="campaign_report",
source_module="campaigns",
source_resource_type="campaign",
source_resource_id="campaign-1",
source_version_id="version-1",
idempotency_key="delivery-1",
profile_id="profile-1",
message_bytes=(
b"Message-ID: <outgoing-1@example.test>\r\n"
b"Subject: Original\r\n\r\nBody"
),
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
from_header="sender@example.test",
expected_smtp_transport_revision="revision-1",
)
first = provider.process_raw_message(
session,
tenant_id="tenant-1",
profile_id="profile-1",
folder="INBOX",
uid="42",
raw_message=DSN,
)
second = provider.process_raw_message(
session,
tenant_id="tenant-1",
profile_id="profile-1",
folder="INBOX",
uid="42",
raw_message=DSN,
)
self.assertEqual(command["id"], first[0].command_id)
self.assertTrue(first[0].matched)
self.assertTrue(first[0].permanent)
self.assertEqual(first[0].id, second[0].id)
self.assertEqual(1, session.query(MailBounceObservation).count())
self.assertEqual(
"pending",
session.get(MailDeliveryCommand, command["id"]).status,
)
def test_ordinary_mail_is_not_misclassified(self) -> None:
self.assertEqual(
(),
parse_delivery_status(
b"From: person@example.test\r\nSubject: Hello\r\n\r\nNot a DSN"
),
)
def test_calendar_reply_is_forwarded_without_becoming_a_bounce(self) -> None:
class CalendarProvider:
def __init__(self) -> None:
self.calls = []
def record_icalendar_reply(self, session, **kwargs):
self.calls.append((session, kwargs))
return (
CalendarInvitationRef(
event_id="event-1",
calendar_id="calendar-1",
uid="invitation-1@govoplan.local",
correlation_id="campaign:version-1:entry-1",
source_module="campaigns",
source_resource_type="campaign_version",
source_resource_id="version-1",
),
)
calendar = CalendarProvider()
provider = SqlMailBounceProcessingProvider()
with (
self.SessionLocal() as session,
patch(
"govoplan_mail.backend.bounce_processing.calendar_invitation_provider",
return_value=calendar,
),
):
observations = provider.process_raw_message(
session,
tenant_id="tenant-1",
profile_id="profile-1",
folder="INBOX",
uid="43",
raw_message=CALENDAR_REPLY,
)
self.assertEqual((), observations)
self.assertEqual(1, len(calendar.calls))
forwarded = calendar.calls[0][1]
self.assertIn("METHOD:REPLY", forwarded["icalendar"])
self.assertEqual("43", forwarded["evidence"]["mailbox_uid"])
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()
+294
View File
@@ -0,0 +1,294 @@
from __future__ import annotations
import unittest
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from govoplan_core.db.base import Base
from govoplan_mail.backend.db.models import (
MailDeliveryAttempt,
MailDeliveryCommand,
MailDeliveryReconciliation,
MailServerProfile,
)
from govoplan_mail.backend.delivery_outbox import (
MailDeliveryIdempotencyConflict,
delivery_command_diagnostics,
dispatch_due,
purge_expired,
submit_delivery_command,
utcnow,
)
from govoplan_mail.backend.sending.smtp import SmtpSendError
class MailDeliveryOutboxTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite+pysqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
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__,
MailDeliveryCommand.__table__,
MailDeliveryAttempt.__table__,
MailDeliveryReconciliation.__table__,
],
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
session.add(
MailServerProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Delivery",
slug="delivery",
smtp_config={"host": "smtp.example.test", "port": 25},
)
)
session.commit()
self.audit = patch(
"govoplan_mail.backend.delivery_outbox.audit_event"
)
self.audit.start()
self.addCleanup(self.audit.stop)
self.addCleanup(self.engine.dispose)
def _submit(
self,
session: Session,
*,
key: str = "request-1",
recipients: list[str] | None = None,
) -> dict[str, object]:
return submit_delivery_command(
session,
tenant_id="tenant-1",
command_type="campaign_report",
source_module="campaigns",
source_resource_type="campaign",
source_resource_id="campaign-1",
source_version_id="version-1",
idempotency_key=key,
profile_id="profile-1",
message_bytes=b"Subject: report\r\n\r\ncontent",
envelope_from="sender@example.test",
envelope_recipients=recipients or ["recipient@example.test"],
from_header="Sender <sender@example.test>",
expected_smtp_transport_revision="revision-1",
created_by_user_id=None,
)
def test_submission_is_idempotent_and_conflicts_on_changed_intent(self) -> None:
with self.SessionLocal() as session:
first = self._submit(session)
session.commit()
repeated = self._submit(session)
self.assertEqual(first["id"], repeated["id"])
self.assertTrue(repeated["duplicate"])
with self.assertRaises(MailDeliveryIdempotencyConflict):
self._submit(
session,
recipients=["different@example.test"],
)
def test_attempt_is_committed_before_effect_and_accepted_is_not_resent(self) -> None:
observed: dict[str, object] = {}
with self.SessionLocal() as session:
command_id = str(self._submit(session)["id"])
session.commit()
def provider_effect(session: Session, **_kwargs):
command = session.get(MailDeliveryCommand, command_id)
attempt = session.query(MailDeliveryAttempt).one()
observed.update(
command_status=command.status,
effect_started=command.effect_started_at is not None,
attempt_status=attempt.status,
)
return SimpleNamespace(
accepted_count=1,
refused_recipients={},
)
with (
self.SessionLocal() as session,
patch(
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
side_effect=provider_effect,
) as send,
):
result = dispatch_due(session, worker_id="worker-1")
repeated = dispatch_due(session, worker_id="worker-2")
self.assertEqual(result["accepted"], 1)
self.assertEqual(repeated["selected"], 0)
self.assertEqual(send.call_count, 1)
self.assertEqual(
observed,
{
"command_status": "in_progress",
"effect_started": True,
"attempt_status": "in_progress",
},
)
def test_partial_refusal_is_terminal_and_diagnostics_are_separate(self) -> None:
with self.SessionLocal() as session:
command_id = str(
self._submit(
session,
recipients=["accepted@example.test", "blocked@example.test"],
)["id"]
)
session.commit()
with patch(
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
return_value=SimpleNamespace(
accepted_count=1,
refused_recipients={
"blocked@example.test": {
"status_code": 550,
"classification": "permanent",
"message": "Permanent recipient rejection",
}
},
),
):
result = dispatch_due(session)
command = session.get(MailDeliveryCommand, command_id)
assert command is not None
self.assertEqual(result["partially_refused"], 1)
self.assertNotIn("blocked@example.test", repr({
"status": command.status,
"summary": command.refusal_summary,
}))
diagnostics = delivery_command_diagnostics(
session,
tenant_id="tenant-1",
command_id=command_id,
)
self.assertIn(
"blocked@example.test",
diagnostics["refused_recipients"],
)
def test_unknown_outcome_is_never_automatically_retried(self) -> None:
with self.SessionLocal() as session:
command_id = str(self._submit(session)["id"])
session.commit()
with patch(
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
side_effect=SmtpSendError(
"unknown",
outcome_unknown=True,
),
) as send:
first = dispatch_due(session)
second = dispatch_due(session)
command = session.get(MailDeliveryCommand, command_id)
assert command is not None
self.assertEqual(first["outcome_unknown"], 1)
self.assertEqual(second["selected"], 0)
self.assertEqual(command.status, "outcome_unknown")
self.assertEqual(send.call_count, 1)
def test_audit_failure_after_acceptance_cannot_make_command_retryable(self) -> None:
with self.SessionLocal() as session:
command_id = str(self._submit(session)["id"])
session.commit()
with (
self.SessionLocal() as session,
patch(
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes",
return_value=SimpleNamespace(
accepted_count=1,
refused_recipients={},
),
) as send,
patch(
"govoplan_mail.backend.delivery_outbox.audit_event",
side_effect=RuntimeError("audit unavailable"),
),
):
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
dispatch_due(session)
with self.SessionLocal() as session:
command = session.get(MailDeliveryCommand, command_id)
assert command is not None
self.assertEqual(command.status, "accepted")
self.assertEqual(dispatch_due(session)["selected"], 0)
self.assertEqual(send.call_count, 1)
def test_stale_effect_started_command_becomes_unknown_without_send(self) -> None:
with self.SessionLocal() as session:
command_id = str(self._submit(session)["id"])
session.commit()
command = session.get(MailDeliveryCommand, command_id)
assert command is not None
command.status = "in_progress"
command.attempt_count = 1
command.claimed_at = utcnow() - timedelta(hours=1)
command.effect_started_at = utcnow() - timedelta(hours=1)
session.add(
MailDeliveryAttempt(
command_id=command.id,
attempt_number=1,
status="in_progress",
started_at=utcnow() - timedelta(hours=1),
effect_started_at=utcnow() - timedelta(hours=1),
)
)
session.commit()
with patch(
"govoplan_mail.backend.delivery_outbox.send_campaign_email_bytes"
) as send:
result = dispatch_due(session)
self.assertEqual(result["outcome_unknown"], 1)
self.assertEqual(command.status, "outcome_unknown")
send.assert_not_called()
def test_expired_payload_is_minimized_but_evidence_remains(self) -> None:
with self.SessionLocal() as session:
command_id = str(self._submit(session)["id"])
session.commit()
command = session.get(MailDeliveryCommand, command_id)
assert command is not None
command.expires_at = utcnow() - timedelta(seconds=1)
session.commit()
self.assertEqual(purge_expired(session), {"purged": 1})
session.refresh(command)
self.assertIsNone(command.message_encrypted)
self.assertIsNone(command.envelope_recipients_encrypted)
self.assertIsNotNone(command.payload_purged_at)
self.assertEqual(command.message_sha256, command.message_sha256)
if __name__ == "__main__":
unittest.main()
+49 -2
View File
@@ -7,7 +7,10 @@ from unittest.mock import patch
from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationContext
from govoplan_mail.backend.documentation import documentation_topics
from govoplan_mail.backend.documentation import (
documentation_configuration_states,
documentation_topics,
)
from govoplan_mail.backend.mail_profiles import EffectiveMailProfilePolicy, MailProfileError
@@ -179,7 +182,51 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
topics = {topic.id: topic for topic in get_manifest().documentation}
self.assertEqual(topics["mail.workflow.choose-and-test-profile"].metadata["help_contexts"], ["mail.profiles", "app.settings"])
self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list"])
self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list", "mail.mailbox"])
self.assertIn("mail.admin.profiles", topics["mail.profiles-and-policy"].metadata["help_contexts"])
self.assertIn("mail.bounce-processing", topics["mail.bounce-processing"].metadata["help_contexts"])
def test_configuration_provider_exposes_only_explicit_or_inherited_state(self) -> None:
inherited = EffectiveMailProfilePolicy(
source_policies=[
{"scope_type": "system", "applied_fields": ["defaults"]},
{"scope_type": "tenant", "applied_fields": []},
]
)
explicit = EffectiveMailProfilePolicy(
source_policies=[
{"scope_type": "system", "applied_fields": ["defaults"]},
{"scope_type": "tenant", "applied_fields": ["smtp_hosts"]},
]
)
context = self.context({"mail:profile:read"}, documentation_type="admin")
with patch(
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
return_value=inherited,
):
inherited_state = documentation_configuration_states(
context,
("mail_profile_policy",),
)
with patch(
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
return_value=explicit,
):
explicit_state = documentation_configuration_states(
context,
("mail_profile_policy",),
)
self.assertEqual(
inherited_state["mail_profile_policy"].state,
"inherited",
)
self.assertEqual(
explicit_state["mail_profile_policy"].state,
"enabled",
)
self.assertNotIn("smtp_hosts", repr(explicit_state))
if __name__ == "__main__":
+374
View File
@@ -0,0 +1,374 @@
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,
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__,
MailMailboxMessageIndex.__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,
)
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,
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",
}.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",
):
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()
+75
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,7 +19,9 @@ 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,
)
@@ -78,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):
@@ -207,6 +249,39 @@ class ImapMessagePaginationTests(unittest.TestCase):
class ImapMailboxCommandTests(unittest.TestCase):
def test_watcher_lists_new_uids_oldest_first(self):
class Client:
def uid(self, command, _charset, criterion):
self.search = (command, criterion)
return "OK", [b"5 9 7"]
def logout(self):
return "BYE", [b"logged out"]
client = Client()
config = ImapConfig(
host="imap.example.org",
username="user",
password="secret",
)
with (
patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client),
patch(
"govoplan_mail.backend.sending.imap._select_readonly",
return_value=(3, "42"),
),
):
result = list_imap_uids_since(
imap_config=config,
folder="INBOX",
highest_uid=6,
expected_uidvalidity="42",
limit=2,
)
self.assertEqual(["7", "9"], result.uids)
self.assertEqual(("search", "ALL"), client.search)
def test_select_quotes_mailbox_name_with_spaces(self):
class Client:
untagged_responses = {"EXISTS": [b"0"], "UIDVALIDITY": [b"1"]}
+61 -3
View File
@@ -13,6 +13,7 @@ from govoplan_mail.backend.mail_profiles import (
_assert_campaign_inherits_profile_credentials,
_campaign_mail_profile_reference_id,
_apply_profile_transport_update,
campaign_mail_selection_from_json,
campaign_profile_transport_revisions,
create_mail_server_profile,
_merge_policy,
@@ -289,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",
@@ -496,14 +542,26 @@ class MailProfileTransportHelperTests(unittest.TestCase):
class MailProfilePolicyHelperTests(unittest.TestCase):
def test_campaign_contract_accepts_only_mail_profile_reference(self):
def test_campaign_contract_accepts_only_mail_owned_references(self):
self.assertEqual(
_campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}),
"profile-1",
)
for legacy in ("smtp", "imap", "credentials", "inherit_smtp_credentials"):
with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select a Mail profile"):
with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select Mail resources"):
_campaign_mail_profile_reference_id({"mail_profile_id": "profile-1", legacy: {}})
self.assertEqual(
campaign_mail_selection_from_json(
{
"server": {
"mail_profile_id": "profile-1",
"smtp_server_id": "smtp-1",
"smtp_credential_id": "credential-1",
}
}
)["smtp_credential_id"],
"credential-1",
)
def test_campaign_delivery_fails_when_policy_requires_local_credentials(self):
profile = SimpleNamespace(imap_config=None)
@@ -511,7 +569,7 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
smtp_credentials=EffectiveCredentialPolicy(inherit=False),
)
with self.assertRaisesRegex(MailProfileError, "Store the credentials on a Mail profile"):
with self.assertRaisesRegex(MailProfileError, "explicit credential selection"):
_assert_campaign_inherits_profile_credentials(profile, policy)
def test_merge_policy_respects_locked_lower_level_limits(self):
+3
View File
@@ -14,6 +14,9 @@ class _DeleteQuery:
def filter(self, *_criteria):
return self
def all(self):
return []
def delete(self, *, synchronize_session: bool) -> int:
if synchronize_session is not False:
raise AssertionError("bulk invalidation must not synchronize loaded cache rows")
+17
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",
@@ -62,6 +72,7 @@ class MailManifestTests(unittest.TestCase):
"mail.workflow.read-mailbox",
"mail.reference.credentials-egress-retirement",
"mail.reference.campaign-delivery-contract",
"mail.address-book-integration",
}.issubset(topics)
)
ownership = topics["mail.profile-ownership-and-consumers"]
@@ -71,6 +82,7 @@ class MailManifestTests(unittest.TestCase):
mailbox = topics["mail.workflow.read-mailbox"]
self.assertEqual(mailbox.metadata["route"], "/mail")
self.assertIn("mail.mailbox", mailbox.metadata["help_contexts"])
self.assertEqual(
mailbox.conditions[0].required_scopes,
("mail:mailbox:read", "mail:profile:use"),
@@ -79,6 +91,11 @@ class MailManifestTests(unittest.TestCase):
campaign_contract = topics["mail.reference.campaign-delivery-contract"]
self.assertEqual(campaign_contract.conditions[0].required_modules, ("mail", "campaigns"))
profile_policy = topics["mail.profiles-and-policy"]
self.assertIn("mail.admin.profiles", profile_policy.metadata["help_contexts"])
bounce_processing = topics["mail.bounce-processing"]
self.assertIn("mail.bounce-processing", bounce_processing.metadata["help_contexts"])
def test_retirement_scrubs_credentials_before_dropping_mail_tables(self) -> None:
events: list[str] = []
@@ -0,0 +1,91 @@
from __future__ import annotations
import unittest
from email import message_from_bytes
from unittest.mock import ANY, patch
from govoplan_core.core.mail import NotificationMailDeliveryRequest
from govoplan_mail.backend.capabilities import MailNotificationDeliveryCapability
class MailNotificationDeliveryCapabilityTests(unittest.TestCase):
def test_missing_policy_selection_pauses_without_transport_effect(self) -> None:
capability = MailNotificationDeliveryCapability()
with patch(
"govoplan_mail.backend.delivery_outbox.submit_delivery_command"
) as submit:
result = capability.submit_notification_mail(
object(),
NotificationMailDeliveryRequest(
tenant_id="tenant-1",
notification_id="notification-1",
recipient="person@example.test",
subject="Subject",
body_text="Body",
),
)
self.assertEqual(result["status"], "paused")
submit.assert_not_called()
def test_notification_is_submitted_to_durable_mail_outbox(self) -> None:
capability = MailNotificationDeliveryCapability()
transport = {
"smtp_available": True,
"smtp_transport_revision": "revision-1",
"smtp_server_id": "server-1",
"smtp_credential_id": "credential-1",
}
command = {
"id": "command-1",
"status": "pending",
"duplicate": False,
}
with (
patch(
"govoplan_mail.backend.capabilities.campaign_profile_delivery_summary",
return_value=transport,
) as summary,
patch(
"govoplan_mail.backend.delivery_outbox.submit_delivery_command",
return_value=command,
) as submit,
):
result = capability.submit_notification_mail(
object(),
NotificationMailDeliveryRequest(
tenant_id="tenant-1",
notification_id="notification-1",
recipient="person@example.test",
subject="Subject",
body_text="Body",
body_html="<p>Body</p>",
mail_profile_id="profile-1",
from_address="notifications@example.test",
),
)
self.assertEqual(result["status"], "accepted")
self.assertEqual(result["external_message_id"], "command-1")
summary.assert_called_once_with(
ANY,
tenant_id="tenant-1",
profile_id="profile-1",
smtp_server_id=None,
smtp_credential_id=None,
)
payload = submit.call_args.kwargs
self.assertEqual(payload["idempotency_key"], "notification:notification-1")
self.assertEqual(payload["profile_id"], "profile-1")
self.assertEqual(payload["smtp_server_id"], "server-1")
self.assertEqual(payload["smtp_credential_id"], "credential-1")
message = message_from_bytes(payload["message_bytes"])
self.assertEqual(message["From"], "notifications@example.test")
self.assertEqual(message["To"], "person@example.test")
self.assertEqual(message["Subject"], "Subject")
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()
+13 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import ANY, patch
from unittest.mock import ANY, Mock, patch
from fastapi import HTTPException
from sqlalchemy.dialects import postgresql
@@ -409,6 +409,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
"govoplan_mail.backend.router._profile_response",
return_value=profile,
),
patch("govoplan_mail.backend.router.sync_default_profile_server"),
):
result = router.update_profile(
profile.id,
@@ -446,6 +447,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
"govoplan_mail.backend.router._profile_response",
return_value=profile,
),
patch("govoplan_mail.backend.router.sync_default_profile_server"),
):
router.update_profile(
profile.id,
@@ -789,11 +791,20 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
],
detected_sent_folder=None,
)
recovery = SimpleNamespace(
operation=SimpleNamespace(closed=False),
reject=Mock(),
complete_folders=Mock(),
)
with (
patch("govoplan_mail.backend.router._imap_config_for_principal", return_value=imap),
patch("govoplan_mail.backend.router.cached_mailbox_folders", return_value=stale),
patch("govoplan_mail.backend.router.list_imap_folders", return_value=provider_result) as provider,
patch("govoplan_mail.backend.router.cache_mailbox_folders") as cache,
patch(
"govoplan_mail.backend.router.begin_mailbox_refresh_recovery",
return_value=recovery,
),
):
response = router.list_profile_mailbox_folders(
"profile-1",
@@ -805,6 +816,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
provider.assert_called_once_with(imap_config=imap, include_status=False)
cache.assert_called_once()
recovery.complete_folders.assert_called_once_with(provider_result)
self.assertFalse(response.from_cache)
self.assertFalse(response.refreshing)
self.assertEqual(session.commits, 1)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_core.core.provider_governance import ExternalProviderStateContext
from govoplan_core.db.base import Base
from govoplan_mail.backend.db.models import (
MailBounceSource,
MailDeliveryCommand,
MailMailboxFolderIndex,
MailMailboxMessageIndex,
MailServerEndpoint,
MailServerProfile,
)
from govoplan_mail.backend.manifest import manifest
from govoplan_mail.backend.provider_state import (
IMAP_PROVIDER_ID,
SMTP_PROVIDER_ID,
imap_provider_states,
smtp_provider_states,
)
class MailProviderStateTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=(
MailServerProfile.__table__,
MailServerEndpoint.__table__,
MailDeliveryCommand.__table__,
MailMailboxFolderIndex.__table__,
MailMailboxMessageIndex.__table__,
MailBounceSource.__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="Mail",
slug="mail",
is_active=True,
smtp_config={"host": "smtp.example.test", "port": 587},
imap_config={"host": "imap.example.test", "port": 993},
)
self.session.add(
self.profile
)
self.session.add(
MailMailboxFolderIndex(
id="folder-1",
tenant_id="tenant-1",
profile_id=self.profile.id,
folder="INBOX",
indexed_at=datetime.now(UTC),
)
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_smtp_state_exposes_reconciliation_without_server_or_secrets(self) -> None:
self.session.add(
MailDeliveryCommand(
id="command-1",
tenant_id="tenant-1",
command_type="message",
source_module="notifications",
source_resource_type="notification",
idempotency_key="notification-1",
canonical_request_hash="a" * 64,
profile_id=self.profile.id,
expected_smtp_transport_revision="revision-1",
message_sha256="b" * 64,
message_size_bytes=10,
recipient_count=1,
status="outcome_unknown",
expires_at=datetime.now(UTC) + timedelta(days=1),
)
)
self.session.flush()
state = smtp_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual("warning", state.health)
self.assertEqual("pending", state.conflict)
self.assertEqual("attention", state.recovery)
self.assertEqual(1, state.metrics["outcome_unknown_commands"])
self.assertNotIn("smtp.example.test", str(state.to_dict()))
def test_imap_state_is_fresh_tenant_bounded_and_registered(self) -> None:
state = imap_provider_states(
ExternalProviderStateContext(session=self.session, tenant_id="tenant-1")
)[0]
self.assertEqual("healthy", state.health)
self.assertEqual("current", state.freshness)
self.assertEqual("external_mirror", state.authority_mode)
self.assertNotIn("imap.example.test", str(state.to_dict()))
self.assertEqual(
(),
imap_provider_states(
ExternalProviderStateContext(
session=self.session,
tenant_id="tenant-2",
)
),
)
self.assertEqual(
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID},
{item.id for item in manifest.external_providers},
)
self.assertEqual(
{SMTP_PROVIDER_ID, IMAP_PROVIDER_ID},
{
item.provider_id
for item in manifest.external_provider_state_providers
},
)
if __name__ == "__main__":
unittest.main()
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import json
from pathlib import Path
import tempfile
import unittest
from sqlalchemy import Column, String, Table, create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.core.recovery import (
RecoveryCheckpoint,
RecoveryGuaranteeError,
RecoveryOperation,
RecoveryStatus,
)
from govoplan_core.core.runtime_coordination import (
DistributedLease,
RuntimeIdentity,
bind_process_runtime_identity,
)
from govoplan_core.db.base import Base
from govoplan_core.db.session import configure_database, reset_database
from govoplan_mail.backend.db.models import (
MailMailboxFolderIndex,
MailMailboxMessageIndex,
MailServerProfile,
)
from govoplan_mail.backend.mailbox_index import cache_mailbox_folders
from govoplan_mail.backend.recovery import (
MailRecoveryError,
MailboxRefreshBusy,
begin_mailbox_refresh_recovery,
begin_provider_effect_recovery,
reconcile_outbox_provider_effect,
)
from govoplan_mail.backend.sending.imap import (
ImapFolderListResult,
ImapMailboxInfo,
)
class MailRecoveryTests(unittest.TestCase):
def setUp(self) -> None:
self.tempdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tempdir.cleanup)
database_path = Path(self.tempdir.name) / "mail-recovery.sqlite3"
self.engine = create_engine(f"sqlite:///{database_path}")
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,
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
MailServerProfile.__table__,
MailMailboxFolderIndex.__table__,
MailMailboxMessageIndex.__table__,
],
)
configure_database(
f"sqlite:///{database_path}",
engine=self.engine,
dispose_previous=True,
)
bind_process_runtime_identity(
RuntimeIdentity(
installation_id="mail-recovery-tests",
node_id="mail-test-node",
incarnation="mail-test-incarnation",
role="worker",
software_version="test",
composition_hash="a" * 64,
)
)
self.SessionLocal = sessionmaker(
bind=self.engine,
class_=Session,
expire_on_commit=False,
)
with self.SessionLocal() as session:
session.add(
MailServerProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
name="Recovery profile",
slug="recovery-profile",
smtp_config={},
)
)
session.commit()
self.addCleanup(self._cleanup_runtime)
def _cleanup_runtime(self) -> None:
bind_process_runtime_identity(None)
reset_database()
self.engine.dispose()
def test_smtp_effect_is_durable_before_provider_and_redacted_on_success(self) -> None:
recovery = begin_provider_effect_recovery(
kind="smtp-delivery",
effect_id="outbox:command-1:smtp-attempt:1",
tenant_id="tenant-1",
profile_id="profile-1",
message_bytes=b"Subject: Recovery\r\n\r\nBody",
expected_transport_revision="revision-1",
recipient_count=1,
resource_type="mail_delivery_command",
resource_id="command-1",
)
assert recovery is not None and recovery.operation is not None
with self.SessionLocal() as session:
operation = session.get(RecoveryOperation, recovery.operation_id)
assert operation is not None
self.assertEqual(RecoveryStatus.RUNNING.value, operation.status)
recovery.succeed_smtp(
accepted_count=1,
refused_recipients={},
)
with self.SessionLocal() as session:
operation = session.get(RecoveryOperation, recovery.operation_id)
assert operation is not None
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
evidence = json.dumps(
[
item.evidence
for item in session.scalars(
select(RecoveryCheckpoint).where(
RecoveryCheckpoint.operation_id == operation.id
)
)
]
)
self.assertNotIn("recipient@example.test", evidence)
self.assertNotIn("Subject: Recovery", evidence)
def test_unknown_smtp_outcome_blocks_replay_until_reconciled(self) -> None:
kwargs = {
"kind": "smtp-delivery",
"effect_id": "outbox:command-2:smtp-attempt:1",
"tenant_id": "tenant-1",
"profile_id": "profile-1",
"message_bytes": b"Subject: Unknown\r\n\r\nBody",
"expected_transport_revision": "revision-1",
"recipient_count": 1,
"resource_type": "mail_delivery_command",
"resource_id": "command-2",
}
recovery = begin_provider_effect_recovery(**kwargs)
assert recovery is not None
recovery.unknown(
code="socket_closed_after_data",
summary="SMTP outcome is unknown",
)
with self.assertRaises(MailRecoveryError):
begin_provider_effect_recovery(**kwargs)
self.assertTrue(
reconcile_outbox_provider_effect(
command_id="command-2",
effect_occurred=False,
evidence_reference="provider-case-42",
user_id="operator-1",
)
)
with self.SessionLocal() as session:
operation = session.get(RecoveryOperation, recovery.operation_id)
assert operation is not None
self.assertEqual(RecoveryStatus.RECOVERED.value, operation.status)
def test_mailbox_refresh_verifies_the_committed_index(self) -> None:
result = ImapFolderListResult(
host="imap.example.test",
port=993,
security="tls",
folders=[
ImapMailboxInfo(
name="INBOX",
flags=["\\HasNoChildren"],
message_count=4,
unseen_count=1,
)
],
)
recovery = begin_mailbox_refresh_recovery(
tenant_id="tenant-1",
profile_id="profile-1",
folder="*",
purpose="folders",
)
with self.SessionLocal() as session:
session.add(
MailMailboxFolderIndex(
tenant_id="tenant-1",
profile_id="profile-1",
folder="Removed",
flags=[],
)
)
session.commit()
cache_mailbox_folders(
session,
tenant_id="tenant-1",
profile_id="profile-1",
result=result,
)
session.commit()
recovery.complete_folders(result)
with self.SessionLocal() as session:
self.assertEqual(
["INBOX"],
list(
session.scalars(
select(MailMailboxFolderIndex.folder).order_by(
MailMailboxFolderIndex.folder
)
)
),
)
operation = session.get(
RecoveryOperation,
recovery.operation.operation_id,
)
assert operation is not None
self.assertEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
def test_missing_runtime_identity_fails_before_a_provider_effect_can_start(self) -> None:
bind_process_runtime_identity(None)
with self.assertRaises(MailRecoveryError):
begin_provider_effect_recovery(
kind="imap-append",
effect_id="campaign-job:job-1:imap-attempt:1",
tenant_id="tenant-1",
profile_id="profile-1",
message_bytes=b"message",
expected_transport_revision="revision-1",
folder="Sent",
)
def test_tampered_provider_evidence_cannot_be_marked_successful(self) -> None:
recovery = begin_provider_effect_recovery(
kind="smtp-delivery",
effect_id="outbox:command-3:smtp-attempt:1",
tenant_id="tenant-1",
profile_id="profile-1",
message_bytes=b"message",
expected_transport_revision="revision-1",
recipient_count=1,
resource_type="mail_delivery_command",
resource_id="command-3",
)
assert recovery is not None
with self.SessionLocal() as session:
checkpoint = session.scalar(
select(RecoveryCheckpoint)
.where(RecoveryCheckpoint.operation_id == recovery.operation_id)
.order_by(RecoveryCheckpoint.sequence)
.limit(1)
)
assert checkpoint is not None
checkpoint.summary = "tampered"
session.commit()
with self.assertRaises(RecoveryGuaranteeError):
recovery.succeed_smtp(accepted_count=1, refused_recipients={})
with self.SessionLocal() as session:
operation = session.get(RecoveryOperation, recovery.operation_id)
assert operation is not None
self.assertNotEqual(RecoveryStatus.SUCCEEDED.value, operation.status)
def test_mailbox_refresh_has_a_cross_runtime_fence(self) -> None:
recovery = begin_mailbox_refresh_recovery(
tenant_id="tenant-1",
profile_id="profile-1",
folder="INBOX",
purpose="messages",
)
bind_process_runtime_identity(
RuntimeIdentity(
installation_id="mail-recovery-tests",
node_id="mail-test-node-2",
incarnation="mail-test-incarnation-2",
role="worker",
software_version="test",
composition_hash="a" * 64,
)
)
with self.assertRaises(MailboxRefreshBusy):
begin_mailbox_refresh_recovery(
tenant_id="tenant-1",
profile_id="profile-1",
folder="INBOX",
purpose="messages",
)
recovery.reject(summary="Test refresh stopped", code="test")
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -166,6 +166,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
with (
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router.create_mail_server_profile", return_value=system_profile),
patch("govoplan_mail.backend.router.initialize_profile_hierarchy"),
patch("govoplan_mail.backend.router._record_mail_change") as create_change,
patch("govoplan_mail.backend.router._profile_response", return_value=system_profile),
):
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
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.core.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_core.db.base import Base
from govoplan_mail.backend.db.models import (
MailMailboxMessageIndex,
MailServerProfile,
)
from govoplan_mail.backend.search_source import (
MailSearchSource,
PROVIDER_ID,
RESOURCE_TYPE,
)
class MailSearchSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite://")
Base.metadata.create_all(
self.engine,
tables=(
Account.__table__,
User.__table__,
MailServerProfile.__table__,
MailMailboxMessageIndex.__table__,
),
)
self.session = Session(self.engine)
self.session.add_all(
(
Account(
id="account-1",
email="one@example.test",
normalized_email="one@example.test",
),
User(
id="user-1",
tenant_id="tenant-1",
account_id="account-1",
email="one@example.test",
),
MailServerProfile(
id="profile-1",
tenant_id="tenant-1",
scope_type="user",
scope_id="user-1",
name="Personal mailbox",
slug="personal",
smtp_config={},
imap_config={"host": "imap.example.test"},
),
MailMailboxMessageIndex(
id="message-1",
tenant_id="tenant-1",
profile_id="profile-1",
folder="INBOX",
uid="42",
subject="Permit status",
from_header="service@example.test",
body_preview="Your permit is ready",
indexed_at=datetime(2026, 8, 5, tzinfo=timezone.utc),
),
)
)
self.session.commit()
self.source = MailSearchSource()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_mailbox_backfill_and_profile_recheck_are_bounded(self) -> None:
page = self.source.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="rebuild-1",
),
)
self.assertEqual(("message-1",), tuple(doc.resource_id for doc in page.documents))
self.assertNotIn("password", str(page.documents[0].metadata).casefold())
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="mail",
resource_type=RESOURCE_TYPE,
resource_id="message-1",
)
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
with patch(
"govoplan_mail.backend.search_source.mail_profile_visible_to_actor",
return_value=True,
):
allowed = self.source.authorize(
self.session,
_principal(),
requests=(request,),
)
self.assertTrue(allowed[reference.key])
self.assertFalse(
self.source.authorize(
self.session,
_principal(scopes={"mail:mailbox:read"}),
requests=(request,),
)[reference.key]
)
def _principal(
*,
scopes: set[str] | None = None,
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
tenant_id="tenant-1",
scopes=frozenset(
scopes or {"mail:mailbox:read", "mail:profile:use"}
),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
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()
+28 -60
View File
@@ -1,21 +1,21 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.10",
"version": "0.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.10",
"version": "0.1.18",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.10",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -23,24 +23,17 @@
}
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"node_modules/cookie-es": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
"integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
"peer": true
},
"node_modules/lucide-react": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
"version": "1.28.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
"license": "ISC",
"peer": true,
"peerDependencies": {
@@ -48,9 +41,9 @@
}
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
@@ -58,34 +51,33 @@
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
"react": "^19.2.8"
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
"integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
"cookie-es": "^3.1.1"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.22.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
"react": ">=19.2.7",
"react-dom": ">=19.2.7"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -93,23 +85,6 @@
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -117,13 +92,6 @@
"license": "MIT",
"peer": true
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT",
"peer": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.10",
"version": "0.1.19",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,11 +14,11 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.10",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
@@ -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"
"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"
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
function read(relativePath) {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
}
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 moduleSource = read("../src/module.ts");
const styles = read("../src/styles/mail-profiles.css");
const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md");
assert.match(profiles, /ActionBlockerHint,[\s\S]*DocumentationHelpLink/);
for (const sharedComponent of ["ConnectionTree", "ConfirmDialog", "Dialog", "LoadingFrame"]) {
assert.match(profiles, new RegExp(`\\b${sharedComponent}\\b`));
}
assert.match(profiles, /topicId: "mail\.profiles-and-policy"/);
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/);
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)/);
assert.match(moduleSource, /"mail\.profiles"/);
assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/);
assert.match(styles, /@media \(max-width: 1280px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
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"]) {
assert.match(migration, new RegExp(archetype.replace("/", "\\/")));
}
assert.match(migration, /Shared `Dialog` owns focus entry, Escape handling, focus containment, and focus\s+return/);
assert.match(migration, /Passwords are write-only[\s\S]*saved\s+state marker/);
console.log("Mail surfaces satisfy the recorded interface pattern-language contract.");
@@ -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"
);
+303 -6
View File
@@ -4,10 +4,12 @@ import type {
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapTestPayload,
MailCredentialEnvelope,
MailProfilePolicy,
MailProfilePolicyResponse,
MailProfileScope,
MailSecurity,
MailServerEndpoint,
MailServerProfile,
MailServerProfilePayload,
MailSmtpTestPayload,
@@ -18,6 +20,7 @@ import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
export type {
MailConnectionTestResponse,
MailCredentialEnvelope,
MailCredentialPolicy,
MailImapFolderListResponse,
MailImapFolderResponse,
@@ -30,6 +33,7 @@ export type {
MailProfilePolicyResponse,
MailProfileScope,
MailSecurity,
MailServerEndpoint,
MailServerProfile,
MailServerProfileCredentialsPayload,
MailServerProfileListResponse,
@@ -61,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;
@@ -135,10 +174,90 @@ export type MailSettingsDeltaResponse = {
full: boolean;
};
export type MailBounceSource = {
id: string;
tenant_id: string;
profile_id: string;
folder: string;
imap_server_id?: string | null;
imap_credential_id?: string | null;
expected_imap_transport_revision: string;
is_active: boolean;
uidvalidity?: string | null;
highest_processed_uid: number;
last_scanned_at?: string | null;
last_success_at?: string | null;
last_error?: string | null;
};
export type MailBounceObservation = {
id: string;
profile_id: string;
folder: string;
uid: string;
original_message_id?: string | null;
command_id?: string | null;
recipient?: string | null;
action: string;
status_code?: string | null;
diagnostic?: string | null;
permanent: boolean;
observed_at: string;
matched: boolean;
evidence: Record<string, unknown>;
};
export type MailBounceSourcePayload = {
profile_id: string;
folder: string;
imap_server_id?: string | null;
imap_credential_id?: string | null;
is_active: boolean;
};
export async function listMailBounceSources(settings: ApiSettings): Promise<MailBounceSource[]> {
const response = await apiFetch<{ sources: MailBounceSource[] }>(settings, "/api/v1/mail/bounce-sources");
return response.sources;
}
export async function saveMailBounceSource(settings: ApiSettings, payload: MailBounceSourcePayload): Promise<MailBounceSource> {
return apiFetch<MailBounceSource>(settings, "/api/v1/mail/bounce-sources", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function removeMailBounceSource(settings: ApiSettings, sourceId: string): Promise<void> {
await apiFetch<void>(settings, `/api/v1/mail/bounce-sources/${encodeURIComponent(sourceId)}`, { method: "DELETE" });
}
export async function scanMailBounceSource(settings: ApiSettings, sourceId: string): Promise<{ sources: number; processed_messages: number; observations: number; failures: Array<Record<string, string>> }> {
return apiFetch(settings, `/api/v1/mail/bounce-sources/${encodeURIComponent(sourceId)}/scan`, { method: "POST" });
}
export async function listMailBounceObservations(settings: ApiSettings, limit = 100): Promise<MailBounceObservation[]> {
const response = await apiFetch<{ observations: MailBounceObservation[] }>(settings, apiPath("/api/v1/mail/bounce-observations", { limit }));
return response.observations;
}
export async function lookupMailAddresses(settings: ApiSettings, query: string, limit = 25): Promise<MailAddressLookupResponse> {
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,
@@ -176,6 +295,33 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
export type MailServerEndpointPayload = {
protocol: "smtp" | "imap";
name: string;
config: Record<string, unknown>;
inherit_to_lower_scopes?: boolean | null;
is_default?: boolean;
is_active?: boolean;
};
export type MailCredentialCreatePayload = {
name: string;
description?: string | null;
credential_kind?: string;
username?: string | null;
password?: string | null;
public_data?: Record<string, unknown>;
secret_data?: Record<string, unknown>;
inherit_to_lower_scopes?: boolean | null;
allowed_modules?: string[];
allowed_server_refs?: string[];
is_default?: boolean;
};
export type MailCredentialUpdatePayload = Partial<MailCredentialCreatePayload> & {
is_active?: boolean;
};
export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, {
method: "PATCH",
@@ -187,6 +333,118 @@ export async function deactivateMailServerProfile(settings: ApiSettings, profile
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export async function createMailServerEndpoint(
settings: ApiSettings,
profileId: string,
payload: MailServerEndpointPayload
): Promise<MailServerEndpoint> {
return apiFetch<MailServerEndpoint>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function updateMailServerEndpoint(
settings: ApiSettings,
profileId: string,
serverId: string,
payload: Partial<Omit<MailServerEndpointPayload, "protocol">>
): Promise<MailServerEndpoint> {
return apiFetch<MailServerEndpoint>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}`,
{ method: "PATCH", body: JSON.stringify(payload) }
);
}
export async function deactivateMailServerEndpoint(
settings: ApiSettings,
profileId: string,
serverId: string
): Promise<MailServerEndpoint> {
return apiFetch<MailServerEndpoint>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}`,
{ method: "DELETE" }
);
}
export async function listAvailableMailCredentials(
settings: ApiSettings,
profileId: string,
serverId: string,
includeInactive = false
): Promise<MailCredentialEnvelope[]> {
return apiGetList<MailCredentialEnvelope, "credentials">(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/available-credentials`,
"credentials",
{ include_inactive: includeInactive ? true : undefined }
);
}
export async function createMailServerCredential(
settings: ApiSettings,
profileId: string,
serverId: string,
payload: MailCredentialCreatePayload
): Promise<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function bindMailServerCredential(
settings: ApiSettings,
profileId: string,
serverId: string,
credentialId: string,
isDefault = false
): Promise<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credential-bindings`,
{
method: "POST",
body: JSON.stringify({ credential_id: credentialId, is_default: isDefault })
}
);
}
export async function updateMailServerCredential(
settings: ApiSettings,
profileId: string,
serverId: string,
credentialId: string,
payload: MailCredentialUpdatePayload
): Promise<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials/${encodeURIComponent(credentialId)}`,
{ method: "PATCH", body: JSON.stringify(payload) }
);
}
export async function unlinkMailServerCredential(
settings: ApiSettings,
profileId: string,
serverId: string,
credentialId: string,
retireIfUnused = false
): Promise<void> {
await apiFetch<void>(
settings,
apiPath(
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials/${encodeURIComponent(credentialId)}`,
{ retire_if_unused: retireIfUnused ? true : undefined }
),
{ method: "DELETE" }
);
}
export async function getMailProfilePolicy(
settings: ApiSettings,
scopeType: MailProfileScope,
@@ -211,16 +469,55 @@ export async function updateMailProfilePolicy(
});
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`);
export async function testMailProfileSmtp(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(
settings,
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, {
server_id: serverId,
credential_id: credentialId,
campaign_id: campaignId
})
);
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`);
export async function testMailProfileImap(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(
settings,
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, {
server_id: serverId,
credential_id: credentialId,
campaign_id: campaignId
})
);
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiPost<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`);
export async function listMailProfileImapFolders(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailImapFolderListResponse> {
return apiPost<MailImapFolderListResponse>(
settings,
apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, {
server_id: serverId,
credential_id: credentialId,
campaign_id: campaignId
})
);
}
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {
+261
View File
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
import { FormGrid, ContentGrid,
ActionBlockerHint,
Button,
Card,
ConfirmDialog,
DataGrid,
Dialog,
DocumentationHelpLink,
FormField,
PageActionBar,
PageLayout,
StatusBadge,
TableActionGroup,
ToggleSwitch,
adminErrorMessage,
formatDateTime,
useGuardedNavigate,
type ApiSettings,
type DataGridColumn
} from "@govoplan/core-webui";
import {
listMailBounceObservations,
listMailBounceSources,
listMailServerProfiles,
removeMailBounceSource,
saveMailBounceSource,
scanMailBounceSource,
type MailBounceObservation,
type MailBounceSource,
type MailServerProfile
} from "../../api/mail";
const MAIL_BOUNCE_DOCUMENTATION = {
topicId: "mail.bounce-processing",
documentationType: "admin"
} as const;
export default function MailBouncePage({ settings }: { settings: ApiSettings }) {
const navigate = useGuardedNavigate();
const [sources, setSources] = useState<MailBounceSource[]>([]);
const [observations, setObservations] = useState<MailBounceObservation[]>([]);
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const [addOpen, setAddOpen] = useState(false);
const [deleteSource, setDeleteSource] = useState<MailBounceSource | null>(null);
const [profileId, setProfileId] = useState("");
const [folder, setFolder] = useState("INBOX");
const [active, setActive] = useState(true);
const profileNames = useMemo(
() => new Map(profiles.map((profile) => [profile.id, profile.name])),
[profiles]
);
const imapProfiles = useMemo(
() => profiles.filter((profile) => profile.is_active && profile.imap),
[profiles]
);
const pageMutationBlocker = loading
? "Bounce evidence is already loading."
: busy
? "Wait for the current bounce-processing action to finish."
: "";
const addWatcherBlocker = pageMutationBlocker
|| (imapProfiles.length === 0 ? "Configure an active IMAP-enabled Mail profile before adding a watcher." : "");
const saveWatcherBlocker = busy
? "Wait for the current bounce-processing action to finish."
: !profileId
? "Select an active IMAP-enabled Mail profile."
: !folder.trim()
? "Enter the mailbox folder that contains delivery-status messages."
: "";
async function load() {
setLoading(true);
setError("");
try {
const [nextSources, nextObservations, nextProfiles] = await Promise.all([
listMailBounceSources(settings),
listMailBounceObservations(settings),
listMailServerProfiles(settings, true)
]);
setSources(nextSources);
setObservations(nextObservations);
setProfiles(nextProfiles);
setProfileId((current) => current || nextProfiles.find((profile) => profile.imap)?.id || "");
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
async function addSource() {
if (!profileId || !folder.trim()) return;
setBusy("add");
setError("");
try {
await saveMailBounceSource(settings, {
profile_id: profileId,
folder: folder.trim(),
is_active: active
});
setAddOpen(false);
setMessage("Bounce mailbox watcher saved.");
await load();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy("");
}
}
async function scan(source: MailBounceSource) {
setBusy(`scan:${source.id}`);
setError("");
try {
const result = await scanMailBounceSource(settings, source.id);
setMessage(`Processed ${result.processed_messages} message(s) and recorded ${result.observations} bounce observation(s).`);
await load();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy("");
}
}
async function removeSource() {
if (!deleteSource) return;
setBusy(`delete:${deleteSource.id}`);
setError("");
try {
await removeMailBounceSource(settings, deleteSource.id);
setDeleteSource(null);
setMessage("Bounce mailbox watcher removed. Existing observations were retained.");
await load();
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setBusy("");
}
}
const sourceColumns: DataGridColumn<MailBounceSource>[] = [
{
id: "profile",
header: "Mail profile",
width: "minmax(180px, 1fr)",
value: (source) => profileNames.get(source.profile_id) || source.profile_id,
render: (source) => <strong>{profileNames.get(source.profile_id) || source.profile_id}</strong>
},
{ id: "folder", header: "Folder", width: "minmax(150px, .8fr)", value: (source) => source.folder },
{
id: "status",
header: "Status",
width: 130,
value: (source) => source.last_error ? "error" : source.is_active ? "active" : "inactive",
render: (source) => <StatusBadge status={source.last_error ? "error" : source.is_active ? "success" : "inactive"} label={source.last_error ? "error" : source.is_active ? "active" : "inactive"} />
},
{ id: "cursor", header: "Last UID", width: 110, value: (source) => source.highest_processed_uid },
{
id: "lastScan",
header: "Last scan",
width: "minmax(180px, .8fr)",
value: (source) => source.last_scanned_at || "",
render: (source) => source.last_scanned_at ? formatDateTime(source.last_scanned_at) : "Never"
},
{
id: "actions",
header: "Actions",
width: 92,
sticky: "end",
render: (source) => <TableActionGroup actions={[
{ id: "scan", label: "Scan now", icon: <RotateCw aria-hidden="true" />, disabled: Boolean(busy), disabledReason: busy === `scan:${source.id}` ? "This mailbox scan is already running." : busy ? "Wait for the current bounce-processing action to finish." : "", onClick: () => void scan(source) },
{ id: "delete", label: "Remove watcher", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: Boolean(busy), disabledReason: busy ? "Wait for the current bounce-processing action to finish." : "", onClick: () => setDeleteSource(source) }
]} />
}
];
const observationColumns: DataGridColumn<MailBounceObservation>[] = [
{ id: "observed", header: "Observed", width: "minmax(170px, .8fr)", value: (item) => item.observed_at, render: (item) => formatDateTime(item.observed_at) },
{ id: "recipient", header: "Recipient", width: "minmax(210px, 1fr)", filterable: true, value: (item) => item.recipient || "Unknown", render: (item) => item.recipient || <span className="muted">Unknown</span> },
{ id: "action", header: "Outcome", width: 130, filterable: true, value: (item) => `${item.action} ${item.status_code || ""}`, render: (item) => <StatusBadge status={item.permanent ? "error" : "warning"} label={item.status_code || item.action} /> },
{ id: "diagnostic", header: "Diagnostic", width: "minmax(260px, 1.4fr)", filterable: true, value: (item) => item.diagnostic || "", render: (item) => item.diagnostic || <span className="muted">No diagnostic</span> },
{ id: "correlation", header: "Correlation", width: "minmax(180px, .8fr)", value: (item) => item.command_id || item.original_message_id || "", render: (item) => item.matched ? item.command_id || item.original_message_id : <span className="muted">Unmatched</span> }
];
return (
<>
<PageLayout
archetype="collection"
title="Bounce processing"
description="Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands."
actions={<PageActionBar
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), disabled: Boolean(pageMutationBlocker), disabledReason: pageMutationBlocker }}
contextActions={<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>}
helpAction={<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />}
createAction={<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>}
/>}
loading={loading}
loadingLabel="Loading bounce processing"
error={error}
success={message}
documentationType="admin"
>
<ContentGrid columns={2} collapseAt="workspace" className="">
<Card title="Watched mailboxes">
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
</Card>
<Card title="Delivery-status observations">
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
</Card>
</ContentGrid>
</PageLayout>
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
<FormGrid columns={1} collapseAt="standard" className="">
{imapProfiles.length === 0 &&
<ActionBlockerHint
reason={{
summary: "No active IMAP-enabled Mail profile can be watched.",
details: "Bounce processing reads a bounded authorized mailbox folder and cannot run without IMAP configuration.",
requiredAction: "Create or activate an IMAP server and credential first.",
actor: "Mail profile administrator",
target: "Settings or Administration > Mail profiles"
}}
documentation={MAIL_BOUNCE_DOCUMENTATION} />
}
<FormField label="Mail profile" documentation={MAIL_BOUNCE_DOCUMENTATION}>
<select value={profileId} disabled={Boolean(busy)} onChange={(event) => setProfileId(event.target.value)}>
<option value="">Select an IMAP profile</option>
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</FormField>
<FormField label="Bounce folder" documentation={MAIL_BOUNCE_DOCUMENTATION}>
<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" />
</FormGrid>
</Dialog>
<ConfirmDialog
open={deleteSource !== null}
title="Remove bounce mailbox watcher"
message="The watcher will stop scanning this folder. Existing bounce observations and delivery evidence remain available."
confirmLabel="Remove watcher"
tone="danger"
busy={Boolean(busy)}
onCancel={() => setDeleteSource(null)}
onConfirm={() => void removeSource()} />
</>
);
}
File diff suppressed because it is too large Load Diff
+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);
}
+289 -18
View File
@@ -1,30 +1,50 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { 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,
ExplorerTree,
IconButton,
LoadingIndicator,
MessageDisplayPanel,
hasAnyScope,
formatDateTime,
i18nMessage,
type ApiSettings
useGuardedNavigate,
type ApiSettings,
type AuthInfo
} 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";
export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
const MAILBOX_DOCUMENTATION = {
topicId: "mail.workflow.read-mailbox",
documentationType: "user"
} as const;
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[]>([]);
@@ -33,6 +53,7 @@ export default function MailboxPage({ settings }: {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("");
@@ -53,6 +74,7 @@ export default function MailboxPage({ settings }: {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]);
@@ -65,12 +87,41 @@ export default function MailboxPage({ settings }: {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");
const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094";
const profileReloadBlocker = loadingProfiles ? "Mail profiles are already loading." : "";
const folderReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing folders."
: loadingFolders || loadingMessages
? "Wait for the current mailbox refresh to finish."
: "";
const messageReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing messages."
: !selectedFolder || !foldersReady
? "Select a loaded mailbox folder before refreshing messages."
: loadingMessages
? "Messages are already loading."
: "";
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]);
@@ -83,6 +134,19 @@ export default function MailboxPage({ settings }: {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;
@@ -130,13 +194,21 @@ export default function MailboxPage({ settings }: {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("");
@@ -152,6 +224,14 @@ export default function MailboxPage({ settings }: {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;
@@ -159,6 +239,7 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
setLoadingMessages(true);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
@@ -169,7 +250,7 @@ export default function MailboxPage({ settings }: {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: [] }];
@@ -177,7 +258,10 @@ export default function MailboxPage({ settings }: {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);
@@ -192,6 +276,7 @@ export default function MailboxPage({ settings }: {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);
@@ -203,6 +288,7 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
@@ -220,6 +306,7 @@ export default function MailboxPage({ settings }: {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("");
@@ -232,6 +319,7 @@ export default function MailboxPage({ settings }: {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)) {
@@ -246,6 +334,7 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
setError(message);
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
@@ -292,6 +381,7 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
@@ -371,7 +461,7 @@ export default function MailboxPage({ settings }: {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>);
@@ -383,7 +473,7 @@ export default function MailboxPage({ settings }: {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)}>
@@ -391,22 +481,41 @@ export default function MailboxPage({ settings }: {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">
<Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</ToolbarGroup>
<ToolbarGroup align="end" className="mailbox-toolbar-actions">
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
<Activity size={16} aria-hidden="true" />
Bounce status
</Button>}
<Button onClick={() => void loadProfiles()} disabled={Boolean(profileReloadBlocker)} disabledReason={profileReloadBlocker} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.profiles.0c2a9300
</Button>
<Button onClick={() => void loadMailboxBootstrap(selectedProfileId, true)} disabled={!selectedProfileId || loadingFolders || loadingMessages} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963">
<Button onClick={() => void loadMailboxBootstrap(selectedProfileId, true)} disabled={Boolean(folderReloadBlocker)} disabledReason={folderReloadBlocker} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.folders.19adc47b
</Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c">
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true)} disabled={Boolean(messageReloadBlocker)} disabledReason={messageReloadBlocker} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c">
<RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.messages.f1702b46
</Button>
</div>
</div>
</ToolbarGroup>
</ActionToolbar>
{noImapProfiles &&
<ActionBlockerHint
className="mailbox-profile-blocker"
reason={{
summary: "No IMAP-enabled Mail profile is available.",
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP server and credential.",
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP-enabled profile.",
actor: "Mail profile administrator",
target: "Settings or Administration > Mail profiles"
}}
documentation={MAILBOX_DOCUMENTATION} />
}
<nav className="file-breadcrumbs" aria-label="i18n:govoplan-mail.current_mailbox_folder.55e2aea5">
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "i18n:govoplan-mail.mail.92379cbb"}</span>
@@ -425,6 +534,13 @@ export default function MailboxPage({ settings }: {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>
@@ -443,10 +559,11 @@ export default function MailboxPage({ settings }: {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)}
@@ -459,7 +576,7 @@ export default function MailboxPage({ settings }: {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>
@@ -468,6 +585,10 @@ export default function MailboxPage({ settings }: {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>
@@ -511,6 +632,8 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
}))}
emptyText={previewEmptyText} />
<MailboxContactActions settings={settings} message={selectedMessage} />
</div>
</section>
@@ -524,6 +647,146 @@ export default function MailboxPage({ settings }: {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}`;
@@ -533,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(",")}`;
}
@@ -1,12 +1,34 @@
export type MailProfileProtocol = "smtp" | "imap";
export type MailProfileEditSection = MailProfileProtocol;
export type MailProfilePanelMode = "all" | "server" | "credentials";
export type MailProfileCreateStage =
| "profile"
| "smtp_server"
| "smtp_credentials"
| "imap_server"
| "imap_credentials";
export const mailProfileCreateStages: readonly MailProfileCreateStage[] = [
"profile",
"smtp_server",
"smtp_credentials",
"imap_server",
"imap_credentials"
];
export type MailProfileCreateStageFields = {
name: string;
smtpHost: string;
imapHost: string;
imapUsername: string;
imapPassword: string;
};
export type MailProfileEditTarget =
| {kind: "create";}
| {kind: "profile";}
| {kind: "server";protocol: MailProfileProtocol;}
| {kind: "credentials";protocol: MailProfileProtocol;};
| {kind: "server";protocol: MailProfileProtocol;serverId?: string;}
| {kind: "credentials";protocol: MailProfileProtocol;serverId?: string;credentialId?: string;};
export type MailProfileTransportLike = {
host?: string | null;
@@ -75,6 +97,49 @@ export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditT
return target.kind !== "profile";
}
export function mailProfileCreateStagePanel(
stage: MailProfileCreateStage
): {
showProfileFields: boolean;
showSettingsPanel: boolean;
initialSection: MailProfileEditSection;
visibleSections: MailProfileEditSection[];
panelMode: MailProfilePanelMode | null;
} {
if (stage === "profile") {
return {
showProfileFields: true,
showSettingsPanel: false,
initialSection: "smtp",
visibleSections: [],
panelMode: null
};
}
const protocol = stage.startsWith("smtp") ? "smtp" : "imap";
return {
showProfileFields: false,
showSettingsPanel: true,
initialSection: protocol,
visibleSections: [protocol],
panelMode: stage.endsWith("credentials") ? "credentials" : "server"
};
}
export function mailProfileCreateStageCanContinue(
stage: MailProfileCreateStage,
fields: MailProfileCreateStageFields
): boolean {
if (stage === "profile") return Boolean(fields.name.trim());
if (stage === "smtp_server") return Boolean(fields.smtpHost.trim());
if (
stage === "imap_credentials"
&& (fields.imapUsername.trim() || fields.imapPassword)
) {
return Boolean(fields.imapHost.trim());
}
return true;
}
/**
* Keep the PATCH authority surface equal to the focused editor. Profile and
* server edits must not accidentally replay credential fields merely because
@@ -0,0 +1,144 @@
import {
filterSearchableSelectOptions,
unavailableReferenceOption,
type ApiSettings,
type CredentialReferenceSelectorContext,
type CredentialReferenceSelectorsUiCapability,
type ReferenceOption,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
import {
fetchMailSettingsDelta,
type MailServerProfile
} from "../../api/mail";
export const mailCredentialReferenceSelectors:
CredentialReferenceSelectorsUiCapability = {
serverProvider: createMailServerReferenceProvider
};
export function createMailServerReferenceProvider(
settings: ApiSettings,
context: CredentialReferenceSelectorContext
): ReferenceOptionProvider {
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
cataloguePromise ??= loadProfiles(settings, context, signal)
.then(mailServerOptions)
.catch((error: unknown) => {
cataloguePromise = null;
throw error;
});
const options = await cataloguePromise;
if (signal.aborted) throw abortError();
return options;
}
return {
async search(query, providerContext) {
const options = await catalogue(providerContext.signal);
return filterSearchableSelectOptions(
options,
query,
providerContext.limit
) as ReferenceOption[];
},
async resolve(values, providerContext) {
const options = await catalogue(providerContext.signal);
const byValue = new Map(
options.map((option) => [option.value, option])
);
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value, "Unavailable mail server")
);
}
};
}
function mailServerOptions(
profiles: readonly MailServerProfile[]
): ReferenceOption[] {
return profiles.flatMap((profile) =>
(profile.servers ?? []).map((server) => ({
value: `mail:${server.id}`,
label: server.name,
description: [
profile.name,
server.protocol.toUpperCase(),
server.is_active ? null : "Inactive",
server.id
].filter(Boolean).join(" · "),
searchText: [
profile.slug,
profile.name,
server.name,
server.protocol,
server.id
].join(" "),
kind: "mail_server",
availability: server.is_active ? "available" : "inactive",
disabled: !server.is_active,
sourceModule: "mail",
provenance: {
profileId: profile.id,
serverId: server.id,
protocol: server.protocol,
scopeType: server.scope_type,
scopeId: server.scope_id
}
} satisfies ReferenceOption))
);
}
async function loadProfiles(
settings: ApiSettings,
context: CredentialReferenceSelectorContext,
signal: AbortSignal
): Promise<MailServerProfile[]> {
let watermark: string | null = null;
let profiles: MailServerProfile[] = [];
let first = true;
do {
const response = await fetchMailSettingsDelta(settings, {
scope_type: context.scopeType,
scope_id: context.scopeId,
include_inactive: true,
since: first ? null : watermark,
limit: 200
});
if (signal.aborted) throw abortError();
profiles = response.full
? response.profiles
: mergeProfiles(profiles, response.profiles, response.deleted);
watermark = response.watermark ?? null;
first = false;
if (!response.has_more) break;
} while (watermark);
return profiles;
}
function mergeProfiles(
current: readonly MailServerProfile[],
changed: readonly MailServerProfile[],
deleted: readonly { resource_type: string; resource_id: string }[]
): MailServerProfile[] {
const removed = new Set(
deleted
.filter((item) => item.resource_type === "mail_profile")
.map((item) => item.resource_id)
);
const merged = new Map(
current
.filter((profile) => !removed.has(profile.id))
.map((profile) => [profile.id, profile])
);
for (const profile of changed) merged.set(profile.id, profile);
return [...merged.values()];
}
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}
+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;
}
+27 -1
View File
@@ -17,7 +17,7 @@ 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.campaign_local_settings.920ecb62": "campaign-scoped profiles",
"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",
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
@@ -95,6 +95,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",
@@ -292,6 +305,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",
+27 -4
View File
@@ -1,30 +1,53 @@
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 mailboxRead = ["mail:mailbox:read"];
const bounceRead = ["mail:bounce:read", "mail:bounce: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",
label: "i18n:govoplan-mail.mail.92379cbb",
version: "1.0.0",
dependencies: ["access"],
optionalDependencies: ["addresses"],
optionalDependencies: ["addresses", "audit", "notifications"],
translations,
viewSurfaces: [
{ id: "mail.admin.system-servers", moduleId: "mail", kind: "section", label: "System mail servers", order: 70 },
{ 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.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 }],
routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
{ 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 }) }],
uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors,
"quickAccess.tools": mailQuickAccessTools
},
runtimeUiCapabilities: {
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
+86 -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 {
@@ -506,6 +490,10 @@
white-space: nowrap;
}
.mailbox-profile-blocker {
margin: 12px;
}
.mailbox-breadcrumb-static {
cursor: default;
}
@@ -572,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%;
}
@@ -595,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;
@@ -610,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,6 +19,9 @@ import {
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
mailProfileEditTargetVisibleSections,
mailProfileCreateStageCanContinue,
mailProfileCreateStagePanel,
mailProfileCreateStages,
mailProfileCreateCredentialsPayload,
mailProfileTargetedUpdatePayload
} from "../src/features/mail/mailProfileEditorModel";
@@ -48,6 +51,68 @@ assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
assertDeepEqual(
mailProfileCreateStages,
[
"profile",
"smtp_server",
"smtp_credentials",
"imap_server",
"imap_credentials"
],
"profile creation keeps server and credential stages explicit"
);
assertDeepEqual(
mailProfileCreateStagePanel("smtp_credentials"),
{
showProfileFields: false,
showSettingsPanel: true,
initialSection: "smtp",
visibleSections: ["smtp"],
panelMode: "credentials"
}
);
assertDeepEqual(
mailProfileCreateStagePanel("imap_server"),
{
showProfileFields: false,
showSettingsPanel: true,
initialSection: "imap",
visibleSections: ["imap"],
panelMode: "server"
}
);
assertEqual(
mailProfileCreateStageCanContinue("profile", {
name: "",
smtpHost: "",
imapHost: "",
imapUsername: "",
imapPassword: ""
}),
false
);
assertEqual(
mailProfileCreateStageCanContinue("smtp_server", {
name: "Profile",
smtpHost: "smtp.example.org",
imapHost: "",
imapUsername: "",
imapPassword: ""
}),
true
);
assertEqual(
mailProfileCreateStageCanContinue("imap_credentials", {
name: "Profile",
smtpHost: "smtp.example.org",
imapHost: "",
imapUsername: "imap-user",
imapPassword: ""
}),
false,
"IMAP credentials cannot be submitted without an IMAP server"
);
const updateParts = {
profile: { name: "Renamed" },
+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"
]
}