43 Commits

Author SHA1 Message Date
dec5a4e350 feat: add reusable mail reference selectors 2026-07-29 14:16:29 +02:00
47c98b3957 Declare mail workspace View surfaces 2026-07-28 21:04:55 +02:00
3735027b3d Add hierarchical mail servers and credentials 2026-07-28 19:32:56 +02:00
2813c671c0 docs: update GovOPlaN repository links 2026-07-27 15:46:50 +02:00
2bccb78960 fix(mail-webui): clarify campaign profile wording 2026-07-22 04:06:25 +02:00
3e23029090 chore(mail): classify permission identifiers for scanners 2026-07-21 21:17:40 +02:00
64009471bb chore(mail): bump version to 0.1.10 2026-07-21 20:48:47 +02:00
2e5921b3ff fix(mail-webui): constrain focused profile updates 2026-07-21 20:48:47 +02:00
9f49591a98 feat(mail): enforce owned profile self-service 2026-07-21 20:48:46 +02:00
193898b00d docs: surface custom Mail profile task 2026-07-21 19:15:16 +02:00
e6a3617a94 chore: declare Campaign access interface dependency 2026-07-21 17:51:29 +02:00
06e0fbd3c3 security: enforce actor-aware Mail profile access 2026-07-21 17:51:25 +02:00
9d1d9bfb58 docs: expand adaptive Mail guidance 2026-07-21 17:29:08 +02:00
0f1327638e docs: add multi-perspective Mail handbook 2026-07-21 17:29:01 +02:00
4a9201aeea security: scrub credentials when Mail profiles are deactivated 2026-07-21 17:12:49 +02:00
88d5012dea feat: enforce Mail-owned campaign transport boundary 2026-07-21 17:11:48 +02:00
a86a779d5b Use shared settings target layout 2026-07-21 13:47:57 +02:00
26932d5ca8 refactor(webui): rely on central button layout 2026-07-21 13:32:10 +02:00
b5b5cc8d78 fix(webui): align Mail Core peer lock 2026-07-21 13:27:04 +02:00
9c0946f8d2 Remove the unused Mail data-grid shim 2026-07-21 13:24:31 +02:00
260e579701 refactor(webui): use central mailbox icon action 2026-07-21 13:24:06 +02:00
dea1e85bd3 Use the central mailbox work-surface CSS 2026-07-21 13:19:20 +02:00
e4594c355d Reuse shared IMAP message listing path 2026-07-21 12:52:05 +02:00
f3faa4dff9 Refactor mail profile lock validation 2026-07-21 12:52:01 +02:00
ff750ed8bd refactor(webui): use central mailbox pagination 2026-07-21 12:36:49 +02:00
e1f298f300 refactor(webui): use central profile selection 2026-07-21 12:34:39 +02:00
7cf5a69a85 refactor(webui): centralize mail profile controls 2026-07-21 12:23:51 +02:00
e67da95df6 Harden mail connector network boundaries 2026-07-21 12:10:32 +02:00
b2c013174a Log mailbox refresh failures safely 2026-07-21 03:16:23 +02:00
658c1e6d5d refactor(mail): focus profile editor on protocols 2026-07-20 20:06:42 +02:00
764577dc23 fix(mail): quote IMAP mailbox commands 2026-07-20 20:06:36 +02:00
2e906d9ddd fix(mail): rate-limit direct and fallback sends 2026-07-20 20:06:31 +02:00
79d3f45068 fix(mail): repair mailbox index schema drift 2026-07-20 20:06:26 +02:00
b0337b2d26 intermittent commit 2026-07-14 13:22:11 +02:00
4865de5007 Harden mail profile validation 2026-07-11 18:37:51 +02:00
5371cb3ad4 Release v0.1.8 2026-07-11 16:49:03 +02:00
286b6cafd9 Release v0.1.7 2026-07-11 02:34:57 +02:00
47e3f9ec44 Sync GovOPlaN module state 2026-07-10 21:57:27 +02:00
30fd6e7e5f Use shared policy path help 2026-07-10 18:40:50 +02:00
caaace14f3 Use capability-based module boundaries 2026-07-10 17:33:43 +02:00
d5d5b89603 chore: sync GovOPlaN module split state 2026-07-10 12:51:22 +02:00
b4b0c76455 Release v0.1.6 2026-07-07 15:55:37 +02:00
5f800a24db Release v0.1.5 2026-07-07 15:49:06 +02:00
67 changed files with 14492 additions and 1669 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/mail
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/mail
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/mail
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/mail
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/mail
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

18
.gitignore vendored
View File

@@ -329,3 +329,21 @@ cython_debug/
# GovOPlaN WebUI test output
webui/.mail-test-build/
# GovOPlaN shared ignore rules from govoplan-core
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
*.db
# GovOPlaN local runtime state
runtime/
webui/.module-test-build/
webui/.component-test-build/

View File

@@ -32,8 +32,8 @@ PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versi
For combined checks, run:
```bash
cd /mnt/DATA/git/govoplan-core
./scripts/check-focused.sh
cd /mnt/DATA/git/govoplan
tools/checks/check-focused.sh
```
## Working Rules

View File

@@ -1,5 +1,9 @@
# govoplan-mail
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile management, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
## Ownership
@@ -7,7 +11,7 @@ GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile m
This repository owns:
- backend module manifest `mail`
- mail permissions such as `mail:profile:read`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
- mail permissions such as `mail:profile:read`, `mail:profile:write_own`, `mail:profile:write`, `mail:profile:use`, `mail:profile:test`, and `mail:mailbox:read`
- SMTP/IMAP profile models, policy checks, encrypted credential storage, and profile resolution
- SMTP send and IMAP append adapters, including mock transports for development
- development mock mailbox endpoints used by test-send flows
@@ -15,9 +19,51 @@ This repository owns:
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
## Credential inheritance policy
## Profile and credential ownership
SMTP and IMAP each have one credential inheritance decision: descendants inherit profile credentials, may inherit profile credentials, or must provide local credentials. The lower-level override switch for `smtp_credentials.inherit` and `imap_credentials.inherit` decides whether child scopes may change that decision. There is no separate "override the override" credential policy field.
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
endpoints, encrypted credentials, tests, scope, and policy. Consumers such as
Campaign store only a stable profile identifier and resolve the authorized,
active profile through `mail.campaign_delivery`; they never copy or override
transport settings or credentials in their own JSON.
The campaign capability returns read-only availability flags and random,
persisted Mail-owned transport revisions without decrypting secrets. Effect calls perform authorization,
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/GovOPlaN/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.
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.
The existing SMTP/IMAP credential-inheritance policy remains part of the Mail
policy model for compatibility. Campaign delivery requires effective
inheritance: a policy that requires campaign-local credentials now fails
closed with guidance to store those credentials on a Mail profile and enable
inheritance.
Deleting a profile deactivates its non-secret tombstone metadata and scrubs
both encrypted SMTP and IMAP passwords immediately in the same transaction as
a non-secret audit event. Destructive module retirement applies the same rule
to every remaining profile before any Mail table is dropped; a scrub or audit
failure blocks retirement.
Personal profile self-service is a distinct authorization path. An actor with
`mail:profile:write_own` can mutate only a user-scoped profile whose scope id is
their current tenant membership id; `mail:secret:manage_own` applies the same
ownership check to credentials. Neither scope permits profile-policy changes or
management of tenant, group, campaign, system, or another user's profiles.
Changing an SMTP/IMAP endpoint while a stored password remains is a secret
operation and requires the matching credential permission. Deactivating a
credential-free profile needs only profile-write authority; if a password will
be scrubbed, credential authority is required and the deletion is audited.
## Development
@@ -50,9 +96,25 @@ Frontend package:
@govoplan/mail-webui
```
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection.
The campaign module consumes `mail.campaign_delivery` for authorized runtime
profile resolution, sending, append-to-Sent behavior, profile selection, and
policy checks. Mail does not
import campaign internals; campaign-scoped policy and owner context are resolved
through the core `campaigns.mailPolicyContext` capability when the campaign
module is installed.
Development mailbox routes are registered by the mail module only when the
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
does not contribute these routes directly.
POP3 and JMAP are deferred. The protocol decision is documented in
[docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md): stabilize
SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3
only for explicit legacy-download requirements.
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.
## Release packaging

405
docs/MAIL_HANDBOOK.md Normal file
View File

@@ -0,0 +1,405 @@
# Mail Handbook
## Purpose and status
This handbook is the canonical multi-perspective description of the GovOPlaN
Mail module. Mail is a governed transport and mailbox capability. It is not a
campaign definition, contact database, or general records store.
| Perspective | Start here |
| --- | --- |
| User selecting a profile or reading a mailbox | [User tasks](#user-tasks) |
| Mail profile administrator | [Profile administration](#profile-administration) |
| Policy or tenant administrator | [Policy hierarchy](#policy-hierarchy) |
| Delivery operator | [Operations and recovery](#operations-and-recovery) |
| Integrator | [Capability contract](#capability-contract) |
| Security or audit reviewer | [Security, deletion, and audit](#security-deletion-and-audit) |
| 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/GovOPlaN/govoplan-campaign/src/branch/main/docs/MAIL_PROFILE_BOUNDARY.md).
## Domain ownership
Mail owns:
- reusable SMTP/IMAP profile definitions and scope;
- encrypted SMTP/IMAP credentials and safe credential replacement;
- effective profile policy and visibility/authorization decisions;
- connection tests and protocol adapters;
- SMTP send and IMAP append operations exposed to consumers;
- read-only mailbox folder/message access and its bounded indexes; and
- the transport sanitization boundary and throttling behavior. A general
Mail-owned provider-attempt/diagnostic ledger remains planned.
Consumers own their business intent. For example, Campaign owns message
content, recipients, approval, jobs, and delivery evidence, while Mail owns the
profile and performs the provider operation. A consumer stores only a stable
Mail profile reference and non-secret evidence returned by Mail. It never
receives decrypted credentials or a raw resolved transport configuration.
Core owns authentication, tenant context, permission evaluation, database
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.
## Concepts
### Profile
A profile is a reusable, named delivery identity with optional SMTP and IMAP
configuration. It has a stable id, lifecycle state, scope, owner context, and
non-secret connection metadata. Passwords are write-only encrypted values and
are never returned through list/read/capability responses.
Profiles may be scoped to system, tenant, user, group, or campaign context.
Scope controls where a profile can be discovered; effective policy can narrow
that further. A visible profile is not automatically authorized for every
operation: using, testing, managing, and managing secrets are separate rights.
### Policy
Mail policy controls approved profile ids, which lower scopes may define
profiles, allowed/denied hosts and addressing patterns, credential inheritance,
and lower-level limits. The effective result is assembled from applicable
system, tenant, user/group, and campaign context. Denials and locked parent
limits cannot be relaxed by a lower scope.
### Transport identity and revision
Mail owns opaque random revisions for relevant SMTP/IMAP identity and
configuration. They are concurrency tokens, not deterministic hashes that a
consumer could use to guess a host or account. Credentials are excluded. A
consumer can freeze these revisions at build time and ask Mail to require the
same identity when the effect is later performed. Credential rotation alone
therefore need not invalidate prepared work, while a host/account/protocol
identity change does.
The revision is evidence and a concurrency guard, not a substitute for
authorization. Mail re-evaluates profile activity, visibility, policy, and
revision immediately before it resolves credentials and performs the
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.
## User tasks
### Choose a profile
1. Open the task that requires mail, such as Campaign **Mail settings**.
2. Choose from the profiles visible and authorized for the current tenant,
owner, group, and task context. Never enter or copy a profile id manually
when the UI can present a picker.
3. Review the safe summary: name, scope, active state, SMTP/IMAP availability,
and policy-relevant sender identity. Credentials and raw provider internals
are not visible.
4. Save the reference in the consuming module. If ownership or policy changes,
select and validate again.
The user needs `mail:profile:use`; reading profile summaries and testing may
require their own scopes. The consuming module still requires its own action
permission.
### Test a profile
An authorized profile test verifies connection and authentication for the
selected active/visible SMTP or IMAP profile using Mail-owned credentials. The
consumer-use path evaluates effective Mail policy separately. Use a
non-production provider and mailbox first. A successful connection test does
not prove policy authorization for a later Campaign context, deliverability,
recipient acceptance, SPF/DKIM/DMARC alignment, or future availability.
Testing a saved profile requires both `mail:profile:test` and
`mail:profile:use`, and the profile must be active. Profile creation or test
authority alone is not enough.
Raw settings test endpoints accept new settings only for actors who may both
test profiles and manage secrets. They are an administration aid, not a way for
ordinary consumers to bypass reusable profiles.
### Read a mailbox
The current mailbox UI and API are read-only. An authorized user can list IMAP
folders, page through messages, and inspect a bounded full message. Folder
names are parsed and quoted defensively; Sent-folder discovery uses provider
flags and common names.
Mailbox access requires both `mail:mailbox:read` and `mail:profile:use`. It must
not mutate read/unread, delete, move, or reply state. Message responses are
bounded by the deployment response policy; ordinary UI should avoid loading a
whole large mailbox or attachment merely to show a list.
## Profile administration
### Roles
The supplied templates are:
- **Mail profile user:** read/use/test approved profiles and read permitted
mailboxes without reading secrets.
- **Mail profile self-service user:** additionally create, edit, deactivate,
and manage credentials only for the current account's own user-scoped
profiles, subject to the effective Mail policy.
- **Mail profile administrator:** additionally create/update profiles and
create/replace encrypted credentials across tenant-owned scopes.
The specific permissions are `mail:profile:read`, `mail:profile:use`,
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write_own`,
`mail:secret:manage_own`, `mail:profile:write`, and `mail:secret:manage`.
The `_own` permissions are enforced against the authenticated membership id and
never authorize a tenant, group, campaign, system, or another user's profile.
They also do not authorize profile-policy changes. System-scoped definitions
use the corresponding system settings authority. Keep secret management
separate when an institution wants profile metadata administrators not to know
or replace credentials.
That separation is fail-closed for transport rebinding: changing an SMTP or
IMAP host, port, or security mode while the profile retains a stored password
requires the matching secret-management permission. A credential-free profile
can be deactivated with profile-write authority alone; deactivation that
scrubs a stored password also requires secret-management authority and records
the deletion in the audit log.
### Create or change a profile
The configured Help Center exposes **Create a custom Mail profile** only when
the current actor has broad or self-service profile-write authority and the
effective user-scope policy permits user profiles. It states the active SMTP/IMAP hostname
allow-list groups and deny rules, plus the actor's separate credential, test,
use, and approval requirements. The Settings task creates in the current
account's user scope. Grant `mail:profile:write_own` for self-service;
`mail:profile:write` remains broad profile administration authority.
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
behavior, and timeouts. Sender/envelope/recipient constraints belong to
effective Mail policy; Campaign rate limits remain delivery configuration.
3. Enter credentials only in the dedicated credential fields. Returned profile
data indicates whether credentials are configured without returning them.
4. Save and run SMTP/IMAP tests against a non-production target.
5. Verify the effective policy for every intended consumer context.
6. Communicate changes that alter the non-secret transport identity; prepared
consumer snapshots will deliberately stop until rebuilt.
An update that omits a password preserves the current encrypted password. A
credential replacement never depends on reading the old cleartext value back.
### Delete a profile
Profile deletion is immediate for Mail-owned secrets and audit evidence:
1. Mail clears both encrypted SMTP and IMAP passwords in the same transaction.
2. It deactivates the remaining non-secret tombstone state so historical stable
references can fail safely rather than resolve to another profile.
3. When owned encrypted secrets existed, it emits
`mail.profile_credentials_deleted` with profile id, scope, reason, and the
kinds of secret removed—never values, usernames/passwords, or raw
configuration.
4. If scrubbing or audit insertion fails, the transaction rolls back and the
deletion fails.
5. Repeating deletion after the secrets are gone is an idempotent no-op for the
secret-deletion effect.
Consumers that still reference the profile fail closed at validation or
execution. Deletion does not silently rebind them to a default profile.
## Policy hierarchy
Effective policy is contextual. Administrators should document:
- which profile ids are approved globally or for a tenant;
- whether tenant, user, group, or campaign-scoped profiles may be created;
- allowed and denied SMTP/IMAP hosts;
- permitted From, envelope sender (including bounce address), and envelope
recipient-domain patterns;
- whether SMTP/IMAP credentials inherit from the reusable profile; and
- which lower-level settings are locked by a parent policy.
Campaign delivery requires reusable profile credentials. A legacy policy that
requires campaign-local credentials fails closed with guidance to store them on
the Mail profile and enable effective inheritance. This preserves compatibility
of the policy model without reopening a consumer-owned secret store.
Policy reads are available through system/tenant/context routes to suitably
authorized actors. Adaptive Docs exposes a safe explanation of the effective
tenant posture; it does not expose credential material.
## Operations and recovery
### Deployment egress policy
Private-network connector access is controlled deployment-wide by
`GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS`. Whether private or public targets
are allowed, SMTP and IMAP resolve, validate, and connect to the exact approved
address records at connection time while retaining the original hostname for
TLS SNI and certificate verification. A DNS change cannot redirect the socket
after validation.
Transports that cannot pin every connection peer or revalidate protocol-managed
redirects/referrals must fail before client construction. Do not weaken this
rule to make a target test pass; change the connector implementation or the
target topology.
### Response and time bounds
The shared defaults are 16 MiB for structured connector responses and 512 MiB
for file transfers. Mailbox full-message fetch additionally uses bounded IMAP
ranges and the applicable deployment limit. Operators may lower limits for
their environment. A declared or streamed over-limit response fails visibly
rather than being retained partially as if complete.
Configure connection/read timeouts and provider-specific message/attachment
limits. A timeout after the SMTP effect may be an unknown outcome; it must not
be flattened into a safe-to-retry connection failure.
### Throttling
Mail rate limiting uses a Redis lock and next-allowed timestamp across worker
processes when worker mode is enabled. Direct development uses a process-local
limiter. If Redis fails in worker mode, the current implementation silently
falls back to a limiter that protects only that process; it does not provide
cluster-wide coordination or emit its own health signal. Production operators
must therefore monitor Redis independently and should stop or restrict
multi-worker delivery during an outage until explicit degradation telemetry is
implemented.
### SMTP/IMAP incidents
1. Stop new consumer work if duplicate effects or credential compromise are
possible.
2. Preserve safe Mail, consumer-job, worker, and provider evidence.
3. Rotate credentials in Mail. Rebuild consumers only if the revisioned
non-secret identity also changed.
4. Treat post-command connection loss as potentially unknown until provider
evidence establishes whether SMTP accepted the message.
5. Handle IMAP append failure independently; do not resend an accepted message
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.
### Backup, restore, and retirement
Backups contain encrypted credentials and therefore need the same protection as
the live database and key material. Restoring a Mail database without the
matching encryption key makes credentials unusable; restoring it with keys can
reactivate sensitive historical state and must be controlled.
Destructive module retirement first applies the same immediate credential
scrub/audit rule to every remaining profile, then drops Mail-owned tables after
the installer snapshot/confirmation gate. Any scrub or audit failure blocks
retirement. Historical backups are separate retained copies and require the
deployment's approved backup-destruction policy.
## Capability contract
Mail provides `mail.campaign_delivery` version `0.2.x`. This version is a
breaking ownership boundary: it exposes reference-oriented operations, not raw
resolved configuration or credentials.
The Mail REST API supplies the profile list used by pickers. Given a stable
profile reference, the capability supports these kinds of operation:
- resolve a profile reference and evaluate its Mail context/policy to produce a
non-secret delivery summary and
opaque Mail-owned revisions;
- send Campaign-owned RFC message bytes using the selected profile;
- optionally append an accepted message to Sent; and
- return sanitized outcome/revision evidence.
For a real effect, one Mail call must:
1. resolve current campaign/tenant/owner context through the optional narrow
context capability;
2. load the selected active profile;
3. evaluate profile-scope eligibility and effective policy;
4. compare the consumer's expected opaque transport revision;
5. decrypt credentials in Mail memory;
6. perform the SMTP or IMAP operation; and
7. return only the safe result.
Keeping comparison, credential resolution, and transport inside one operation
avoids a time-of-check/time-of-use gap and prevents the consumer from becoming
a credential-processing boundary. The capability does not receive the acting
principal: the Campaign route must first enforce `mail:profile:use`, while Mail
still re-evaluates active profile scope and contextual policy inside the call.
Mail optionally consumes `campaigns.mail_policy_context` and
`addresses.lookup`. Their physical absence must leave Mail installable and its
unrelated profile/mailbox behavior usable. Interface-version compatibility is a
mandatory release gate.
## Security, deletion, and audit
Security invariants:
- Decrypted SMTP/IMAP passwords never cross the Mail capability/API boundary.
- Password fields are write-only and encrypted at rest; safe responses expose
configuration state, not values.
- Consumers persist stable profile references, not transport copies.
- Host policy is deployment-wide and every supported connection is DNS/IP
pinned at socket creation.
- Redirect/referral-capable transports fail closed unless every peer can be
revalidated and pinned.
- Profile deletion and destructive retirement scrub every Mail-owned encrypted
password and emit non-secret audit in the same lifecycle action.
- Mailbox reads are separately authorized, read-only, paginated, and bounded.
- Logs/audit/results minimize message content, addresses, provider responses,
and secret-like values.
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/GovOPlaN/govoplan-mail/issues/17).
## Acceptance checklist
Before claiming a Mail composition is production-ready:
1. Install Mail alone with Core and with each optional consumer absent.
2. Prove profile scope, visibility, use, test, write, and secret permissions for
representative tenant/user/group contexts.
3. Prove policy deny precedence, locked lower limits, approved-profile mode, and
the campaign-local-credential fail-closed path.
4. Verify all read/list/capability/audit/log responses contain no plaintext or
encrypted password, raw credential token, or consumer-owned inline config.
5. Test DNS rebinding resistance and private/public deployment policy for SMTP
and IMAP.
6. Test SMTP acceptance, refusal, temporary/permanent failure, timeout/unknown
outcome, and IMAP append success/failure against the target provider.
7. Test Redis worker throttling and prove that independent infrastructure
monitoring detects Redis loss; restrict multi-worker delivery during the
current silent local-only fallback.
8. Delete tenant and system profiles, inject audit failure, repeat deletion, and
prove transactional scrub/rollback/idempotency.
9. Run destructive-retirement preflight on a snapshot and prove credentials are
scrubbed/audited before table drop.
10. Pass module permutations, interface/version alignment, WebUI/i18n, full
security audit, backup/restore, and target-environment release checks.
## 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/GovOPlaN/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.
- Reusable SMTP batch sessions and their final connection/error/isolation
semantics (`govoplan-mail#16`).
- Final Campaign **test / single send / single resend** semantics; those are a
Campaign business-action contract built on Mail transport operations.
- JMAP mailbox synchronization/search; it is preferred only after the IMAP MVP
is stable.
- POP3 except for a future explicit legacy download/import requirement.
- A full mail client with compose/reply/move/delete/read-state mutation.
- Proof that process-local throttling coordinates multiple workers when Redis
is unavailable; it deliberately does not.

View File

@@ -0,0 +1,50 @@
# Mail Protocol Roadmap
GovOPlaN Mail currently focuses on SMTP sending and IMAP mailbox access. POP3
and JMAP are deferred until the IMAP mailbox MVP is stable.
## Current Baseline
- SMTP is the send protocol.
- IMAP is the read/append protocol.
- Mail profile policy, encrypted credentials, mailbox folder parsing, test
buttons, and read-only mailbox UI are built around SMTP and IMAP.
This baseline matches the first production use case: send campaign mail, append
sent copies when configured, and inspect mailboxes read-only.
## JMAP
JMAP is the preferred future sync/search protocol where target mail servers
support it.
Reasons:
- HTTP/JSON transport fits the platform API style better than stateful IMAP
- efficient mailbox state sync and changes endpoints
- modern search and thread models
- better fit for browser-facing mailbox UX through a server proxy
JMAP should be added only after:
- the IMAP mailbox MVP has stable folder/message pagination behavior
- mail profile policy can express protocol-specific availability
- mailbox UI can handle protocol-neutral folder/message DTOs
- test infrastructure includes at least one reliable JMAP server target
## POP3
POP3 should remain legacy-only.
Add it only when a concrete deployment requires mailbox download from a server
that cannot offer IMAP or JMAP. POP3 is a poor fit for the normal GovOPlaN
mailbox UX because it has limited folder, sync, and server-side state semantics.
If implemented, POP3 should be scoped to explicit download/import workflows, not
general mailbox browsing.
## Decision
Do not add POP3 or JMAP now. Stabilize SMTP/IMAP first, design protocol-neutral
mailbox DTOs, then prefer JMAP for modern servers and reserve POP3 for explicit
legacy download requirements.

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.4",
"version": "0.1.10",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,8 +19,8 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-mail"
version = "0.1.4"
version = "0.1.10"
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.4",
"govoplan-core>=0.1.9",
"pydantic>=2,<3",
"redis>=5,<6",
"SQLAlchemy>=2,<3",

View File

@@ -1,24 +1,501 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.modules import ModuleContext
from govoplan_mail.backend.runtime import configure_runtime
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
apply_campaign_credentials,
_assert_campaign_inherits_profile_credentials,
assert_campaign_mail_policy_allows_json,
assert_mail_policy_allows_send,
effective_profile_credentials_inherited,
campaign_mail_owner_context,
campaign_profile_transport_revisions,
effective_mail_profile_policy,
ensure_mail_profile_allowed_for_campaign,
get_mail_server_profile,
imap_config_from_profile,
mail_profile_id_from_campaign_json,
materialize_campaign_mail_profile_config,
smtp_config_from_profile,
)
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
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.sending.imap import (
ImapAppendError,
ImapConfigurationError,
append_message_to_sent,
)
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes, send_email_message
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
@dataclass(frozen=True, slots=True)
class CampaignSmtpDeliveryResult:
envelope_recipients: list[str]
refused_recipients: dict[str, dict[str, int | str]]
@property
def accepted_count(self) -> int:
return len(self.envelope_recipients) - len(self.refused_recipients)
@dataclass(frozen=True, slots=True)
class CampaignImapAppendResult:
folder: str
def _sanitized_refusals(
refused_recipients: dict[str, tuple[int, bytes | str]],
) -> dict[str, dict[str, int | str]]:
sanitized: dict[str, dict[str, int | str]] = {}
for recipient, (raw_code, _provider_message) in refused_recipients.items():
try:
code = int(raw_code)
except (TypeError, ValueError):
code = 0
if 400 <= code < 500:
classification = "temporary"
message = "Temporary recipient rejection"
elif 500 <= code < 600:
classification = "permanent"
message = "Permanent recipient rejection"
else:
classification = "unknown"
message = "Recipient rejected"
sanitized[str(recipient)] = {
"status_code": code,
"classification": classification,
"message": message,
}
return sanitized
def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
if exc.outcome_unknown:
message = "Mail delivery outcome is unknown after transmission started."
elif exc.temporary:
message = "Mail delivery failed temporarily."
else:
message = "Mail delivery was rejected."
return SmtpSendError(
message,
temporary=exc.temporary,
outcome_unknown=exc.outcome_unknown,
)
def _sanitized_imap_error(exc: ImapAppendError) -> ImapAppendError:
if exc.outcome_unknown:
message = "The Sent-folder append outcome is unknown; inspect the mailbox before retrying."
elif exc.temporary:
message = "Appending the sent message failed temporarily."
else:
message = "Appending the sent message was rejected."
return ImapAppendError(
message,
temporary=exc.temporary,
outcome_unknown=exc.outcome_unknown,
)
def _authorized_campaign_profile(
session: Session,
*,
tenant_id: str,
campaign_id: str,
profile_id: str,
selection: dict[str, str | None] | None = None,
):
profile = ensure_mail_profile_allowed_for_campaign(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
profile_id=profile_id,
require_active=True,
)
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
_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,
*,
tenant_id: str,
profile_id: str,
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": {key: value for key, value in selection.items() if value}},
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
profile = get_mail_server_profile(
session,
tenant_id=tenant_id,
profile_id=profile_id,
require_active=True,
)
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_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,
}
def send_campaign_email_bytes(
session: Session,
*,
tenant_id: str,
campaign_id: 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,
) -> 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
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:
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:
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
try:
assert_mail_policy_allows_send(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
smtp=smtp,
imap=None,
envelope_sender=envelope_from,
from_header=from_header,
recipients=envelope_recipients,
)
except MailProfileError:
raise MailProfileError("Mail delivery is blocked by the effective Mail policy.") from None
try:
result = send_email_bytes(
message_bytes,
smtp_config=smtp,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
except SmtpSendError as exc:
raise _sanitized_smtp_error(exc) from None
except SmtpConfigurationError:
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
except Exception:
raise SmtpSendError(
"Mail delivery outcome is unknown after the provider effect started.",
outcome_unknown=True,
) from None
return CampaignSmtpDeliveryResult(
envelope_recipients=list(result.envelope_recipients),
refused_recipients=_sanitized_refusals(result.refused_recipients),
)
def append_campaign_message_to_sent(
session: Session,
*,
tenant_id: str,
campaign_id: str,
profile_id: str,
message_bytes: bytes,
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,
) -> 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
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 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:
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:
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
if imap is None:
raise ImapConfigurationError("The selected Mail profile has no IMAP configuration")
try:
assert_mail_policy_allows_send(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
smtp=None,
imap=imap,
)
except MailProfileError:
raise MailProfileError("Appending to Sent is blocked by the effective Mail policy.") from None
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:
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
except Exception:
raise ImapAppendError(
"The Sent-folder append outcome is unknown; inspect the mailbox before retrying.",
outcome_unknown=True,
) from None
return CampaignImapAppendResult(folder=result.folder)
class MailCampaignCapability:
@@ -27,19 +504,18 @@ class MailCampaignCapability:
SmtpSendError = SmtpSendError
ImapConfigurationError = ImapConfigurationError
ImapAppendError = ImapAppendError
materialize_campaign_mail_profile_config = staticmethod(materialize_campaign_mail_profile_config)
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
assert_mail_policy_allows_send = staticmethod(assert_mail_policy_allows_send)
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
ensure_mail_profile_allowed_for_campaign = staticmethod(ensure_mail_profile_allowed_for_campaign)
smtp_config_from_profile = staticmethod(smtp_config_from_profile)
imap_config_from_profile = staticmethod(imap_config_from_profile)
effective_profile_credentials_inherited = staticmethod(effective_profile_credentials_inherited)
apply_campaign_credentials = staticmethod(apply_campaign_credentials)
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
send_email_bytes = staticmethod(send_email_bytes)
append_message_to_sent = staticmethod(append_message_to_sent)
send_email_message = staticmethod(send_email_message)
@staticmethod
def mock_mailbox():
from govoplan_mail.backend.dev import mock_mailbox
return mock_mailbox
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:

View File

@@ -8,6 +8,7 @@ from govoplan_core.mail.config import (
StrictModel,
TransportCredentials,
TransportSecurity,
normalize_split_transport_credentials,
)
__all__ = [
@@ -18,4 +19,5 @@ __all__ = [
"StrictModel",
"TransportCredentials",
"TransportSecurity",
"normalize_split_transport_credentials",
]

View File

@@ -3,10 +3,13 @@ from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
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:
@@ -21,22 +24,131 @@ class MailServerProfile(Base, TimestampMixin):
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
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)
name: Mapped[str] = mapped_column(String(255), nullable=False)
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)
smtp_transport_revision: Mapped[str] = mapped_column(String(36), default=new_uuid, nullable=False)
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
imap_username: Mapped[str | None] = mapped_column(String(320))
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
tenant: Mapped["Tenant"] = relationship()
imap_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 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__ = (
UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"),
Index("ix_mail_profile_policies_scope", "scope_type", "scope_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class MailMailboxFolderIndex(Base, TimestampMixin):
__tablename__ = "mail_mailbox_folder_index"
__table_args__ = (
UniqueConstraint("profile_id", "folder", name="uq_mail_mailbox_folder_index_profile_folder"),
Index("ix_mail_mailbox_folder_index_tenant_profile", "tenant_id", "profile_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)
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, index=True)
flags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
unseen_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
uidvalidity: Mapped[str | None] = mapped_column(String(255), nullable=True)
indexed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
message_indexed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class MailMailboxMessageIndex(Base, TimestampMixin):
__tablename__ = "mail_mailbox_message_index"
__table_args__ = (
UniqueConstraint("profile_id", "folder", "uid", name="uq_mail_mailbox_message_index_profile_folder_uid"),
Index("ix_mail_mailbox_message_index_page", "tenant_id", "profile_id", "folder", "sort_position"),
)
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, index=True)
uid: Mapped[str] = mapped_column(String(255), nullable=False)
uid_int: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False, index=True)
sort_position: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False, index=True)
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
from_header: Mapped[str | None] = mapped_column(Text, nullable=True)
to_header: Mapped[str | None] = mapped_column(Text, nullable=True)
cc_header: Mapped[str | None] = mapped_column(Text, nullable=True)
date: Mapped[str | None] = mapped_column(String(255), nullable=True)
message_id: Mapped[str | None] = mapped_column(Text, nullable=True)
flags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
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)

View File

@@ -5,7 +5,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_mail.backend.dev.mock_mailbox import (
clear_records,
get_failures,

View File

@@ -0,0 +1,540 @@
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
from govoplan_mail.backend.mail_profiles import (
EffectiveMailProfilePolicy,
MailProfileError,
effective_mail_profile_policy_for_scope,
)
MAIL_POLICY_DOC_SCOPES = ("mail:profile:read", "admin:policies:read", "system:settings:read")
MAIL_PROFILE_READ_SCOPE = "mail:profile:read"
MAIL_PROFILE_WRITE_SCOPE = "mail:profile:write"
MAIL_PROFILE_WRITE_OWN_SCOPE = "mail:profile:write_own"
# RBAC permission identifiers; neither value is a stored credential.
MAIL_SECRET_MANAGE_SCOPE = "mail:secret:manage" # noqa: S105 # nosec B105
MAIL_SECRET_MANAGE_OWN_SCOPE = "mail:secret:manage_own" # noqa: S105 # nosec B105
MAIL_PROFILE_TEST_SCOPE = "mail:profile:test"
MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"))
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
topics = [
topic
for topic in (
_tenant_mail_policy_topic(context),
_custom_mail_profile_topic(context),
)
if topic is not None
]
return tuple(topics)
def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTopic | None:
principal = context.principal
tenant_id = str(getattr(principal, "tenant_id", "") or "")
if not tenant_id:
return None
if context.documentation_type == "admin" and not _has_any_scope(principal, MAIL_POLICY_DOC_SCOPES):
return None
session = context.session
if not isinstance(session, Session):
return None
try:
policy = effective_mail_profile_policy_for_scope(session, tenant_id=tenant_id, scope_type="tenant")
except MailProfileError as exc:
user_documentation = context.documentation_type == "user"
return DocumentationTopic(
id="mail.tenant-profile-policy-unavailable",
title="Mail server policy could not be evaluated",
summary="Mail profile documentation is installed, but the tenant-level effective policy could not be read for this request.",
body=(
"The current Mail policy could not be loaded. Try again or ask a Mail administrator for help."
if user_documentation
else str(exc)
),
layer="available",
documentation_types=(context.documentation_type,),
source_module_id="mail",
order=41,
links=(
(DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"),)
if user_documentation
else (_mail_policy_api_link(),)
),
metadata={} if user_documentation else {"error_type": type(exc).__name__},
)
effective = policy.as_dict()
if context.documentation_type == "user":
summary, body = _mail_policy_user_text(effective)
return DocumentationTopic(
id="mail.tenant-profile-policy-user",
title="Choosing a mail server",
summary=summary,
body=body,
layer="configured",
documentation_types=("user",),
audience=("mail_user", "campaign_user"),
order=40,
links=(
DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("Campaigns can use these mail choices when the campaign module is available.",),
configuration_keys=("mail_profile_policy",),
i18n_key="mail.topic.tenant_profile_policy_user",
translations=_user_mail_policy_translations(effective),
source_module_id="mail",
metadata=_mail_policy_user_metadata(effective),
)
summary, body = _mail_policy_admin_text(effective, source_count=len(policy.source_policies))
return DocumentationTopic(
id="mail.tenant-profile-policy-admin",
title="Mail server choices for this tenant",
summary=summary,
body=body,
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"),
order=40,
links=(
DocumentationLink(label="Mail profiles API", href="/api/v1/mail/profiles", kind="api"),
_mail_policy_api_link(),
DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("Campaign delivery can use reusable mail profiles when govoplan-campaign is installed.",),
configuration_keys=("mail_profile_policy",),
i18n_key="mail.topic.tenant_profile_policy_admin",
source_module_id="mail",
metadata=_mail_policy_metadata(effective, source_count=len(policy.source_policies)),
)
def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTopic | None:
if context.documentation_type != "user":
return None
principal = context.principal
if not _has_any_scope(principal, (MAIL_PROFILE_WRITE_SCOPE, MAIL_PROFILE_WRITE_OWN_SCOPE)):
return None
if not _has_all_scopes(principal, (MAIL_PROFILE_READ_SCOPE,)):
return None
tenant_id = str(getattr(principal, "tenant_id", "") or "")
user_id = str(getattr(getattr(principal, "user", None), "id", "") or "")
session = context.session
if not tenant_id or not user_id or not isinstance(session, Session):
return None
try:
policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=tenant_id,
scope_type="user",
scope_id=user_id,
)
except MailProfileError:
# A user-facing runtime topic must not turn internal policy resolution
# details into documentation output. The task simply remains absent
# until its effective policy can be proven.
return None
if not policy.allow_user_profiles:
return None
can_manage_credentials = _has_any_scope(
principal,
(MAIL_SECRET_MANAGE_SCOPE, MAIL_SECRET_MANAGE_OWN_SCOPE),
)
can_test_profile = _has_all_scopes(principal, (MAIL_PROFILE_TEST_SCOPE, MAIL_PROFILE_USE_SCOPE))
can_use_profile = _has_any_scope(principal, (MAIL_PROFILE_USE_SCOPE,))
approval_required = bool(policy.allowed_profile_id_sets)
constraints = _host_policy_constraint_records(policy)
authority_lines = _custom_profile_authority_lines(
can_manage_credentials=can_manage_credentials,
can_test_profile=can_test_profile,
can_use_profile=can_use_profile,
approval_required=approval_required,
)
steps = _custom_profile_steps(
can_manage_credentials=can_manage_credentials,
can_test_profile=can_test_profile,
can_use_profile=can_use_profile,
approval_required=approval_required,
)
return DocumentationTopic(
id="mail.workflow.create-custom-profile",
title="Create a custom Mail profile",
summary=(
"Create a reusable profile in the current account's user-scoped Settings view, "
"within the active SMTP and IMAP hostname policy."
),
body="\n".join(authority_lines),
layer="configured",
documentation_types=("user",),
audience=("mail_profile_author", "campaign_manager"),
order=41,
conditions=(
DocumentationCondition(
required_modules=("mail",),
required_scopes=(MAIL_PROFILE_READ_SCOPE,),
any_scopes=(MAIL_PROFILE_WRITE_SCOPE, MAIL_PROFILE_WRITE_OWN_SCOPE),
configuration_keys=("mail_profile_policy",),
),
),
links=(
DocumentationLink(label="My Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("A custom Mail-owned transport definition that authorized tasks can reference after all policy checks pass.",),
configuration_keys=("mail_profile_policy",),
i18n_key="mail.topic.create_custom_profile",
source_module_id="mail",
metadata={
"kind": "workflow",
"route": "/settings?section=mail-profiles",
"screen": "My Mail profiles",
"help_contexts": ["mail.profiles", "app.settings"],
"prerequisites": [
"The Mail profile editor is available in Settings and opens in the current account's user scope.",
"The effective Mail policy permits user-scoped profiles.",
],
"steps": list(steps),
"outcome": "A user-scoped custom Mail profile is saved without copying its credentials into a consuming module.",
"current_configuration": list(authority_lines),
"constraints": list(constraints),
"verification": _custom_profile_verification(
can_test_profile=can_test_profile,
can_use_profile=can_use_profile,
approval_required=approval_required,
),
"can_manage_credentials": can_manage_credentials,
"can_test_profile": can_test_profile,
"can_use_profile": can_use_profile,
"approval_required_before_use": approval_required,
"related_topic_ids": [
"mail.workflow.choose-and-test-profile",
"mail.profile-ownership-and-consumers",
"mail.profiles-and-policy",
],
},
)
def _host_policy_constraint_records(policy: EffectiveMailProfilePolicy) -> tuple[dict[str, Any], ...]:
constraints: list[dict[str, Any]] = []
for label, key in _HOST_POLICY_FIELDS:
prefix = label.casefold()
denied = _display_patterns(policy.blacklist_patterns.get(key, []))
constraints.append({
"id": f"{prefix}-host-deny",
"label": f"{label} denied hosts",
"description": (
"Deny rules are checked first. The hostname must not match any listed pattern."
if denied
else "Deny rules are checked first. No hostname deny pattern is active."
),
**({"values": list(denied)} if denied else {}),
})
allowed_groups = tuple(
group
for group in (_display_patterns(items) for items in policy.whitelist_groups.get(key, []))
if group
)
if not allowed_groups:
constraints.append({
"id": f"{prefix}-host-allow",
"label": f"{label} allowed hosts",
"description": "After deny checks, no hostname allow-list group is active.",
})
continue
for index, group in enumerate(allowed_groups, start=1):
constraints.append({
"id": f"{prefix}-host-allow-{index}",
"label": f"{label} allowed hosts — group {index}",
"description": (
"After deny checks, the hostname must match at least one pattern in this group. "
"It must satisfy every active allow-list group shown for this protocol."
),
"values": list(group),
})
return tuple(constraints)
def _display_patterns(patterns: list[str]) -> tuple[str, ...]:
result: list[str] = []
for pattern in patterns:
value = str(pattern).strip()
if value and value not in result:
result.append(value)
return tuple(result)
def _custom_profile_authority_lines(
*,
can_manage_credentials: bool,
can_test_profile: bool,
can_use_profile: bool,
approval_required: bool,
) -> tuple[str, ...]:
credentials = (
"Credential authority: you may save or replace Mail-owned SMTP/IMAP passwords."
if can_manage_credentials
else "Credential authority: you may define the profile, but you cannot save or replace passwords; an actor with both profile-write and secret-management authority must do that when authentication requires one."
)
testing = (
"Test authority: you may run the profile's SMTP/IMAP connection tests after saving it as active."
if can_test_profile
else "Test authority: creating the profile does not let you run connection tests; ask an actor with both profile-test and profile-use authority to verify an active profile."
)
use = (
"Use authority: you may select the profile in an authorized task after its contextual policy checks pass."
if can_use_profile
else "Use authority: creating the profile does not let you select or use it; separate Mail profile use authority is required."
)
approval = (
"Approval: an approved-profile list is active. A newly generated profile reference must be approved before the profile can be selected or used."
if approval_required
else "Approval: no approved-profile list currently blocks a newly created profile, but each consuming task still rechecks its contextual policy."
)
return credentials, testing, use, approval
def _custom_profile_steps(
*,
can_manage_credentials: bool,
can_test_profile: bool,
can_use_profile: bool,
approval_required: bool,
) -> tuple[str, ...]:
steps = [
"Open Settings, choose Mail profiles, and select Add profile in the current account's user-scoped view.",
"Enter a stable name and configure SMTP plus optional IMAP hostnames that satisfy every host-policy statement shown above.",
]
steps.append(
"Enter the required SMTP/IMAP credentials in the dedicated password fields."
if can_manage_credentials
else "Save the non-secret profile definition, then ask an actor with both profile-write and secret-management authority to add credentials if the server requires authentication."
)
steps.append("Save the profile; Mail validates the effective user-scope host policy again on the server.")
steps.append(
"Save the profile as active, then run the available SMTP and IMAP connection tests."
if can_test_profile
else "Ask an actor with both profile-test and profile-use authority to run the SMTP and IMAP connection tests after the profile is active."
)
if approval_required:
steps.append("Ask a Mail administrator to add the new profile to the active approved-profile list.")
steps.append(
"Select the profile from the consuming task's picker and complete that task's contextual validation."
if can_use_profile
else "Ask an actor with Mail profile use authority to select it in the consuming task after approval and testing."
)
return tuple(steps)
def _custom_profile_verification(*, can_test_profile: bool, can_use_profile: bool, approval_required: bool) -> str:
checks = ["Reopen My Mail profiles and confirm the saved profile remains in the current account's user-scoped view."]
checks.append(
"Confirm the authorized SMTP/IMAP tests succeed."
if can_test_profile
else "Have an authorized tester confirm the SMTP/IMAP tests succeed."
)
if approval_required:
checks.append("Confirm an administrator approved the generated profile reference before expecting it in a picker.")
checks.append(
"Confirm the intended task can select it and passes its own policy validation."
if can_use_profile
else "Confirm a separately authorized user can select it and passes the consuming task's policy validation."
)
return " ".join(checks)
def _mail_policy_admin_text(policy: dict[str, Any], *, source_count: int) -> tuple[str, str]:
allowed_profile_ids = policy.get("allowed_profile_ids")
lower_scopes = _allowed_lower_scopes(policy)
approved_profile_limit = isinstance(allowed_profile_ids, list)
pattern_counts = _pattern_counts(policy)
locked_limit_count = sum(1 for value in _lower_limit_values(policy) if value is False)
if approved_profile_limit and not lower_scopes:
summary = "This tenant is in approved-profile mode: users can choose configured mail profiles, but lower scopes cannot bring arbitrary SMTP or IMAP servers."
elif approved_profile_limit:
summary = "This tenant limits mail sending to approved profile ids, while selected lower scopes can still define profiles within policy limits."
elif lower_scopes:
summary = "This tenant permits lower-scope mail profiles subject to the effective host, sender, recipient, and credential policy."
else:
summary = "This tenant uses centrally managed mail profiles; lower-scope profile creation is disabled by the effective policy."
approved_line = _approved_profile_line(allowed_profile_ids)
lower_scope_line = _lower_scope_line(lower_scopes)
credential_line = _credential_line(policy)
pattern_line = f"Allow-list pattern groups: {pattern_counts['whitelist']}. Deny-list patterns: {pattern_counts['blacklist']}."
lower_limit_line = f"Locked lower-level limits: {locked_limit_count}. Policy sources applied: {source_count}."
body = "\n".join([approved_line, lower_scope_line, credential_line, pattern_line, lower_limit_line])
return summary, body
def _mail_policy_user_text(policy: dict[str, Any]) -> tuple[str, str]:
allowed_profile_ids = policy.get("allowed_profile_ids")
lower_scopes = _allowed_lower_scopes(policy)
approved_profile_limit = isinstance(allowed_profile_ids, list)
if approved_profile_limit and not lower_scopes:
summary = "You can choose from approved mail servers, but you cannot add your own mail server for this tenant."
body = "This is set by tenant policy. If the mail server you need is not offered, ask an administrator to add it as an approved mail profile."
elif approved_profile_limit:
summary = "You can use approved Mail profiles. Some scopes may also define additional reusable profiles."
body = "The available profiles are limited by tenant policy. Campaigns select one profile by reference; SMTP/IMAP settings and credentials remain managed in Mail."
elif lower_scopes:
summary = "You may be able to define reusable Mail profiles in selected scopes, subject to tenant rules."
body = "GovOPlaN checks each profile before use. Campaigns reference an available profile and never store its SMTP/IMAP settings or credentials."
else:
summary = "Mail servers are managed centrally for this tenant."
body = "You cannot add a personal, group, or campaign mail server here. Choose one of the configured options or ask an administrator to add another approved profile."
return summary, body
def _user_mail_policy_translations(policy: dict[str, Any]) -> dict[str, dict[str, str]]:
allowed_profile_ids = policy.get("allowed_profile_ids")
lower_scopes = _allowed_lower_scopes(policy)
approved_profile_limit = isinstance(allowed_profile_ids, list)
if approved_profile_limit and not lower_scopes:
return {
"de": {
"title": "Mailserver auswaehlen",
"summary": "Sie koennen aus freigegebenen Mailservern waehlen, aber keinen eigenen Mailserver fuer diesen Tenant eintragen.",
"body": "Das wird durch die Tenant-Regeln festgelegt. Wenn der benoetigte Mailserver nicht angeboten wird, bitten Sie eine Administratorin oder einen Administrator, ihn als freigegebenes Mailprofil anzulegen.",
},
}
if approved_profile_limit:
return {
"de": {
"title": "Mailserver auswaehlen",
"summary": "Sie koennen freigegebene Mailprofile nutzen. In manchen Bereichen koennen weitere wiederverwendbare Profile angelegt werden.",
"body": "Die Auswahl wird durch Tenant-Regeln begrenzt. Kampagnen speichern nur die Profilreferenz; SMTP-/IMAP-Einstellungen und Zugangsdaten verbleiben im Mail-Modul.",
},
}
if lower_scopes:
return {
"de": {
"title": "Mailserver auswaehlen",
"summary": "In bestimmten Bereichen koennen Sie wiederverwendbare Mailprofile anlegen, solange die aktiven Tenant-Regeln eingehalten werden.",
"body": "GovOPlaN prueft jedes Profil vor der Verwendung. Kampagnen referenzieren ein freigegebenes Profil und speichern keine SMTP-/IMAP-Einstellungen oder Zugangsdaten.",
},
}
return {
"de": {
"title": "Mailserver auswaehlen",
"summary": "Mailserver werden fuer diesen Tenant zentral verwaltet.",
"body": "Sie koennen hier keinen persoenlichen, Gruppen- oder Kampagnen-Mailserver eintragen. Waehlen Sie eine konfigurierte Option oder bitten Sie eine Administratorin oder einen Administrator um ein weiteres freigegebenes Profil.",
},
}
def _approved_profile_line(allowed_profile_ids: Any) -> str:
if isinstance(allowed_profile_ids, list):
count = len(allowed_profile_ids)
if count == 0:
return "Approved profiles: the active policy currently allows no reusable profile ids."
return f"Approved profiles: users are limited to {count} reusable profile id(s) selected by policy."
return "Approved profiles: no explicit approved-profile list is active."
def _lower_scope_line(lower_scopes: tuple[str, ...]) -> str:
if not lower_scopes:
return "Lower scopes: user, group, and campaign-local profile creation are disabled."
return "Lower scopes allowed to define profiles: " + ", ".join(lower_scopes) + "."
def _credential_line(policy: dict[str, Any]) -> str:
smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True))
imap_inherit = bool((policy.get("imap_credentials") or {}).get("inherit", True))
return (
f"Credential inheritance: SMTP {'inherits' if smtp_inherit else 'requires local credentials'}; "
f"IMAP {'inherits' if imap_inherit else 'requires local credentials'}. "
"Campaign delivery is available only for protocols that inherit credentials from the selected Mail profile."
)
def _allowed_lower_scopes(policy: dict[str, Any]) -> tuple[str, ...]:
scopes: list[str] = []
if policy.get("allow_user_profiles") is not False:
scopes.append("user")
if policy.get("allow_group_profiles") is not False:
scopes.append("group")
if policy.get("allow_campaign_profiles") is not False:
scopes.append("campaign")
return tuple(scopes)
def _pattern_counts(policy: dict[str, Any]) -> dict[str, int]:
return {
"whitelist": _pattern_count(policy.get("whitelist")),
"blacklist": _pattern_count(policy.get("blacklist")),
}
def _pattern_count(payload: Any) -> int:
if not isinstance(payload, dict):
return 0
return sum(len(value) for value in payload.values() if isinstance(value, list))
def _lower_limit_values(policy: dict[str, Any]) -> tuple[bool, ...]:
lower_limits = policy.get("allow_lower_level_limits")
if not isinstance(lower_limits, dict):
return ()
return tuple(value for value in lower_limits.values() if isinstance(value, bool))
def _mail_policy_metadata(policy: dict[str, Any], *, source_count: int) -> dict[str, Any]:
allowed_profile_ids = policy.get("allowed_profile_ids")
pattern_counts = _pattern_counts(policy)
return {
"approved_profile_limit_active": isinstance(allowed_profile_ids, list),
"approved_profile_count": len(allowed_profile_ids) if isinstance(allowed_profile_ids, list) else None,
"lower_scopes_allowed": list(_allowed_lower_scopes(policy)),
"whitelist_pattern_count": pattern_counts["whitelist"],
"blacklist_pattern_count": pattern_counts["blacklist"],
"locked_lower_level_limit_count": sum(1 for value in _lower_limit_values(policy) if value is False),
"policy_source_count": source_count,
}
def _mail_policy_user_metadata(policy: dict[str, Any]) -> dict[str, Any]:
allowed_profile_ids = policy.get("allowed_profile_ids")
return {
"approved_profile_limit_active": isinstance(allowed_profile_ids, list),
"can_add_local_mail_server": bool(_allowed_lower_scopes(policy)),
"lower_scopes_allowed": list(_allowed_lower_scopes(policy)),
}
def _has_any_scope(principal: object | None, scopes: tuple[str, ...]) -> bool:
has = getattr(principal, "has", None)
if callable(has):
return any(bool(has(scope)) for scope in scopes)
principal_scopes = set(getattr(principal, "scopes", ()) or ())
return "*" in principal_scopes or any(scope in principal_scopes for scope in scopes)
def _has_all_scopes(principal: object | None, scopes: tuple[str, ...]) -> bool:
has = getattr(principal, "has", None)
if callable(has):
try:
return all(bool(has(scope)) for scope in scopes)
except Exception:
return False
principal_scopes = set(getattr(principal, "scopes", ()) or ())
return "*" in principal_scopes or all(scope in principal_scopes for scope in scopes)
def _mail_policy_api_link() -> DocumentationLink:
return DocumentationLink(label="Tenant mail policy API", href="/api/v1/mail/policies/tenant", kind="api")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.security.time import ensure_aware_utc, utc_now
from govoplan_mail.backend.db.models import MailMailboxFolderIndex, MailMailboxMessageIndex
from govoplan_mail.backend.sending.imap import ImapFolderListResult, ImapMailboxInfo, ImapMailboxMessageListResult, ImapMailboxMessageSummary
MAILBOX_INDEX_TTL_SECONDS = 30
_refresh_lock = Lock()
_refreshing_keys: set[tuple[str, str, str]] = set()
@dataclass(frozen=True, slots=True)
class CachedFolderList:
folders: list[ImapMailboxInfo]
indexed_at: datetime | None
stale: bool
@dataclass(frozen=True, slots=True)
class CachedMessagePage:
folder: str
messages: list[ImapMailboxMessageSummary]
total_count: int
offset: int
limit: int
uidvalidity: str | None
indexed_at: datetime | None
stale: bool
def clear_mailbox_index(session: Session, *, profile_id: str) -> tuple[int, int]:
"""Remove every cached mailbox row for a profile in the caller's transaction.
System profiles can be used by more than one tenant, so invalidation is
intentionally profile-wide rather than limited to the tenant making the
transport change. Message rows are removed before their folder metadata.
"""
deleted_messages = (
session.query(MailMailboxMessageIndex)
.filter(MailMailboxMessageIndex.profile_id == profile_id)
.delete(synchronize_session=False)
)
deleted_folders = (
session.query(MailMailboxFolderIndex)
.filter(MailMailboxFolderIndex.profile_id == profile_id)
.delete(synchronize_session=False)
)
return int(deleted_folders or 0), int(deleted_messages or 0)
def begin_mailbox_refresh(tenant_id: str, profile_id: str, folder: str) -> bool:
key = (tenant_id, profile_id, folder)
with _refresh_lock:
if key in _refreshing_keys:
return False
_refreshing_keys.add(key)
return True
def finish_mailbox_refresh(tenant_id: str, profile_id: str, folder: str) -> None:
with _refresh_lock:
_refreshing_keys.discard((tenant_id, profile_id, folder))
def cache_mailbox_folders(
session: Session,
*,
tenant_id: str,
profile_id: str,
result: ImapFolderListResult,
indexed_at: datetime | None = None,
) -> None:
indexed_at = indexed_at or utc_now()
existing = {
row.folder: row
for row in session.query(MailMailboxFolderIndex)
.filter(MailMailboxFolderIndex.tenant_id == tenant_id, MailMailboxFolderIndex.profile_id == profile_id)
.all()
}
for folder in result.folders:
row = existing.get(folder.name)
if row is None:
row = MailMailboxFolderIndex(tenant_id=tenant_id, profile_id=profile_id, folder=folder.name)
row.flags = list(folder.flags or [])
row.message_count = folder.message_count
row.unseen_count = folder.unseen_count
row.indexed_at = indexed_at
session.add(row)
def cache_mailbox_messages(
session: Session,
*,
tenant_id: str,
profile_id: str,
result: ImapMailboxMessageListResult,
indexed_at: datetime | None = None,
) -> None:
indexed_at = indexed_at or utc_now()
session.flush()
folder_row = (
session.query(MailMailboxFolderIndex)
.filter(
MailMailboxFolderIndex.tenant_id == tenant_id,
MailMailboxFolderIndex.profile_id == profile_id,
MailMailboxFolderIndex.folder == result.folder,
)
.one_or_none()
)
if folder_row is None:
folder_row = MailMailboxFolderIndex(tenant_id=tenant_id, profile_id=profile_id, folder=result.folder)
folder_row.message_count = result.total_count
folder_row.uidvalidity = result.uidvalidity
folder_row.message_indexed_at = indexed_at
session.add(folder_row)
uids = [message.uid for message in result.messages]
if result.total_count <= 0:
(
session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == result.folder,
)
.delete(synchronize_session=False)
)
return
window_count = max(0, min(result.limit, result.total_count - result.offset))
if window_count:
stale_query = session.query(MailMailboxMessageIndex).filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == result.folder,
MailMailboxMessageIndex.sort_position >= result.offset,
MailMailboxMessageIndex.sort_position < result.offset + window_count,
)
if uids:
stale_query = stale_query.filter(MailMailboxMessageIndex.uid.notin_(uids))
for row in stale_query.all():
session.delete(row)
existing = {}
if uids:
existing = {
row.uid: row
for row in session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == result.folder,
MailMailboxMessageIndex.uid.in_(uids),
)
.all()
}
for index, message in enumerate(result.messages):
row = existing.get(message.uid)
if row is None:
row = MailMailboxMessageIndex(
tenant_id=tenant_id,
profile_id=profile_id,
folder=result.folder,
uid=message.uid,
)
row.uid_int = _uid_int(message.uid)
row.sort_position = result.offset + index
row.subject = message.subject
row.from_header = message.from_header
row.to_header = message.to_header
row.cc_header = message.cc_header
row.date = message.date
row.message_id = message.message_id
row.flags = list(message.flags or [])
row.size_bytes = message.size_bytes
row.body_preview = message.body_preview
row.attachment_count = message.attachment_count
row.indexed_at = indexed_at
session.add(row)
def cached_mailbox_folders(
session: Session,
*,
tenant_id: str,
profile_id: str,
max_age_seconds: int = MAILBOX_INDEX_TTL_SECONDS,
) -> CachedFolderList | None:
rows = (
session.query(MailMailboxFolderIndex)
.filter(
MailMailboxFolderIndex.tenant_id == tenant_id,
MailMailboxFolderIndex.profile_id == profile_id,
MailMailboxFolderIndex.indexed_at.isnot(None),
)
.order_by(MailMailboxFolderIndex.folder.asc())
.all()
)
if not rows:
return None
indexed_at = max((row.indexed_at for row in rows if row.indexed_at), default=None)
return CachedFolderList(
folders=[
ImapMailboxInfo(name=row.folder, flags=list(row.flags or []), message_count=row.message_count, unseen_count=row.unseen_count)
for row in rows
],
indexed_at=indexed_at,
stale=_is_stale(indexed_at, max_age_seconds=max_age_seconds),
)
def cached_mailbox_message_page(
session: Session,
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int,
max_age_seconds: int = MAILBOX_INDEX_TTL_SECONDS,
) -> CachedMessagePage | None:
folder_row = (
session.query(MailMailboxFolderIndex)
.filter(
MailMailboxFolderIndex.tenant_id == tenant_id,
MailMailboxFolderIndex.profile_id == profile_id,
MailMailboxFolderIndex.folder == folder,
)
.one_or_none()
)
if folder_row is None or folder_row.message_indexed_at is None:
return None
total_count = folder_row.message_count
if total_count is None:
total_count = int(
session.query(func.count(MailMailboxMessageIndex.id))
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == folder,
)
.scalar()
or 0
)
rows = (
session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == folder,
)
.order_by(MailMailboxMessageIndex.sort_position.asc(), MailMailboxMessageIndex.uid_int.desc(), MailMailboxMessageIndex.uid.desc())
.offset(offset)
.limit(limit)
.all()
)
expected_count = max(0, min(limit, total_count - offset))
if len(rows) < expected_count:
return None
indexed_at = min((row.indexed_at for row in rows if row.indexed_at), default=folder_row.message_indexed_at)
return CachedMessagePage(
folder=folder,
messages=[_message_from_index(row) for row in rows],
total_count=total_count,
offset=offset,
limit=limit,
uidvalidity=folder_row.uidvalidity,
indexed_at=indexed_at,
stale=_is_stale(indexed_at, max_age_seconds=max_age_seconds),
)
def _message_from_index(row: MailMailboxMessageIndex) -> ImapMailboxMessageSummary:
return ImapMailboxMessageSummary(
uid=row.uid,
folder=row.folder,
subject=row.subject,
from_header=row.from_header,
to_header=row.to_header,
cc_header=row.cc_header,
date=row.date,
message_id=row.message_id,
flags=list(row.flags or []),
size_bytes=row.size_bytes,
body_preview=row.body_preview,
attachment_count=row.attachment_count,
)
def _uid_int(uid: str) -> int:
try:
return int(str(uid))
except ValueError:
return 0
def _is_stale(indexed_at: datetime | None, *, max_age_seconds: int) -> bool:
aware = ensure_aware_utc(indexed_at)
if aware is None:
return True
return (utc_now() - aware).total_seconds() > max_age_seconds

View File

@@ -1,12 +1,69 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from sqlalchemy import inspect
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
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.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
_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,
label="Mail",
)
def _mail_retirement_provider(session: object | None, module_id: str):
plan = _mail_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Mail credential retirement.")
if inspect(execute_session.get_bind()).has_table(mail_models.MailServerProfile.__tablename__):
from govoplan_mail.backend.mail_profiles import delete_mail_profile_credentials_for_retirement
delete_mail_profile_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Mail-owned encrypted SMTP/IMAP passwords are scrubbed with non-secret audit records immediately before tables are dropped; any scrub or audit failure blocks retirement.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
@@ -27,7 +84,17 @@ PERMISSIONS = (
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."),
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
_permission(
"mail:profile:write_own",
"Manage own mail profiles",
"Create, edit, and deactivate only the current account's user-scoped mail profiles within effective policy.",
),
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
_permission(
"mail:secret:manage_own",
"Manage own mail secrets",
"Create, replace, and delete credentials only for the current account's user-scoped mail profiles.",
),
)
ROLE_TEMPLATES = (
@@ -50,24 +117,68 @@ ROLE_TEMPLATES = (
description="Use and test approved mail profiles without reading secrets.",
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test", "mail:mailbox:read"),
),
RoleTemplate(
slug="mail_profile_self_service",
name="Mail profile self-service user",
description="Create and manage only personal Mail profiles and credentials within effective policy.",
permissions=(
"mail:profile:read",
"mail:profile:use",
"mail:profile:test",
"mail:mailbox:read",
"mail:profile:write_own",
"mail:secret:manage_own",
),
),
)
def _mail_router(context: ModuleContext):
from govoplan_mail.backend.runtime import configure_runtime
configure_runtime(settings=context.settings)
configure_runtime(registry=context.registry, settings=context.settings)
from fastapi import APIRouter
from govoplan_mail.backend.router import router
return router
aggregate = APIRouter()
aggregate.include_router(router)
app_env = str(getattr(context.settings, "app_env", "dev")).lower()
if app_env == "dev" and bool(getattr(context.settings, "dev_mailbox_api_enabled", False)):
from govoplan_mail.backend.dev_router import router as dev_router
aggregate.include_router(dev_router)
return aggregate
manifest = ModuleManifest(
id="mail",
name="Mail",
version="0.1.4",
dependencies=("access",),
optional_dependencies=(),
version="0.1.10",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses"),
provides_interfaces=(
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="campaigns.access",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="campaigns.mail_policy_context",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.lookup",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_mail_router,
role_templates=ROLE_TEMPLATES,
@@ -75,16 +186,280 @@ manifest = ModuleManifest(
frontend=FrontendModule(
module_id="mail",
package_name="@govoplan/mail-webui",
routes=(
FrontendRoute(
path="/mail",
component="MailboxPage",
required_any=("mail:mailbox:read",),
order=50,
),
),
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), 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",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=_mail_retirement_provider,
retirement_notes="Destructive retirement first scrubs and audits Mail-owned credentials, then drops Mail-owned database tables after the installer captures a database snapshot.",
),
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,
label="Mail",
),
),
capability_factories={
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
},
documentation=(
DocumentationTopic(
id="mail.profiles-and-policy",
title="Mail profiles and policy hierarchy",
summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.",
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"),
order=39,
i18n_key="mail.topic.profiles_and_policy",
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:profile:read", "admin:policies:read", "system:settings:read"),
configuration_keys=("mail_profile_policy",),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/api/v1/mail/profiles", kind="api"),
DocumentationLink(label="Tenant mail policy", href="/api/v1/mail/policies/tenant", kind="api"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
configuration_keys=("mail_profile_policy",),
metadata={
"kind": "reference",
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles and policy",
"section": "Effective profile policy",
"related_topic_ids": [
"mail.profile-ownership-and-consumers",
"campaigns.mail-profile-governance",
],
},
),
DocumentationTopic(
id="mail.profile-ownership-and-consumers",
title="Mail owns transport profiles and credentials",
summary="Other modules select authorized Mail profiles by stable identifier; they do not copy SMTP/IMAP settings or secrets.",
body="Mail encrypts credentials, tests connections, evaluates profile scope and policy, and performs revision-gated transport effects without returning resolved configuration. Random persisted revisions rotate when normalized transport or account identity changes, while password-only rotation remains transparent to built business intent. Campaign stores only server.mail_profile_id plus sanitized delivery evidence. Campaign-local transport fields are rejected, and legacy campaign records fail closed until an explicit profile migration preserves the source audit record and updates an editable version.",
layer="available",
documentation_types=("admin", "user"),
audience=("mail_user", "mail_admin", "campaign_manager", "campaign_sender"),
order=40,
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=(
"mail:profile:read",
"mail:profile:use",
"mail:profile:write",
"mail:profile:write_own",
),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Mail profiles API", href="/api/v1/mail/profiles", kind="api"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
),
related_modules=("campaigns", "access"),
unlocks=("Reusable, governed delivery identities without cross-module secret duplication.",),
metadata={
"kind": "reference",
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles",
"section": "Profile ownership, credentials, and consumers",
"related_topic_ids": [
"mail.profiles-and-policy",
"campaigns.mail-profile-user-journey",
"campaigns.mail-profile-governance",
"campaigns.mail-profile-operations",
],
},
),
DocumentationTopic(
id="mail.workflow.choose-and-test-profile",
title="Choose and test a reusable Mail profile",
summary="Select a profile visible in the current context, test its SMTP or IMAP connection, and let the consuming task store only its stable reference.",
body="Profile tests verify current connection and authentication using Mail-owned credentials. They do not prove later Campaign policy authorization, deliverability, recipient acceptance, or future availability. Use the picker rather than entering identifiers or copying transport settings into another module.",
layer="configured",
documentation_types=("user",),
audience=("mail_user", "campaign_manager"),
order=41,
conditions=(
DocumentationCondition(
required_modules=("mail",),
required_scopes=("mail:profile:read", "mail:profile:use", "mail:profile:test"),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
),
related_modules=("campaigns",),
unlocks=("Reusable SMTP/IMAP identities that consumers can reference without handling credentials.",),
metadata={
"kind": "workflow",
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles",
"help_contexts": ["mail.profiles", "app.settings"],
"prerequisites": [
"Mail is installed and you may read, use, and test profiles visible in the current context.",
"A profile administrator has configured credentials and effective policy.",
],
"steps": [
"Open Mail profiles and choose a visible active profile.",
"Review its safe scope, SMTP/IMAP availability, and sender identity without expecting credential values.",
"Run the relevant SMTP or IMAP connectivity/authentication test against a non-production target first.",
"Return to the consuming task and select the same profile through its picker.",
],
"outcome": "The consuming task references an available Mail-owned profile and contains no copied transport configuration or credentials.",
"verification": "Reload both surfaces, confirm only the stable reference is retained by the consumer, and perform the consumer's own contextual policy validation.",
"related_topic_ids": [
"mail.profile-ownership-and-consumers",
"mail.reference.campaign-delivery-contract",
],
},
),
DocumentationTopic(
id="mail.workflow.read-mailbox",
title="Read a permitted mailbox without changing it",
summary="Choose an IMAP-enabled profile, browse folders, and inspect bounded message content through the read-only mailbox surface.",
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
layer="configured",
documentation_types=("user",),
audience=("mail_user",),
order=42,
conditions=(
DocumentationCondition(
required_modules=("mail",),
required_scopes=("mail:mailbox:read", "mail:profile:use"),
),
),
links=(
DocumentationLink(label="Mail", href="/mail", kind="runtime"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
),
unlocks=("Read-only access to an authorized operational mailbox without broadening profile administration rights.",),
metadata={
"kind": "workflow",
"route": "/mail",
"screen": "Mail",
"help_contexts": ["mail.list"],
"prerequisites": [
"An active visible profile has IMAP configured.",
"You may both use that profile and read its mailbox.",
],
"steps": [
"Open Mail and choose an authorized IMAP-enabled profile.",
"Select a folder and page through its bounded message index.",
"Open only the message needed for the task and close it when finished.",
],
"outcome": "The required message was inspected without changing provider mailbox state.",
"verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.",
"related_topic_ids": [
"mail.workflow.choose-and-test-profile",
"mail.reference.credentials-egress-retirement",
],
},
),
DocumentationTopic(
id="mail.reference.credentials-egress-retirement",
title="Protect Mail credentials, network egress, and retirement",
summary="Keep secrets Mail-owned, pin every SMTP/IMAP peer, bound responses, and delete owned credentials immediately with non-secret audit evidence.",
body="Private-network connector access is deployment-wide, but every allowed hostname still resolves to an approved peer that is pinned at socket creation. Unsupported transports fail before connection. Deleting a profile immediately scrubs its owned encrypted SMTP/IMAP passwords and records non-secret audit when secrets existed; a scrub or audit failure rolls the action back. Destructive module retirement applies the same rule before table drop.",
layer="evidence",
documentation_types=("admin",),
audience=("mail_admin", "platform_operator", "security_reviewer", "release_reviewer"),
order=43,
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:secret:manage", "mail:profile:write", "system:settings:read"),
),
),
links=(
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Mail profiles API", href="/api/v1/mail/profiles", kind="api"),
),
related_modules=("audit",),
unlocks=("A testable Mail trust boundary with fail-closed egress and auditable secret disposal.",),
metadata={
"kind": "reference",
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles",
"section": "Credentials, network egress, deletion, and retirement",
"verification": "Test DNS rebinding and denied addresses, prove the connected peer is pinned, inject credential-scrub and audit failures, repeat deletion for idempotency, and run the retirement preflight against a snapshot.",
"related_topic_ids": [
"mail.workflow.choose-and-test-profile",
"mail.reference.campaign-delivery-contract",
"campaigns.reference.composition-assurance",
],
},
),
DocumentationTopic(
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.",
layer="available",
documentation_types=("admin", "user"),
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
order=44,
conditions=(
DocumentationCondition(
required_modules=("mail", "campaigns"),
required_scopes=("mail:profile:use", "campaigns:campaign:read"),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Campaign Mail profile boundary", href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md", kind="repository"),
),
related_modules=("campaigns", "audit"),
unlocks=("Profile-backed campaign delivery without cross-module credential or configuration copies.",),
metadata={
"kind": "reference",
"route": "/campaigns/{campaign_id}/mail-settings",
"screen": "Campaign Mail settings",
"section": "Mail-owned profile and transport boundary",
"verification": "Prove stale revisions fail before credential decryption, SMTP never decrypts IMAP credentials, IMAP never decrypts SMTP credentials, provider details are sanitized, and the interface/version gate passes.",
"related_topic_ids": [
"mail.profile-ownership-and-consumers",
"campaigns.mail-profile-user-journey",
"campaigns.workflow.retry-and-reconcile",
],
},
),
),
documentation_providers=(documentation_topics,),
)

View File

@@ -0,0 +1,56 @@
"""mail profile policies
Revision ID: 3d4e5f708192
Revises: 2e3f4a5b6c7d
Create Date: 2026-07-06 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "3d4e5f708192"
down_revision = "2e3f4a5b6c7d"
branch_labels = None
depends_on = None
def _scope_fk_target(inspector) -> str:
tables = set(inspector.get_table_names())
return "core_scopes.id" if "core_scopes" in tables else "tenancy_tenants.id"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "mail_profile_policies" in inspector.get_table_names():
return
scope_fk_target = _scope_fk_target(inspector)
op.create_table(
"mail_profile_policies",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=True),
sa.Column("scope_type", sa.String(length=20), nullable=False),
sa.Column("scope_id", sa.String(length=36), nullable=True),
sa.Column("policy", 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(["tenant_id"], [scope_fk_target], name=op.f("fk_mail_profile_policies_tenant_id_scopes"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_profile_policies")),
sa.UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"),
)
op.create_index(op.f("ix_mail_profile_policies_tenant_id"), "mail_profile_policies", ["tenant_id"], unique=False)
op.create_index(op.f("ix_mail_profile_policies_scope_type"), "mail_profile_policies", ["scope_type"], unique=False)
op.create_index(op.f("ix_mail_profile_policies_scope_id"), "mail_profile_policies", ["scope_id"], unique=False)
op.create_index("ix_mail_profile_policies_scope", "mail_profile_policies", ["scope_type", "scope_id"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "mail_profile_policies" not in inspector.get_table_names():
return
op.drop_index("ix_mail_profile_policies_scope", table_name="mail_profile_policies")
op.drop_index(op.f("ix_mail_profile_policies_scope_id"), table_name="mail_profile_policies")
op.drop_index(op.f("ix_mail_profile_policies_scope_type"), table_name="mail_profile_policies")
op.drop_index(op.f("ix_mail_profile_policies_tenant_id"), table_name="mail_profile_policies")
op.drop_table("mail_profile_policies")

View File

@@ -0,0 +1,112 @@
"""mail mailbox index
Revision ID: 4e5f708192ab
Revises: 3d4e5f708192
Create Date: 2026-07-14 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4e5f708192ab"
down_revision = "3d4e5f708192"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_mailbox_folder_index" not in tables:
op.create_table(
"mail_mailbox_folder_index",
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("flags", sa.JSON(), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=True),
sa.Column("unseen_count", sa.Integer(), nullable=True),
sa.Column("uidvalidity", sa.String(length=255), nullable=True),
sa.Column("indexed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("message_indexed_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_mailbox_folder_index_profile_id_mail_server_profiles"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_mailbox_folder_index")),
sa.UniqueConstraint("profile_id", "folder", name="uq_mail_mailbox_folder_index_profile_folder"),
)
op.create_index(op.f("ix_mail_mailbox_folder_index_folder"), "mail_mailbox_folder_index", ["folder"], unique=False)
op.create_index(op.f("ix_mail_mailbox_folder_index_indexed_at"), "mail_mailbox_folder_index", ["indexed_at"], unique=False)
op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False)
op.create_index(op.f("ix_mail_mailbox_folder_index_profile_id"), "mail_mailbox_folder_index", ["profile_id"], unique=False)
op.create_index(op.f("ix_mail_mailbox_folder_index_tenant_id"), "mail_mailbox_folder_index", ["tenant_id"], unique=False)
op.create_index("ix_mail_mailbox_folder_index_tenant_profile", "mail_mailbox_folder_index", ["tenant_id", "profile_id"], unique=False)
else:
columns = {column["name"] for column in inspector.get_columns("mail_mailbox_folder_index")}
if "message_indexed_at" not in columns:
op.add_column("mail_mailbox_folder_index", sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False)
if "mail_mailbox_message_index" not in tables:
op.create_table(
"mail_mailbox_message_index",
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("uid_int", sa.BigInteger(), nullable=False),
sa.Column("sort_position", sa.BigInteger(), nullable=False),
sa.Column("subject", sa.Text(), nullable=True),
sa.Column("from_header", sa.Text(), nullable=True),
sa.Column("to_header", sa.Text(), nullable=True),
sa.Column("cc_header", sa.Text(), nullable=True),
sa.Column("date", sa.String(length=255), nullable=True),
sa.Column("message_id", sa.Text(), nullable=True),
sa.Column("flags", sa.JSON(), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=True),
sa.Column("body_preview", sa.Text(), nullable=True),
sa.Column("attachment_count", sa.Integer(), nullable=False),
sa.Column("indexed_at", sa.DateTime(timezone=True), 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"], name=op.f("fk_mail_mailbox_message_index_profile_id_mail_server_profiles"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_mailbox_message_index")),
sa.UniqueConstraint("profile_id", "folder", "uid", name="uq_mail_mailbox_message_index_profile_folder_uid"),
)
op.create_index(op.f("ix_mail_mailbox_message_index_folder"), "mail_mailbox_message_index", ["folder"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_indexed_at"), "mail_mailbox_message_index", ["indexed_at"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_profile_id"), "mail_mailbox_message_index", ["profile_id"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_tenant_id"), "mail_mailbox_message_index", ["tenant_id"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_uid_int"), "mail_mailbox_message_index", ["uid_int"], unique=False)
op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "sort_position"], unique=False)
else:
columns = {column["name"] for column in inspector.get_columns("mail_mailbox_message_index")}
if "sort_position" not in columns:
op.add_column("mail_mailbox_message_index", sa.Column("sort_position", sa.BigInteger(), nullable=False, server_default="0"))
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_mailbox_message_index" in tables:
op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_uid_int"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_tenant_id"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_profile_id"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_indexed_at"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_folder"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index")
op.drop_table("mail_mailbox_message_index")
if "mail_mailbox_folder_index" in tables:
op.drop_index("ix_mail_mailbox_folder_index_tenant_profile", table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_tenant_id"), table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_profile_id"), table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_indexed_at"), table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_folder"), table_name="mail_mailbox_folder_index")
op.drop_table("mail_mailbox_folder_index")

View File

@@ -0,0 +1,67 @@
"""repair mail mailbox index columns
Revision ID: 5f708192abcd
Revises: 4e5f708192ab
Create Date: 2026-07-14 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5f708192abcd"
down_revision = "4e5f708192ab"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_mailbox_folder_index" in tables:
columns = _columns(inspector, "mail_mailbox_folder_index")
indexes = _indexes(inspector, "mail_mailbox_folder_index")
if "message_indexed_at" not in columns:
op.add_column("mail_mailbox_folder_index", sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True))
if "ix_mail_mailbox_folder_index_message_indexed_at" not in indexes:
op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False)
if "mail_mailbox_message_index" in tables:
columns = _columns(inspector, "mail_mailbox_message_index")
indexes = _indexes(inspector, "mail_mailbox_message_index")
if "sort_position" not in columns:
op.add_column("mail_mailbox_message_index", sa.Column("sort_position", sa.BigInteger(), nullable=False, server_default="0"))
if op.get_bind().dialect.name != "sqlite":
op.alter_column("mail_mailbox_message_index", "sort_position", server_default=None)
if "ix_mail_mailbox_message_index_sort_position" not in indexes:
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
if "ix_mail_mailbox_message_index_page" not in indexes:
op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "sort_position"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_mailbox_message_index" in tables:
indexes = _indexes(inspector, "mail_mailbox_message_index")
if "ix_mail_mailbox_message_index_page" in indexes:
op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index")
if "ix_mail_mailbox_message_index_sort_position" in indexes:
op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index")
if "sort_position" in _columns(inspector, "mail_mailbox_message_index"):
op.drop_column("mail_mailbox_message_index", "sort_position")
if "mail_mailbox_folder_index" in tables:
indexes = _indexes(inspector, "mail_mailbox_folder_index")
if "ix_mail_mailbox_folder_index_message_indexed_at" in indexes:
op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index")
if "message_indexed_at" in _columns(inspector, "mail_mailbox_folder_index"):
op.drop_column("mail_mailbox_folder_index", "message_indexed_at")
def _columns(inspector, table_name: str) -> set[str]:
return {column["name"] for column in inspector.get_columns(table_name)}
def _indexes(inspector, table_name: str) -> set[str]:
return {index["name"] for index in inspector.get_indexes(table_name)}

View File

@@ -0,0 +1,78 @@
"""add opaque Mail-owned transport revisions
Revision ID: 608192abcdef
Revises: 5f708192abcd
Create Date: 2026-07-21 00:00:00.000000
"""
from __future__ import annotations
import uuid
from alembic import op
import sqlalchemy as sa
revision = "608192abcdef"
down_revision = "5f708192abcd"
branch_labels = None
depends_on = None
_SMTP_COLUMN = "smtp_transport_revision"
_IMAP_COLUMN = "imap_transport_revision"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "mail_server_profiles" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
with op.batch_alter_table("mail_server_profiles") as batch:
if _SMTP_COLUMN not in columns:
batch.add_column(sa.Column(_SMTP_COLUMN, sa.String(length=36), nullable=True))
if _IMAP_COLUMN not in columns:
batch.add_column(sa.Column(_IMAP_COLUMN, sa.String(length=36), nullable=True))
rows = list(bind.execute(
sa.text(
"SELECT id, smtp_transport_revision, imap_transport_revision "
"FROM mail_server_profiles"
)
).mappings())
for row in rows:
values: dict[str, str] = {}
if not row[_SMTP_COLUMN]:
values[_SMTP_COLUMN] = str(uuid.uuid4())
if not row[_IMAP_COLUMN]:
values[_IMAP_COLUMN] = str(uuid.uuid4())
if values:
bind.execute(
sa.text(
"UPDATE mail_server_profiles "
"SET smtp_transport_revision = COALESCE(:smtp_revision, smtp_transport_revision), "
"imap_transport_revision = COALESCE(:imap_revision, imap_transport_revision) "
"WHERE id = :profile_id"
),
{
"profile_id": row["id"],
"smtp_revision": values.get(_SMTP_COLUMN),
"imap_revision": values.get(_IMAP_COLUMN),
},
)
with op.batch_alter_table("mail_server_profiles") as batch:
batch.alter_column(_SMTP_COLUMN, existing_type=sa.String(length=36), nullable=False)
batch.alter_column(_IMAP_COLUMN, existing_type=sa.String(length=36), nullable=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "mail_server_profiles" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
with op.batch_alter_table("mail_server_profiles") as batch:
if _IMAP_COLUMN in columns:
batch.drop_column(_IMAP_COLUMN)
if _SMTP_COLUMN in columns:
batch.drop_column(_SMTP_COLUMN)

View File

@@ -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

View File

@@ -0,0 +1,39 @@
"""v0.1.7 mail baseline
Revision ID: 3d4e5f708192
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '3d4e5f708192'
down_revision = None
branch_labels = None
depends_on = '4f2a9c8e7b6d'
def upgrade() -> None:
op.create_table('mail_profile_policies',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('tenant_id', sa.String(length=36), nullable=True),
sa.Column('scope_type', sa.String(length=20), nullable=False),
sa.Column('scope_id', sa.String(length=36), nullable=True),
sa.Column('policy', 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(['tenant_id'], ['core_scopes.id'], name=op.f('fk_mail_profile_policies_tenant_id_scopes'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_mail_profile_policies')),
sa.UniqueConstraint('tenant_id', 'scope_type', 'scope_id', name='uq_mail_profile_policies_scope')
)
op.create_index('ix_mail_profile_policies_scope', 'mail_profile_policies', ['scope_type', 'scope_id'], unique=False)
op.create_index(op.f('ix_mail_profile_policies_scope_id'), 'mail_profile_policies', ['scope_id'], unique=False)
op.create_index(op.f('ix_mail_profile_policies_scope_type'), 'mail_profile_policies', ['scope_type'], unique=False)
op.create_index(op.f('ix_mail_profile_policies_tenant_id'), 'mail_profile_policies', ['tenant_id'], unique=False)
def downgrade() -> None:
op.drop_table('mail_profile_policies')

View File

@@ -0,0 +1,112 @@
"""v0.1.8 mail mailbox index
Revision ID: 4e5f708192ab
Revises: 3d4e5f708192
Create Date: 2026-07-14 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "4e5f708192ab"
down_revision = "3d4e5f708192"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_mailbox_folder_index" not in tables:
op.create_table(
"mail_mailbox_folder_index",
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("flags", sa.JSON(), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=True),
sa.Column("unseen_count", sa.Integer(), nullable=True),
sa.Column("uidvalidity", sa.String(length=255), nullable=True),
sa.Column("indexed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("message_indexed_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_mailbox_folder_index_profile_id_mail_server_profiles"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_mailbox_folder_index")),
sa.UniqueConstraint("profile_id", "folder", name="uq_mail_mailbox_folder_index_profile_folder"),
)
op.create_index(op.f("ix_mail_mailbox_folder_index_folder"), "mail_mailbox_folder_index", ["folder"], unique=False)
op.create_index(op.f("ix_mail_mailbox_folder_index_indexed_at"), "mail_mailbox_folder_index", ["indexed_at"], unique=False)
op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False)
op.create_index(op.f("ix_mail_mailbox_folder_index_profile_id"), "mail_mailbox_folder_index", ["profile_id"], unique=False)
op.create_index(op.f("ix_mail_mailbox_folder_index_tenant_id"), "mail_mailbox_folder_index", ["tenant_id"], unique=False)
op.create_index("ix_mail_mailbox_folder_index_tenant_profile", "mail_mailbox_folder_index", ["tenant_id", "profile_id"], unique=False)
else:
columns = {column["name"] for column in inspector.get_columns("mail_mailbox_folder_index")}
if "message_indexed_at" not in columns:
op.add_column("mail_mailbox_folder_index", sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True))
op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False)
if "mail_mailbox_message_index" not in tables:
op.create_table(
"mail_mailbox_message_index",
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("uid_int", sa.BigInteger(), nullable=False),
sa.Column("sort_position", sa.BigInteger(), nullable=False),
sa.Column("subject", sa.Text(), nullable=True),
sa.Column("from_header", sa.Text(), nullable=True),
sa.Column("to_header", sa.Text(), nullable=True),
sa.Column("cc_header", sa.Text(), nullable=True),
sa.Column("date", sa.String(length=255), nullable=True),
sa.Column("message_id", sa.Text(), nullable=True),
sa.Column("flags", sa.JSON(), nullable=False),
sa.Column("size_bytes", sa.Integer(), nullable=True),
sa.Column("body_preview", sa.Text(), nullable=True),
sa.Column("attachment_count", sa.Integer(), nullable=False),
sa.Column("indexed_at", sa.DateTime(timezone=True), 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"], name=op.f("fk_mail_mailbox_message_index_profile_id_mail_server_profiles"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_mailbox_message_index")),
sa.UniqueConstraint("profile_id", "folder", "uid", name="uq_mail_mailbox_message_index_profile_folder_uid"),
)
op.create_index(op.f("ix_mail_mailbox_message_index_folder"), "mail_mailbox_message_index", ["folder"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_indexed_at"), "mail_mailbox_message_index", ["indexed_at"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_profile_id"), "mail_mailbox_message_index", ["profile_id"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_tenant_id"), "mail_mailbox_message_index", ["tenant_id"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
op.create_index(op.f("ix_mail_mailbox_message_index_uid_int"), "mail_mailbox_message_index", ["uid_int"], unique=False)
op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "sort_position"], unique=False)
else:
columns = {column["name"] for column in inspector.get_columns("mail_mailbox_message_index")}
if "sort_position" not in columns:
op.add_column("mail_mailbox_message_index", sa.Column("sort_position", sa.BigInteger(), nullable=False, server_default="0"))
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_mailbox_message_index" in tables:
op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_uid_int"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_tenant_id"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_profile_id"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_indexed_at"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_folder"), table_name="mail_mailbox_message_index")
op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index")
op.drop_table("mail_mailbox_message_index")
if "mail_mailbox_folder_index" in tables:
op.drop_index("ix_mail_mailbox_folder_index_tenant_profile", table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_tenant_id"), table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_profile_id"), table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_indexed_at"), table_name="mail_mailbox_folder_index")
op.drop_index(op.f("ix_mail_mailbox_folder_index_folder"), table_name="mail_mailbox_folder_index")
op.drop_table("mail_mailbox_folder_index")

View File

@@ -0,0 +1,67 @@
"""repair mail mailbox index columns
Revision ID: 5f708192abcd
Revises: 4e5f708192ab
Create Date: 2026-07-14 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5f708192abcd"
down_revision = "4e5f708192ab"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_mailbox_folder_index" in tables:
columns = _columns(inspector, "mail_mailbox_folder_index")
indexes = _indexes(inspector, "mail_mailbox_folder_index")
if "message_indexed_at" not in columns:
op.add_column("mail_mailbox_folder_index", sa.Column("message_indexed_at", sa.DateTime(timezone=True), nullable=True))
if "ix_mail_mailbox_folder_index_message_indexed_at" not in indexes:
op.create_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), "mail_mailbox_folder_index", ["message_indexed_at"], unique=False)
if "mail_mailbox_message_index" in tables:
columns = _columns(inspector, "mail_mailbox_message_index")
indexes = _indexes(inspector, "mail_mailbox_message_index")
if "sort_position" not in columns:
op.add_column("mail_mailbox_message_index", sa.Column("sort_position", sa.BigInteger(), nullable=False, server_default="0"))
if op.get_bind().dialect.name != "sqlite":
op.alter_column("mail_mailbox_message_index", "sort_position", server_default=None)
if "ix_mail_mailbox_message_index_sort_position" not in indexes:
op.create_index(op.f("ix_mail_mailbox_message_index_sort_position"), "mail_mailbox_message_index", ["sort_position"], unique=False)
if "ix_mail_mailbox_message_index_page" not in indexes:
op.create_index("ix_mail_mailbox_message_index_page", "mail_mailbox_message_index", ["tenant_id", "profile_id", "folder", "sort_position"], unique=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
if "mail_mailbox_message_index" in tables:
indexes = _indexes(inspector, "mail_mailbox_message_index")
if "ix_mail_mailbox_message_index_page" in indexes:
op.drop_index("ix_mail_mailbox_message_index_page", table_name="mail_mailbox_message_index")
if "ix_mail_mailbox_message_index_sort_position" in indexes:
op.drop_index(op.f("ix_mail_mailbox_message_index_sort_position"), table_name="mail_mailbox_message_index")
if "sort_position" in _columns(inspector, "mail_mailbox_message_index"):
op.drop_column("mail_mailbox_message_index", "sort_position")
if "mail_mailbox_folder_index" in tables:
indexes = _indexes(inspector, "mail_mailbox_folder_index")
if "ix_mail_mailbox_folder_index_message_indexed_at" in indexes:
op.drop_index(op.f("ix_mail_mailbox_folder_index_message_indexed_at"), table_name="mail_mailbox_folder_index")
if "message_indexed_at" in _columns(inspector, "mail_mailbox_folder_index"):
op.drop_column("mail_mailbox_folder_index", "message_indexed_at")
def _columns(inspector, table_name: str) -> set[str]:
return {column["name"] for column in inspector.get_columns(table_name)}
def _indexes(inspector, table_name: str) -> set[str]:
return {index["name"] for index in inspector.get_indexes(table_name)}

View File

@@ -0,0 +1,78 @@
"""add opaque Mail-owned transport revisions
Revision ID: 608192abcdef
Revises: 5f708192abcd
Create Date: 2026-07-21 00:00:00.000000
"""
from __future__ import annotations
import uuid
from alembic import op
import sqlalchemy as sa
revision = "608192abcdef"
down_revision = "5f708192abcd"
branch_labels = None
depends_on = None
_SMTP_COLUMN = "smtp_transport_revision"
_IMAP_COLUMN = "imap_transport_revision"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "mail_server_profiles" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
with op.batch_alter_table("mail_server_profiles") as batch:
if _SMTP_COLUMN not in columns:
batch.add_column(sa.Column(_SMTP_COLUMN, sa.String(length=36), nullable=True))
if _IMAP_COLUMN not in columns:
batch.add_column(sa.Column(_IMAP_COLUMN, sa.String(length=36), nullable=True))
rows = list(bind.execute(
sa.text(
"SELECT id, smtp_transport_revision, imap_transport_revision "
"FROM mail_server_profiles"
)
).mappings())
for row in rows:
values: dict[str, str] = {}
if not row[_SMTP_COLUMN]:
values[_SMTP_COLUMN] = str(uuid.uuid4())
if not row[_IMAP_COLUMN]:
values[_IMAP_COLUMN] = str(uuid.uuid4())
if values:
bind.execute(
sa.text(
"UPDATE mail_server_profiles "
"SET smtp_transport_revision = COALESCE(:smtp_revision, smtp_transport_revision), "
"imap_transport_revision = COALESCE(:imap_revision, imap_transport_revision) "
"WHERE id = :profile_id"
),
{
"profile_id": row["id"],
"smtp_revision": values.get(_SMTP_COLUMN),
"imap_revision": values.get(_IMAP_COLUMN),
},
)
with op.batch_alter_table("mail_server_profiles") as batch:
batch.alter_column(_SMTP_COLUMN, existing_type=sa.String(length=36), nullable=False)
batch.alter_column(_IMAP_COLUMN, existing_type=sa.String(length=36), nullable=False)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if "mail_server_profiles" not in inspector.get_table_names():
return
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
with op.batch_alter_table("mail_server_profiles") as batch:
if _IMAP_COLUMN in columns:
batch.drop_column(_IMAP_COLUMN)
if _SMTP_COLUMN in columns:
batch.drop_column(_SMTP_COLUMN)

View File

@@ -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 {}

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,10 @@
from __future__ import annotations
from typing import Any
from govoplan_core.core.runtime import ModuleRuntimeState
_runtime_settings: object | None = None
_runtime = ModuleRuntimeState("Mail")
def configure_runtime(*, settings: object | None = None) -> None:
global _runtime_settings
if settings is not None:
_runtime_settings = settings
def get_settings() -> object:
if _runtime_settings is not None:
return _runtime_settings
try:
from govoplan_core.settings import settings as legacy_settings
except ModuleNotFoundError as exc:
raise RuntimeError("GovOPlaN Mail runtime settings are not configured") from exc
return legacy_settings
class SettingsProxy:
def __getattr__(self, name: str) -> Any:
return getattr(get_settings(), name)
settings = SettingsProxy()
configure_runtime = _runtime.configure_runtime
get_registry = _runtime.get_registry
get_settings = _runtime.get_settings
settings = _runtime.settings

View File

@@ -3,9 +3,17 @@ 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_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_mail.backend.config import (
ImapConfig,
ImapServerConfig,
SmtpConfig,
SmtpServerConfig,
TransportCredentials,
normalize_split_transport_credentials,
)
class MailSmtpTestRequest(SmtpConfig):
@@ -28,28 +36,7 @@ class MailServerProfileCredentialsPayload(BaseModel):
def _normalize_profile_transport_payload(value: object) -> object:
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
return normalize_split_transport_credentials(value)
def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]:
@@ -93,6 +80,7 @@ class MailProfilePolicyUpdateRequest(BaseModel):
class PolicySourceStepResponse(BaseModel):
scope_type: str
scope_id: str | None = None
path: str
label: str
applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
@@ -115,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
@@ -149,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)
@@ -179,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
@@ -188,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
@@ -201,6 +314,36 @@ class MailServerProfileListResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
class MailSettingsDeltaResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
policy: MailProfilePolicyResponse | None = None
changed_sections: list[str] = Field(default_factory=list)
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
watermark: str | None = None
has_more: bool = False
full: bool = False
class MailAddressLookupCandidate(BaseModel):
contact_id: str
address_book_id: str
display_name: str
email: str | None = None
email_label: str | None = None
organization: str | None = None
role_title: str | None = None
tags: list[str] = Field(default_factory=list)
source_kind: str = "local"
source_ref: str | None = None
source_revision: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class MailAddressLookupResponse(BaseModel):
available: bool = False
candidates: list[MailAddressLookupCandidate] = Field(default_factory=list)
class MailConnectionTestResponse(BaseModel):
ok: bool
protocol: Literal["smtp", "imap"]
@@ -227,6 +370,9 @@ class MailImapFolderListResponse(BaseModel):
message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None
from_cache: bool = False
refreshing: bool = False
indexed_at: datetime | None = None
details: dict[str, Any] = Field(default_factory=dict)
class MailMailboxAttachmentResponse(BaseModel):
@@ -266,9 +412,23 @@ class MailMailboxMessageListResponse(BaseModel):
total_count: int = 0
offset: int = 0
limit: int = 50
cursor: str | None = None
next_cursor: str | None = None
cursor_stable: bool = False
full: bool = False
from_cache: bool = False
refreshing: bool = False
indexed_at: datetime | None = None
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
class MailMailboxBootstrapResponse(BaseModel):
profile_id: str
folder: str
folders: MailImapFolderListResponse
messages: MailMailboxMessageListResponse
class MailMailboxMessageResponse(BaseModel):
profile_id: str
folder: str
@@ -276,4 +436,3 @@ class MailMailboxMessageResponse(BaseModel):
port: int | None = None
security: str | None = None
message: MailMailboxMessageDetailResponse

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import imaplib
import logging
import re
import socket
import ssl
@@ -11,6 +12,13 @@ from email.message import EmailMessage
from email.parser import BytesParser
from typing import Any
from govoplan_core.security.outbound_http import (
OutboundHttpError,
create_outbound_connection,
response_limit,
validate_outbound_host,
)
from govoplan_mail.backend.config import ImapConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import (
MOCK_IMAP_FOLDERS,
@@ -21,6 +29,33 @@ from govoplan_mail.backend.dev.mock_mailbox import (
record_imap_append,
)
logger = logging.getLogger(__name__)
class _OutboundPolicyIMAP4(imaplib.IMAP4):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
return create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="IMAP connector",
)
class _OutboundPolicyIMAP4SSL(imaplib.IMAP4_SSL):
def _create_socket(self, timeout: float | None): # type: ignore[no-untyped-def]
sock = create_outbound_connection(
self.host,
self.port,
timeout=timeout,
label="IMAP connector",
)
try:
return self.ssl_context.wrap_socket(sock, server_hostname=self.host)
except Exception:
sock.close()
raise
class ImapConfigurationError(ValueError):
"""Raised when IMAP settings are incomplete or inconsistent."""
@@ -33,9 +68,16 @@ class ImapAppendError(RuntimeError):
configuration or mailbox choice probably needs user/admin attention.
"""
def __init__(self, message: str, *, temporary: bool | None = None):
def __init__(
self,
message: str,
*,
temporary: bool | None = None,
outcome_unknown: bool = False,
):
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
@dataclass(frozen=True, slots=True)
@@ -70,6 +112,10 @@ class ImapMailboxAttachmentInfo:
size_bytes: int
def _log_imap_cleanup_failure(action: str, exc: BaseException) -> None:
logger.debug("IMAP cleanup failed while %s: %s", action, exc, exc_info=True)
@dataclass(frozen=True, slots=True)
class ImapMailboxMessageSummary:
uid: str
@@ -115,6 +161,14 @@ class ImapMailboxMessageListResult:
total_count: int
offset: int
limit: int
uidvalidity: str | None = None
cursor_reset: bool = False
@dataclass(frozen=True, slots=True)
class ImapMailboxBootstrapResult:
folders: ImapFolderListResult
messages: ImapMailboxMessageListResult
@dataclass(frozen=True, slots=True)
@@ -148,13 +202,22 @@ def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
host, port = _require_imap_config(config)
try:
validate_outbound_host(host, port=port, label="IMAP connector")
except OutboundHttpError as exc:
raise ImapConfigurationError(str(exc)) from exc
context = ssl.create_default_context()
try:
if config.security == TransportSecurity.TLS:
client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context)
client: imaplib.IMAP4 = _OutboundPolicyIMAP4SSL(
host=host,
port=port,
timeout=config.timeout_seconds,
ssl_context=context,
)
else:
client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds)
client = _OutboundPolicyIMAP4(host=host, port=port, timeout=config.timeout_seconds)
if config.security == TransportSecurity.STARTTLS:
typ, data = client.starttls(ssl_context=context)
if typ != "OK":
@@ -168,8 +231,8 @@ def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
except Exception:
try:
client.logout() # type: ignore[possibly-undefined]
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("opening connection", cleanup_exc)
raise
@@ -304,58 +367,200 @@ def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
finally:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("testing login", cleanup_exc)
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
host, port = _require_imap_config(imap_config)
records = list_records(limit=500)
folders = []
for item in MOCK_IMAP_FOLDERS:
name = str(item["name"])
count = sum(1 for record in records if _mock_folder_matches(record, name))
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder="Sent",
)
def _list_imap_folders_on_client(
client: imaplib.IMAP4,
*,
host: str,
port: int,
security: str,
include_status: bool,
) -> ImapFolderListResult:
typ, data = client.list()
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
message_count, unseen_count = (
(None, None)
if not include_status or _has_folder_flag(flags, "noselect")
else _imap_folder_status(client, name)
)
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
return ImapFolderListResult(
host=host,
port=port,
security=security,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
)
def list_imap_folders(*, imap_config: ImapConfig, include_status: bool = True) -> ImapFolderListResult:
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
records = list_records(limit=500)
folders = []
for item in MOCK_IMAP_FOLDERS:
name = str(item["name"])
count = sum(1 for record in records if _mock_folder_matches(record, name))
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder="Sent",
)
return _mock_imap_folders(imap_config=imap_config)
client = _open_imap(imap_config)
try:
typ, data = client.list()
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
message_count, unseen_count = (None, None) if _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name)
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
return ImapFolderListResult(
return _list_imap_folders_on_client(
client,
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
include_status=include_status,
)
finally:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("listing folders", cleanup_exc)
def _preferred_mailbox_folder(folders: list[ImapMailboxInfo], requested: str | None, detected_sent_folder: str | None) -> str:
folder_names = {folder.name for folder in folders}
requested = (requested or "").strip()
if requested and requested in folder_names:
return requested
if "INBOX" in folder_names:
return "INBOX"
if detected_sent_folder and detected_sent_folder in folder_names:
return detected_sent_folder
return folders[0].name if folders else (requested or "INBOX")
def load_imap_mailbox_bootstrap(
*,
imap_config: ImapConfig,
folder: str = "INBOX",
limit: int = 50,
offset: int = 0,
include_folder_status: bool = False,
) -> ImapMailboxBootstrapResult:
"""Load folders and one message page through a single IMAP connection."""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
folders = _mock_imap_folders(imap_config=imap_config)
selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder)
messages = list_imap_messages(imap_config=imap_config, folder=selected_folder, limit=limit, offset=offset)
return ImapMailboxBootstrapResult(folders=folders, messages=messages)
client = _open_imap(imap_config)
try:
folders = _list_imap_folders_on_client(
client,
host=host,
port=port,
security=imap_config.security.value,
include_status=include_folder_status,
)
selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder)
selected_folder, limit, offset = _normalize_mailbox_page(folder=selected_folder, limit=limit, offset=offset)
messages = _list_imap_messages_on_client(
client,
host=host,
port=port,
security=imap_config.security.value,
folder=selected_folder,
limit=limit,
offset=offset,
after_uid=None,
expected_uidvalidity=None,
)
return ImapMailboxBootstrapResult(folders=folders, messages=messages)
finally:
try:
client.logout()
except Exception as cleanup_exc:
_log_imap_cleanup_failure("bootstrapping mailbox", cleanup_exc)
def _list_imap_messages_on_client(
client: imaplib.IMAP4,
*,
host: str,
port: int,
security: str,
folder: str,
limit: int,
offset: int,
after_uid: str | None,
expected_uidvalidity: str | None,
) -> ImapMailboxMessageListResult:
total_count, uidvalidity = _select_readonly(client, folder)
if after_uid is None and expected_uidvalidity is None:
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=security,
folder=folder,
messages=messages,
total_count=total_count,
offset=offset,
limit=limit,
uidvalidity=None,
cursor_reset=False,
)
uids = _search_message_uids(client)
cursor_reset = False
effective_after_uid = after_uid
effective_offset = offset
if expected_uidvalidity and expected_uidvalidity != uidvalidity:
effective_after_uid = None
effective_offset = 0
cursor_reset = True
page_uids, effective_offset, anchor_missing = _paged_descending_uids(
uids,
offset=effective_offset,
limit=limit,
after_uid=effective_after_uid,
)
messages = _fetch_message_summaries_by_uid(client, page_uids, folder)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=security,
folder=folder,
messages=messages,
total_count=len(uids) if uids else total_count,
offset=effective_offset,
limit=limit,
uidvalidity=uidvalidity,
cursor_reset=cursor_reset or anchor_missing,
)
def _has_folder_flag(flags: set[str], flag: str) -> bool:
@@ -527,30 +732,42 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -
return parts
def _select_readonly(client: imaplib.IMAP4, folder: str) -> int:
typ, data = client.select(folder, readonly=True)
def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]:
typ, data = client.select(_quote_mailbox_name(folder), readonly=True)
if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
selected_count = _decode_item(data[0] if data else None).strip()
try:
return max(0, int(selected_count))
total_count = max(0, int(selected_count))
except ValueError:
return 0
total_count = 0
uidvalidity = None
try:
_, uidvalidity_data = client.response("UIDVALIDITY")
if uidvalidity_data:
uidvalidity = _decode_item(uidvalidity_data[0]).strip() or None
except Exception:
uidvalidity = None
return total_count, uidvalidity
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.uid("fetch", str(uid), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
max_bytes = response_limit("file")
typ, data = client.uid("fetch", str(uid), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)")
if typ != "OK":
raise ImapAppendError(f"IMAP message {uid!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
_ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message")
return fetched_uid or str(uid), flags, size_bytes, raw
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
max_bytes = response_limit("file")
typ, data = client.fetch(str(sequence), f"(UID FLAGS RFC822.SIZE BODY.PEEK[]<0.{max_bytes + 1}>)")
if typ != "OK":
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
_ensure_imap_payload_within_limit(raw, declared_size=size_bytes, max_bytes=max_bytes, label="IMAP message")
if not fetched_uid:
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
return fetched_uid, flags, size_bytes, raw
@@ -569,12 +786,19 @@ def _sequence_set(sequences: list[str]) -> str:
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not sequences:
return []
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
max_bytes = response_limit("structured")
per_message_bytes = max(1, max_bytes // len(sequences)) + 1
typ, data = client.fetch(
_sequence_set(sequences),
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)",
)
if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
parsed_parts = _parse_fetch_parts_with_sequence(data)
_ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response")
for sequence, fetched_uid, flags, size_bytes, headers in parsed_parts:
if not sequence or not fetched_uid:
continue
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
@@ -590,6 +814,92 @@ def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[
return summaries
def _search_message_uids(client: imaplib.IMAP4) -> list[str]:
typ, data = client.uid("search", None, "ALL")
if typ != "OK":
raise ImapAppendError(f"IMAP UID search failed: {data!r}", temporary=True)
uids: set[int] = set()
for item in data or []:
for token in _decode_item(item).split():
if token.isdigit():
uids.add(int(token))
return [str(uid) for uid in sorted(uids, reverse=True)]
def _paged_descending_uids(
uids: list[str],
*,
offset: int,
limit: int,
after_uid: str | None = None,
) -> tuple[list[str], int, bool]:
if not uids:
return [], 0, False
cursor_reset = False
if after_uid:
try:
start = uids.index(str(after_uid)) + 1
except ValueError:
start = 0
cursor_reset = True
else:
start = min(max(0, offset), len(uids))
return uids[start:start + limit], start, cursor_reset
def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not uids:
return []
max_bytes = response_limit("structured")
per_message_bytes = max(1, max_bytes // len(uids)) + 1
typ, data = client.uid(
"fetch",
_sequence_set(uids),
f"(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)]<0.{per_message_bytes}>)",
)
if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_uid: dict[str, ImapMailboxMessageSummary] = {}
parsed_parts = _parse_fetch_parts_with_sequence(data)
_ensure_imap_batch_within_limit(parsed_parts, max_bytes=max_bytes, label="IMAP message summary response")
for _sequence, fetched_uid, flags, size_bytes, headers in parsed_parts:
if not fetched_uid:
continue
by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
summaries: list[ImapMailboxMessageSummary] = []
for uid in uids:
summary = by_uid.get(str(uid))
if summary is not None:
summaries.append(summary)
continue
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
return summaries
def _ensure_imap_payload_within_limit(
payload: bytes,
*,
declared_size: int | None,
max_bytes: int,
label: str,
) -> None:
if (declared_size is not None and declared_size > max_bytes) or len(payload) > max_bytes:
raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False)
def _ensure_imap_batch_within_limit(
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]],
*,
max_bytes: int,
label: str,
) -> None:
if sum(len(part[4]) for part in parts) > max_bytes:
raise ImapAppendError(f"{label} exceeds the deployment limit of {max_bytes} bytes", temporary=False)
def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip()
if folder:
@@ -612,39 +922,118 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
return "\n".join(lines).encode("utf-8", errors="replace")
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult:
def _mock_mailbox_page_offset(
records: list[dict[str, Any]],
*,
offset: int,
after_uid: str | None,
expected_uidvalidity: str | None,
) -> tuple[int, bool]:
if expected_uidvalidity and expected_uidvalidity != "mock-v1":
return 0, True
if after_uid:
record_ids = [str(record.get("id") or "") for record in records]
try:
return record_ids.index(str(after_uid)) + 1, False
except ValueError:
return 0, True
return min(offset, len(records)), False
def _mock_message_summary(record: dict[str, Any]) -> ImapMailboxMessageSummary:
return _message_summary_from_raw(
uid=str(record.get("id") or ""),
folder=_mock_record_folder(record),
raw=_mock_raw_bytes(record),
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=int(record.get("size_bytes") or 0) or None,
)
def _list_mock_imap_messages(
*,
host: str,
port: int,
security: str,
folder: str,
limit: int,
offset: int,
after_uid: str | None,
expected_uidvalidity: str | None,
) -> ImapMailboxMessageListResult:
records = [
record
for record in list_records(limit=500)
if _mock_folder_matches(record, folder)
]
effective_offset, cursor_reset = _mock_mailbox_page_offset(
records,
offset=offset,
after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
)
page_records = records[effective_offset : effective_offset + limit]
return ImapMailboxMessageListResult(
host=host,
port=port,
security=security,
folder=folder,
messages=[_mock_message_summary(record) for record in page_records],
total_count=len(records),
offset=effective_offset,
limit=limit,
uidvalidity="mock-v1",
cursor_reset=cursor_reset,
)
def list_imap_messages(
*,
imap_config: ImapConfig,
folder: str = "INBOX",
limit: int = 50,
offset: int = 0,
after_uid: str | None = None,
expected_uidvalidity: str | None = None,
) -> ImapMailboxMessageListResult:
"""List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX"
limit = max(1, min(limit, 100))
offset = max(0, offset)
folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset)
if is_mock_imap_host(imap_config.host):
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
page_records = records[offset:offset + limit]
messages = [
_message_summary_from_raw(
uid=str(record.get("id") or ""),
folder=_mock_record_folder(record),
raw=_mock_raw_bytes(record),
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=int(record.get("size_bytes") or 0) or None,
)
for record in page_records
]
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit)
return _list_mock_imap_messages(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
limit=limit,
offset=offset,
after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
)
client = _open_imap(imap_config)
try:
total_count = _select_readonly(client, folder)
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit)
return _list_imap_messages_on_client(
client,
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
limit=limit,
offset=offset,
after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
)
finally:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("listing messages", cleanup_exc)
def _normalize_mailbox_page(*, folder: str | None, limit: int, offset: int) -> tuple[str, int, int]:
return (folder or "INBOX").strip() or "INBOX", max(1, min(limit, 100)), max(0, offset)
def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]:
@@ -686,8 +1075,8 @@ def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapM
finally:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("reading message", cleanup_exc)
def append_message_to_sent(
@@ -718,11 +1107,13 @@ def append_message_to_sent(
)
client: imaplib.IMAP4 | None = None
append_started = False
try:
client = _open_imap(imap_config)
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
internal_date = imaplib.Time2Internaldate(time.time())
typ, data = client.append(target_folder, "\\Seen", internal_date, message_bytes)
append_started = True
typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes)
if typ != "OK":
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
response = "; ".join(_decode_item(item) for item in (data or [])) or None
@@ -737,12 +1128,20 @@ def append_message_to_sent(
except ImapAppendError:
raise
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=True) from exc
raise ImapAppendError(
f"IMAP append failed: {exc}",
temporary=not append_started,
outcome_unknown=append_started,
) from exc
except imaplib.IMAP4.error as exc:
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=False) from exc
raise ImapAppendError(
f"IMAP append failed: {exc}",
temporary=False,
outcome_unknown=append_started,
) from exc
finally:
if client is not None:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("appending sent message", cleanup_exc)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import time
import threading
from dataclasses import dataclass
from redis import Redis
@@ -17,6 +18,10 @@ class RateLimitDecision:
waited_seconds: float
_local_rate_limit_lock = threading.Lock()
_local_next_allowed: dict[str, float] = {}
def _redis_client() -> Redis:
return Redis.from_url(settings.redis_url, decode_responses=True)
@@ -30,18 +35,21 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
The implementation stores the next allowed send timestamp per key. A Redis
lock keeps multiple Celery processes from reading/updating the timestamp at
the same time. Direct local development runs do not have a broker, so Redis
is only used when Celery/worker mode is explicitly enabled. If Redis is
unavailable, it falls back to no distributed wait.
the same time. Direct local development runs do not have a broker, so they
use a process-local limiter. If Redis is unavailable in worker mode, the
process-local fallback still protects single-process sends.
"""
messages_per_minute = max(1, int(messages_per_minute or 1))
gap = 60.0 / messages_per_minute
if not enabled or not _distributed_rate_limit_enabled():
if not enabled:
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
if not _distributed_rate_limit_enabled():
waited = _wait_for_local_rate_limit(key, gap)
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
redis_key = f"multimailer:ratelimit:{key}:next_allowed"
lock_key = f"multimailer:ratelimit:{key}:lock"
redis_key = f"govoplan:ratelimit:{key}:next_allowed"
lock_key = f"govoplan:ratelimit:{key}:lock"
waited = 0.0
try:
@@ -56,7 +64,17 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
now = time.time()
client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
except (RedisError, TimeoutError, ValueError):
# Development fallback: do not fail sending because Redis is absent.
waited = 0.0
waited = _wait_for_local_rate_limit(key, gap)
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
def _wait_for_local_rate_limit(key: str, gap: float) -> float:
with _local_rate_limit_lock:
now = time.time()
next_allowed = _local_next_allowed.get(key, now)
waited = max(0.0, next_allowed - now)
_local_next_allowed[key] = max(now, next_allowed) + gap
if waited > 0:
time.sleep(waited)
return waited

View File

@@ -1,12 +1,19 @@
from __future__ import annotations
import copy
import logging
import smtplib
import ssl
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from govoplan_core.security.outbound_http import (
OutboundHttpError,
create_outbound_connection,
validate_outbound_host,
)
from govoplan_mail.backend.config import SmtpConfig, TransportSecurity
from govoplan_mail.backend.dev.mock_mailbox import (
consume_fail_next_smtp,
@@ -15,6 +22,35 @@ from govoplan_mail.backend.dev.mock_mailbox import (
record_smtp_delivery,
)
logger = logging.getLogger(__name__)
class _OutboundPolicySMTP(smtplib.SMTP):
def _get_socket(self, host: str, port: int, timeout: float | None): # type: ignore[no-untyped-def]
return create_outbound_connection(
host,
port,
timeout=timeout,
source_address=self.source_address,
label="SMTP connector",
)
class _OutboundPolicySMTPSSL(smtplib.SMTP_SSL):
def _get_socket(self, host: str, port: int, timeout: float | None): # type: ignore[no-untyped-def]
sock = create_outbound_connection(
host,
port,
timeout=timeout,
source_address=self.source_address,
label="SMTP connector",
)
try:
return self.context.wrap_socket(sock, server_hostname=self._host)
except Exception:
sock.close()
raise
class SmtpConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or inconsistent."""
@@ -56,6 +92,10 @@ class SmtpSendResult:
return len(self.envelope_recipients) - len(self.refused_recipients)
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
if not config.host:
raise SmtpConfigurationError("SMTP host is required")
@@ -68,14 +108,23 @@ def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
host, port = _require_smtp_config(config)
try:
validate_outbound_host(host, port=port, label="SMTP connector")
except OutboundHttpError as exc:
raise SmtpConfigurationError(str(exc)) from exc
context = ssl.create_default_context()
try:
if config.security == TransportSecurity.TLS:
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context)
smtp: smtplib.SMTP = _OutboundPolicySMTPSSL(
host=host,
port=port,
timeout=config.timeout_seconds,
context=context,
)
smtp.ehlo()
else:
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds)
smtp = _OutboundPolicySMTP(host=host, port=port, timeout=config.timeout_seconds)
smtp.ehlo()
if config.security == TransportSecurity.STARTTLS:
smtp.starttls(context=context)
@@ -89,8 +138,8 @@ def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
# on GC, but explicit cleanup is safer when the variable exists.
try:
smtp.quit() # type: ignore[possibly-undefined]
except Exception:
pass
except Exception as cleanup_exc:
_log_smtp_cleanup_failure("opening connection", cleanup_exc)
raise
@@ -134,11 +183,12 @@ def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
finally:
try:
smtp.quit()
except Exception:
except Exception as quit_exc:
_log_smtp_cleanup_failure("testing login quit", quit_exc)
try:
smtp.close()
except Exception:
pass
except Exception as close_exc:
_log_smtp_cleanup_failure("testing login close", close_exc)
def prepare_test_message(
@@ -161,12 +211,12 @@ def prepare_test_message(
del test_message[header]
# Replace potential previous marker headers if the user test-sends an EML twice.
for header in ["X-MultiMailer-Test-Send"]:
for header in ["X-GovOPlaN-Test-Send"]:
if header in test_message:
del test_message[header]
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient))
test_message["X-MultiMailer-Test-Send"] = "true"
test_message["X-GovOPlaN-Test-Send"] = "true"
return test_message
@@ -177,43 +227,97 @@ def _send_smtp_payload(
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
host, port, recipients = _prepare_smtp_send(
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
if is_mock_smtp_host(smtp_config.host):
_accepted, refused = _send_mock_smtp_payload(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=recipients,
)
return _smtp_send_result(
smtp_config=smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
)
refused = _send_network_smtp_payload(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=recipients,
)
return _smtp_send_result(
smtp_config=smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
)
def _prepare_smtp_send(
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> tuple[str, int, list[str]]:
host, port = _require_smtp_config(smtp_config)
if not envelope_from:
raise SmtpConfigurationError("SMTP envelope sender is required")
if not envelope_recipients:
recipients = [recipient for recipient in envelope_recipients if recipient]
if not recipients:
raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
return host, port, recipients
if is_mock_smtp_host(smtp_config.host):
if consume_fail_next_smtp():
raise SmtpSendError("Mock SMTP configured to fail the next send")
failures = get_failures()
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
refused: dict[str, tuple[int, bytes]] = {}
accepted = list(envelope_recipients)
if reject_text:
refused = {
recipient: (550, b"mock recipient rejected")
for recipient in envelope_recipients
if reject_text in recipient.lower()
}
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
if not accepted:
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
record_smtp_delivery(
message,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host=smtp_config.host,
)
return SmtpSendResult(
host=host,
port=port,
security=smtp_config.security.value,
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
)
def _send_mock_smtp_payload(
message: EmailMessage | bytes,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> tuple[list[str], dict[str, tuple[int, bytes]]]:
if consume_fail_next_smtp():
raise SmtpSendError("Mock SMTP configured to fail the next send")
failures = get_failures()
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
refused: dict[str, tuple[int, bytes]] = {}
accepted = list(envelope_recipients)
if reject_text:
refused = {
recipient: (550, b"mock recipient rejected")
for recipient in envelope_recipients
if reject_text in recipient.lower()
}
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
if not accepted:
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
record_smtp_delivery(
message,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host=smtp_config.host,
)
return accepted, refused
def _send_network_smtp_payload(
message: EmailMessage | bytes,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> dict[str, tuple[int, bytes]]:
try:
smtp = _open_smtp(smtp_config)
except smtplib.SMTPAuthenticationError as exc:
@@ -266,12 +370,24 @@ def _send_smtp_payload(
finally:
try:
smtp.quit()
except Exception:
except Exception as quit_exc:
_log_smtp_cleanup_failure("sending message quit", quit_exc)
try:
smtp.close()
except Exception:
pass
except Exception as close_exc:
_log_smtp_cleanup_failure("sending message close", close_exc)
return refused
def _smtp_send_result(
*,
smtp_config: SmtpConfig,
host: str,
port: int,
envelope_from: str,
envelope_recipients: list[str],
refused: dict[str, tuple[int, bytes]],
) -> SmtpSendResult:
return SmtpSendResult(
host=host,
port=port,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,322 @@
from __future__ import annotations
import json
import unittest
from dataclasses import asdict
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_mail.backend.capabilities import (
MailCampaignCapability,
append_campaign_message_to_sent,
campaign_profile_delivery_summary,
send_campaign_email_bytes,
)
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.mail_profiles import MailProfileError
from govoplan_mail.backend.sending.imap import ImapAppendError
from govoplan_mail.backend.sending.smtp import SmtpSendError
def _profile() -> SimpleNamespace:
return SimpleNamespace(
smtp_config={
"host": "smtp.internal.example",
"port": 587,
"security": "starttls",
},
smtp_username="service-account",
smtp_password_encrypted="encrypted-secret",
smtp_transport_revision="smtp-revision",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-revision",
)
class CampaignMailCapabilityTests(unittest.TestCase):
def test_summary_does_not_materialize_or_decrypt_credentials(self) -> None:
profile = _profile()
with (
patch(
"govoplan_mail.backend.capabilities.ensure_mail_profile_allowed_for_campaign",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.effective_mail_profile_policy", return_value=object()),
patch("govoplan_mail.backend.capabilities._assert_campaign_inherits_profile_credentials"),
patch(
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
side_effect=AssertionError("summary must not decrypt SMTP credentials"),
),
patch(
"govoplan_mail.backend.capabilities.imap_config_from_profile",
side_effect=AssertionError("summary must not decrypt IMAP credentials"),
),
):
summary = campaign_profile_delivery_summary(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
)
self.assertTrue(summary["smtp_available"])
self.assertFalse(summary["imap_available"])
self.assertNotIn("service-account", repr(summary))
self.assertNotIn("internal.example", repr(summary))
self.assertNotIn("secret", repr(summary))
def test_send_compares_revision_before_provider_effect(self) -> None:
profile = _profile()
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch(
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
side_effect=AssertionError("stale revisions must be rejected before SMTP credential decryption"),
),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "current", "imap": None},
),
patch("govoplan_mail.backend.capabilities.send_email_bytes") as provider_send,
):
with self.assertRaisesRegex(MailProfileError, "changed after this campaign was built"):
send_campaign_email_bytes(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
from_header="sender@example.test",
expected_smtp_transport_revision="stale",
)
provider_send.assert_not_called()
def test_stale_imap_revision_is_rejected_before_credential_decryption(self) -> None:
profile = _profile()
profile.imap_config = {
"host": "imap.internal.example",
"port": 993,
"security": "tls",
}
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "smtp-current", "imap": "imap-current"},
),
patch(
"govoplan_mail.backend.capabilities.imap_config_from_profile",
side_effect=AssertionError("stale revisions must be rejected before IMAP credential decryption"),
),
patch("govoplan_mail.backend.capabilities.append_message_to_sent") as provider_append,
):
with self.assertRaisesRegex(MailProfileError, "changed after this campaign was built"):
append_campaign_message_to_sent(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
folder="Sent",
expected_smtp_transport_revision="smtp-current",
expected_imap_transport_revision="imap-stale",
)
provider_append.assert_not_called()
def test_provider_response_is_sanitized_before_campaign_evidence(self) -> None:
profile = _profile()
smtp = SmtpConfig(host="smtp.internal.example", port=587)
provider_secret = b"smtp.internal.example says credential=provider-secret"
provider_result = SimpleNamespace(
envelope_recipients=["ok@example.test", "blocked@example.test"],
refused_recipients={"blocked@example.test": (550, provider_secret)},
)
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
patch(
"govoplan_mail.backend.capabilities.imap_config_from_profile",
side_effect=AssertionError("SMTP delivery must not decrypt IMAP credentials"),
),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "current", "imap": None},
),
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
patch("govoplan_mail.backend.capabilities.send_email_bytes", return_value=provider_result),
):
result = send_campaign_email_bytes(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=provider_result.envelope_recipients,
from_header="sender@example.test",
expected_smtp_transport_revision="current",
)
evidence = json.dumps(asdict(result), sort_keys=True)
self.assertEqual(result.accepted_count, 1)
self.assertIn('"status_code": 550', evidence)
self.assertIn('"classification": "permanent"', evidence)
self.assertNotIn("smtp.internal.example", evidence)
self.assertNotIn("provider-secret", evidence)
def test_append_materializes_only_imap_credentials_and_preserves_unknown_outcome(self) -> None:
profile = _profile()
profile.imap_config = {
"host": "imap.internal.example",
"port": 993,
"security": "tls",
}
imap = ImapConfig(host="imap.internal.example", port=993)
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.imap_config_from_profile", return_value=imap),
patch(
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
side_effect=AssertionError("IMAP append must not decrypt SMTP credentials"),
),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "smtp-current", "imap": "imap-current"},
),
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
patch(
"govoplan_mail.backend.capabilities.append_message_to_sent",
side_effect=ImapAppendError(
"imap.internal.example provider-secret",
outcome_unknown=True,
),
),
):
with self.assertRaises(ImapAppendError) as captured:
append_campaign_message_to_sent(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
folder="Sent",
expected_smtp_transport_revision="smtp-current",
expected_imap_transport_revision="imap-current",
)
self.assertTrue(captured.exception.outcome_unknown)
self.assertFalse(captured.exception.temporary)
self.assertIn("inspect the mailbox", str(captured.exception))
self.assertNotIn("internal.example", str(captured.exception))
self.assertNotIn("provider-secret", str(captured.exception))
def test_provider_exception_is_sanitized_and_bounded(self) -> None:
profile = _profile()
smtp = SmtpConfig(host="smtp.internal.example", port=587)
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "current", "imap": None},
),
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
patch(
"govoplan_mail.backend.capabilities.send_email_bytes",
side_effect=SmtpSendError(
"smtp.internal.example provider-secret " + ("x" * 1_000),
temporary=True,
),
),
):
with self.assertRaises(SmtpSendError) as captured:
send_campaign_email_bytes(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
from_header="sender@example.test",
expected_smtp_transport_revision="current",
)
message = str(captured.exception)
self.assertEqual(message, "Mail delivery failed temporarily.")
self.assertNotIn("internal.example", message)
self.assertLessEqual(len(message), 80)
self.assertIsNone(captured.exception.__cause__)
def test_unclassified_provider_exception_is_outcome_unknown(self) -> None:
profile = _profile()
smtp = SmtpConfig(host="smtp.internal.example", port=587)
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "current", "imap": None},
),
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
patch(
"govoplan_mail.backend.capabilities.send_email_bytes",
side_effect=RuntimeError("smtp.internal.example provider-secret"),
),
):
with self.assertRaises(SmtpSendError) as captured:
send_campaign_email_bytes(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
from_header="sender@example.test",
expected_smtp_transport_revision="current",
)
self.assertTrue(captured.exception.outcome_unknown)
self.assertNotIn("internal.example", str(captured.exception))
self.assertIsNone(captured.exception.__cause__)
def test_capability_does_not_export_raw_profile_or_transport_helpers(self) -> None:
capability = MailCampaignCapability()
for name in (
"smtp_config_from_profile",
"imap_config_from_profile",
"send_email_bytes",
"send_email_message",
"materialize_campaign_mail_profile_config",
):
self.assertFalse(hasattr(capability, name), name)
if __name__ == "__main__":
unittest.main()

186
tests/test_documentation.py Normal file
View File

@@ -0,0 +1,186 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
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.mail_profiles import EffectiveMailProfilePolicy, MailProfileError
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
def __init__(self, scopes: set[str]) -> None:
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes
class MailRuntimeDocumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.session = Session()
def tearDown(self) -> None:
self.session.close()
def context(self, scopes: set[str], *, documentation_type: str = "user") -> DocumentationContext:
return DocumentationContext(
registry=object(),
principal=_Principal(scopes),
settings=None,
session=self.session,
documentation_type=documentation_type, # type: ignore[arg-type]
)
def topics(self, context: DocumentationContext, user_policy: EffectiveMailProfilePolicy) -> dict[str, object]:
tenant_policy = EffectiveMailProfilePolicy()
def effective_policy(_session, *, scope_type, **_kwargs):
return user_policy if scope_type == "user" else tenant_policy
with patch(
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
side_effect=effective_policy,
):
return {topic.id: topic for topic in documentation_topics(context)}
def test_custom_profile_task_requires_write_authority_and_enabled_user_profiles(self) -> None:
enabled = EffectiveMailProfilePolicy(allow_user_profiles=True)
without_write = self.topics(self.context({"mail:profile:read"}), enabled)
self.assertNotIn("mail.workflow.create-custom-profile", without_write)
without_read = self.topics(self.context({"mail:profile:write"}), enabled)
self.assertNotIn("mail.workflow.create-custom-profile", without_read)
disabled = EffectiveMailProfilePolicy(allow_user_profiles=False)
blocked = self.topics(self.context({"mail:profile:read", "mail:profile:write"}), disabled)
self.assertNotIn("mail.workflow.create-custom-profile", blocked)
available = self.topics(self.context({"mail:profile:read", "mail:profile:write"}), enabled)
self.assertIn("mail.workflow.create-custom-profile", available)
task = available["mail.workflow.create-custom-profile"]
self.assertEqual(task.title, "Create a custom Mail profile")
self.assertEqual(task.metadata["route"], "/settings?section=mail-profiles")
self.assertEqual(task.metadata["help_contexts"], ["mail.profiles", "app.settings"])
self.assertIn("current account's user scope", task.metadata["prerequisites"][0])
self_service = self.topics(
self.context(
{"mail:profile:read", "mail:profile:write_own"}
),
enabled,
)
self.assertIn("mail.workflow.create-custom-profile", self_service)
condition = self_service["mail.workflow.create-custom-profile"].conditions[0]
self.assertEqual(condition.required_scopes, ("mail:profile:read",))
self.assertEqual(
condition.any_scopes,
("mail:profile:write", "mail:profile:write_own"),
)
def test_custom_profile_task_distinguishes_secret_test_and_use_authority(self) -> None:
policy = EffectiveMailProfilePolicy()
cases = (
(set(), False, False, False),
({"mail:secret:manage"}, True, False, False),
({"mail:profile:test"}, False, False, False),
({"mail:profile:use"}, False, False, True),
({"mail:profile:test", "mail:profile:use"}, False, True, True),
({"mail:secret:manage", "mail:profile:test", "mail:profile:use"}, True, True, True),
({"mail:secret:manage_own"}, True, False, False),
)
for extra_scopes, credentials, testing, use in cases:
with self.subTest(extra_scopes=extra_scopes):
topics = self.topics(self.context({"mail:profile:read", "mail:profile:write", *extra_scopes}), policy)
task = topics["mail.workflow.create-custom-profile"]
self.assertEqual(task.metadata["can_manage_credentials"], credentials)
self.assertEqual(task.metadata["can_test_profile"], testing)
self.assertEqual(task.metadata["can_use_profile"], use)
self.assertIn(
"you may save or replace" if credentials else "you cannot save or replace passwords",
task.body,
)
self.assertIn(
"you may run" if testing else "does not let you run connection tests",
task.body,
)
self.assertIn(
"you may select" if use else "does not let you select or use it",
task.body,
)
def test_custom_profile_task_preserves_host_allow_group_and_deny_precedence(self) -> None:
policy = EffectiveMailProfilePolicy(
whitelist_groups={
"smtp_hosts": [
["*.example.edu", "smtp.shared.test"],
["smtp-*.example.edu"],
],
"imap_hosts": [["imap.example.edu"]],
},
blacklist_patterns={
"smtp_hosts": ["smtp-blocked.example.edu"],
"imap_hosts": ["imap-legacy.example.edu"],
},
allowed_profile_id_sets=[{"private-profile-id"}],
)
topics = self.topics(
self.context({"mail:profile:read", "mail:profile:write", "mail:secret:manage", "mail:profile:test", "mail:profile:use"}),
policy,
)
task = topics["mail.workflow.create-custom-profile"]
constraints = {item["id"]: item for item in task.metadata["constraints"]}
constraints_text = " ".join(item["description"] for item in task.metadata["constraints"])
self.assertIn("Deny rules are checked first", constraints_text)
self.assertEqual(constraints["smtp-host-deny"]["values"], ["smtp-blocked.example.edu"])
self.assertEqual(constraints["smtp-host-allow-1"]["values"], ["*.example.edu", "smtp.shared.test"])
self.assertEqual(constraints["smtp-host-allow-2"]["values"], ["smtp-*.example.edu"])
self.assertIn("every active allow-list group", constraints_text)
self.assertEqual(constraints["imap-host-allow-1"]["values"], ["imap.example.edu"])
self.assertTrue(task.metadata["approval_required_before_use"])
self.assertIn("must be approved before the profile can be selected or used", task.body)
self.assertNotIn("private-profile-id", repr(task))
self.assertIn("every active allow-list group", constraints["smtp-host-allow-1"]["description"])
def test_custom_profile_task_explains_when_no_approval_list_is_active(self) -> None:
topics = self.topics(
self.context({"mail:profile:read", "mail:profile:write", "mail:profile:use"}),
EffectiveMailProfilePolicy(),
)
task = topics["mail.workflow.create-custom-profile"]
self.assertFalse(task.metadata["approval_required_before_use"])
self.assertIn("no approved-profile list currently blocks", task.body)
def test_user_policy_errors_are_generic_and_never_create_a_task(self) -> None:
context = self.context({"mail:profile:read", "mail:profile:write"})
with patch(
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
side_effect=MailProfileError("sensitive source-id profile-id username secret"),
):
topics = {topic.id: topic for topic in documentation_topics(context)}
self.assertNotIn("mail.workflow.create-custom-profile", topics)
unavailable = topics["mail.tenant-profile-policy-unavailable"]
self.assertEqual(
unavailable.body,
"The current Mail policy could not be loaded. Try again or ask a Mail administrator for help.",
)
self.assertNotIn("sensitive", repr(unavailable))
def test_manifest_workflows_declare_contextual_help_targets(self) -> None:
from govoplan_mail.backend.manifest import get_manifest
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"])
if __name__ == "__main__":
unittest.main()

View File

@@ -1,17 +1,53 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import ImapConfig
from govoplan_mail.backend.sending.imap import (
ImapAppendError,
ImapConfigurationError,
_detect_sent_folder,
_extract_mailbox_name,
_fetch_message_by_uid,
_normalize_mailbox_page,
_open_imap,
_paged_descending_sequences,
_parse_fetch_sequence,
_select_readonly,
_sequence_set,
append_message_to_sent,
list_imap_messages,
)
class ImapFolderParserTests(unittest.TestCase):
def test_real_imap_connections_honor_deployment_egress_policy(self):
config = ImapConfig(host="imap.internal", port=993, security="tls")
with patch(
"govoplan_mail.backend.sending.imap.validate_outbound_host",
side_effect=OutboundHttpBlocked("private network blocked"),
), self.assertRaisesRegex(ImapConfigurationError, "private network blocked"):
_open_imap(config)
def test_imap_revalidates_and_pins_at_connection_time(self):
config = ImapConfig(host="imap.example.test", port=993, security="tls")
public = [(2, 1, 6, "", ("93.184.216.34", 993))]
private = [(2, 1, 6, "", ("127.0.0.1", 993))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
OutboundHttpBlocked,
"non-public network",
):
_open_imap(config)
socket_factory.assert_not_called()
def test_extracts_quoted_mailbox_after_quoted_delimiter(self):
self.assertEqual(
_extract_mailbox_name(b'(\\HasNoChildren \\Sent) "/" "Sent Items"'),
@@ -44,6 +80,108 @@ class ImapFolderParserTests(unittest.TestCase):
class ImapMessagePaginationTests(unittest.TestCase):
def test_mock_message_cursor_preserves_order_and_resets_when_stale(self):
records = [
{
"id": "3",
"kind": "smtp",
"raw_eml": "Subject: Three\r\n\r\nBody",
"size_bytes": 26,
},
{
"id": "2",
"kind": "smtp",
"raw_eml": "Subject: Two\r\n\r\nBody",
"size_bytes": 24,
},
]
config = ImapConfig(host="mock.imap.local")
with patch(
"govoplan_mail.backend.sending.imap.list_records",
return_value=records,
):
page = list_imap_messages(
imap_config=config,
after_uid="3",
expected_uidvalidity="mock-v1",
limit=1,
)
reset_page = list_imap_messages(
imap_config=config,
after_uid="3",
expected_uidvalidity="stale",
limit=1,
)
self.assertEqual([message.uid for message in page.messages], ["2"])
self.assertEqual(page.offset, 1)
self.assertFalse(page.cursor_reset)
self.assertEqual([message.uid for message in reset_page.messages], ["3"])
self.assertEqual(reset_page.offset, 0)
self.assertTrue(reset_page.cursor_reset)
def test_real_message_listing_delegates_to_shared_client_path(self):
class Client:
logged_out = False
def logout(self):
self.logged_out = True
client = Client()
expected = object()
config = ImapConfig(host="imap.example.org")
with (
patch(
"govoplan_mail.backend.sending.imap._open_imap",
return_value=client,
),
patch(
"govoplan_mail.backend.sending.imap._list_imap_messages_on_client",
return_value=expected,
) as list_on_client,
):
result = list_imap_messages(
imap_config=config,
folder=" Archive ",
limit=25,
offset=5,
after_uid="42",
expected_uidvalidity="7",
)
self.assertIs(result, expected)
self.assertTrue(client.logged_out)
list_on_client.assert_called_once_with(
client,
host="imap.example.org",
port=993,
security="tls",
folder="Archive",
limit=25,
offset=5,
after_uid="42",
expected_uidvalidity="7",
)
def test_full_message_fetch_uses_partial_range_and_deployment_limit(self):
class Client:
command = ""
def uid(self, command, uid, fetch_spec):
del command, uid
self.command = fetch_spec
return "OK", [(b"1 (UID 1 RFC822.SIZE 11)", b"12345678901")]
client = Client()
with patch.dict("os.environ", {"GOVOPLAN_CONNECTOR_MAX_FILE_TRANSFER_BYTES": "10"}), self.assertRaisesRegex(
ImapAppendError,
"deployment limit",
):
_fetch_message_by_uid(client, "1")
self.assertIn("BODY.PEEK[]<0.11>", client.command)
def test_paginates_sequence_numbers_newest_first(self):
self.assertEqual(
_paged_descending_sequences(120, offset=0, limit=5),
@@ -63,6 +201,68 @@ class ImapMessagePaginationTests(unittest.TestCase):
)
self.assertIsNone(_parse_fetch_sequence("UID 123 FLAGS ()"))
def test_normalizes_mailbox_pagination_request(self):
self.assertEqual(_normalize_mailbox_page(folder="", limit=0, offset=-5), ("INBOX", 1, 0))
self.assertEqual(_normalize_mailbox_page(folder=" Sent ", limit=500, offset=3), ("Sent", 100, 3))
class ImapMailboxCommandTests(unittest.TestCase):
def test_select_quotes_mailbox_name_with_spaces(self):
class Client:
untagged_responses = {"EXISTS": [b"0"], "UIDVALIDITY": [b"1"]}
def select(self, mailbox, readonly=False):
self.mailbox = mailbox
self.readonly = readonly
return "OK", [b"0"]
def response(self, code):
return "OK", [b"1"] if code == "UIDVALIDITY" else []
client = Client()
self.assertEqual(_select_readonly(client, "Gesendete Elemente"), (0, "1"))
self.assertEqual(client.mailbox, '"Gesendete Elemente"')
self.assertTrue(client.readonly)
def test_append_quotes_mailbox_name_with_spaces(self):
class Client:
def append(self, mailbox, flags, date_time, message):
self.mailbox = mailbox
self.flags = flags
self.date_time = date_time
self.message = message
return "OK", [b"APPEND completed"]
def logout(self):
return "BYE", [b"logged out"]
client = Client()
config = ImapConfig(host="imap.example.org", username="user", password="secret", sent_folder="auto")
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=client):
result = append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config, folder="Gesendete Elemente")
self.assertEqual(client.mailbox, '"Gesendete Elemente"')
self.assertEqual(result.folder, "Gesendete Elemente")
self.assertEqual(result.bytes_appended, len(b"Subject: test\r\n\r\nBody"))
def test_connection_loss_after_append_starts_has_unknown_outcome(self):
class Client:
def append(self, _mailbox, _flags, _date_time, _message):
raise OSError("provider detail")
def logout(self):
return "BYE", [b"logged out"]
config = ImapConfig(host="imap.example.org", username="user", password="secret", sent_folder="Sent")
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=Client()):
with self.assertRaises(ImapAppendError) as captured:
append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config, folder="Sent")
self.assertTrue(captured.exception.outcome_unknown)
self.assertFalse(captured.exception.temporary)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,581 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.mail_profiles import (
EffectiveCredentialPolicy,
EffectiveMailProfilePolicy,
MailProfileError,
_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,
_next_profile_transport_state,
_policy_parent_lock_message,
_policy_parent_lock_violations,
delete_mail_profile_credentials,
update_mail_server_profile,
)
class MailProfileTransportHelperTests(unittest.TestCase):
def test_inactive_profile_creation_rejects_dormant_credentials(self):
session = SimpleNamespace()
with (
patch(
"govoplan_mail.backend.mail_profiles._scope_tuple_for_create",
return_value=("tenant-1", "tenant", "tenant-1"),
),
patch("govoplan_mail.backend.mail_profiles._ensure_scope_allows_profile_creation"),
patch("govoplan_mail.backend.mail_profiles.assert_mail_policy_allows_transport"),
patch("govoplan_mail.backend.mail_profiles._ensure_unique_slug"),
self.assertRaisesRegex(MailProfileError, "cannot retain credentials"),
):
create_mail_server_profile(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
name="Dormant secret",
slug=None,
description=None,
smtp=SmtpConfig(host="smtp.example.test", password="secret"),
imap=None,
is_active=False,
)
def test_inactive_profile_cannot_retain_or_recreate_dormant_credentials(self):
session = SimpleNamespace()
legacy = SimpleNamespace(
is_active=False,
smtp_password_encrypted="legacy-ciphertext",
imap_password_encrypted=None,
)
with self.assertRaisesRegex(MailProfileError, "use DELETE to scrub"):
update_mail_server_profile(
session, # type: ignore[arg-type]
legacy, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
is_active=True,
)
self.assertFalse(legacy.is_active)
self.assertEqual(legacy.smtp_password_encrypted, "legacy-ciphertext")
scrubbed = SimpleNamespace(
is_active=False,
smtp_password_encrypted=None,
imap_password_encrypted=None,
)
with self.assertRaisesRegex(MailProfileError, "activated in the same update"):
update_mail_server_profile(
session, # type: ignore[arg-type]
scrubbed, # type: ignore[arg-type]
tenant_id="tenant-1",
user_id="user-1",
smtp=SmtpConfig(host="smtp.example.test", password="new-secret"),
)
self.assertFalse(scrubbed.is_active)
self.assertIsNone(scrubbed.smtp_password_encrypted)
def test_campaign_transport_revisions_are_random_opaque_values(self):
profile = SimpleNamespace(
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="first-user",
smtp_password_encrypted="first-secret-ciphertext",
smtp_transport_revision="032837ce-fd97-401a-8a9f-8df67f3ab41d",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="9ca09521-c543-42ed-a8f2-f06783f1ebd3",
)
original = campaign_profile_transport_revisions(profile)
profile.smtp_password_encrypted = "second-secret-ciphertext"
self.assertEqual(campaign_profile_transport_revisions(profile), original)
self.assertNotIn("smtp.example.org", repr(original))
self.assertEqual(original["smtp"], profile.smtp_transport_revision)
def test_profile_deletion_scrubs_both_passwords_and_writes_non_secret_audit(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_password_encrypted="smtp-ciphertext",
imap_password_encrypted="imap-ciphertext",
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
deleted = delete_mail_profile_credentials(
session, # type: ignore[arg-type]
profile=profile, # type: ignore[arg-type]
deletion_reason="profile_deactivated",
user_id="user-1",
)
self.assertEqual(deleted, ("smtp", "imap"))
self.assertIsNone(profile.smtp_password_encrypted)
self.assertIsNone(profile.imap_password_encrypted)
self.assertEqual(audit.call_count, 1)
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_deleted")
self.assertEqual(audit.call_args.kwargs["scope"], "tenant")
self.assertEqual(audit.call_args.kwargs["details"]["deletion_reason"], "profile_deactivated")
self.assertNotIn("ciphertext", repr(audit.call_args.kwargs))
def test_system_profile_secret_deletion_uses_system_audit_scope(self):
profile = SimpleNamespace(
id="profile-system",
tenant_id=None,
scope_type="system",
scope_id=None,
smtp_password_encrypted="smtp-ciphertext",
imap_password_encrypted=None,
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
delete_mail_profile_credentials(
session, # type: ignore[arg-type]
profile=profile, # type: ignore[arg-type]
deletion_reason="module_data_retired",
)
self.assertEqual(audit.call_args.kwargs["scope"], "system")
self.assertIsNone(audit.call_args.kwargs["tenant_id"])
self.assertNotIn("ciphertext", repr(audit.call_args.kwargs))
def test_repeated_profile_secret_deletion_is_an_idempotent_no_op(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_password_encrypted=None,
imap_password_encrypted=None,
)
session = SimpleNamespace(add=lambda _value: self.fail("no-op must not add"), flush=lambda: self.fail("no-op must not flush"))
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
deleted = delete_mail_profile_credentials(
session, # type: ignore[arg-type]
profile=profile, # type: ignore[arg-type]
deletion_reason="profile_deactivated",
)
self.assertEqual(deleted, ())
audit.assert_not_called()
def test_profile_secret_deletion_does_not_mutate_ciphertext_when_audit_fails(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_password_encrypted="smtp-ciphertext",
imap_password_encrypted="imap-ciphertext",
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with (
patch(
"govoplan_mail.backend.mail_profiles.audit_event",
side_effect=RuntimeError("audit unavailable"),
),
self.assertRaisesRegex(RuntimeError, "audit unavailable"),
):
delete_mail_profile_credentials(
session, # type: ignore[arg-type]
profile=profile, # type: ignore[arg-type]
deletion_reason="profile_deactivated",
)
self.assertEqual(profile.smtp_password_encrypted, "smtp-ciphertext")
self.assertEqual(profile.imap_password_encrypted, "imap-ciphertext")
def test_next_transport_state_uses_only_saved_non_secret_metadata(self):
profile = SimpleNamespace(
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypt_secret("smtp-secret"),
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
imap_username="saved-imap",
imap_password_encrypted=encrypt_secret("imap-secret"),
)
with patch(
"govoplan_mail.backend.mail_profiles.decrypt_secret",
side_effect=AssertionError("policy validation must not decrypt credentials"),
):
smtp, imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=False)
self.assertEqual(smtp["host"], "smtp.example.org")
self.assertNotIn("username", smtp)
self.assertNotIn("password", smtp)
self.assertIsNotNone(imap)
assert imap is not None
self.assertEqual(imap["host"], "imap.example.org")
self.assertNotIn("username", imap)
self.assertNotIn("password", imap)
_, cleared_imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=True)
self.assertIsNone(cleared_imap)
def test_apply_transport_update_preserves_unsupplied_passwords(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypt_secret("smtp-secret"),
smtp_transport_revision="smtp-before",
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
imap_username="saved-imap",
imap_password_encrypted=encrypt_secret("imap-secret"),
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index:
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=SmtpConfig(host="smtp2.example.org", username="new-smtp"),
imap=ImapConfig(host="imap2.example.org", username="new-imap"),
clear_imap=False,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertEqual(profile.smtp_config["host"], "smtp2.example.org")
self.assertEqual(profile.smtp_username, "new-smtp")
self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "smtp-secret")
self.assertEqual(profile.imap_config["host"], "imap2.example.org")
self.assertEqual(profile.imap_username, "new-imap")
self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "imap-secret")
self.assertNotEqual(profile.smtp_transport_revision, "smtp-before")
self.assertNotEqual(profile.imap_transport_revision, "imap-before")
with (
patch("govoplan_mail.backend.mail_profiles.audit_event") as audit,
patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index,
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=None,
imap=None,
clear_imap=True,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertIsNone(profile.imap_config)
self.assertIsNone(profile.imap_username)
self.assertIsNone(profile.imap_password_encrypted)
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_deleted")
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
def test_imap_password_replacement_clears_cache_without_rotating_identity_revision(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org"},
smtp_username=None,
smtp_password_encrypted=None,
smtp_transport_revision="smtp-before",
imap_config={
"host": "imap.example.org",
"port": 993,
"security": "tls",
"sent_folder": "auto",
"timeout_seconds": 30,
},
imap_username="saved-imap",
imap_password_encrypted=encrypt_secret("old-secret"),
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with (
patch("govoplan_mail.backend.mail_profiles.audit_event"),
patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index,
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=None,
imap=ImapConfig(
host="imap.example.org",
port=993,
security="tls",
timeout_seconds=30,
password="new-secret",
),
clear_imap=False,
)
clear_index.assert_called_once_with(session, profile_id="profile-1")
self.assertEqual(profile.imap_transport_revision, "imap-before")
self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "new-secret")
def test_password_replacement_preserves_revision_and_is_audited_without_secret(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypt_secret("old-secret"),
smtp_transport_revision="smtp-before",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id="key-1",
smtp=SmtpConfig(
host="smtp.example.org",
port=587,
security="starttls",
timeout_seconds=30,
password="new-secret",
),
imap=None,
clear_imap=False,
)
self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "new-secret")
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_replaced")
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "smtp")
self.assertNotIn("old-secret", repr(audit.call_args.kwargs))
self.assertNotIn("new-secret", repr(audit.call_args.kwargs))
def test_audit_failure_leaves_existing_ciphertext_and_revision_unchanged(self):
encrypted = encrypt_secret("old-secret")
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypted,
smtp_transport_revision="smtp-before",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with (
patch(
"govoplan_mail.backend.mail_profiles.audit_event",
side_effect=RuntimeError("audit unavailable"),
),
self.assertRaisesRegex(RuntimeError, "audit unavailable"),
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=SmtpConfig(host="smtp.example.org", password="new-secret"),
imap=None,
clear_imap=False,
)
self.assertEqual(profile.smtp_password_encrypted, encrypted)
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
def test_supplied_password_is_replaced_without_decrypting_old_ciphertext(self):
encrypted = encrypt_secret("same-secret")
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypted,
smtp_transport_revision="smtp-before",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with (
patch("govoplan_mail.backend.mail_profiles.audit_event") as audit,
patch(
"govoplan_mail.backend.mail_profiles.decrypt_secret",
side_effect=AssertionError("replacement must not decrypt old ciphertext"),
),
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=SmtpConfig(host="smtp.example.org", password="same-secret"),
imap=None,
clear_imap=False,
)
self.assertNotEqual(profile.smtp_password_encrypted, encrypted)
self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "same-secret")
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_replaced")
def test_metadata_update_does_not_decrypt_unrelated_transport_credentials(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted="corrupt-smtp-ciphertext",
smtp_transport_revision="smtp-before",
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
imap_username="saved-imap",
imap_password_encrypted="corrupt-imap-ciphertext",
imap_transport_revision="imap-before",
name="Profile",
slug="profile",
description=None,
is_active=True,
updated_by_user_id=None,
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with (
patch("govoplan_mail.backend.mail_profiles.decrypt_secret", side_effect=AssertionError("must not decrypt")),
patch("govoplan_mail.backend.mail_profiles._assert_profile_transport_allowed") as policy_check,
):
update_mail_server_profile(
session, # type: ignore[arg-type]
profile,
tenant_id="tenant-1",
user_id="user-1",
description="Updated",
)
policy_check.assert_called_once()
self.assertEqual(profile.description, "Updated")
self.assertEqual(profile.smtp_password_encrypted, "corrupt-smtp-ciphertext")
self.assertEqual(profile.imap_password_encrypted, "corrupt-imap-ciphertext")
class MailProfilePolicyHelperTests(unittest.TestCase):
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 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)
policy = EffectiveMailProfilePolicy(
smtp_credentials=EffectiveCredentialPolicy(inherit=False),
)
with self.assertRaisesRegex(MailProfileError, "explicit credential selection"):
_assert_campaign_inherits_profile_credentials(profile, policy)
def test_merge_policy_respects_locked_lower_level_limits(self):
policy = EffectiveMailProfilePolicy()
_merge_policy(
policy,
{
"blacklist": {"smtp_hosts": ["*.blocked.example"]},
"allow_lower_level_limits": {"blacklist.smtp_hosts": False},
},
source="system",
)
_merge_policy(
policy,
{"blacklist": {"smtp_hosts": ["*.tenant.example"]}},
source="tenant",
source_id="tenant-1",
)
self.assertEqual(policy.blacklist_patterns["smtp_hosts"], ["*.blocked.example"])
self.assertFalse(policy.allow_lower_level_limits["blacklist.smtp_hosts"])
def test_parent_lock_violations_are_reported_per_field(self):
violations = _policy_parent_lock_violations(
{
"allow_user_profiles": False,
"smtp_credentials.inherit": False,
"blacklist.smtp_hosts": False,
},
{
"allow_user_profiles": True,
"smtp_credentials": {"inherit": False},
"blacklist": {"smtp_hosts": ["*.blocked.example"]},
"allow_lower_level_limits": {"allow_user_profiles": True},
},
)
self.assertEqual(
violations,
[
"allow_user_profiles",
"blacklist.smtp_hosts",
"smtp_credentials.inherit",
"allow_lower_level_limits.allow_user_profiles",
],
)
self.assertEqual(
_policy_parent_lock_message("smtp_credentials.inherit"),
"SMTP credential inheritance is locked by an ancestor policy",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import unittest
from govoplan_mail.backend.db.models import MailMailboxFolderIndex, MailMailboxMessageIndex
from govoplan_mail.backend.mailbox_index import clear_mailbox_index
class _DeleteQuery:
def __init__(self, model: type, deleted_models: list[type]) -> None:
self.model = model
self.deleted_models = deleted_models
def filter(self, *_criteria):
return self
def delete(self, *, synchronize_session: bool) -> int:
if synchronize_session is not False:
raise AssertionError("bulk invalidation must not synchronize loaded cache rows")
self.deleted_models.append(self.model)
return 2 if self.model is MailMailboxMessageIndex else 1
class _Session:
def __init__(self) -> None:
self.deleted_models: list[type] = []
def query(self, model: type) -> _DeleteQuery:
return _DeleteQuery(model, self.deleted_models)
class MailboxIndexInvalidationTests(unittest.TestCase):
def test_profile_wide_invalidation_deletes_messages_before_folders(self) -> None:
session = _Session()
deleted_folders, deleted_messages = clear_mailbox_index(
session, # type: ignore[arg-type]
profile_id="profile-1",
)
self.assertEqual(
session.deleted_models,
[MailMailboxMessageIndex, MailMailboxFolderIndex],
)
self.assertEqual((deleted_folders, deleted_messages), (1, 2))
if __name__ == "__main__":
unittest.main()

128
tests/test_manifest.py Normal file
View File

@@ -0,0 +1,128 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleManifest
from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manifest
class MailManifestTests(unittest.TestCase):
def test_manifest_declares_optional_addresses_lookup(self) -> None:
manifest = get_manifest()
self.assertIsInstance(manifest, ModuleManifest)
self.assertEqual(manifest.id, "mail")
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn(
{
"name": "campaigns.access",
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
"optional": True,
},
[
{
"name": interface.name,
"version_min": interface.version_min,
"version_max_exclusive": interface.version_max_exclusive,
"optional": interface.optional,
}
for interface in manifest.requires_interfaces
],
)
self.assertIn(
{"name": "mail.campaign_delivery", "version": "0.2.0"},
[
{"name": interface.name, "version": interface.version}
for interface in manifest.provides_interfaces
],
)
permissions = {permission.scope for permission in manifest.permissions}
self.assertIn("mail:profile:write_own", permissions)
self.assertIn("mail:secret:manage_own", permissions)
roles = {template.slug: template for template in manifest.role_templates}
self.assertEqual(
set(roles["mail_profile_self_service"].permissions),
{
"mail:profile:read",
"mail:profile:use",
"mail:profile:test",
"mail:mailbox:read",
"mail:profile:write_own",
"mail:secret:manage_own",
},
)
topics = {topic.id: topic for topic in manifest.documentation}
self.assertTrue(
{
"mail.workflow.choose-and-test-profile",
"mail.workflow.read-mailbox",
"mail.reference.credentials-egress-retirement",
"mail.reference.campaign-delivery-contract",
}.issubset(topics)
)
ownership = topics["mail.profile-ownership-and-consumers"]
self.assertEqual(ownership.metadata["kind"], "reference")
self.assertEqual(ownership.metadata["route"], "/settings?section=mail-profiles")
self.assertIn("campaigns.mail-profile-user-journey", ownership.metadata["related_topic_ids"])
mailbox = topics["mail.workflow.read-mailbox"]
self.assertEqual(mailbox.metadata["route"], "/mail")
self.assertEqual(
mailbox.conditions[0].required_scopes,
("mail:mailbox:read", "mail:profile:use"),
)
campaign_contract = topics["mail.reference.campaign-delivery-contract"]
self.assertEqual(campaign_contract.conditions[0].required_modules, ("mail", "campaigns"))
def test_retirement_scrubs_credentials_before_dropping_mail_tables(self) -> None:
events: list[str] = []
def table_provider(_session, _module_id):
return MigrationRetirementPlan(
supported=True,
summary="Mail tables",
destroy_data_supported=True,
destroy_data_executor=lambda _execute_session, _execute_module_id: events.append("drop"),
)
session = SimpleNamespace(get_bind=lambda: object(), query=lambda *_args: None)
inspector = SimpleNamespace(has_table=lambda _name: True)
with (
patch("govoplan_mail.backend.manifest._mail_table_retirement_provider", table_provider),
patch("govoplan_mail.backend.manifest.inspect", return_value=inspector),
patch(
"govoplan_mail.backend.mail_profiles.delete_mail_profile_credentials_for_retirement",
side_effect=lambda _session: events.append("scrub"),
),
):
plan = _mail_retirement_provider(session, "mail")
assert plan.destroy_data_executor is not None
plan.destroy_data_executor(session, "mail")
self.assertEqual(events, ["scrub", "drop"])
self.assertTrue(any("audit" in warning for warning in plan.destroy_data_warnings))
events.clear()
with (
patch("govoplan_mail.backend.manifest._mail_table_retirement_provider", table_provider),
patch("govoplan_mail.backend.manifest.inspect", return_value=inspector),
patch(
"govoplan_mail.backend.mail_profiles.delete_mail_profile_credentials_for_retirement",
side_effect=RuntimeError("audit unavailable"),
),
):
blocked_plan = _mail_retirement_provider(session, "mail")
assert blocked_plan.destroy_data_executor is not None
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
blocked_plan.destroy_data_executor(session, "mail")
self.assertEqual(events, [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,854 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import ANY, patch
from fastapi import HTTPException
from sqlalchemy.dialects import postgresql
from govoplan_mail.backend import router
from govoplan_mail.backend import mail_profiles
from govoplan_mail.backend.mail_profiles import (
EffectiveMailProfilePolicy,
MailProfileError,
get_mail_server_profile_for_actor,
list_mail_server_profiles,
mail_profile_visible_to_actor,
)
from govoplan_mail.backend.schemas import (
MailServerProfileCreateRequest,
MailServerProfileUpdateRequest,
)
def _profile(
profile_id: str,
*,
scope_type: str,
scope_id: str | None,
tenant_id: str | None = "tenant-1",
):
return SimpleNamespace(
id=profile_id,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
is_active=True,
name=profile_id,
smtp_config={"host": "smtp.example.test"},
imap_config={"host": "imap.example.test"},
smtp_password_encrypted=None,
imap_password_encrypted=None,
)
class _Query:
def __init__(self, profiles):
self._profiles = profiles
def filter(self, *_args):
return self
def all(self):
return list(self._profiles)
class _Session:
def __init__(self, profiles=()):
self.profiles = list(profiles)
self.commits = 0
self.rollbacks = 0
def query(self, _model):
return _Query(self.profiles)
def get(self, _model, profile_id):
return next((profile for profile in self.profiles if profile.id == profile_id), None)
def commit(self):
self.commits += 1
def rollback(self):
self.rollbacks += 1
def refresh(self, _value):
return None
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
group_ids = frozenset({"group-1"})
api_key_id = None
def __init__(self, scopes):
self._scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self._scopes
class ProfileActorAuthorizationTests(unittest.TestCase):
def test_profile_mutation_selector_is_owner_bounded_and_row_locked(self) -> None:
statement = mail_profiles._mail_server_profile_mutation_statement( # noqa: SLF001
tenant_id="tenant-1",
profile_id="profile-1",
owner_user_id="user-1",
can_manage_tenant_profiles=False,
can_manage_system_profiles=False,
)
sql = str(statement.compile(dialect=postgresql.dialect()))
self.assertIn("FOR UPDATE", sql)
self.assertIn("mail_server_profiles.tenant_id =", sql)
self.assertIn("mail_server_profiles.scope_type =", sql)
self.assertIn("mail_server_profiles.scope_id =", sql)
self.assertTrue(statement.get_execution_options()["populate_existing"])
def test_profile_mutation_selector_without_authority_cannot_lock_a_row(self) -> None:
statement = mail_profiles._mail_server_profile_mutation_statement( # noqa: SLF001
tenant_id="tenant-1",
profile_id="profile-1",
owner_user_id=None,
can_manage_tenant_profiles=False,
can_manage_system_profiles=False,
)
sql = str(statement.compile(dialect=postgresql.dialect()))
self.assertIn("mail_server_profiles.id IS NULL", sql)
self.assertIn("FOR UPDATE", sql)
def test_self_service_profile_permissions_are_limited_to_own_user_scope(self) -> None:
principal = _Principal(
{"mail:profile:write_own", "mail:secret:manage_own"}
)
router._require_profile_write_scope( # noqa: SLF001 - authorization seam
principal, # type: ignore[arg-type]
"user",
"user-1",
)
router._require_profile_credentials_scope( # noqa: SLF001 - authorization seam
principal, # type: ignore[arg-type]
"user",
"user-1",
)
for scope_type, scope_id in (
("user", "user-2"),
("tenant", "tenant-1"),
("group", "group-1"),
("campaign", "campaign-1"),
("system", None),
):
with self.subTest(scope_type=scope_type, scope_id=scope_id):
with self.assertRaises(HTTPException) as write_denied:
router._require_profile_write_scope( # noqa: SLF001
principal, # type: ignore[arg-type]
scope_type,
scope_id,
)
self.assertEqual(write_denied.exception.status_code, 403)
with self.assertRaises(HTTPException) as secret_denied:
router._require_profile_credentials_scope( # noqa: SLF001
principal, # type: ignore[arg-type]
scope_type,
scope_id,
)
self.assertEqual(secret_denied.exception.status_code, 403)
def test_self_service_create_rejects_another_user_before_persistence(self) -> None:
principal = _Principal(
{
"mail:profile:read",
"mail:profile:write_own",
"mail:secret:manage_own",
}
)
payload = MailServerProfileCreateRequest.model_validate(
{
"name": "Other user's profile",
"scope_type": "user",
"scope_id": "user-2",
"smtp": {
"host": "smtp.example.test",
"password": "not-persisted",
},
}
)
with (
patch("govoplan_mail.backend.router.create_mail_server_profile") as create,
self.assertRaises(HTTPException) as denied,
):
router.create_profile(
payload,
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(denied.exception.status_code, 403)
create.assert_not_called()
def test_self_service_update_hides_another_user_before_mutation(self) -> None:
principal = _Principal(
{"mail:profile:write_own", "mail:secret:manage_own"}
)
profile = _profile(
"other-user-profile",
scope_type="user",
scope_id="user-2",
)
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile",
return_value=profile,
),
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
self.assertRaises(HTTPException) as denied,
):
router.update_profile(
profile.id,
MailServerProfileUpdateRequest(name="Must not change"),
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(denied.exception.status_code, 404)
update.assert_not_called()
def test_self_service_delete_hides_another_user_before_mutation(self) -> None:
principal = _Principal(
{"mail:profile:write_own", "mail:secret:manage_own"}
)
profile = _profile(
"other-user-profile",
scope_type="user",
scope_id="user-2",
)
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile",
return_value=profile,
),
patch("govoplan_mail.backend.router.delete_mail_profile_credentials") as delete,
self.assertRaises(HTTPException) as denied,
):
router.deactivate_profile(
profile.id,
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(denied.exception.status_code, 404)
delete.assert_not_called()
def test_mutation_lookup_returns_the_same_not_found_for_missing_and_non_owned(self) -> None:
principal = _Principal({"mail:profile:write_own"})
other_profile = _profile(
"other-user-profile",
scope_type="user",
scope_id="user-2",
)
cases = (
(other_profile, None),
(None, MailProfileError("Mail-server profile not found")),
)
for returned, failure in cases:
with self.subTest(failure=failure is not None):
kwargs = {"side_effect": failure} if failure else {"return_value": returned}
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile",
**kwargs,
),
self.assertRaises(HTTPException) as denied,
):
router._profile_for_mutation( # noqa: SLF001 - authorization seam
_Session(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
profile_id="candidate-id",
)
self.assertEqual(denied.exception.status_code, 404)
self.assertEqual(denied.exception.detail, "Mail-server profile not found")
def test_broad_profile_admin_retains_cross_owner_mutation_access(self) -> None:
principal = _Principal({"mail:profile:write"})
profile = _profile(
"other-user-profile",
scope_type="user",
scope_id="user-2",
)
with patch(
"govoplan_mail.backend.router.get_mail_server_profile",
return_value=profile,
) as get_profile:
resolved = router._profile_for_mutation( # noqa: SLF001 - authorization seam
_Session(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
profile_id=profile.id,
)
self.assertIs(resolved, profile)
get_profile.assert_called_once_with(
ANY,
tenant_id="tenant-1",
profile_id=profile.id,
for_update=True,
mutation_owner_user_id=None,
can_manage_tenant_profiles=True,
can_manage_system_profiles=False,
)
def test_self_service_transport_rebind_requires_own_secret_authority(self) -> None:
principal = _Principal({"mail:profile:write_own"})
profile = _profile(
"own-user-profile",
scope_type="user",
scope_id="user-1",
)
profile.smtp_config = {
"host": "smtp.example.test",
"port": 587,
"security": "starttls",
"timeout_seconds": 30,
}
profile.smtp_password_encrypted = "existing-ciphertext"
payload = MailServerProfileUpdateRequest.model_validate(
{"smtp": {"host": "smtp.attacker.test"}}
)
session = _Session()
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile",
return_value=profile,
),
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
self.assertRaises(HTTPException) as denied,
):
router.update_profile(
profile.id,
payload,
principal=principal, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
)
self.assertEqual(denied.exception.status_code, 403)
self.assertEqual(session.rollbacks, 1)
update.assert_not_called()
def test_self_service_imap_rebind_requires_own_secret_authority(self) -> None:
principal = _Principal({"mail:profile:write_own"})
profile = _profile(
"own-user-profile",
scope_type="user",
scope_id="user-1",
)
profile.imap_config = {
"host": "imap.example.test",
"port": 993,
"security": "tls",
"sent_folder": "auto",
"timeout_seconds": 30,
}
profile.imap_password_encrypted = "existing-ciphertext"
payload = MailServerProfileUpdateRequest.model_validate(
{"imap": {"host": "imap.attacker.test"}}
)
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile",
return_value=profile,
),
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
self.assertRaises(HTTPException) as denied,
):
router.update_profile(
profile.id,
payload,
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(denied.exception.status_code, 403)
update.assert_not_called()
def test_self_service_transport_rebind_is_allowed_with_own_secret_authority(self) -> None:
principal = _Principal(
{"mail:profile:write_own", "mail:secret:manage_own"}
)
profile = _profile(
"own-user-profile",
scope_type="user",
scope_id="user-1",
)
profile.smtp_config = {
"host": "smtp.example.test",
"port": 587,
"security": "starttls",
"timeout_seconds": 30,
}
profile.smtp_password_encrypted = "existing-ciphertext"
payload = MailServerProfileUpdateRequest.model_validate(
{"smtp": {"host": "smtp.allowed.test"}}
)
session = _Session()
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile",
return_value=profile,
),
patch(
"govoplan_mail.backend.router.update_mail_server_profile",
return_value=profile,
) as update,
patch("govoplan_mail.backend.router._record_mail_change"),
patch(
"govoplan_mail.backend.router._profile_response",
return_value=profile,
),
patch("govoplan_mail.backend.router.sync_default_profile_server"),
):
result = router.update_profile(
profile.id,
payload,
principal=principal, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
)
self.assertIs(result, profile)
self.assertEqual(session.commits, 1)
update.assert_called_once()
def test_transport_endpoint_change_without_stored_secret_needs_only_write_authority(self) -> None:
principal = _Principal({"mail:profile:write_own"})
profile = _profile(
"own-user-profile",
scope_type="user",
scope_id="user-1",
)
payload = MailServerProfileUpdateRequest.model_validate(
{"smtp": {"host": "smtp.allowed.test"}}
)
session = _Session()
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile",
return_value=profile,
),
patch(
"govoplan_mail.backend.router.update_mail_server_profile",
return_value=profile,
) as update,
patch("govoplan_mail.backend.router._record_mail_change"),
patch(
"govoplan_mail.backend.router._profile_response",
return_value=profile,
),
patch("govoplan_mail.backend.router.sync_default_profile_server"),
):
router.update_profile(
profile.id,
payload,
principal=principal, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
)
self.assertEqual(session.commits, 1)
update.assert_called_once()
def test_general_profile_list_hides_other_owner_contexts(self) -> None:
profiles = [
_profile("system", scope_type="system", scope_id=None, tenant_id=None),
_profile("tenant", scope_type="tenant", scope_id="tenant-1"),
_profile("own-user", scope_type="user", scope_id="user-1"),
_profile("other-user", scope_type="user", scope_id="user-2"),
_profile("own-group", scope_type="group", scope_id="group-1"),
_profile("other-group", scope_type="group", scope_id="group-2"),
]
session = _Session(profiles)
with patch(
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
return_value=EffectiveMailProfilePolicy(),
):
visible = list_mail_server_profiles(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
actor_user_id="user-1",
actor_group_ids=(group_id for group_id in ("group-1",)),
)
self.assertEqual(
{profile.id for profile in visible},
{"system", "tenant", "own-user", "own-group"},
)
def test_general_access_denies_shared_profile_excluded_by_effective_policy(self) -> None:
denied = EffectiveMailProfilePolicy(allowed_profile_id_sets=[{"approved"}])
cases = (("system", None), ("tenant", "tenant-1"))
for scope_type, tenant_id in cases:
with self.subTest(scope_type=scope_type):
profile = _profile("denied", scope_type=scope_type, scope_id=tenant_id, tenant_id=tenant_id)
with patch("govoplan_mail.backend.mail_profiles.effective_mail_profile_policy", return_value=denied):
visible = mail_profile_visible_to_actor(
_Session(), # type: ignore[arg-type]
profile=profile,
tenant_id="tenant-1",
user_id="user-1",
group_ids={"group-1"},
)
self.assertFalse(visible)
def test_administrative_list_can_include_inactive_visible_profiles(self) -> None:
profile = _profile("inactive", scope_type="tenant", scope_id="tenant-1")
profile.is_active = False
session = _Session([profile])
with patch(
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
return_value=EffectiveMailProfilePolicy(),
):
visible = list_mail_server_profiles(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
include_inactive=True,
actor_user_id="user-1",
actor_can_manage_tenant_profiles=True,
actor_administrative_visibility=True,
)
self.assertEqual([item.id for item in visible], ["inactive"])
def test_self_service_repair_visibility_is_limited_to_the_exact_owner(self) -> None:
own_profile = _profile(
"own-policy-invalid",
scope_type="user",
scope_id="user-1",
)
other_profile = _profile(
"other-policy-invalid",
scope_type="user",
scope_id="user-2",
)
denied = EffectiveMailProfilePolicy(
allowed_profile_id_sets=[{"different-profile"}]
)
session = _Session([own_profile, other_profile])
with patch(
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
return_value=denied,
):
ordinary_visible = list_mail_server_profiles(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
include_inactive=True,
actor_user_id="user-1",
actor_administrative_visibility=True,
)
repair_visible = list_mail_server_profiles(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
include_inactive=True,
actor_user_id="user-1",
actor_can_manage_own_profiles=True,
actor_administrative_visibility=True,
)
resolved = get_mail_server_profile_for_actor(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
profile_id=own_profile.id,
user_id="user-1",
can_manage_own_profiles=True,
administrative_visibility=True,
)
with self.assertRaises(MailProfileError):
get_mail_server_profile_for_actor(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
profile_id=other_profile.id,
user_id="user-1",
can_manage_own_profiles=True,
administrative_visibility=True,
)
self.assertEqual(ordinary_visible, [])
self.assertEqual([item.id for item in repair_visible], [own_profile.id])
self.assertIs(resolved, own_profile)
def test_profile_admin_can_list_but_not_use_a_policy_denied_profile(self) -> None:
profile = _profile("denied", scope_type="tenant", scope_id="tenant-1")
session = _Session([profile])
denied = EffectiveMailProfilePolicy(allowed_profile_id_sets=[{"approved"}])
with patch("govoplan_mail.backend.mail_profiles.effective_mail_profile_policy", return_value=denied):
listed = list_mail_server_profiles(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
actor_user_id="user-1",
actor_group_ids={"group-1"},
actor_can_manage_tenant_profiles=True,
actor_administrative_visibility=True,
)
self.assertEqual([item.id for item in listed], ["denied"])
principal = _Principal(
{"mail:profile:write", "mail:profile:test", "mail:profile:use"}
)
with (
patch("govoplan_mail.backend.router.smtp_config_from_profile") as materialize,
patch("govoplan_mail.backend.router.test_smtp_login") as provider,
self.assertRaises(HTTPException) as captured,
):
router.test_profile_smtp(
"denied",
principal=principal, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
)
self.assertEqual(captured.exception.status_code, 404)
materialize.assert_not_called()
provider.assert_not_called()
def test_campaign_scoped_profile_requires_campaign_acl_before_policy_resolution(self) -> None:
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
access = SimpleNamespace(can_read_campaign=lambda *_args, **_kwargs: False)
with (
patch("govoplan_mail.backend.mail_profiles._campaign_access_provider", return_value=access),
patch("govoplan_mail.backend.mail_profiles._campaign_policy_context") as policy_context,
):
visible = mail_profile_visible_to_actor(
_Session(), # type: ignore[arg-type]
profile=profile,
tenant_id="tenant-1",
user_id="user-1",
)
self.assertFalse(visible)
policy_context.assert_not_called()
def test_mail_profile_admin_does_not_bypass_campaign_acl_but_tenant_admin_does(self) -> None:
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
tenant_admin_values: list[bool] = []
def can_read(*_args, **kwargs):
tenant_admin_values.append(bool(kwargs["tenant_admin"]))
return bool(kwargs["tenant_admin"])
access = SimpleNamespace(can_read_campaign=can_read)
with (
patch("govoplan_mail.backend.mail_profiles._campaign_access_provider", return_value=access),
patch("govoplan_mail.backend.mail_profiles._campaign_policy_context") as policy_context,
):
mail_admin_visible = mail_profile_visible_to_actor(
_Session(), # type: ignore[arg-type]
profile=profile,
tenant_id="tenant-1",
user_id="user-1",
can_manage_tenant_profiles=True,
administrative_visibility=True,
)
tenant_admin_visible = mail_profile_visible_to_actor(
_Session(), # type: ignore[arg-type]
profile=profile,
tenant_id="tenant-1",
user_id="user-1",
can_manage_tenant_profiles=True,
tenant_admin=True,
administrative_visibility=True,
)
self.assertFalse(mail_admin_visible)
self.assertTrue(tenant_admin_visible)
self.assertEqual(tenant_admin_values, [False, True])
policy_context.assert_not_called()
def test_policy_read_rejects_unshared_and_mismatched_campaign_contexts(self) -> None:
principal = _Principal({"mail:profile:read"})
with (
patch(
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
return_value=False,
),
patch("govoplan_mail.backend.router._policy_response") as policy_response,
self.assertRaises(HTTPException) as unshared,
):
router.read_mail_profile_policy(
"tenant",
scope_id="tenant-1",
campaign_id="campaign-1",
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(unshared.exception.status_code, 404)
policy_response.assert_not_called()
with self.assertRaises(HTTPException) as mismatched:
router._require_policy_campaign_context( # noqa: SLF001 - authorization seam regression
_Session(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
scope_type="campaign",
scope_id="campaign-1",
campaign_id="campaign-2",
)
self.assertEqual(mismatched.exception.status_code, 422)
def test_campaign_profile_crud_requires_campaign_acl(self) -> None:
principal = _Principal({"mail:profile:write", "mail:secret:manage"})
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
create_payload = MailServerProfileCreateRequest.model_validate(
{
"name": "Campaign profile",
"scope_type": "campaign",
"scope_id": "campaign-1",
"smtp": {"host": "smtp.example.test"},
}
)
with (
patch(
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
return_value=False,
),
patch("govoplan_mail.backend.router.create_mail_server_profile") as create,
self.assertRaises(HTTPException) as create_denied,
):
router.create_profile(
create_payload,
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(create_denied.exception.status_code, 404)
create.assert_not_called()
for operation in ("update", "delete"):
with (
self.subTest(operation=operation),
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=profile),
patch(
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
return_value=False,
),
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
patch("govoplan_mail.backend.router.delete_mail_profile_credentials") as delete,
self.assertRaises(HTTPException) as denied,
):
if operation == "update":
router.update_profile(
profile.id,
MailServerProfileUpdateRequest(name="Renamed"),
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
else:
router.deactivate_profile(
profile.id,
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(denied.exception.status_code, 404)
update.assert_not_called()
delete.assert_not_called()
def test_inactive_visible_profile_change_remains_a_tombstone_candidate(self) -> None:
profile = _profile("deactivated", scope_type="tenant", scope_id="tenant-1")
profile.is_active = False
entry = SimpleNamespace(
resource_id=profile.id,
tenant_id="tenant-1",
payload={"scope_type": "tenant", "scope_id": "tenant-1"},
)
principal = _Principal({"mail:profile:read"})
with patch(
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
return_value=EffectiveMailProfilePolicy(),
):
visible = router._profile_change_visible_to_principal( # noqa: SLF001
_Session([profile]), # type: ignore[arg-type]
entry=entry, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
self.assertTrue(visible)
def test_stale_mailbox_cache_refreshes_synchronously_under_request_authorization(self) -> None:
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
session = _Session()
imap = SimpleNamespace(
host="imap.example.test",
port=993,
security=SimpleNamespace(value="tls"),
)
stale = SimpleNamespace(stale=True)
provider_result = SimpleNamespace(
host="imap.example.test",
port=993,
security="tls",
folders=[
SimpleNamespace(
name="INBOX",
flags=[],
message_count=0,
unseen_count=0,
)
],
detected_sent_folder=None,
)
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,
):
response = router.list_profile_mailbox_folders(
"profile-1",
include_status=False,
refresh=False,
principal=principal, # type: ignore[arg-type]
session=session, # type: ignore[arg-type]
)
provider.assert_called_once_with(imap_config=imap, include_status=False)
cache.assert_called_once()
self.assertFalse(response.from_cache)
self.assertFalse(response.refreshing)
self.assertEqual(session.commits, 1)
def test_unauthorized_profile_test_fails_before_credentials_or_provider_effect(self) -> None:
principal = _Principal({"mail:profile:test", "mail:profile:use"})
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile_for_actor",
side_effect=MailProfileError("Mail-server profile not found"),
),
patch("govoplan_mail.backend.router.smtp_config_from_profile") as materialize,
patch("govoplan_mail.backend.router.test_smtp_login") as provider,
):
with self.assertRaises(HTTPException) as captured:
router.test_profile_smtp(
"other-user-profile",
principal=principal, # type: ignore[arg-type]
session=_Session(), # type: ignore[arg-type]
)
self.assertEqual(captured.exception.status_code, 404)
materialize.assert_not_called()
provider.assert_not_called()
def test_unauthorized_mailbox_read_fails_before_credential_decryption(self) -> None:
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
with (
patch(
"govoplan_mail.backend.router.get_mail_server_profile_for_actor",
side_effect=MailProfileError("Mail-server profile not found"),
),
patch("govoplan_mail.backend.router.imap_config_from_profile") as materialize,
):
with self.assertRaisesRegex(MailProfileError, "not found"):
router._imap_config_for_principal( # noqa: SLF001 - authorization seam regression
_Session(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
profile_id="other-group-profile",
)
materialize.assert_not_called()
if __name__ == "__main__":
unittest.main()

80
tests/test_rate_limit.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from redis.exceptions import RedisError
from govoplan_mail.backend.sending import rate_limit
class RateLimitTests(unittest.TestCase):
def setUp(self) -> None:
rate_limit._local_next_allowed.clear()
def test_local_fallback_waits_between_sends(self) -> None:
now = [100.0]
sleeps: list[float] = []
def fake_time() -> float:
return now[0]
def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
now[0] += seconds
with (
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=False),
patch.object(rate_limit.time, "time", fake_time),
patch.object(rate_limit.time, "sleep", fake_sleep),
):
first = rate_limit.wait_for_rate_limit(key="tenant:t:campaign:c", messages_per_minute=4)
second = rate_limit.wait_for_rate_limit(key="tenant:t:campaign:c", messages_per_minute=4)
self.assertEqual(first.waited_seconds, 0.0)
self.assertEqual(second.waited_seconds, 15.0)
self.assertEqual(sleeps, [15.0])
def test_disabled_rate_limit_does_not_reserve_local_slot(self) -> None:
now = [100.0]
sleeps: list[float] = []
with (
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=False),
patch.object(rate_limit.time, "time", lambda: now[0]),
patch.object(rate_limit.time, "sleep", lambda seconds: sleeps.append(seconds)),
):
disabled = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=4, enabled=False)
enabled = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=4)
self.assertEqual(disabled.waited_seconds, 0.0)
self.assertEqual(enabled.waited_seconds, 0.0)
self.assertEqual(sleeps, [])
def test_redis_failure_falls_back_to_local_wait(self) -> None:
now = [100.0]
sleeps: list[float] = []
def fail_redis_client():
raise RedisError("redis unavailable")
def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
now[0] += seconds
with (
patch.object(rate_limit, "_distributed_rate_limit_enabled", return_value=True),
patch.object(rate_limit, "_redis_client", fail_redis_client),
patch.object(rate_limit.time, "time", lambda: now[0]),
patch.object(rate_limit.time, "sleep", fake_sleep),
):
first = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=60)
second = rate_limit.wait_for_rate_limit(key="k", messages_per_minute=60)
self.assertEqual(first.waited_seconds, 0.0)
self.assertEqual(second.waited_seconds, 1.0)
self.assertEqual(sleeps, [1.0])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,201 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from govoplan_mail.backend.router import create_profile, deactivate_profile, update_profile
from govoplan_mail.backend.schemas import MailServerProfileCreateRequest, MailServerProfileUpdateRequest
class _Session:
def __init__(self) -> None:
self.commits = 0
self.rollbacks = 0
def add(self, _value) -> None:
return None
def commit(self) -> None:
self.commits += 1
def rollback(self) -> None:
self.rollbacks += 1
def refresh(self, _value) -> None:
return None
class MailProfileDeletionRouteTests(unittest.TestCase):
def setUp(self) -> None:
self.principal = SimpleNamespace(
tenant_id="tenant-1",
user=SimpleNamespace(id="user-1"),
api_key_id=None,
has=lambda _scope: False,
)
self.profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
is_active=False,
)
def test_repeated_delete_does_not_emit_a_false_change(self) -> None:
session = _Session()
with (
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router._require_profile_credentials_scope") as require_credentials,
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
patch("govoplan_mail.backend.router.clear_mailbox_index") as clear_index,
patch("govoplan_mail.backend.router._record_mail_change") as record_change,
patch("govoplan_mail.backend.router._profile_response", return_value=self.profile),
):
result = deactivate_profile("profile-1", principal=self.principal, session=session)
self.assertIs(result, self.profile)
self.assertEqual(session.commits, 1)
self.assertEqual(session.rollbacks, 0)
clear_index.assert_called_once_with(session, profile_id="profile-1")
record_change.assert_not_called()
require_credentials.assert_not_called()
def test_delete_requires_secret_authority_when_credentials_will_be_deleted(self) -> None:
self.profile.is_active = True
self.profile.smtp_password_encrypted = "existing-ciphertext"
self.profile.imap_password_encrypted = None
session = _Session()
with (
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router._require_profile_credentials_scope") as require_credentials,
patch(
"govoplan_mail.backend.router.delete_mail_profile_credentials",
return_value=("smtp",),
),
patch("govoplan_mail.backend.router.clear_mailbox_index"),
patch("govoplan_mail.backend.router._record_mail_change"),
patch("govoplan_mail.backend.router._profile_response", return_value=self.profile),
):
deactivate_profile("profile-1", principal=self.principal, session=session)
require_credentials.assert_called_once_with(
self.principal,
"tenant",
"tenant-1",
)
def test_change_feed_reports_whether_credentials_were_deleted(self) -> None:
self.profile.is_active = True
session = _Session()
with (
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router._require_profile_credentials_scope"),
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
patch("govoplan_mail.backend.router.clear_mailbox_index") as clear_index,
patch("govoplan_mail.backend.router._record_mail_change") as record_change,
patch("govoplan_mail.backend.router._profile_response", return_value=self.profile),
):
deactivate_profile("profile-1", principal=self.principal, session=session)
self.assertFalse(record_change.call_args.kwargs["payload"]["credentials_deleted"])
self.assertEqual(record_change.call_args.kwargs["payload"]["deleted_credential_protocols"], [])
clear_index.assert_called_once_with(session, profile_id="profile-1")
def test_generic_secret_deletion_failure_rolls_back(self) -> None:
self.profile.is_active = True
session = _Session()
with (
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router._require_profile_credentials_scope"),
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", side_effect=RuntimeError("audit unavailable")),
):
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
deactivate_profile("profile-1", principal=self.principal, session=session)
self.assertEqual(session.commits, 0)
self.assertEqual(session.rollbacks, 1)
def test_patch_deactivation_is_rejected_in_favor_of_audited_delete(self) -> None:
self.profile.is_active = True
self.profile.smtp_password_encrypted = "existing-ciphertext"
session = _Session()
payload = MailServerProfileUpdateRequest(is_active=False)
with (
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=self.profile),
patch("govoplan_mail.backend.router._require_profile_write_scope"),
self.assertRaises(HTTPException) as captured,
):
update_profile(
self.profile.id,
payload,
principal=self.principal,
session=session,
)
self.assertEqual(captured.exception.status_code, 422)
self.assertTrue(self.profile.is_active)
self.assertEqual(self.profile.smtp_password_encrypted, "existing-ciphertext")
self.assertEqual(session.commits, 0)
self.assertEqual(session.rollbacks, 1)
def test_system_profile_changes_are_instance_wide_in_the_change_feed(self) -> None:
system_profile = SimpleNamespace(
id="profile-system",
tenant_id=None,
scope_type="system",
scope_id=None,
is_active=True,
smtp_config={"host": "smtp.example.test"},
imap_config=None,
)
session = _Session()
create_payload = MailServerProfileCreateRequest.model_validate(
{
"name": "System profile",
"scope_type": "system",
"smtp": {"host": "smtp.example.test"},
}
)
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),
):
create_profile(create_payload, principal=self.principal, session=session)
self.assertIsNone(create_change.call_args.kwargs["tenant_id"])
update_payload = MailServerProfileUpdateRequest(name="Renamed system profile")
with (
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=system_profile),
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router.update_mail_server_profile", return_value=system_profile),
patch("govoplan_mail.backend.router._record_mail_change") as update_change,
patch("govoplan_mail.backend.router._profile_response", return_value=system_profile),
):
update_profile("profile-system", update_payload, principal=self.principal, session=session)
self.assertIsNone(update_change.call_args.kwargs["tenant_id"])
with (
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=system_profile),
patch("govoplan_mail.backend.router._require_profile_write_scope"),
patch("govoplan_mail.backend.router._require_profile_credentials_scope"),
patch("govoplan_mail.backend.router.delete_mail_profile_credentials", return_value=()),
patch("govoplan_mail.backend.router.clear_mailbox_index"),
patch("govoplan_mail.backend.router._record_mail_change") as delete_change,
patch("govoplan_mail.backend.router._profile_response", return_value=system_profile),
):
deactivate_profile("profile-system", principal=self.principal, session=session)
self.assertIsNone(delete_change.call_args.kwargs["tenant_id"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,75 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from govoplan_core.security.outbound_http import OutboundHttpBlocked
from govoplan_mail.backend.config import SmtpConfig
from govoplan_mail.backend.sending.smtp import (
SmtpConfigurationError,
_open_smtp,
_prepare_smtp_send,
_smtp_send_result,
)
class SmtpSendHelperTests(unittest.TestCase):
def test_real_smtp_connections_honor_deployment_egress_policy(self):
config = SmtpConfig(host="smtp.internal", port=587, security="starttls")
with patch(
"govoplan_mail.backend.sending.smtp.validate_outbound_host",
side_effect=OutboundHttpBlocked("private network blocked"),
), self.assertRaisesRegex(SmtpConfigurationError, "private network blocked"):
_open_smtp(config)
def test_smtp_revalidates_and_pins_at_connection_time(self):
config = SmtpConfig(host="smtp.example.test", port=587, security="starttls")
public = [(2, 1, 6, "", ("93.184.216.34", 587))]
private = [(2, 1, 6, "", ("127.0.0.1", 587))]
with patch.dict(
"os.environ",
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
side_effect=(public, private),
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
OutboundHttpBlocked,
"non-public network",
):
_open_smtp(config)
socket_factory.assert_not_called()
def test_prepare_smtp_send_validates_envelope(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
with self.assertRaisesRegex(SmtpConfigurationError, "envelope sender"):
_prepare_smtp_send(smtp_config=config, envelope_from="", envelope_recipients=["user@example.org"])
with self.assertRaisesRegex(SmtpConfigurationError, "recipient"):
_prepare_smtp_send(smtp_config=config, envelope_from="sender@example.org", envelope_recipients=[""])
def test_prepare_smtp_send_filters_blank_recipients(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
self.assertEqual(
_prepare_smtp_send(
smtp_config=config,
envelope_from="sender@example.org",
envelope_recipients=["", "user@example.org"],
),
("smtp.example.org", 587, ["user@example.org"]),
)
def test_smtp_send_result_decodes_refused_recipients(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
result = _smtp_send_result(
smtp_config=config,
host="smtp.example.org",
port=587,
envelope_from="sender@example.org",
envelope_recipients=["ok@example.org", "blocked@example.org"],
refused={"blocked@example.org": (550, b"blocked")},
)
self.assertEqual(result.accepted_count, 1)
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
if __name__ == "__main__":
unittest.main()

142
webui/package-lock.json generated Normal file
View File

@@ -0,0 +1,142 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.10",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"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==",
"license": "ISC",
"peer": true,
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"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==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
}
},
"node_modules/react-router": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-router": "7.18.2"
},
"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",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"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",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.4",
"version": "0.1.10",
"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.4",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react-router-dom": ">=7.18.2 <8"
},
"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-policy-validation.test.js"
"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"
},
"devDependencies": {
"typescript": "^5.7.2"

View File

@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const webuiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const source = fs.readFileSync(path.join(webuiDir, "src/features/mail/MailboxPage.tsx"), "utf8");
const styles = fs.readFileSync(path.join(webuiDir, "src/styles/mail-profiles.css"), "utf8");
function assert(condition, message) {
if (!condition) throw new Error(message);
}
assert(source.includes("IconButton,"), "MailboxPage must import the central IconButton");
assert(
source.includes('<IconButton label="i18n:govoplan-mail.clear_message_search.cc9f2800"'),
"the clear-search action must use the central IconButton"
);
assert(
!source.includes('<button type="button" onClick={() => setMessageQuery("")}'),
"the raw clear-search button must not return"
);
assert(
!styles.includes(".mailbox-search-field button"),
"Mail must not redefine the central icon-button appearance"
);

View File

@@ -1 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";
export { apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "@govoplan/core-webui";

View File

@@ -1,62 +1,70 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
import type {
ApiSettings,
DeltaDeletedItem,
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapTestPayload,
MailCredentialEnvelope,
MailProfilePolicy,
MailProfilePolicyResponse,
MailProfileScope,
MailSecurity,
MailServerEndpoint,
MailServerProfile,
MailServerProfilePayload,
MailSmtpTestPayload,
MockMailboxMessage,
MockMailboxMessageResponse
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
export type {
MailConnectionTestResponse,
MailCredentialEnvelope,
MailCredentialPolicy,
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestPayload,
MailProfilePatternKey,
MailProfilePatternRules,
MailProfilePolicy,
MailProfilePolicyLimitKey,
MailProfilePolicyLimitPermissions,
MailProfilePolicyResponse,
MailProfileScope,
MailSecurity,
MailServerEndpoint,
MailServerProfile,
MailServerProfileCredentialsPayload,
MailServerProfileListResponse,
MailServerProfilePayload,
MailSmtpTestPayload,
MailTransportCredentialsPayload,
MockMailboxMessage,
MockMailboxMessageResponse
} from "@govoplan/core-webui";
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";
export type MailSecurity = "plain" | "tls" | "starttls";
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
export type MailSmtpTestPayload = {
host?: string | null;
port?: number | null;
username?: string | null;
password?: string | null;
security?: MailSecurity;
timeout_seconds?: number;
export type MailAddressLookupCandidate = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
email_label?: string | null;
organization?: string | null;
role_title?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
};
export type MailImapTestPayload = MailSmtpTestPayload & {
sent_folder?: string | null;
export type MailAddressLookupResponse = {
available: boolean;
candidates: MailAddressLookupCandidate[];
};
export type MailTransportCredentialsPayload = {
username?: string | null;
password?: string | null;
};
export type MailServerProfileCredentialsPayload = {
smtp?: MailTransportCredentialsPayload;
imap?: MailTransportCredentialsPayload;
};
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
details?: Record<string, unknown>;
};
export type MailMailboxAttachment = {
filename?: string | null;
content_type: string;
@@ -95,9 +103,23 @@ export type MailMailboxMessageListResponse = {
total_count?: number;
offset?: number;
limit?: number;
cursor?: string | null;
next_cursor?: string | null;
cursor_stable?: boolean;
full?: boolean;
from_cache?: boolean;
refreshing?: boolean;
indexed_at?: string | null;
messages: MailMailboxMessageSummary[];
};
export type MailMailboxBootstrapResponse = {
profile_id: string;
folder: string;
folders: MailImapFolderListResponse;
messages: MailMailboxMessageListResponse;
};
export type MailMailboxMessageResponse = {
profile_id: string;
folder: string;
@@ -107,112 +129,46 @@ export type MailMailboxMessageResponse = {
message: MailMailboxMessageDetail;
};
export type MailServerProfile = {
id: string;
tenant_id?: string | null;
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
updated_at: string;
export type MailSettingsDeltaResponse = {
profiles: MailServerProfile[];
policy?: MailProfilePolicyResponse | null;
changed_sections?: string[];
deleted: DeltaDeletedItem[];
watermark?: string | null;
has_more: boolean;
full: boolean;
};
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const;
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export const mailProfilePolicyLimitKeys = [
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"allow_campaign_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
"whitelist.imap_hosts",
"whitelist.envelope_senders",
"whitelist.from_headers",
"whitelist.recipient_domains",
"blacklist.smtp_hosts",
"blacklist.imap_hosts",
"blacklist.envelope_senders",
"blacklist.from_headers",
"blacklist.recipient_domains"
] as const;
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
};
export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null;
allow_user_profiles?: boolean | null;
allow_group_profiles?: boolean | null;
allow_campaign_profiles?: boolean | null;
smtp_credentials?: MailCredentialPolicy | null;
imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null;
allow_lower_level_limits?: MailProfilePolicyLimitPermissions | null;
};
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
policy?: MailProfilePolicy | null;
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
export async function lookupMailAddresses(settings: ApiSettings, query: string, limit = 25): Promise<MailAddressLookupResponse> {
return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams();
if (includeInactive) params.set("include_inactive", "true");
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
return response.profiles ?? [];
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined,
campaign_id: campaignId
});
}
export async function fetchMailSettingsDelta(
settings: ApiSettings,
params: {
scope_type?: MailProfileScope;
scope_id?: string | null;
include_inactive?: boolean;
campaign_id?: string | null;
since?: string | null;
limit?: number;
} = {}
): Promise<MailSettingsDeltaResponse> {
return apiFetch<MailSettingsDeltaResponse>(settings, apiPath("/api/v1/mail/settings/delta", {
scope_type: params.scope_type,
scope_id: params.scope_id,
include_inactive: params.include_inactive ? true : undefined,
campaign_id: params.campaign_id,
since: params.since,
limit: params.limit
}));
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
@@ -224,6 +180,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",
@@ -235,17 +218,128 @@ 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,
scopeId?: string | null,
campaignId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
scope_id: scopeId,
campaign_id: campaignId
}));
}
export async function updateMailProfilePolicy(
@@ -254,29 +348,84 @@ export async function updateMailProfilePolicy(
policy: MailProfilePolicy,
scopeId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, {
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, { scope_id: scopeId }), {
method: "PUT",
body: JSON.stringify({ policy })
});
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
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 apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
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 apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
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): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`);
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, {
include_status: includeStatus ? true : undefined,
refresh: refresh ? true : undefined
}));
}
export async function bootstrapMailbox(
settings: ApiSettings,
profileId: string,
folder = "INBOX",
limit = 50,
offset = 0,
refresh = false
): Promise<MailMailboxBootstrapResponse> {
return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, {
folder,
limit,
offset,
refresh: refresh ? true : undefined
}));
}
export async function listMailboxMessages(
@@ -284,10 +433,17 @@ export async function listMailboxMessages(
profileId: string,
folder = "INBOX",
limit = 50,
offset = 0
offset = 0,
cursor?: string | null,
refresh = false
): Promise<MailMailboxMessageListResponse> {
const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) });
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
folder,
limit,
offset,
cursor,
refresh: refresh ? true : undefined
}));
}
export async function getMailboxMessage(
@@ -296,69 +452,34 @@ export async function getMailboxMessage(
folder: string,
uid: string
): Promise<MailMailboxMessageResponse> {
const params = new URLSearchParams({ folder });
return apiFetch<MailMailboxMessageResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`);
return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder }));
}
export async function testSmtpSettings(
settings: ApiSettings,
payload: MailSmtpTestPayload
): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
method: "POST",
body: JSON.stringify(payload)
});
return apiPostJson<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", payload);
}
export async function testImapSettings(
settings: ApiSettings,
payload: MailImapTestPayload
): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
method: "POST",
body: JSON.stringify(payload)
});
return apiPostJson<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", payload);
}
export async function listImapFolders(
settings: ApiSettings,
payload: MailImapTestPayload
): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
method: "POST",
body: JSON.stringify(payload)
});
return apiPostJson<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", payload);
}
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxListResponse = {
messages: MockMailboxMessage[];
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export type MockMailboxFailureConfig = {
fail_next_smtp?: boolean | null;
fail_next_imap?: boolean | null;
@@ -366,8 +487,7 @@ export type MockMailboxFailureConfig = {
};
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
return apiFetch<MockMailboxListResponse>(settings, apiPath("/api/v1/dev/mailbox/messages", { kind }));
}
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {

View File

@@ -1,4 +0,0 @@
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
export { DataGridEmptyAction, DataGridRowActions };
export default DataGrid;

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,30 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import {
Button,
DataGridPaginationBar,
DismissibleAlert,
ExplorerTree,
IconButton,
LoadingIndicator,
MessageDisplayPanel,
formatDateTime,
i18nMessage,
type ApiSettings
} from "@govoplan/core-webui";
import {
bootstrapMailbox,
getMailboxMessage,
listMailboxFolders,
listMailboxMessages,
listMailServerProfiles,
type MailImapFolderResponse,
type MailMailboxMessageDetail,
type MailMailboxMessageSummary,
type MailServerProfile
} from "../../api/mail";
type MailServerProfile } from
"../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
export default function MailboxPage({ settings }: { settings: ApiSettings }) {
export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
@@ -48,6 +51,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0);
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
const skipNextMessageLoadRef = useRef(false);
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
@@ -60,16 +65,23 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const folderEmptyText = folderError || (noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.");
const messageEmptyText = messageError || (!selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : messages.length > 0 && filteredMessages.length === 0 ? "No messages match the current filter on this page." : "No messages in this folder.");
const previewEmptyText = detailError || (loadingMessage ? "Loading message..." : "Select a message to inspect its content.");
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094";
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => { selectedMessageKeyRef.current = selectedMessageKeyState; }, [selectedMessageKeyState]);
useEffect(() => { if (messagePage > messagePageCount) setMessagePage(messagePageCount); }, [messagePage, messagePageCount]);
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize); }, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
useEffect(() => {
if (!selectedProfileId || !selectedFolder || !foldersReady) return;
if (skipNextMessageLoadRef.current) {
skipNextMessageLoadRef.current = false;
return;
}
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => {
const handlePreviewShortcut = (event: KeyboardEvent) => {
@@ -88,10 +100,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (shellBusy || loadingMessage || filteredMessages.length === 0) return;
const selectedIndex = filteredMessages.findIndex((message) => mailboxMessageKey(message.folder || selectedFolder, message.uid) === selectedMessageKey);
let nextIndex: number | null = null;
if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0;
else if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = filteredMessages.length - 1;
if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0;else
if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0;else
if (event.key === "Home") nextIndex = 0;else
if (event.key === "End") nextIndex = filteredMessages.length - 1;
if (nextIndex === null) return;
event.preventDefault();
const nextMessage = filteredMessages[nextIndex];
@@ -128,6 +140,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
}
} catch (err) {
setError(errorText(err));
@@ -136,64 +150,86 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
}
}
async function loadFolders(profileId = selectedProfileId) {
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
if (!profileId) return;
const requestId = ++folderRequestRef.current;
messageListRequestRef.current += 1;
const folderRequestId = ++folderRequestRef.current;
const messageRequestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1;
setLoadingFolders(true);
setLoadingMessages(true);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setFolderError("");
setMessageError("");
setDetailError("");
setError("");
try {
const response = await listMailboxFolders(settings, profileId);
if (requestId !== folderRequestRef.current) return;
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded.");
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
setFolders(loaded);
setExpandedFolders(new Set());
let nextFolder = selectedFolder;
if (!nextFolder || !loaded.some((folder) => folder.name === nextFolder)) {
nextFolder = loaded.some((folder) => folder.name === "INBOX") ? "INBOX" : response.detected_sent_folder || loaded[0]?.name || "INBOX";
const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
const loadedMessages = response.messages.messages ?? [];
const total = response.messages.total_count ?? loadedMessages.length;
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
nextFolder = loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
}
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);
mailboxPageCursorsRef.current = {
[`${cursorKey}:1`]: null,
[`${cursorKey}:2`]: response.messages.next_cursor ?? null
};
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== 1;
setFolders(foldersWithCounts);
setExpandedFolders(new Set());
setSelectedFolder(nextFolder);
setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages);
setMessageTotalCount(total);
} catch (err) {
if (requestId !== folderRequestRef.current) return;
setFolderError(errorText(err));
setError(errorText(err));
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
const message = errorText(err);
skipNextMessageLoadRef.current = false;
setFolderError(message);
setMessageError(message);
setError(message);
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
} finally {
if (requestId === folderRequestRef.current) setLoadingFolders(false);
if (folderRequestId === folderRequestRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current) setLoadingMessages(false);
}
}
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize) {
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) {
if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current;
const offset = (Math.max(1, page) - 1) * pageSize;
const cursorKey = mailboxCursorKey(profileId, folder, pageSize);
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
setLoadingMessages(true);
setMessageError("");
setDetailError("");
setError("");
try {
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset);
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor, refresh);
if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length;
if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null;
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
setMessages(loaded);
setMessageTotalCount(total);
setFolderMessageCount(folder, total);
@@ -265,12 +301,14 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setLoadingFolders(false);
setLoadingMessages(false);
setLoadingMessage(false);
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
}
function openFolderNode(node: MailFolderNode) {
if (node.folderName) {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);
else {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else
{
setSelectedFolder(node.folderName);
setMessagePage(1);
setSelectedMessage(null);
@@ -287,8 +325,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function toggleFolderNode(node: MailFolderNode) {
setExpandedFolders((current) => {
const next = new Set(current);
if (next.has(node.id)) next.delete(node.id);
else next.add(node.id);
if (next.has(node.id)) next.delete(node.id);else
next.add(node.id);
return next;
});
}
@@ -312,8 +350,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="Mailbox folders">
<div className="file-tree-heading">Folders</div>
<aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
<div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
<div className="file-tree-list">
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>}
<ExplorerTree
@@ -336,70 +374,70 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
</span>
{flagText && <small>{flagText}</small>}
</span>
);
}}
/>
</span>);
}} />
</div>
</aside>
<section className="file-list-panel mailbox-message-list-panel" aria-label="Mailbox messages">
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
<div className="file-list-sticky">
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
<div className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
<label className="mailbox-profile-field">
<span>IMAP profile</span>
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>}
{imapProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_imap_profiles_available.d64589f8</option>}
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
<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="Reload IMAP profiles">
<Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<RefreshCw size={16} aria-hidden="true" />
Profiles
i18n:govoplan-mail.profiles.0c2a9300
</Button>
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || loadingFolders} title="Refresh mailbox folders">
<Button onClick={() => void loadMailboxBootstrap(selectedProfileId, true)} disabled={!selectedProfileId || loadingFolders || loadingMessages} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963">
<RefreshCw size={16} aria-hidden="true" />
Folders
i18n:govoplan-mail.folders.19adc47b
</Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="Refresh messages in the current folder">
<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">
<RefreshCw size={16} aria-hidden="true" />
Messages
i18n:govoplan-mail.messages.f1702b46
</Button>
</div>
</div>
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "Mail"}</span>
<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>
</nav>
<div className="mailbox-filter-row">
<label className="mailbox-search-field">
<Search size={15} aria-hidden="true" />
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="Search messages" />
{messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="Clear message search"><X size={14} /></button>}
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="i18n:govoplan-mail.search_messages.abea65ae" />
{messageQuery && <IconButton label="i18n:govoplan-mail.clear_message_search.cc9f2800" icon={<X />} variant="ghost" onClick={() => setMessageQuery("")} />}
</label>
</div>
<div className="file-list-meta">
<span>{messageCountLabel}</span>
<span>{selectedFolder}</span>
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} on page</span>}
{shellBusy && <span>Working...</span>}
{loadingMessage && <span>Loading preview...</span>}
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
</div>
<div className="file-list-table-head mailbox-message-head" role="row">
<span>Subject</span>
<span>Date</span>
<span>Size</span>
<span>i18n:govoplan-mail.subject.8d183dbd</span>
<span>i18n:govoplan-mail.date.eb9a4bc1</span>
<span>i18n:govoplan-mail.size.b7152342</span>
</div>
</div>
<div className="file-list-drop-target mailbox-message-scroll">
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
<div className="file-list-table mailbox-message-table" role="table" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
{filteredMessages.length === 0 && <div className={`file-list-empty${messageError ? " mailbox-error-state" : ""}`}>{messageEmptyText}</div>}
{filteredMessages.map((message) => {
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
@@ -417,13 +455,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
event.preventDefault();
void openMessage(message);
}
}}
>
}}>
<div className="file-list-name-cell">
<div className="file-list-name">
<Mail className="file-row-icon" size={20} aria-hidden="true" />
<span>
<strong>{message.subject || "(no subject)"}</strong>
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
<small>{message.from_header || "-"}</small>
</span>
</div>
@@ -433,33 +471,35 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
<span>{formatBytes(message.size_bytes)}</span>
</span>
</div>
);
</div>);
})}
</div>
</div>
<MailboxPagination
<DataGridPaginationBar
page={messagePage}
pageSize={messagePageSize}
totalRows={messageTotalCount ?? 0}
className="mailbox-pagination"
ariaLabel="i18n:govoplan-mail.mailbox_message_pagination.965407bf"
disabled={loadingMessages || !foldersReady}
onPageChange={changeMessagePage}
onPageSizeChange={changeMessagePageSize}
/>
onPageSizeChange={changeMessagePageSize} />
</section>
<section className="mailbox-preview-panel" aria-label="Message preview">
<div className="file-tree-heading">Preview</div>
<section className="mailbox-preview-panel" aria-label="i18n:govoplan-mail.message_preview.58de1450">
<div className="file-tree-heading">i18n:govoplan-mail.preview.f1fbb2b4</div>
<div className="mailbox-preview-scroll">
<MessageDisplayPanel
title={selectedMessage?.subject}
fields={selectedMessage ? [
{ label: "From", value: selectedMessage.from_header || "-" },
{ label: "To", value: selectedMessage.to_header || "-" },
{ label: "Cc", value: selectedMessage.cc_header || null },
{ label: "Bcc", value: selectedMessage.bcc_header || null },
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
] : []}
{ label: "i18n:govoplan-mail.from.3f66052a", value: selectedMessage.from_header || "-" },
{ label: "i18n:govoplan-mail.to.ae79ea1e", value: selectedMessage.to_header || "-" },
{ label: "i18n:govoplan-mail.cc.1fd6a880", value: selectedMessage.cc_header || null },
{ label: "i18n:govoplan-mail.bcc.8431acad", value: selectedMessage.bcc_header || null },
{ label: "i18n:govoplan-mail.date.eb9a4bc1", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }] :
[]}
bodyText={selectedMessage?.body_text}
bodyHtml={selectedMessage?.body_html}
bodyPreview={selectedMessage?.body_preview}
@@ -469,70 +509,49 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
contentType: attachment.content_type,
sizeBytes: attachment.size_bytes
}))}
emptyText={previewEmptyText}
/>
emptyText={previewEmptyText} />
</div>
</section>
{shellBusy && (
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div>
{shellBusy &&
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{i18nMessage(loadingLabel)}</span></div>
</div>
)}
}
</div>
</div>
);
</div>);
}
function MailboxPagination({ page, pageSize, totalRows, disabled, onPageChange, onPageSizeChange }: { page: number; pageSize: number; totalRows: number; disabled: boolean; onPageChange: (page: number) => void; onPageSizeChange: (pageSize: number) => void }) {
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
const last = Math.min(totalRows, page * pageSize);
const options = [10, 25, 50, 100];
return (
<div className="data-grid-pagination mailbox-pagination" aria-label="Mailbox message pagination">
<div className="data-grid-pagination-summary">{first}-{last} of {totalRows}</div>
<label className="data-grid-page-size">
<span>Rows per page</span>
<select value={pageSize} disabled={disabled} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
{options.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<div className="data-grid-page-controls">
<button type="button" aria-label="First page" disabled={disabled || page <= 1} onClick={() => onPageChange(1)}><ChevronsLeft size={16} /></button>
<button type="button" aria-label="Previous page" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button>
<span>Page {page} of {pageCount}</span>
<button type="button" aria-label="Next page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(page + 1)}><ChevronRight size={16} /></button>
<button type="button" aria-label="Last page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight size={16} /></button>
</div>
</div>
);
}
function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`;
}
function mailboxCursorKey(profileId: string, folder: string, pageSize: number): string {
return `${profileId}::${folder || "INBOX"}::${pageSize}`;
}
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
const normalized = query.trim().toLocaleLowerCase();
if (!normalized) return messages;
return messages.filter((message) => [
message.subject,
message.from_header,
message.to_header,
message.cc_header,
message.date,
message.body_preview
].some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
message.subject,
message.from_header,
message.to_header,
message.cc_header,
message.date,
message.body_preview].
some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
}
function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
if (!ready) return "No folder loaded";
if (loading) return "Loading messages";
if (totalCount !== null && totalCount > visibleCount) return `${visibleCount} of ${totalCount} messages`;
if (!ready) return "i18n:govoplan-mail.no_folder_loaded.889bdb1b";
if (loading) return "i18n:govoplan-mail.loading_messages.4294022c";
if (totalCount !== null && totalCount > visibleCount) return i18nMessage("i18n:govoplan-mail.value_of_value_messages.981a1618", { value0: visibleCount, value1: totalCount });
const count = totalCount ?? visibleCount;
return `${count} message${count === 1 ? "" : "s"}`;
return i18nMessage(count === 1 ? "i18n:govoplan-mail.value_message" : "i18n:govoplan-mail.value_messages", { value0: count });
}
function folderFlagLabel(flags: string[]): string {
@@ -544,17 +563,17 @@ function displayFolderFlag(flag: string): string | null {
const normalized = flag.replace(/^\\/, "").toLowerCase();
switch (normalized) {
case "sent":
return "Sent";
return "i18n:govoplan-mail.sent.35f49dcf";
case "drafts":
return "Drafts";
return "i18n:govoplan-mail.drafts.22a31d86";
case "trash":
return "Trash";
return "i18n:govoplan-mail.trash.e3bf62bb";
case "archive":
return "Archive";
return "i18n:govoplan-mail.archive.2621c6fd";
case "junk":
return "Junk";
return "i18n:govoplan-mail.junk.86c7d94c";
case "flagged":
return "Flagged";
return "i18n:govoplan-mail.flagged.f8db8a17";
case "haschildren":
case "hasnochildren":
case "noselect":
@@ -566,15 +585,15 @@ function displayFolderFlag(flag: string): string | null {
function transportLabel(profile: MailServerProfile): string {
const imap = profile.imap;
if (!imap?.host) return "IMAP not configured";
if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3";
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
}
function formatBytes(value?: number | null): string {
if (!value) return "-";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-mail.bytes_kb", { value0: (value / 1024).toFixed(1) });
return i18nMessage("i18n:govoplan-mail.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
function errorText(error: unknown): string {

View File

@@ -22,11 +22,11 @@ export type MailPolicyValidationMessage = {
};
const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "SMTP host",
imap_hosts: "IMAP host",
envelope_senders: "Envelope sender",
from_headers: "From header",
recipient_domains: "Recipient domain"
smtp_hosts: "i18n:govoplan-mail.smtp_host.2d4a434b",
imap_hosts: "i18n:govoplan-mail.imap_host.b53c3751",
envelope_senders: "i18n:govoplan-mail.envelope_sender.5ec276a0",
from_headers: "i18n:govoplan-mail.from_header.bb78e85d",
recipient_domains: "i18n:govoplan-mail.recipient_domain.778f2dcf"
};
type ValueCheck = {
@@ -35,19 +35,19 @@ type ValueCheck = {
};
export function validateMailPolicy(
policy: MailProfilePolicy | null | undefined,
input: MailPolicyValidationInput,
): MailPolicyValidationMessage[] {
policy: MailProfilePolicy | null | undefined,
input: MailPolicyValidationInput)
: MailPolicyValidationMessage[] {
if (!policy) return [];
const checks: ValueCheck[] = [
{ key: "smtp_hosts", value: input.smtpHost ?? "" },
{ key: "imap_hosts", value: input.imapHost ?? "" },
{ key: "envelope_senders", value: input.envelopeSender ?? "" },
{ key: "from_headers", value: input.fromHeader ?? "" },
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean)))
.map((value) => ({ key: "recipient_domains" as const, value }))
];
{ key: "smtp_hosts", value: input.smtpHost ?? "" },
{ key: "imap_hosts", value: input.imapHost ?? "" },
{ key: "envelope_senders", value: input.envelopeSender ?? "" },
{ key: "from_headers", value: input.fromHeader ?? "" },
...Array.from(new Set((input.recipientDomains ?? []).map(normalizeDomain).filter(Boolean))).
map((value) => ({ key: "recipient_domains" as const, value }))];
return checks.flatMap(({ key, value }) => {
const result = mailPolicyValueAllowed(policy, key, value);
@@ -58,18 +58,18 @@ export function validateMailPolicy(
label,
value: result.value,
severity: "danger" as const,
message: result.reason === "blacklist"
? `${label} ${result.value} is denied by pattern ${result.pattern}.`
: `${label} ${result.value} is outside the effective allow-list.`
message: result.reason === "blacklist" ?
`${label} ${result.value} is denied by pattern ${result.pattern}.` :
`${label} ${result.value} is outside the effective allow-list.`
}];
});
}
export function mailPolicyValueAllowed(
policy: MailProfilePolicy | null | undefined,
key: MailProfilePatternKey,
rawValue: string | null | undefined,
): { allowed: true; value: string } | { allowed: false; value: string; reason: "blacklist" | "whitelist"; pattern?: string } {
policy: MailProfilePolicy | null | undefined,
key: MailProfilePatternKey,
rawValue: string | null | undefined)
: {allowed: true;value: string;} | {allowed: false;value: string;reason: "blacklist" | "whitelist";pattern?: string;} {
const value = normalizePolicyValue(key, rawValue);
if (!value || !policy) return { allowed: true, value };
@@ -88,16 +88,45 @@ export function wildcardPatternMatches(pattern: string, rawValue: string): boole
const normalizedPattern = pattern.trim();
const value = rawValue.trim();
if (!normalizedPattern || !value) return false;
if (normalizedPattern === "*") return true;
const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
const regex = `^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`;
return new RegExp(regex, "i").test(value);
return wildcardMatch(normalizedPattern.toLowerCase(), value.toLowerCase());
}
function wildcardMatch(pattern: string, value: string): boolean {
let patternIndex = 0;
let valueIndex = 0;
let starIndex = -1;
let valueRetryIndex = 0;
while (valueIndex < value.length) {
const token = pattern[patternIndex];
if (token === "?" || token === value[valueIndex]) {
patternIndex += 1;
valueIndex += 1;
continue;
}
if (token === "*") {
starIndex = patternIndex;
valueRetryIndex = valueIndex;
patternIndex += 1;
continue;
}
if (starIndex !== -1) {
patternIndex = starIndex + 1;
valueRetryIndex += 1;
valueIndex = valueRetryIndex;
continue;
}
return false;
}
while (pattern[patternIndex] === "*") patternIndex += 1;
return patternIndex === pattern.length;
}
function patternsFor(
rules: MailProfilePolicy["whitelist"] | MailProfilePolicy["blacklist"],
key: MailProfilePatternKey,
): string[] {
rules: MailProfilePolicy["whitelist"] | MailProfilePolicy["blacklist"],
key: MailProfilePatternKey)
: string[] {
return (rules?.[key] ?? []).map((pattern) => String(pattern).trim()).filter(Boolean);
}

View File

@@ -0,0 +1,115 @@
export type MailProfileProtocol = "smtp" | "imap";
export type MailProfileEditSection = MailProfileProtocol;
export type MailProfilePanelMode = "all" | "server" | "credentials";
export type MailProfileEditTarget =
| {kind: "create";}
| {kind: "profile";}
| {kind: "server";protocol: MailProfileProtocol;serverId?: string;}
| {kind: "credentials";protocol: MailProfileProtocol;serverId?: string;credentialId?: string;};
export type MailProfileTransportLike = {
host?: string | null;
};
export type MailProfileTreeProfileLike = {
id: string;
imap?: MailProfileTransportLike | null;
};
export type MailProfileChildDescriptor = {
kind: "server" | "credential";
id: string;
protocol: MailProfileProtocol;
};
export type MailProfileTransportCredentialsLike = {
username?: string | null;
password?: string | null;
};
export type MailProfileTargetedUpdateParts = {
profile: Record<string, unknown>;
smtp: Record<string, unknown>;
imap: Record<string, unknown> | null;
credentials: {
smtp: MailProfileTransportCredentialsLike;
imap: MailProfileTransportCredentialsLike;
};
clearImap: boolean;
};
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
const children: MailProfileChildDescriptor[] = [
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
{ kind: "credential", id: `credential:${profile.id}:smtp`, protocol: "smtp" },
{ kind: "server", id: `server:${profile.id}:imap`, protocol: "imap" }
];
if (profile.imap) children.push({ kind: "credential", id: `credential:${profile.id}:imap`, protocol: "imap" });
return children;
}
export function mailProfileEditTargetInitialSection(target: MailProfileEditTarget): MailProfileEditSection {
if (target.kind === "server" || target.kind === "credentials") return target.protocol;
return "smtp";
}
export function mailProfileEditTargetPanelMode(target: MailProfileEditTarget): MailProfilePanelMode | null {
if (target.kind === "create") return "all";
if (target.kind === "server") return "server";
if (target.kind === "credentials") return "credentials";
return null;
}
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] {
if (target.kind === "server" || target.kind === "credentials") return [target.protocol];
if (target.kind === "create") return ["smtp", "imap"];
return [];
}
export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditTarget): boolean {
return target.kind === "create" || target.kind === "profile";
}
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
return target.kind !== "profile";
}
/**
* Keep the PATCH authority surface equal to the focused editor. Profile and
* server edits must not accidentally replay credential fields merely because
* the form draft contains their displayed values.
*/
export function mailProfileTargetedUpdatePayload(
target: MailProfileEditTarget,
parts: MailProfileTargetedUpdateParts
): Record<string, unknown> {
if (target.kind === "profile") return parts.profile;
if (target.kind === "server") {
if (target.protocol === "smtp") return { smtp: parts.smtp };
return parts.imap === null
? { imap: null, clear_imap: parts.clearImap }
: { imap: parts.imap };
}
if (target.kind === "credentials") {
return { credentials: { [target.protocol]: parts.credentials[target.protocol] } };
}
throw new Error("Create is not an update target");
}
/** Omit blank create credentials so profile-write remains independent. */
export function mailProfileCreateCredentialsPayload(
smtp: MailProfileTransportCredentialsLike,
imap: MailProfileTransportCredentialsLike
): {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} | undefined {
const populated = (value: MailProfileTransportCredentialsLike): MailProfileTransportCredentialsLike =>
Object.fromEntries(
Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== "")
);
const smtpCredentials = populated(smtp);
const imapCredentials = populated(imap);
const result: {smtp?: MailProfileTransportCredentialsLike;imap?: MailProfileTransportCredentialsLike;} = {};
if (Object.keys(smtpCredentials).length > 0) result.smtp = smtpCredentials;
if (Object.keys(imapCredentials).length > 0) result.imap = imapCredentials;
return Object.keys(result).length > 0 ? result : undefined;
}

View File

@@ -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");
}

View File

@@ -0,0 +1,398 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-mail.active.a733b809": "Active",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow",
"i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4": "Allowed profiles and wildcard rules for this scope.",
"i18n:govoplan-mail.allowed.77c7b490": "Allowed",
"i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179": "An ancestor allow-list limits selectable profiles to",
"i18n:govoplan-mail.archive.2621c6fd": "Archive",
"i18n:govoplan-mail.bcc.8431acad": "Bcc",
"i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d": "because an ancestor policy blocks those definitions.",
"i18n:govoplan-mail.blacklist_value.556334d0": "blacklist.{value0}",
"i18n:govoplan-mail.blacklist.7b2dd04c": "Blacklist",
"i18n:govoplan-mail.blocked.99613c74": "Blocked",
"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": "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",
"i18n:govoplan-mail.cancel.77dfd213": "Cancel",
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Controls whether reusable campaign-scoped Mail profiles may be defined below this scope.",
"i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4": "Controls whether group-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7": "Controls whether user-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.create_mail_profile.4d2f8f9f": "Create mail profile",
"i18n:govoplan-mail.credential_inheritance_is_locked_by_an_ancestor_.1ee5d263": "credential inheritance is locked by an ancestor policy.",
"i18n:govoplan-mail.credential_inheritance.ec8d7191": "Credential inheritance",
"i18n:govoplan-mail.current_mailbox_folder.55e2aea5": "Current mailbox folder",
"i18n:govoplan-mail.date.eb9a4bc1": "Date",
"i18n:govoplan-mail.deactivate_mail_profile.0e0fd0b8": "Deactivate mail profile",
"i18n:govoplan-mail.deactivate_value_campaign_drafts_using_it_will_n.656f1b9e": "Deactivate {value0}? Campaign drafts using it will need another allowed profile before sending.",
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Controls credential inheritance for compatible consumers. Campaign delivery requires credentials to remain on and inherit from the selected Mail profile.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Controls credential inheritance for compatible consumers. Campaign delivery requires credentials to remain on and inherit from the selected Mail profile.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Description",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Development mock mailbox",
"i18n:govoplan-mail.drafts.22a31d86": "Drafts",
"i18n:govoplan-mail.edit_mail_profile.95d1af9c": "Edit mail profile",
"i18n:govoplan-mail.edit_value_credentials.1e2dc4f6": "Edit {value0} credentials",
"i18n:govoplan-mail.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-mail.effective_mail_policy_blocks_the_current_profile.1b555820": "Effective mail policy blocks the current profile values.",
"i18n:govoplan-mail.effective_policy.feedb950": "Effective policy",
"i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d": "Effective values are shown in the table rows above.",
"i18n:govoplan-mail.envelope_sender.5ec276a0": "Envelope sender",
"i18n:govoplan-mail.envelope_senders.269065cd": "Envelope senders",
"i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-mail.explicit_allow.6a7946f8": "Explicit allow",
"i18n:govoplan-mail.first_page.49d74b49": "First page",
"i18n:govoplan-mail.flagged.f8db8a17": "Flagged",
"i18n:govoplan-mail.folders.19adc47b": "Folders",
"i18n:govoplan-mail.from_header.bb78e85d": "From header",
"i18n:govoplan-mail.from_headers.b3ea473b": "From headers",
"i18n:govoplan-mail.from.3f66052a": "From",
"i18n:govoplan-mail.generated_from_name.33d69a91": "Generated from name",
"i18n:govoplan-mail.group_profiles.74568838": "Group profiles",
"i18n:govoplan-mail.group.171a0606": "Group",
"i18n:govoplan-mail.groups": "Groups",
"i18n:govoplan-mail.imap_credentials.da847469": "IMAP credentials",
"i18n:govoplan-mail.imap_host.b53c3751": "IMAP host",
"i18n:govoplan-mail.imap_hostnames.ac9c1d78": "IMAP hostnames",
"i18n:govoplan-mail.imap_not_configured.b2892af3": "IMAP not configured",
"i18n:govoplan-mail.imap_profile.5165df81": "IMAP profile",
"i18n:govoplan-mail.imap_server_host_patterns.52b20b83": "IMAP server host patterns.",
"i18n:govoplan-mail.imap.271f9ef2": "IMAP",
"i18n:govoplan-mail.inactive.09af574c": "Inactive",
"i18n:govoplan-mail.inherit_parent_decision.42125bda": "Inherit parent decision",
"i18n:govoplan-mail.inherit_profile_credentials.0034bac0": "Inherit profile credentials",
"i18n:govoplan-mail.inherit.18f99833": "Inherit",
"i18n:govoplan-mail.junk.86c7d94c": "Junk",
"i18n:govoplan-mail.last_page.b01f16ae": "Last page",
"i18n:govoplan-mail.loading_folders.17f9f0e2": "Loading folders...",
"i18n:govoplan-mail.loading_mail_profile_policy.b746a2e8": "Loading mail profile policy...",
"i18n:govoplan-mail.loading_mail_profiles.87de3560": "Loading mail profiles...",
"i18n:govoplan-mail.loading_message.815c2094": "Loading message...",
"i18n:govoplan-mail.loading_messages.4294022c": "Loading messages",
"i18n:govoplan-mail.loading_messages.77b62232": "Loading messages...",
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
"i18n:govoplan-mail.local.dc99d54d": "Local",
"i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d": "Lower-level mail definitions",
"i18n:govoplan-mail.lower_levels.940821ee": "Lower levels",
"i18n:govoplan-mail.mail_actions.c08b5f08": "Mail actions",
"i18n:govoplan-mail.mail_profile_policy_saved.666847bf": "Mail profile policy saved.",
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
"i18n:govoplan-mail.mailbox_messages.5c06afaf": "Mailbox messages",
"i18n:govoplan-mail.message_preview.58de1450": "Message preview",
"i18n:govoplan-mail.messages.f1702b46": "Messages",
"i18n:govoplan-mail.name.709a2322": "Name",
"i18n:govoplan-mail.new_profile.ca36da25": "New profile",
"i18n:govoplan-mail.next_page.4bfc194b": "Next page",
"i18n:govoplan-mail.no_explicit_intersection.f2d62c71": "No explicit intersection",
"i18n:govoplan-mail.no_folder_loaded.889bdb1b": "No folder loaded",
"i18n:govoplan-mail.no_folders_available.14133b26": "No folders available.",
"i18n:govoplan-mail.no_host.4c710d7d": "No host",
"i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8": "No IMAP-enabled mail profiles.",
"i18n:govoplan-mail.no_imap_profile_selected.e7d1516f": "No IMAP profile selected",
"i18n:govoplan-mail.no_imap_profiles_available.d64589f8": "No IMAP profiles available",
"i18n:govoplan-mail.no_value_available": "No {value0} available.",
"i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39": "No local profile allow-list is set.",
"i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d": "No messages in this folder.",
"i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916": "No messages match the current filter on this page.",
"i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85": "No profiles are visible for this policy scope.",
"i18n:govoplan-mail.no_profiles_in_this_scope.302b21d8": "No profiles in this scope.",
"i18n:govoplan-mail.no_saved_password.32ce2b16": "No saved password",
"i18n:govoplan-mail.no_subject.49b20da0": "(no subject)",
"i18n:govoplan-mail.no_username.1c182624": "No username",
"i18n:govoplan-mail.not_configured.67f2141f": "not configured",
"i18n:govoplan-mail.not_configured.811931bb": "Not configured",
"i18n:govoplan-mail.on_page.ca7166f4": "on page",
"i18n:govoplan-mail.owner_policy.1e8df143": "Owner policy",
"i18n:govoplan-mail.page.fb06270f": "Page",
"i18n:govoplan-mail.password_saved.f6fab237": "Password saved",
"i18n:govoplan-mail.password.8be3c943": "Password",
"i18n:govoplan-mail.policy_path.1ba91ee5": "Policy path",
"i18n:govoplan-mail.policy_target.a19dcee9": "Policy target",
"i18n:govoplan-mail.policy.bb9cf141": "Policy",
"i18n:govoplan-mail.preview.f1fbb2b4": "Preview",
"i18n:govoplan-mail.previous_page.81f54719": "Previous page",
"i18n:govoplan-mail.profile_allow_list.507dfe6c": "Profile allow-list",
"i18n:govoplan-mail.profile_inherited.ea25d2e1": "Profile inherited",
"i18n:govoplan-mail.profile_s.742e9200": "profile(s).",
"i18n:govoplan-mail.profile_value_created.2a088d8d": "Profile {value0} created.",
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
"i18n:govoplan-mail.refresh_mailbox_folders.d9af9963": "Refresh mailbox folders",
"i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c": "Refresh messages in the current folder",
"i18n:govoplan-mail.reload_imap_profiles.b04c11c8": "Reload IMAP profiles",
"i18n:govoplan-mail.reload.cce71553": "Reload",
"i18n:govoplan-mail.require_local_credentials.da7b1d7c": "Require local credentials",
"i18n:govoplan-mail.rows_per_page.af2f9c1b": "Rows per page",
"i18n:govoplan-mail.save_policy.77d67ce3": "Save policy",
"i18n:govoplan-mail.save_profile.f597c0e8": "Save profile",
"i18n:govoplan-mail.saved.c0ae8f6e": "Saved",
"i18n:govoplan-mail.saving.56a2285c": "Saving…",
"i18n:govoplan-mail.search_messages.abea65ae": "Search messages",
"i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342": "Select a message to inspect its content.",
"i18n:govoplan-mail.select_an_imap_profile.5445648c": "Select an IMAP profile.",
"i18n:govoplan-mail.sent.35f49dcf": "Sent",
"i18n:govoplan-mail.server_credential.7fb9a24e": "Server / credential",
"i18n:govoplan-mail.size.b7152342": "Size",
"i18n:govoplan-mail.slug.094da9b9": "Slug",
"i18n:govoplan-mail.smtp_credentials.f73ef315": "SMTP credentials",
"i18n:govoplan-mail.smtp_envelope_sender_patterns.8c1fd95e": "SMTP envelope sender patterns.",
"i18n:govoplan-mail.smtp_host.2d4a434b": "SMTP host",
"i18n:govoplan-mail.smtp_hostnames.36eb51d8": "SMTP hostnames",
"i18n:govoplan-mail.smtp_server_host_patterns.cf6120c3": "SMTP server host patterns.",
"i18n:govoplan-mail.smtp.efff9cca": "SMTP",
"i18n:govoplan-mail.status.bae7d5be": "Status",
"i18n:govoplan-mail.subject.8d183dbd": "Subject",
"i18n:govoplan-mail.system_allow.ed6744b1": "System: Allow",
"i18n:govoplan-mail.system_profile_inherited.f4b8b5ce": "System: Profile inherited",
"i18n:govoplan-mail.system.bc0792d8": "System",
"i18n:govoplan-mail.target.61ad50a9": "Target",
"i18n:govoplan-mail.tenant.3ca93c78": "Tenant",
"i18n:govoplan-mail.test_imap.ef1bd79c": "Test IMAP",
"i18n:govoplan-mail.test_saved_imap.923dbe4a": "Test saved IMAP",
"i18n:govoplan-mail.test_saved_smtp.008d8054": "Test saved SMTP",
"i18n:govoplan-mail.test_smtp.e5697981": "Test SMTP",
"i18n:govoplan-mail.this_profile.5fd9cdf1": "this profile",
"i18n:govoplan-mail.to.ae79ea1e": "To",
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
"i18n:govoplan-mail.user.9f8a2389": "User",
"i18n:govoplan-mail.users": "Users",
"i18n:govoplan-mail.value_message": "{value0} message",
"i18n:govoplan-mail.value_messages": "{value0} messages",
"i18n:govoplan-mail.value_of_value_messages.981a1618": "{value0} of {value1} messages",
"i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44": "{value0} profile(s) allowed by this scope.",
"i18n:govoplan-mail.value_profile_s.d6b9d0af": "{value0} profile(s)",
"i18n:govoplan-mail.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-mail.value.48afe802": "· {value0}",
"i18n:govoplan-mail.value.8dce170d": "Value",
"i18n:govoplan-mail.value_scope": "{value0} scope",
"i18n:govoplan-mail.visible_from_header_patterns.ea77d99d": "Visible From header patterns.",
"i18n:govoplan-mail.whitelist_value.d4cc3755": "whitelist.{value0}",
"i18n:govoplan-mail.whitelist.53c2ad30": "Whitelist",
"i18n:govoplan-mail.wildcard_rules.54fb3fc0": "Wildcard rules",
"i18n:govoplan-mail.working.049ac820": "Working..."
},
"de": {
"i18n:govoplan-mail.active.a733b809": "Aktiv",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow",
"i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4": "Allowed profiles and wildcard rules for this scope.",
"i18n:govoplan-mail.allowed.77c7b490": "Allowed",
"i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179": "An ancestor allow-list limits selectable profiles to",
"i18n:govoplan-mail.archive.2621c6fd": "Archive",
"i18n:govoplan-mail.bcc.8431acad": "Bcc",
"i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d": "because an ancestor policy blocks those definitions.",
"i18n:govoplan-mail.blacklist_value.556334d0": "blacklist.{value0}",
"i18n:govoplan-mail.blacklist.7b2dd04c": "Blacklist",
"i18n:govoplan-mail.blocked.99613c74": "Blocked",
"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": "kampagnenbezogene Profile",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Kampagnenbezogene Profile",
"i18n:govoplan-mail.campaigns": "Kampagnen",
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
"i18n:govoplan-mail.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Legt fest, ob unterhalb dieses Bereichs wiederverwendbare kampagnenbezogene Mailprofile angelegt werden duerfen.",
"i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4": "Controls whether group-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7": "Controls whether user-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.create_mail_profile.4d2f8f9f": "Create mail profile",
"i18n:govoplan-mail.credential_inheritance_is_locked_by_an_ancestor_.1ee5d263": "credential inheritance is locked by an ancestor policy.",
"i18n:govoplan-mail.credential_inheritance.ec8d7191": "Credential inheritance",
"i18n:govoplan-mail.current_mailbox_folder.55e2aea5": "Current mailbox folder",
"i18n:govoplan-mail.date.eb9a4bc1": "Date",
"i18n:govoplan-mail.deactivate_mail_profile.0e0fd0b8": "Deactivate mail profile",
"i18n:govoplan-mail.deactivate_value_campaign_drafts_using_it_will_n.656f1b9e": "Deactivate {value0}? Campaign drafts using it will need another allowed profile before sending.",
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Steuert die Vererbung fuer kompatible Verbraucher. Kampagnen muessen die Zugangsdaten aus dem ausgewaehlten Mailprofil erben.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Steuert die Vererbung fuer kompatible Verbraucher. Kampagnen muessen die Zugangsdaten aus dem ausgewaehlten Mailprofil erben.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox",
"i18n:govoplan-mail.drafts.22a31d86": "Drafts",
"i18n:govoplan-mail.edit_mail_profile.95d1af9c": "Edit mail profile",
"i18n:govoplan-mail.edit_value_credentials.1e2dc4f6": "Edit {value0} credentials",
"i18n:govoplan-mail.edit_value.fad75899": "Edit {value0}",
"i18n:govoplan-mail.effective_mail_policy_blocks_the_current_profile.1b555820": "Effective mail policy blocks the current profile values.",
"i18n:govoplan-mail.effective_policy.feedb950": "Effective policy",
"i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d": "Effective values are shown in the table rows above.",
"i18n:govoplan-mail.envelope_sender.5ec276a0": "Envelope sender",
"i18n:govoplan-mail.envelope_senders.269065cd": "Envelope senders",
"i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-mail.explicit_allow.6a7946f8": "Explicit allow",
"i18n:govoplan-mail.first_page.49d74b49": "Erste Seite",
"i18n:govoplan-mail.flagged.f8db8a17": "Flagged",
"i18n:govoplan-mail.folders.19adc47b": "Ordner",
"i18n:govoplan-mail.from_header.bb78e85d": "From header",
"i18n:govoplan-mail.from_headers.b3ea473b": "From headers",
"i18n:govoplan-mail.from.3f66052a": "From",
"i18n:govoplan-mail.generated_from_name.33d69a91": "Generated from name",
"i18n:govoplan-mail.group_profiles.74568838": "Group profiles",
"i18n:govoplan-mail.group.171a0606": "Group",
"i18n:govoplan-mail.groups": "Gruppen",
"i18n:govoplan-mail.imap_credentials.da847469": "IMAP credentials",
"i18n:govoplan-mail.imap_host.b53c3751": "IMAP host",
"i18n:govoplan-mail.imap_hostnames.ac9c1d78": "IMAP hostnames",
"i18n:govoplan-mail.imap_not_configured.b2892af3": "IMAP not configured",
"i18n:govoplan-mail.imap_profile.5165df81": "IMAP profile",
"i18n:govoplan-mail.imap_server_host_patterns.52b20b83": "IMAP server host patterns.",
"i18n:govoplan-mail.imap.271f9ef2": "IMAP",
"i18n:govoplan-mail.inactive.09af574c": "Inaktiv",
"i18n:govoplan-mail.inherit_parent_decision.42125bda": "Inherit parent decision",
"i18n:govoplan-mail.inherit_profile_credentials.0034bac0": "Inherit profile credentials",
"i18n:govoplan-mail.inherit.18f99833": "Inherit",
"i18n:govoplan-mail.junk.86c7d94c": "Junk",
"i18n:govoplan-mail.last_page.b01f16ae": "Last page",
"i18n:govoplan-mail.loading_folders.17f9f0e2": "Loading folders...",
"i18n:govoplan-mail.loading_mail_profile_policy.b746a2e8": "Mailprofil-Richtlinie wird geladen...",
"i18n:govoplan-mail.loading_mail_profiles.87de3560": "Loading mail profiles...",
"i18n:govoplan-mail.loading_message.815c2094": "Loading message...",
"i18n:govoplan-mail.loading_messages.4294022c": "Loading messages",
"i18n:govoplan-mail.loading_messages.77b62232": "Loading messages...",
"i18n:govoplan-mail.loading_preview.ebd86225": "Loading preview...",
"i18n:govoplan-mail.loading.33ce4174": "Loading…",
"i18n:govoplan-mail.loading.b04ba49f": "Loading...",
"i18n:govoplan-mail.local_required.1f5f4aba": "Local required",
"i18n:govoplan-mail.local_setting.967607a9": "Local setting",
"i18n:govoplan-mail.local.dc99d54d": "Local",
"i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d": "Lower-level mail definitions",
"i18n:govoplan-mail.lower_levels.940821ee": "Lower levels",
"i18n:govoplan-mail.mail_actions.c08b5f08": "Mail actions",
"i18n:govoplan-mail.mail_profile_policy_saved.666847bf": "Mail profile policy saved.",
"i18n:govoplan-mail.mail_profile_policy.f2ac4b92": "Mail profile policy",
"i18n:govoplan-mail.mail_server_profiles.b1726682": "Mail server profiles",
"i18n:govoplan-mail.mail.92379cbb": "Mail",
"i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e": "Mailbox folders could not be loaded.",
"i18n:govoplan-mail.mailbox_folders.c92f6de4": "Mailbox folders",
"i18n:govoplan-mail.mailbox_message_pagination.965407bf": "Mailbox message pagination",
"i18n:govoplan-mail.mailbox_messages.5c06afaf": "Mailbox messages",
"i18n:govoplan-mail.message_preview.58de1450": "Nachrichtenvorschau",
"i18n:govoplan-mail.messages.f1702b46": "Nachrichten",
"i18n:govoplan-mail.name.709a2322": "Name",
"i18n:govoplan-mail.new_profile.ca36da25": "New profile",
"i18n:govoplan-mail.next_page.4bfc194b": "Nächste Seite",
"i18n:govoplan-mail.no_explicit_intersection.f2d62c71": "No explicit intersection",
"i18n:govoplan-mail.no_folder_loaded.889bdb1b": "No folder loaded",
"i18n:govoplan-mail.no_folders_available.14133b26": "No folders available.",
"i18n:govoplan-mail.no_host.4c710d7d": "No host",
"i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8": "No IMAP-enabled mail profiles.",
"i18n:govoplan-mail.no_imap_profile_selected.e7d1516f": "No IMAP profile selected",
"i18n:govoplan-mail.no_imap_profiles_available.d64589f8": "No IMAP profiles available",
"i18n:govoplan-mail.no_value_available": "Keine {value0} verfügbar.",
"i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39": "No local profile allow-list is set.",
"i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d": "No messages in this folder.",
"i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916": "No messages match the current filter on this page.",
"i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85": "No profiles are visible for this policy scope.",
"i18n:govoplan-mail.no_profiles_in_this_scope.302b21d8": "No profiles in this scope.",
"i18n:govoplan-mail.no_saved_password.32ce2b16": "No saved password",
"i18n:govoplan-mail.no_subject.49b20da0": "(no subject)",
"i18n:govoplan-mail.no_username.1c182624": "No username",
"i18n:govoplan-mail.not_configured.67f2141f": "not configured",
"i18n:govoplan-mail.not_configured.811931bb": "Nicht konfiguriert",
"i18n:govoplan-mail.on_page.ca7166f4": "on page",
"i18n:govoplan-mail.owner_policy.1e8df143": "Owner policy",
"i18n:govoplan-mail.page.fb06270f": "Seite",
"i18n:govoplan-mail.password_saved.f6fab237": "Password saved",
"i18n:govoplan-mail.password.8be3c943": "Passwort",
"i18n:govoplan-mail.policy_path.1ba91ee5": "Policy path",
"i18n:govoplan-mail.policy_target.a19dcee9": "Policy target",
"i18n:govoplan-mail.policy.bb9cf141": "Richtlinie",
"i18n:govoplan-mail.preview.f1fbb2b4": "Vorschau",
"i18n:govoplan-mail.previous_page.81f54719": "Vorherige Seite",
"i18n:govoplan-mail.profile_allow_list.507dfe6c": "Profile allow-list",
"i18n:govoplan-mail.profile_inherited.ea25d2e1": "Profile inherited",
"i18n:govoplan-mail.profile_s.742e9200": "profile(s).",
"i18n:govoplan-mail.profile_value_created.2a088d8d": "Profile {value0} created.",
"i18n:govoplan-mail.profile_value_deactivated.fa7fcc1a": "Profile {value0} deactivated.",
"i18n:govoplan-mail.profile_value_updated.fdbad0ea": "Profile {value0} updated.",
"i18n:govoplan-mail.profiles.0c2a9300": "Profiles",
"i18n:govoplan-mail.recipient_domain_patterns.68466f5b": "Recipient domain patterns.",
"i18n:govoplan-mail.recipient_domain.778f2dcf": "Recipient domain",
"i18n:govoplan-mail.recipient_domains.cb9b7b44": "Recipient domains",
"i18n:govoplan-mail.refresh_mailbox_folders.d9af9963": "Refresh mailbox folders",
"i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c": "Refresh messages in the current folder",
"i18n:govoplan-mail.reload_imap_profiles.b04c11c8": "Reload IMAP profiles",
"i18n:govoplan-mail.reload.cce71553": "Neu laden",
"i18n:govoplan-mail.require_local_credentials.da7b1d7c": "Require local credentials",
"i18n:govoplan-mail.rows_per_page.af2f9c1b": "Zeilen pro Seite",
"i18n:govoplan-mail.save_policy.77d67ce3": "Save policy",
"i18n:govoplan-mail.save_profile.f597c0e8": "Save profile",
"i18n:govoplan-mail.saved.c0ae8f6e": "Gespeichert",
"i18n:govoplan-mail.saving.56a2285c": "Saving…",
"i18n:govoplan-mail.search_messages.abea65ae": "Search messages",
"i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342": "Select a message to inspect its content.",
"i18n:govoplan-mail.select_an_imap_profile.5445648c": "Select an IMAP profile.",
"i18n:govoplan-mail.sent.35f49dcf": "Sent",
"i18n:govoplan-mail.server_credential.7fb9a24e": "Server / credential",
"i18n:govoplan-mail.size.b7152342": "Größe",
"i18n:govoplan-mail.slug.094da9b9": "Slug",
"i18n:govoplan-mail.smtp_credentials.f73ef315": "SMTP credentials",
"i18n:govoplan-mail.smtp_envelope_sender_patterns.8c1fd95e": "SMTP envelope sender patterns.",
"i18n:govoplan-mail.smtp_host.2d4a434b": "SMTP host",
"i18n:govoplan-mail.smtp_hostnames.36eb51d8": "SMTP hostnames",
"i18n:govoplan-mail.smtp_server_host_patterns.cf6120c3": "SMTP server host patterns.",
"i18n:govoplan-mail.smtp.efff9cca": "SMTP",
"i18n:govoplan-mail.status.bae7d5be": "Status",
"i18n:govoplan-mail.subject.8d183dbd": "Betreff",
"i18n:govoplan-mail.system_allow.ed6744b1": "System: Allow",
"i18n:govoplan-mail.system_profile_inherited.f4b8b5ce": "System: Profile inherited",
"i18n:govoplan-mail.system.bc0792d8": "System",
"i18n:govoplan-mail.target.61ad50a9": "Target",
"i18n:govoplan-mail.tenant.3ca93c78": "Tenant",
"i18n:govoplan-mail.test_imap.ef1bd79c": "Test IMAP",
"i18n:govoplan-mail.test_saved_imap.923dbe4a": "Test saved IMAP",
"i18n:govoplan-mail.test_saved_smtp.008d8054": "Test saved SMTP",
"i18n:govoplan-mail.test_smtp.e5697981": "Test SMTP",
"i18n:govoplan-mail.this_profile.5fd9cdf1": "this profile",
"i18n:govoplan-mail.to.ae79ea1e": "To",
"i18n:govoplan-mail.transport.c10d76c9": "Transport",
"i18n:govoplan-mail.trash.e3bf62bb": "Trash",
"i18n:govoplan-mail.user_profiles.57730285": "User profiles",
"i18n:govoplan-mail.user.9f8a2389": "User",
"i18n:govoplan-mail.users": "Benutzer",
"i18n:govoplan-mail.value_message": "{value0} message",
"i18n:govoplan-mail.value_messages": "{value0} messages",
"i18n:govoplan-mail.value_of_value_messages.981a1618": "{value0} of {value1} messages",
"i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44": "{value0} profile(s) allowed by this scope.",
"i18n:govoplan-mail.value_profile_s.d6b9d0af": "{value0} profile(s)",
"i18n:govoplan-mail.value_value.c189e8bc": "{value0} ({value1})",
"i18n:govoplan-mail.value.48afe802": "· {value0}",
"i18n:govoplan-mail.value.8dce170d": "Value",
"i18n:govoplan-mail.value_scope": "Geltungsbereich: {value0}",
"i18n:govoplan-mail.visible_from_header_patterns.ea77d99d": "Visible From header patterns.",
"i18n:govoplan-mail.whitelist_value.d4cc3755": "whitelist.{value0}",
"i18n:govoplan-mail.whitelist.53c2ad30": "Whitelist",
"i18n:govoplan-mail.wildcard_rules.54fb3fc0": "Wildcard rules",
"i18n:govoplan-mail.working.049ac820": "Working..."
}
};

View File

@@ -1,4 +1,3 @@
import "./styles/mail-profiles.css";
export { default } from "./module";
export * from "./module";
export * from "./api/mail";

View File

@@ -2,24 +2,41 @@ 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 mailboxRead = ["mail:mailbox:read"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const mailModule: PlatformWebModule = {
id: "mail",
label: "Mail",
label: "i18n:govoplan-mail.mail.92379cbb",
version: "1.0.0",
dependencies: ["access"],
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
optionalDependencies: ["addresses"],
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 }) }],
uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability,
"core.credentialReferenceSelectors": mailCredentialReferenceSelectors
},
runtimeUiCapabilities: {
"mail.devMailbox": { enabled: true, label: "Development mock mailbox" } satisfies MailDevMailboxUiCapability
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
}
};

View File

@@ -1,47 +1,8 @@
.admin-form-grid.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.button-icon-label {
display: inline-flex;
align-items: center;
gap: 7px;
}
.mail-profile-manager {
display: grid;
gap: 18px;
}
.mail-profile-target-row {
max-width: 520px;
}
.mail-profile-table-surface {
width: 100%;
max-width: 100%;
min-width: 0;
}
.mail-profile-table-surface > .data-grid-shell {
width: 100%;
max-width: 100%;
min-width: 0;
}
.card-body > .mail-profile-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
}
.card-body > .mail-profile-table-surface:only-child > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.mail-profile-dialog .dialog-body {
max-height: min(76vh, 820px);
overflow: auto;
@@ -52,8 +13,40 @@
gap: 18px;
}
.mail-profile-transport-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.mail-profile-transport-summary > div {
display: grid;
gap: 4px;
min-width: 0;
padding: 10px 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
.mail-profile-transport-summary span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.mail-profile-transport-summary strong,
.mail-profile-transport-summary small {
min-width: 0;
overflow-wrap: anywhere;
}
.mail-profile-transport-summary small {
color: var(--muted);
}
.mail-profile-form h3,
.mail-policy-section h3,
.mail-policy-pattern-grid h4 {
margin: 0;
color: var(--text-strong);
@@ -83,46 +76,6 @@
margin: 0;
}
.mail-policy-section {
display: grid;
gap: 12px;
padding-top: 4px;
}
.mail-profile-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.mail-profile-checkbox {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.mail-profile-checkbox input {
width: auto;
margin-top: 3px;
box-shadow: none;
}
.mail-profile-checkbox span {
display: grid;
gap: 2px;
min-width: 0;
}
.mail-profile-checkbox small {
color: var(--muted);
overflow-wrap: anywhere;
}
.mail-policy-row {
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr);
}
@@ -172,7 +125,7 @@
}
.mail-policy-effective {
border-top: 1px solid var(--line);
border-top: var(--border-line);
padding-top: 14px;
}
@@ -186,7 +139,7 @@
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
@@ -202,20 +155,10 @@
color: var(--text-strong);
}
.status-badge.success {
background: var(--success-bg);
color: var(--success-text);
}
.status-badge.neutral {
background: #e7e4df;
color: #666;
}
@media (max-width: 900px) {
.admin-form-grid.three-columns,
.mail-policy-pattern-grid,
.mail-policy-effective-grid,
.mail-profile-transport-summary,
.mail-policy-row,
.mail-policy-table.with-effective-column .mail-policy-row,
.mail-policy-table.with-allow-column .mail-policy-row,
@@ -292,7 +235,7 @@
gap: 8px;
align-items: center;
width: 100%;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
@@ -387,7 +330,7 @@
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
color: var(--text);
@@ -411,7 +354,7 @@
gap: 8px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
}
@@ -466,15 +409,6 @@
/* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */
.mailbox-page.file-manager-fullscreen {
position: relative;
display: grid;
grid-template-rows: 1fr;
height: calc(100vh - 115px);
padding: 0;
overflow: hidden;
}
.mailbox-shell.file-manager-shell {
position: relative;
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr);
@@ -526,7 +460,7 @@
flex: 0 0 auto;
min-width: 20px;
padding: 1px 6px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 999px;
background: var(--surface-subtle);
color: var(--text);
@@ -577,7 +511,7 @@
}
.mailbox-message-list-panel {
border-right: 1px solid var(--line);
border-right: var(--border-line);
}
.mailbox-message-table {
@@ -599,7 +533,7 @@
width: min(430px, 100%);
min-height: 34px;
padding: 0 8px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
@@ -613,27 +547,8 @@
padding: 6px 0;
}
.mailbox-search-field button {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--radius-xs, 5px);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.mailbox-search-field button:hover,
.mailbox-search-field button:focus-visible {
background: var(--surface-subtle);
color: var(--text-strong);
}
.mailbox-error-state {
color: var(--danger-text, #8f2e2e);
color: var(--danger-text);
}
.mailbox-pagination {
@@ -703,7 +618,7 @@
.mailbox-preview-panel {
grid-column: 1 / -1;
min-height: 320px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
}

View File

@@ -0,0 +1,89 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
}
import {
mailProfileChildDescriptors,
mailProfileEditTargetInitialSection,
mailProfileEditTargetPanelMode,
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
mailProfileEditTargetVisibleSections,
mailProfileCreateCredentialsPayload,
mailProfileTargetedUpdatePayload
} from "../src/features/mail/mailProfileEditorModel";
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
assertDeepEqual(
smtpOnlyChildren.map((child) => `${child.kind}:${child.protocol}`),
["server:smtp", "credential:smtp", "server:imap"],
"SMTP-only profiles expose SMTP server/credentials and an addable IMAP server"
);
assert(!smtpOnlyChildren.some((child) => child.kind === "credential" && child.protocol === "imap"), "IMAP credentials are hidden until an IMAP server exists");
const fullChildren = mailProfileChildDescriptors({ id: "profile-1", imap: { host: "imap.example.org" } });
assertDeepEqual(
fullChildren.map((child) => `${child.kind}:${child.protocol}`),
["server:smtp", "credential:smtp", "server:imap", "credential:imap"],
"profiles with IMAP expose focused rows for both transports and credentials"
);
assertEqual(mailProfileEditTargetInitialSection({ kind: "server", protocol: "imap" }), "imap");
assertEqual(mailProfileEditTargetPanelMode({ kind: "server", protocol: "smtp" }), "server");
assertEqual(mailProfileEditTargetPanelMode({ kind: "credentials", protocol: "imap" }), "credentials");
assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "create" }), ["smtp", "imap"]);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["imap"]);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);
const updateParts = {
profile: { name: "Renamed" },
smtp: { host: "smtp.example.org" },
imap: { host: "imap.example.org" },
credentials: {
smtp: { username: "smtp-user", password: "smtp-secret" },
imap: { username: "imap-user", password: "imap-secret" }
},
clearImap: false
};
assertDeepEqual(
mailProfileTargetedUpdatePayload({ kind: "profile" }, updateParts),
{ name: "Renamed" },
"profile edits do not replay transport or credential fields"
);
assertDeepEqual(
mailProfileTargetedUpdatePayload({ kind: "server", protocol: "smtp" }, updateParts),
{ smtp: { host: "smtp.example.org" } },
"server edits do not replay credential fields"
);
assertDeepEqual(
mailProfileTargetedUpdatePayload({ kind: "credentials", protocol: "imap" }, updateParts),
{ credentials: { imap: { username: "imap-user", password: "imap-secret" } } },
"credential edits send only the selected protocol"
);
assertEqual(
mailProfileCreateCredentialsPayload({ username: null }, { password: "" }),
undefined,
"credential-free creates omit the credential object"
);
assertDeepEqual(
mailProfileCreateCredentialsPayload(
{ username: "smtp-user", password: null },
{ username: null, password: "imap-secret" }
),
{ smtp: { username: "smtp-user" }, imap: { password: "imap-secret" } },
"create payloads retain only explicitly populated credential fields"
);

View File

@@ -18,8 +18,10 @@
},
"include": [
"tests/mailbox-folders.test.ts",
"tests/mail-profile-editor-model.test.ts",
"tests/mail-policy-validation.test.ts",
"src/features/mail/mailboxFolders.ts",
"src/features/mail/mailProfileEditorModel.ts",
"src/features/mail/mailPolicyValidation.ts"
]
}