Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a040de4fe5 | ||
|
|
622f63b4b5 | ||
|
|
b65431f437 | ||
|
|
c42a7816fd | ||
|
|
a63d541f0e | ||
|
|
7c8cb21144 | ||
|
|
d7dc4f6b04 | ||
|
|
72d6f8432c | ||
|
|
7844d9cab4 | ||
|
|
5b5077cde7 | ||
|
|
ff3aa066ae | ||
|
|
5a3b13e26e | ||
|
|
fe309dbff5 | ||
|
|
2721ee6542 | ||
|
|
0cb6719b91 | ||
|
|
b8029c24d6 | ||
|
|
dec5a4e350 | ||
|
|
47c98b3957 | ||
|
|
3735027b3d | ||
|
|
2813c671c0 | ||
|
|
2bccb78960 |
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
@@ -33,15 +33,30 @@ 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`.
|
||||
|
||||
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 +125,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
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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 `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.
|
||||
- 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.
|
||||
+70
-13
@@ -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,25 @@ 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.
|
||||
|
||||
## 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
|
||||
@@ -82,10 +101,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
|
||||
|
||||
@@ -270,6 +299,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 +324,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 +412,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 +445,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 +456,7 @@ 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.
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+93
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.16",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.16",
|
||||
"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.16",
|
||||
"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
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-mail"
|
||||
version = "0.1.10"
|
||||
version = "0.1.16"
|
||||
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.16",
|
||||
"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",
|
||||
]
|
||||
@@ -1,16 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid
|
||||
from typing import Any
|
||||
|
||||
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,7 +23,17 @@ 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,
|
||||
@@ -104,6 +118,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 +128,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 +185,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,15 +221,61 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -172,26 +290,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,13 +362,32 @@ 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,
|
||||
@@ -217,18 +396,43 @@ def send_campaign_email_bytes(
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
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),
|
||||
)
|
||||
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 +445,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 +536,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)
|
||||
|
||||
|
||||
@@ -309,6 +608,32 @@ class MailCampaignCapability:
|
||||
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 +644,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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -6,12 +6,19 @@ 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,
|
||||
)
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationConfigurationProviderRegistration,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
@@ -21,16 +28,42 @@ from govoplan_core.core.modules import (
|
||||
PermissionDefinition,
|
||||
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.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.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",
|
||||
)
|
||||
|
||||
@@ -86,6 +119,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 +158,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 +203,119 @@ 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.16",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns", "addresses"),
|
||||
optional_dependencies=("campaigns", "addresses", "calendar", "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"),
|
||||
),
|
||||
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 +334,49 @@ manifest = ModuleManifest(
|
||||
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),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="mail",
|
||||
@@ -194,17 +388,53 @@ 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.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(),
|
||||
},
|
||||
documentation=(
|
||||
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=38,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.profiles-and-policy",
|
||||
title="Mail profiles and policy hierarchy",
|
||||
@@ -236,12 +466,49 @@ 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.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",
|
||||
@@ -350,7 +617,7 @@ 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.",
|
||||
@@ -406,7 +673,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. 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"),
|
||||
@@ -439,6 +706,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",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+22
@@ -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
|
||||
+22
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
+1434
-49
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -311,3 +436,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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -180,6 +180,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
|
||||
@@ -1079,6 +1102,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,
|
||||
*,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
@@ -19,6 +19,7 @@ from govoplan_mail.backend.sending.imap import (
|
||||
_sequence_set,
|
||||
append_message_to_sent,
|
||||
list_imap_messages,
|
||||
list_imap_uids_since,
|
||||
)
|
||||
|
||||
|
||||
@@ -207,6 +208,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"]}
|
||||
|
||||
@@ -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,
|
||||
@@ -496,14 +497,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 +524,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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -71,6 +71,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 +80,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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
@@ -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()
|
||||
Generated
+28
-60
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.16",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.10",
|
||||
"@govoplan/core-webui": "^0.1.16",
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.16",
|
||||
"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.16",
|
||||
"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-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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(mailbox, /ActionBlockerHint/);
|
||||
assert.match(mailbox, /DocumentationHelpLink/);
|
||||
assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
|
||||
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
|
||||
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: 1250px\)[\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.");
|
||||
+254
-6
@@ -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,
|
||||
@@ -135,6 +139,72 @@ 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 }));
|
||||
}
|
||||
@@ -176,6 +246,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 +284,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 +420,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> {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
PageScrollViewport,
|
||||
PageTitle,
|
||||
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 (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
|
||||
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
|
||||
<Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
|
||||
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||
<LoadingFrame loading={loading} label="Loading bounce processing">
|
||||
<div className="dashboard-grid">
|
||||
<Card title="Watched mailboxes">
|
||||
<DataGrid id="mail-bounce-sources" rows={sources} columns={sourceColumns} getRowKey={(source) => source.id} emptyText="No bounce mailbox watchers configured." />
|
||||
</Card>
|
||||
<Card title="Delivery-status observations">
|
||||
<DataGrid id="mail-bounce-observations" rows={observations} columns={observationColumns} getRowKey={(item) => item.id} emptyText="No bounce observations recorded." />
|
||||
</Card>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
|
||||
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
|
||||
<div className="form-grid">
|
||||
{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" />
|
||||
</div>
|
||||
</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()} />
|
||||
</PageScrollViewport>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,21 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
||||
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
DataGridPaginationBar,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
MessageDisplayPanel,
|
||||
hasAnyScope,
|
||||
formatDateTime,
|
||||
i18nMessage,
|
||||
type ApiSettings
|
||||
useGuardedNavigate,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
bootstrapMailbox,
|
||||
@@ -24,7 +29,13 @@ import {
|
||||
"../../api/mail";
|
||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
||||
|
||||
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 [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||
@@ -69,6 +80,19 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
|
||||
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(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
|
||||
@@ -393,21 +417,40 @@ export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
|
||||
</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">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
+15
-3
@@ -2,11 +2,14 @@ import { createElement, lazy } from "react";
|
||||
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } 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 "./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
|
||||
@@ -17,14 +20,23 @@ export const mailModule: PlatformWebModule = {
|
||||
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 }
|
||||
],
|
||||
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
|
||||
},
|
||||
runtimeUiCapabilities: {
|
||||
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
||||
|
||||
@@ -506,6 +506,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-profile-blocker {
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.mailbox-breadcrumb-static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user