Compare commits
6 Commits
a86a779d5b
...
e6a3617a94
| Author | SHA1 | Date | |
|---|---|---|---|
| e6a3617a94 | |||
| 06e0fbd3c3 | |||
| 9d1d9bfb58 | |||
| 0f1327638e | |||
| 4a9201aeea | |||
| 88d5012dea |
43
README.md
43
README.md
@@ -19,9 +19,41 @@ This repository owns:
|
|||||||
|
|
||||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
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/add-ideas/govoplan-mail/issues/17), is a Mail-owned durable outbox and transport-attempt store with
|
||||||
|
restricted diagnostic access, retention controls, and correlation identifiers.
|
||||||
|
Until that exists, Campaign retains only sanitized delivery evidence; raw
|
||||||
|
provider diagnostics must not be copied into consumer records.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -54,8 +86,9 @@ Frontend package:
|
|||||||
@govoplan/mail-webui
|
@govoplan/mail-webui
|
||||||
```
|
```
|
||||||
|
|
||||||
The campaign module consumes `mail.campaign_delivery` for sending,
|
The campaign module consumes `mail.campaign_delivery` for authorized runtime
|
||||||
append-to-Sent behavior, profile selection, and policy checks. Mail does not
|
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
|
import campaign internals; campaign-scoped policy and owner context are resolved
|
||||||
through the core `campaigns.mailPolicyContext` capability when the campaign
|
through the core `campaigns.mailPolicyContext` capability when the campaign
|
||||||
module is installed.
|
module is installed.
|
||||||
@@ -70,6 +103,8 @@ SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3
|
|||||||
only for explicit legacy-download requirements.
|
only for explicit legacy-download requirements.
|
||||||
|
|
||||||
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
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
|
## Release packaging
|
||||||
|
|
||||||
|
|||||||
379
docs/MAIL_HANDBOOK.md
Normal file
379
docs/MAIL_HANDBOOK.md
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
# 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/add-ideas/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.
|
||||||
|
|
||||||
|
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 administrator:** additionally create/update profiles and
|
||||||
|
create/replace encrypted credentials.
|
||||||
|
|
||||||
|
The specific permissions are `mail:profile:read`, `mail:profile:use`,
|
||||||
|
`mail:profile:test`, `mail:mailbox:read`, `mail:profile:write`, and
|
||||||
|
`mail:secret:manage`. 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.
|
||||||
|
|
||||||
|
### Create or change a profile
|
||||||
|
|
||||||
|
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/add-ideas/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/add-ideas/govoplan-mail/issues/17)).
|
||||||
|
- Canonical audit events for profile tests and the remaining profile/policy
|
||||||
|
administration lifecycle, plus an operator-visible Redis-throttling
|
||||||
|
degradation signal.
|
||||||
|
- 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-mail"
|
name = "govoplan-mail"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -1,22 +1,299 @@
|
|||||||
from __future__ import annotations
|
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_core.core.modules import ModuleContext
|
||||||
from govoplan_mail.backend.runtime import configure_runtime
|
|
||||||
from govoplan_mail.backend.mail_profiles import (
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
MailProfileError,
|
MailProfileError,
|
||||||
apply_campaign_credentials,
|
_assert_campaign_inherits_profile_credentials,
|
||||||
assert_campaign_mail_policy_allows_json,
|
assert_campaign_mail_policy_allows_json,
|
||||||
assert_mail_policy_allows_send,
|
assert_mail_policy_allows_send,
|
||||||
effective_profile_credentials_inherited,
|
campaign_profile_transport_revisions,
|
||||||
|
effective_mail_profile_policy,
|
||||||
ensure_mail_profile_allowed_for_campaign,
|
ensure_mail_profile_allowed_for_campaign,
|
||||||
|
get_mail_server_profile,
|
||||||
imap_config_from_profile,
|
imap_config_from_profile,
|
||||||
mail_profile_id_from_campaign_json,
|
mail_profile_id_from_campaign_json,
|
||||||
materialize_campaign_mail_profile_config,
|
|
||||||
smtp_config_from_profile,
|
smtp_config_from_profile,
|
||||||
)
|
)
|
||||||
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
|
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.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,
|
||||||
|
):
|
||||||
|
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)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return only non-secret capabilities and opaque drift evidence."""
|
||||||
|
|
||||||
|
if campaign_id:
|
||||||
|
profile = _authorized_campaign_profile(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert_campaign_mail_policy_allows_json(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
raw_json={"server": {"mail_profile_id": profile_id}},
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
revisions = campaign_profile_transport_revisions(profile)
|
||||||
|
smtp = profile.smtp_config or {}
|
||||||
|
imap = profile.imap_config or {}
|
||||||
|
return {
|
||||||
|
"mail_profile_id": profile_id,
|
||||||
|
"smtp_available": bool(smtp.get("host") and smtp.get("port")),
|
||||||
|
"imap_available": bool(imap.get("host") and imap.get("port")),
|
||||||
|
"smtp_transport_revision": revisions["smtp"],
|
||||||
|
"imap_transport_revision": revisions["imap"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> CampaignSmtpDeliveryResult:
|
||||||
|
try:
|
||||||
|
profile = _authorized_campaign_profile(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
)
|
||||||
|
except MailProfileError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||||
|
revisions = campaign_profile_transport_revisions(profile)
|
||||||
|
if revisions["smtp"] != expected_smtp_transport_revision:
|
||||||
|
raise MailProfileError(
|
||||||
|
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||||
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
smtp = smtp_config_from_profile(profile)
|
||||||
|
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=profile.imap_config or 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,
|
||||||
|
) -> CampaignImapAppendResult:
|
||||||
|
try:
|
||||||
|
profile = _authorized_campaign_profile(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
)
|
||||||
|
except MailProfileError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
|
||||||
|
revisions = campaign_profile_transport_revisions(profile)
|
||||||
|
if revisions["smtp"] != expected_smtp_transport_revision:
|
||||||
|
raise MailProfileError(
|
||||||
|
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||||
|
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
||||||
|
)
|
||||||
|
if revisions["imap"] != expected_imap_transport_revision:
|
||||||
|
raise MailProfileError(
|
||||||
|
"The selected Mail profile's IMAP settings changed after this campaign was built. "
|
||||||
|
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
imap = imap_config_from_profile(profile)
|
||||||
|
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=profile.smtp_config or 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:
|
class MailCampaignCapability:
|
||||||
@@ -25,19 +302,12 @@ class MailCampaignCapability:
|
|||||||
SmtpSendError = SmtpSendError
|
SmtpSendError = SmtpSendError
|
||||||
ImapConfigurationError = ImapConfigurationError
|
ImapConfigurationError = ImapConfigurationError
|
||||||
ImapAppendError = ImapAppendError
|
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_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)
|
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)
|
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
|
||||||
smtp_config_from_profile = staticmethod(smtp_config_from_profile)
|
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
|
||||||
imap_config_from_profile = staticmethod(imap_config_from_profile)
|
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
||||||
effective_profile_credentials_inherited = staticmethod(effective_profile_credentials_inherited)
|
|
||||||
apply_campaign_credentials = staticmethod(apply_campaign_credentials)
|
|
||||||
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
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
|
@staticmethod
|
||||||
def mock_mailbox():
|
def mock_mailbox():
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ class MailServerProfile(Base, TimestampMixin):
|
|||||||
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, 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_username: Mapped[str | None] = mapped_column(String(320))
|
||||||
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
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_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
imap_username: Mapped[str | None] = mapped_column(String(320))
|
imap_username: Mapped[str | None] = mapped_column(String(320))
|
||||||
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||||
|
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)
|
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)
|
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
|
|||||||
@@ -124,11 +124,11 @@ def _mail_policy_user_text(policy: dict[str, Any]) -> tuple[str, str]:
|
|||||||
summary = "You can choose from approved mail servers, but you cannot add your own mail server for this tenant."
|
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."
|
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:
|
elif approved_profile_limit:
|
||||||
summary = "You can use approved mail servers. Some local mail-server settings may also be allowed, depending on where you work."
|
summary = "You can use approved Mail profiles. Some scopes may also define additional reusable profiles."
|
||||||
body = "The available choices are limited by tenant policy. If you are working in a user, group, or campaign area that allows local settings, GovOPlaN still checks the server, sender, recipient, and credential rules."
|
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:
|
elif lower_scopes:
|
||||||
summary = "You may be able to add a mail server in selected areas, as long as it follows the active tenant rules."
|
summary = "You may be able to define reusable Mail profiles in selected scopes, subject to tenant rules."
|
||||||
body = "GovOPlaN checks mail server settings before they are used. If a setting is blocked, it usually means the tenant has limited hosts, senders, recipients, or credentials."
|
body = "GovOPlaN checks each profile before use. Campaigns reference an available profile and never store its SMTP/IMAP settings or credentials."
|
||||||
else:
|
else:
|
||||||
summary = "Mail servers are managed centrally for this tenant."
|
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."
|
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."
|
||||||
@@ -151,16 +151,16 @@ def _user_mail_policy_translations(policy: dict[str, Any]) -> dict[str, dict[str
|
|||||||
return {
|
return {
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Mailserver auswaehlen",
|
"title": "Mailserver auswaehlen",
|
||||||
"summary": "Sie koennen freigegebene Mailserver nutzen. In manchen Bereichen koennen zusaetzliche lokale Einstellungen erlaubt sein.",
|
"summary": "Sie koennen freigegebene Mailprofile nutzen. In manchen Bereichen koennen weitere wiederverwendbare Profile angelegt werden.",
|
||||||
"body": "Die Auswahl wird durch Tenant-Regeln begrenzt. Auch wenn lokale Einstellungen erlaubt sind, prueft GovOPlaN Server, Absender, Empfaenger und Zugangsdaten.",
|
"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:
|
if lower_scopes:
|
||||||
return {
|
return {
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Mailserver auswaehlen",
|
"title": "Mailserver auswaehlen",
|
||||||
"summary": "In bestimmten Bereichen koennen Sie einen Mailserver eintragen, solange die aktiven Tenant-Regeln eingehalten werden.",
|
"summary": "In bestimmten Bereichen koennen Sie wiederverwendbare Mailprofile anlegen, solange die aktiven Tenant-Regeln eingehalten werden.",
|
||||||
"body": "GovOPlaN prueft Mailserver-Einstellungen, bevor sie verwendet werden. Wenn eine Einstellung blockiert wird, begrenzen die Tenant-Regeln meist Server, Absender, Empfaenger oder Zugangsdaten.",
|
"body": "GovOPlaN prueft jedes Profil vor der Verwendung. Kampagnen referenzieren ein freigegebenes Profil und speichern keine SMTP-/IMAP-Einstellungen oder Zugangsdaten.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -190,7 +190,11 @@ def _lower_scope_line(lower_scopes: tuple[str, ...]) -> str:
|
|||||||
def _credential_line(policy: dict[str, Any]) -> str:
|
def _credential_line(policy: dict[str, Any]) -> str:
|
||||||
smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True))
|
smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True))
|
||||||
imap_inherit = bool((policy.get("imap_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'}; IMAP {'inherits' if imap_inherit else 'requires local credentials'}."
|
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, ...]:
|
def _allowed_lower_scopes(policy: dict[str, Any]) -> tuple[str, ...]:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -11,16 +10,20 @@ from sqlalchemy import and_, or_, text
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.admin.settings import get_system_settings
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
||||||
from govoplan_core.core.campaigns import (
|
from govoplan_core.core.campaigns import (
|
||||||
|
CAPABILITY_CAMPAIGNS_ACCESS,
|
||||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||||
|
CampaignAccessProvider,
|
||||||
CampaignMailPolicyContext,
|
CampaignMailPolicyContext,
|
||||||
CampaignMailPolicyContextProvider,
|
CampaignMailPolicyContextProvider,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.policy import policy_source_step
|
from govoplan_core.core.policy import policy_source_step
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile
|
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile, new_uuid
|
||||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||||
|
from govoplan_mail.backend.mailbox_index import clear_mailbox_index
|
||||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
|
||||||
@@ -270,6 +273,16 @@ def _campaign_policy_provider() -> CampaignMailPolicyContextProvider | None:
|
|||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_access_provider() -> CampaignAccessProvider | None:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
|
||||||
|
if not isinstance(capability, CampaignAccessProvider):
|
||||||
|
raise MailProfileError("Campaign access capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
def _campaign_policy_context(session: Session, *, tenant_id: str, campaign_id: str) -> CampaignMailPolicyContext:
|
def _campaign_policy_context(session: Session, *, tenant_id: str, campaign_id: str) -> CampaignMailPolicyContext:
|
||||||
provider = _campaign_policy_provider()
|
provider = _campaign_policy_provider()
|
||||||
if provider is None:
|
if provider is None:
|
||||||
@@ -855,10 +868,11 @@ def _profile_allowed_for_context(
|
|||||||
campaign: CampaignMailPolicyContext | None = None,
|
campaign: CampaignMailPolicyContext | None = None,
|
||||||
owner_user_id: str | None = None,
|
owner_user_id: str | None = None,
|
||||||
owner_group_id: str | None = None,
|
owner_group_id: str | None = None,
|
||||||
|
require_active: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
scope_type = _profile_scope_type(profile)
|
scope_type = _profile_scope_type(profile)
|
||||||
scope_id = _profile_scope_id(profile)
|
scope_id = _profile_scope_id(profile)
|
||||||
if not profile.is_active:
|
if require_active and not profile.is_active:
|
||||||
return False
|
return False
|
||||||
if not policy.profile_id_allowed(profile.id):
|
if not policy.profile_id_allowed(profile.id):
|
||||||
return False
|
return False
|
||||||
@@ -881,15 +895,229 @@ def _profile_allowed_for_context(
|
|||||||
return _profile_transport_allowed(profile, policy)
|
return _profile_transport_allowed(profile, policy)
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_mail_context_visible_to_actor(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
user_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
tenant_admin: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
provider = _campaign_access_provider()
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
provider.can_read_campaign(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
user_id=user_id,
|
||||||
|
group_ids=group_ids,
|
||||||
|
tenant_admin=tenant_admin,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mail_profile_scope_visible_to_actor(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
profile_tenant_id: str | None,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
can_manage_tenant_profiles: bool = False,
|
||||||
|
tenant_admin: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Apply stable ownership/ACL discovery without activity or policy state."""
|
||||||
|
|
||||||
|
clean_scope_type = _normalize_scope_type(scope_type)
|
||||||
|
if clean_scope_type == "system":
|
||||||
|
return True
|
||||||
|
if profile_tenant_id != tenant_id:
|
||||||
|
return False
|
||||||
|
if clean_scope_type == "tenant":
|
||||||
|
return True
|
||||||
|
if clean_scope_type == "user":
|
||||||
|
return bool(can_manage_tenant_profiles or (scope_id and scope_id == user_id))
|
||||||
|
if clean_scope_type == "group":
|
||||||
|
return bool(
|
||||||
|
can_manage_tenant_profiles
|
||||||
|
or (scope_id and scope_id in {str(group_id) for group_id in group_ids})
|
||||||
|
)
|
||||||
|
if clean_scope_type != "campaign" or not scope_id:
|
||||||
|
return False
|
||||||
|
# Mail administration is not a Campaign ACL override. Only Campaign's own
|
||||||
|
# access capability decides this, including its exact tenant-admin rule.
|
||||||
|
return campaign_mail_context_visible_to_actor(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=scope_id,
|
||||||
|
user_id=user_id,
|
||||||
|
group_ids=group_ids,
|
||||||
|
tenant_admin=tenant_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mail_profile_visible_to_actor(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
profile: MailServerProfile,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
can_manage_tenant_profiles: bool = False,
|
||||||
|
can_manage_system_profiles: bool = False,
|
||||||
|
tenant_admin: bool = False,
|
||||||
|
administrative_visibility: bool = False,
|
||||||
|
require_active: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether an actor may discover or directly use a profile.
|
||||||
|
|
||||||
|
System and tenant profiles are shared definitions. Narrower profiles are
|
||||||
|
visible only to their owner context, unless the actor is a tenant-wide Mail
|
||||||
|
profile administrator. Campaign visibility is delegated to Campaign's ACL
|
||||||
|
capability so Mail never guesses another module's object permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_group_ids = tuple(sorted({str(group_id) for group_id in group_ids}))
|
||||||
|
scope_type = _profile_scope_type(profile)
|
||||||
|
scope_id = _profile_scope_id(profile)
|
||||||
|
if not mail_profile_scope_visible_to_actor(
|
||||||
|
session,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
profile_tenant_id=profile.tenant_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
group_ids=normalized_group_ids,
|
||||||
|
can_manage_tenant_profiles=(
|
||||||
|
administrative_visibility and can_manage_tenant_profiles
|
||||||
|
),
|
||||||
|
tenant_admin=tenant_admin,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if administrative_visibility and scope_type == "system" and can_manage_system_profiles:
|
||||||
|
return True
|
||||||
|
if administrative_visibility and scope_type != "system" and can_manage_tenant_profiles:
|
||||||
|
return True
|
||||||
|
if scope_type in {"system", "tenant"}:
|
||||||
|
return _profile_allowed_for_actor_policy(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
group_ids=normalized_group_ids,
|
||||||
|
require_active=require_active,
|
||||||
|
)
|
||||||
|
if scope_type == "user":
|
||||||
|
return _profile_allowed_for_actor_policy(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
group_ids=(),
|
||||||
|
require_active=require_active,
|
||||||
|
)
|
||||||
|
if scope_type == "group":
|
||||||
|
return _profile_allowed_for_actor_policy(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
group_ids=(scope_id,),
|
||||||
|
require_active=require_active,
|
||||||
|
)
|
||||||
|
if scope_type != "campaign" or not scope_id:
|
||||||
|
return False
|
||||||
|
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
|
||||||
|
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id)
|
||||||
|
return _profile_allowed_for_context(
|
||||||
|
profile,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
policy=policy,
|
||||||
|
campaign=campaign,
|
||||||
|
owner_user_id=campaign.owner_user_id,
|
||||||
|
owner_group_id=campaign.owner_group_id,
|
||||||
|
require_active=require_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_allowed_for_actor_policy(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
profile: MailServerProfile,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
group_ids: Iterable[str],
|
||||||
|
require_active: bool = True,
|
||||||
|
) -> bool:
|
||||||
|
"""Apply every unambiguous actor policy context conservatively.
|
||||||
|
|
||||||
|
Direct Mail screens have no selected task/group context. The actor's user
|
||||||
|
policy always applies; for shared system/tenant profiles each group policy
|
||||||
|
also has to allow the profile. A caller that needs campaign-specific policy
|
||||||
|
must use the explicit campaign context path instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_group_ids = tuple(sorted({str(group_id) for group_id in group_ids}))
|
||||||
|
scope_type = _profile_scope_type(profile)
|
||||||
|
if scope_type == "group":
|
||||||
|
contexts: tuple[str | None, ...] = normalized_group_ids
|
||||||
|
elif scope_type == "user":
|
||||||
|
contexts = (None,)
|
||||||
|
else:
|
||||||
|
contexts = (None, *normalized_group_ids)
|
||||||
|
if not contexts:
|
||||||
|
return False
|
||||||
|
for group_id in contexts:
|
||||||
|
policy = effective_mail_profile_policy(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
owner_user_id=user_id,
|
||||||
|
owner_group_id=group_id,
|
||||||
|
)
|
||||||
|
if not _profile_allowed_for_context(
|
||||||
|
profile,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
policy=policy,
|
||||||
|
owner_user_id=user_id,
|
||||||
|
owner_group_id=group_id,
|
||||||
|
require_active=require_active,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def list_mail_server_profiles(
|
def list_mail_server_profiles(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
include_inactive: bool = False,
|
include_inactive: bool = False,
|
||||||
campaign_id: str | None = None,
|
campaign_id: str | None = None,
|
||||||
|
actor_user_id: str | None = None,
|
||||||
|
actor_group_ids: Iterable[str] = (),
|
||||||
|
actor_can_manage_tenant_profiles: bool = False,
|
||||||
|
actor_can_manage_system_profiles: bool = False,
|
||||||
|
actor_tenant_admin: bool = False,
|
||||||
|
actor_administrative_visibility: bool = False,
|
||||||
) -> list[MailServerProfile]:
|
) -> list[MailServerProfile]:
|
||||||
|
normalized_actor_group_ids = tuple(
|
||||||
|
sorted({str(group_id) for group_id in actor_group_ids})
|
||||||
|
)
|
||||||
if campaign_id:
|
if campaign_id:
|
||||||
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||||
|
if actor_user_id and not campaign_mail_context_visible_to_actor(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
user_id=actor_user_id,
|
||||||
|
group_ids=normalized_actor_group_ids,
|
||||||
|
tenant_admin=actor_tenant_admin,
|
||||||
|
):
|
||||||
|
raise MailProfileError("Campaign not found for mail-server profile policy")
|
||||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
|
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
|
||||||
query = session.query(MailServerProfile).filter(or_(*_context_profile_clauses(campaign, policy)))
|
query = session.query(MailServerProfile).filter(or_(*_context_profile_clauses(campaign, policy)))
|
||||||
if not include_inactive:
|
if not include_inactive:
|
||||||
@@ -905,6 +1133,7 @@ def list_mail_server_profiles(
|
|||||||
campaign=campaign,
|
campaign=campaign,
|
||||||
owner_user_id=campaign.owner_user_id,
|
owner_user_id=campaign.owner_user_id,
|
||||||
owner_group_id=campaign.owner_group_id,
|
owner_group_id=campaign.owner_group_id,
|
||||||
|
require_active=not include_inactive,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
@@ -912,9 +1141,63 @@ def list_mail_server_profiles(
|
|||||||
if not include_inactive:
|
if not include_inactive:
|
||||||
query = query.filter(MailServerProfile.is_active.is_(True))
|
query = query.filter(MailServerProfile.is_active.is_(True))
|
||||||
profiles = query.all()
|
profiles = query.all()
|
||||||
|
if actor_user_id:
|
||||||
|
profiles = [
|
||||||
|
profile
|
||||||
|
for profile in profiles
|
||||||
|
if mail_profile_visible_to_actor(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=actor_user_id,
|
||||||
|
group_ids=normalized_actor_group_ids,
|
||||||
|
can_manage_tenant_profiles=actor_can_manage_tenant_profiles,
|
||||||
|
can_manage_system_profiles=actor_can_manage_system_profiles,
|
||||||
|
tenant_admin=actor_tenant_admin,
|
||||||
|
administrative_visibility=actor_administrative_visibility,
|
||||||
|
require_active=not include_inactive,
|
||||||
|
)
|
||||||
|
]
|
||||||
return sorted(profiles, key=lambda item: (PROFILE_SCOPE_ORDER.get(_profile_scope_type(item), 99), (item.name or "").casefold()))
|
return sorted(profiles, key=lambda item: (PROFILE_SCOPE_ORDER.get(_profile_scope_type(item), 99), (item.name or "").casefold()))
|
||||||
|
|
||||||
|
|
||||||
|
def get_mail_server_profile_for_actor(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
profile_id: str,
|
||||||
|
user_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
can_manage_tenant_profiles: bool = False,
|
||||||
|
can_manage_system_profiles: bool = False,
|
||||||
|
tenant_admin: bool = False,
|
||||||
|
administrative_visibility: bool = False,
|
||||||
|
require_active: bool = False,
|
||||||
|
) -> MailServerProfile:
|
||||||
|
profile = get_mail_server_profile(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
require_active=require_active,
|
||||||
|
)
|
||||||
|
if not mail_profile_visible_to_actor(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
group_ids=group_ids,
|
||||||
|
can_manage_tenant_profiles=can_manage_tenant_profiles,
|
||||||
|
can_manage_system_profiles=can_manage_system_profiles,
|
||||||
|
tenant_admin=tenant_admin,
|
||||||
|
administrative_visibility=administrative_visibility,
|
||||||
|
require_active=require_active,
|
||||||
|
):
|
||||||
|
# Deliberately indistinguishable from an absent profile to prevent id
|
||||||
|
# probing across user, group, and campaign scopes.
|
||||||
|
raise MailProfileError("Mail-server profile not found")
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
def get_mail_server_profile(
|
def get_mail_server_profile(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -948,6 +1231,7 @@ def ensure_mail_profile_allowed_for_campaign(
|
|||||||
campaign=campaign,
|
campaign=campaign,
|
||||||
owner_user_id=campaign.owner_user_id,
|
owner_user_id=campaign.owner_user_id,
|
||||||
owner_group_id=campaign.owner_group_id,
|
owner_group_id=campaign.owner_group_id,
|
||||||
|
require_active=require_active,
|
||||||
):
|
):
|
||||||
raise MailProfileError("Mail-server profile is not allowed for this campaign owner or policy")
|
raise MailProfileError("Mail-server profile is not allowed for this campaign owner or policy")
|
||||||
return profile
|
return profile
|
||||||
@@ -961,89 +1245,44 @@ def _credential_policy_for_protocol(policy: EffectiveMailProfilePolicy, protocol
|
|||||||
raise MailProfileError("Credential policy protocol must be smtp or imap")
|
raise MailProfileError("Credential policy protocol must be smtp or imap")
|
||||||
|
|
||||||
|
|
||||||
def _legacy_profile_credentials_inherited(server: dict[str, Any], protocol: str) -> bool:
|
|
||||||
return server.get(f"inherit_{protocol}_credentials") is not False
|
|
||||||
|
|
||||||
|
|
||||||
def _local_credential_inheritance_override_allowed(policy: EffectiveMailProfilePolicy, protocol: str) -> bool:
|
|
||||||
credential_policy = _credential_policy_for_protocol(policy, protocol)
|
|
||||||
return (
|
|
||||||
credential_policy.inherit_source != "campaign"
|
|
||||||
and policy.allow_lower_level_limits.get(f"{protocol}_credentials.inherit", True)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def profile_credentials_inherited_for_server(policy: EffectiveMailProfilePolicy, server: dict[str, Any], protocol: str) -> bool:
|
|
||||||
credential_policy = _credential_policy_for_protocol(policy, protocol)
|
|
||||||
legacy_key = f"inherit_{protocol}_credentials"
|
|
||||||
if legacy_key in server and _local_credential_inheritance_override_allowed(policy, protocol):
|
|
||||||
return _legacy_profile_credentials_inherited(server, protocol)
|
|
||||||
return credential_policy.inherit
|
|
||||||
|
|
||||||
|
|
||||||
def effective_profile_credentials_inherited(
|
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
server: dict[str, Any],
|
|
||||||
protocol: str,
|
|
||||||
campaign_id: str | None = None,
|
|
||||||
owner_user_id: str | None = None,
|
|
||||||
owner_group_id: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
policy = effective_mail_profile_policy(
|
|
||||||
session,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
campaign_id=campaign_id,
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
owner_group_id=owner_group_id,
|
|
||||||
)
|
|
||||||
return profile_credentials_inherited_for_server(policy, server, protocol)
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_has_password(profile: MailServerProfile, protocol: str) -> bool:
|
|
||||||
if protocol == "smtp":
|
|
||||||
return bool(profile.smtp_password_encrypted)
|
|
||||||
if protocol == "imap":
|
|
||||||
return bool(profile.imap_password_encrypted)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool:
|
def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool:
|
||||||
if protocol == "smtp":
|
if protocol == "smtp":
|
||||||
return True
|
return True
|
||||||
return bool(profile.imap_config)
|
return bool(profile.imap_config)
|
||||||
|
|
||||||
|
|
||||||
def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, policy: EffectiveMailProfilePolicy, server: dict[str, Any]) -> None:
|
_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset({"mail_profile_id"})
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
|
||||||
|
unexpected = sorted(key for key in server if key not in _CAMPAIGN_MAIL_REFERENCE_KEYS)
|
||||||
|
if unexpected:
|
||||||
|
paths = ", ".join(f"server.{key}" for key in unexpected)
|
||||||
|
raise MailProfileError(
|
||||||
|
"Campaign JSON may only reference a Mail-owned profile through server.mail_profile_id; "
|
||||||
|
f"remove campaign-local SMTP/IMAP settings ({paths}), select a Mail profile, and save a new campaign version."
|
||||||
|
)
|
||||||
|
value = server.get("mail_profile_id")
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise MailProfileError("server.mail_profile_id must be a non-empty Mail profile identifier")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_campaign_inherits_profile_credentials(
|
||||||
|
profile: MailServerProfile,
|
||||||
|
policy: EffectiveMailProfilePolicy,
|
||||||
|
) -> None:
|
||||||
for protocol in ("smtp", "imap"):
|
for protocol in ("smtp", "imap"):
|
||||||
if not _profile_has_transport(profile, protocol):
|
if not _profile_has_transport(profile, protocol):
|
||||||
continue
|
continue
|
||||||
credential_policy = _credential_policy_for_protocol(policy, protocol)
|
if not _credential_policy_for_protocol(policy, protocol).inherit:
|
||||||
legacy_key = f"inherit_{protocol}_credentials"
|
raise MailProfileError(
|
||||||
if legacy_key in server:
|
f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} "
|
||||||
legacy_inherit = _legacy_profile_credentials_inherited(server, protocol)
|
"credential policy requires campaign-local credentials. Store the credentials on a Mail profile "
|
||||||
if not _local_credential_inheritance_override_allowed(policy, protocol) and legacy_inherit != credential_policy.inherit:
|
"and change the policy to inherit them."
|
||||||
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by the effective mail profile policy")
|
)
|
||||||
inherited = profile_credentials_inherited_for_server(policy, server, protocol)
|
|
||||||
username, password = _transport_credentials_from_server(server, protocol)
|
|
||||||
if inherited and (username or password):
|
|
||||||
raise MailProfileError(f"{protocol.upper()} credentials must not be stored on the campaign while inherited profile credentials are enforced")
|
|
||||||
if not inherited and _profile_has_password(profile, protocol) and not password:
|
|
||||||
raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
|
|
||||||
|
|
||||||
|
|
||||||
def _inline_transport_configured(server: dict[str, Any], protocol: str) -> bool:
|
|
||||||
value = server.get(protocol) if isinstance(server.get(protocol), dict) else {}
|
|
||||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
|
||||||
credential_value = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
|
|
||||||
for key in ("host",):
|
|
||||||
if str(value.get(key) or "").strip():
|
|
||||||
return True
|
|
||||||
for key in ("username", "password"):
|
|
||||||
if str(credential_value.get(key) or value.get(key) or "").strip():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def assert_campaign_mail_policy_allows_json(
|
def assert_campaign_mail_policy_allows_json(
|
||||||
@@ -1057,7 +1296,7 @@ def assert_campaign_mail_policy_allows_json(
|
|||||||
) -> None:
|
) -> None:
|
||||||
data = raw_json if isinstance(raw_json, dict) else {}
|
data = raw_json if isinstance(raw_json, dict) else {}
|
||||||
server = data.get("server") if isinstance(data.get("server"), dict) else {}
|
server = data.get("server") if isinstance(data.get("server"), dict) else {}
|
||||||
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
|
profile_id = _campaign_mail_profile_reference_id(server)
|
||||||
if profile_id:
|
if profile_id:
|
||||||
if campaign_id:
|
if campaign_id:
|
||||||
profile = ensure_mail_profile_allowed_for_campaign(
|
profile = ensure_mail_profile_allowed_for_campaign(
|
||||||
@@ -1068,7 +1307,7 @@ def assert_campaign_mail_policy_allows_json(
|
|||||||
require_active=True,
|
require_active=True,
|
||||||
)
|
)
|
||||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||||
_assert_profile_credentials_allowed_for_server(profile, policy, server)
|
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||||
return
|
return
|
||||||
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
|
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
|
||||||
policy = effective_mail_profile_policy(
|
policy = effective_mail_profile_policy(
|
||||||
@@ -1085,25 +1324,9 @@ def assert_campaign_mail_policy_allows_json(
|
|||||||
owner_group_id=owner_group_id,
|
owner_group_id=owner_group_id,
|
||||||
):
|
):
|
||||||
raise MailProfileError("Mail-server profile is not allowed by the effective policy")
|
raise MailProfileError("Mail-server profile is not allowed by the effective policy")
|
||||||
_assert_profile_credentials_allowed_for_server(profile, policy, server)
|
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||||
return
|
return
|
||||||
smtp = server.get("smtp") if isinstance(server, dict) and isinstance(server.get("smtp"), dict) else None
|
return
|
||||||
imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
|
|
||||||
if campaign_id:
|
|
||||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
|
||||||
if not policy.allow_campaign_profiles and (_inline_transport_configured(server, "smtp") or _inline_transport_configured(server, "imap")):
|
|
||||||
raise MailProfileError("Campaign-local inline mail settings are disabled by policy")
|
|
||||||
_assert_transport_values_allowed(policy, smtp, imap)
|
|
||||||
return
|
|
||||||
assert_mail_policy_allows_transport(
|
|
||||||
session,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
smtp=smtp,
|
|
||||||
imap=imap,
|
|
||||||
campaign_id=campaign_id,
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
owner_group_id=owner_group_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_mail_server_profile(
|
def create_mail_server_profile(
|
||||||
@@ -1147,6 +1370,10 @@ def create_mail_server_profile(
|
|||||||
imap_password = None
|
imap_password = None
|
||||||
if imap is not None:
|
if imap is not None:
|
||||||
imap_payload, imap_username, imap_password, _, _ = _transport_payload(imap)
|
imap_payload, imap_username, imap_password, _, _ = _transport_payload(imap)
|
||||||
|
if not is_active and (smtp_password not in (None, "") or imap_password not in (None, "")):
|
||||||
|
raise MailProfileError(
|
||||||
|
"Inactive mail-server profiles cannot retain credentials; create the active profile or omit credentials"
|
||||||
|
)
|
||||||
profile = MailServerProfile(
|
profile = MailServerProfile(
|
||||||
tenant_id=profile_tenant_id,
|
tenant_id=profile_tenant_id,
|
||||||
scope_type=clean_scope_type,
|
scope_type=clean_scope_type,
|
||||||
@@ -1174,6 +1401,7 @@ def update_mail_server_profile(
|
|||||||
profile: MailServerProfile,
|
profile: MailServerProfile,
|
||||||
*,
|
*,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
api_key_id: str | None = None,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
@@ -1183,6 +1411,30 @@ def update_mail_server_profile(
|
|||||||
imap: ImapConfig | None = None,
|
imap: ImapConfig | None = None,
|
||||||
clear_imap: bool = False,
|
clear_imap: bool = False,
|
||||||
) -> MailServerProfile:
|
) -> MailServerProfile:
|
||||||
|
if is_active is False:
|
||||||
|
raise MailProfileError(
|
||||||
|
"Deactivate profiles through DELETE so credentials are scrubbed and the deletion is audited"
|
||||||
|
)
|
||||||
|
if not profile.is_active and (
|
||||||
|
profile.smtp_password_encrypted or profile.imap_password_encrypted
|
||||||
|
):
|
||||||
|
raise MailProfileError(
|
||||||
|
"Inactive profile still contains legacy credentials; use DELETE to scrub them before editing or reactivating"
|
||||||
|
)
|
||||||
|
inactive_password_update = (
|
||||||
|
not profile.is_active
|
||||||
|
and is_active is not True
|
||||||
|
and any(
|
||||||
|
config is not None
|
||||||
|
and "password" in config.model_fields_set
|
||||||
|
and config.password not in (None, "")
|
||||||
|
for config in (smtp, imap)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if inactive_password_update:
|
||||||
|
raise MailProfileError(
|
||||||
|
"Credentials may only be added to an inactive profile when it is activated in the same update"
|
||||||
|
)
|
||||||
if name is not None:
|
if name is not None:
|
||||||
clean_name = name.strip()
|
clean_name = name.strip()
|
||||||
if not clean_name:
|
if not clean_name:
|
||||||
@@ -1205,7 +1457,15 @@ def update_mail_server_profile(
|
|||||||
|
|
||||||
next_smtp, next_imap = _next_profile_transport_state(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
|
next_smtp, next_imap = _next_profile_transport_state(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
|
||||||
_assert_profile_transport_allowed(session, tenant_id=tenant_id, profile=profile, smtp=next_smtp, imap=next_imap)
|
_assert_profile_transport_allowed(session, tenant_id=tenant_id, profile=profile, smtp=next_smtp, imap=next_imap)
|
||||||
_apply_profile_transport_update(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
|
_apply_profile_transport_update(
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
smtp=smtp,
|
||||||
|
imap=imap,
|
||||||
|
clear_imap=clear_imap,
|
||||||
|
)
|
||||||
profile.updated_by_user_id = user_id
|
profile.updated_by_user_id = user_id
|
||||||
session.add(profile)
|
session.add(profile)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -1218,13 +1478,17 @@ def _next_profile_transport_state(
|
|||||||
smtp: SmtpConfig | None,
|
smtp: SmtpConfig | None,
|
||||||
imap: ImapConfig | None,
|
imap: ImapConfig | None,
|
||||||
clear_imap: bool,
|
clear_imap: bool,
|
||||||
) -> tuple[SmtpConfig, ImapConfig | None]:
|
) -> tuple[SmtpConfig | dict[str, Any], ImapConfig | dict[str, Any] | None]:
|
||||||
next_smtp = smtp or smtp_config_from_profile(profile)
|
# Policy validation only needs non-secret transport metadata. Never decrypt
|
||||||
|
# an unchanged protocol merely because profile metadata or the other
|
||||||
|
# protocol is being updated; corrupt/retired ciphertext must not block the
|
||||||
|
# credential replacement and deletion recovery paths.
|
||||||
|
next_smtp: SmtpConfig | dict[str, Any] = smtp or dict(profile.smtp_config or {})
|
||||||
if clear_imap:
|
if clear_imap:
|
||||||
return next_smtp, None
|
return next_smtp, None
|
||||||
if imap is not None:
|
if imap is not None:
|
||||||
return next_smtp, imap
|
return next_smtp, imap
|
||||||
return next_smtp, imap_config_from_profile(profile)
|
return next_smtp, dict(profile.imap_config or {}) if profile.imap_config else None
|
||||||
|
|
||||||
|
|
||||||
def _assert_profile_transport_allowed(
|
def _assert_profile_transport_allowed(
|
||||||
@@ -1232,8 +1496,8 @@ def _assert_profile_transport_allowed(
|
|||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
profile: MailServerProfile,
|
profile: MailServerProfile,
|
||||||
smtp: SmtpConfig,
|
smtp: SmtpConfig | dict[str, Any],
|
||||||
imap: ImapConfig | None,
|
imap: ImapConfig | dict[str, Any] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
scope_type = _profile_scope_type(profile)
|
scope_type = _profile_scope_type(profile)
|
||||||
scope_id = _profile_scope_id(profile)
|
scope_id = _profile_scope_id(profile)
|
||||||
@@ -1248,36 +1512,148 @@ def _assert_profile_transport_allowed(
|
|||||||
|
|
||||||
|
|
||||||
def _apply_profile_transport_update(
|
def _apply_profile_transport_update(
|
||||||
|
session: Session,
|
||||||
profile: MailServerProfile,
|
profile: MailServerProfile,
|
||||||
*,
|
*,
|
||||||
|
user_id: str | None,
|
||||||
|
api_key_id: str | None,
|
||||||
smtp: SmtpConfig | None,
|
smtp: SmtpConfig | None,
|
||||||
imap: ImapConfig | None,
|
imap: ImapConfig | None,
|
||||||
clear_imap: bool,
|
clear_imap: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
if smtp is not None:
|
if smtp is not None:
|
||||||
_apply_profile_transport_payload(profile, "smtp", smtp)
|
_apply_profile_transport_payload(
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
"smtp",
|
||||||
|
smtp,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
)
|
||||||
if clear_imap:
|
if clear_imap:
|
||||||
|
had_imap_identity = bool(
|
||||||
|
profile.imap_config
|
||||||
|
or profile.imap_username
|
||||||
|
or profile.imap_password_encrypted
|
||||||
|
)
|
||||||
|
_audit_profile_credential_mutation(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
protocol="imap",
|
||||||
|
password_supplied=True,
|
||||||
|
next_password=None,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
)
|
||||||
|
clear_mailbox_index(session, profile_id=profile.id)
|
||||||
profile.imap_config = None
|
profile.imap_config = None
|
||||||
profile.imap_username = None
|
profile.imap_username = None
|
||||||
profile.imap_password_encrypted = None
|
profile.imap_password_encrypted = None
|
||||||
|
if had_imap_identity:
|
||||||
|
profile.imap_transport_revision = new_uuid()
|
||||||
elif imap is not None:
|
elif imap is not None:
|
||||||
_apply_profile_transport_payload(profile, "imap", imap)
|
_apply_profile_transport_payload(
|
||||||
|
session,
|
||||||
|
profile,
|
||||||
|
"imap",
|
||||||
|
imap,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _apply_profile_transport_payload(profile: MailServerProfile, protocol: str, config: SmtpConfig | ImapConfig) -> None:
|
def _apply_profile_transport_payload(
|
||||||
|
session: Session,
|
||||||
|
profile: MailServerProfile,
|
||||||
|
protocol: str,
|
||||||
|
config: SmtpConfig | ImapConfig,
|
||||||
|
*,
|
||||||
|
user_id: str | None,
|
||||||
|
api_key_id: str | None,
|
||||||
|
) -> None:
|
||||||
payload, username, password, username_supplied, password_supplied = _transport_payload(config)
|
payload, username, password, username_supplied, password_supplied = _transport_payload(config)
|
||||||
|
current_payload = dict(profile.smtp_config or {}) if protocol == "smtp" else dict(profile.imap_config or {})
|
||||||
|
for field_name in ("username", "password", "enabled"):
|
||||||
|
current_payload.pop(field_name, None)
|
||||||
|
current_username = _profile_username(profile, protocol)
|
||||||
|
identity_changed = (
|
||||||
|
current_payload != payload
|
||||||
|
or (username_supplied and current_username != username)
|
||||||
|
)
|
||||||
|
_audit_profile_credential_mutation(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
protocol=protocol,
|
||||||
|
password_supplied=password_supplied,
|
||||||
|
next_password=password,
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
)
|
||||||
if protocol == "smtp":
|
if protocol == "smtp":
|
||||||
profile.smtp_config = payload
|
profile.smtp_config = payload
|
||||||
if username_supplied:
|
if username_supplied:
|
||||||
profile.smtp_username = username
|
profile.smtp_username = username
|
||||||
if password_supplied:
|
if password_supplied:
|
||||||
profile.smtp_password_encrypted = encrypt_secret(password)
|
profile.smtp_password_encrypted = encrypt_secret(password)
|
||||||
|
if identity_changed:
|
||||||
|
profile.smtp_transport_revision = new_uuid()
|
||||||
return
|
return
|
||||||
|
# Cached mailbox metadata and message summaries belong to the exact IMAP
|
||||||
|
# account identity. Clear them in the same transaction before changing an
|
||||||
|
# identity or credential so another account can never inherit them.
|
||||||
|
if identity_changed or password_supplied:
|
||||||
|
clear_mailbox_index(session, profile_id=profile.id)
|
||||||
profile.imap_config = payload
|
profile.imap_config = payload
|
||||||
if username_supplied:
|
if username_supplied:
|
||||||
profile.imap_username = username
|
profile.imap_username = username
|
||||||
if password_supplied:
|
if password_supplied:
|
||||||
profile.imap_password_encrypted = encrypt_secret(password)
|
profile.imap_password_encrypted = encrypt_secret(password)
|
||||||
|
if identity_changed:
|
||||||
|
profile.imap_transport_revision = new_uuid()
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_profile_credential_mutation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
profile: MailServerProfile,
|
||||||
|
protocol: str,
|
||||||
|
password_supplied: bool,
|
||||||
|
next_password: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
api_key_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
encrypted = (
|
||||||
|
profile.smtp_password_encrypted
|
||||||
|
if protocol == "smtp"
|
||||||
|
else profile.imap_password_encrypted
|
||||||
|
)
|
||||||
|
if not password_supplied or not encrypted:
|
||||||
|
return
|
||||||
|
replacing = next_password not in (None, "")
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=profile.tenant_id,
|
||||||
|
scope="system" if profile.scope_type == "system" else "tenant",
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
action=(
|
||||||
|
"mail.profile_credentials_replaced"
|
||||||
|
if replacing
|
||||||
|
else "mail.profile_credentials_deleted"
|
||||||
|
),
|
||||||
|
object_type="mail_server_profile",
|
||||||
|
object_id=profile.id,
|
||||||
|
details={
|
||||||
|
"scope_type": profile.scope_type,
|
||||||
|
"scope_id": profile.scope_id,
|
||||||
|
"protocol": protocol,
|
||||||
|
"reason": "profile_updated",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Force the non-secret audit record to durable storage before replacing or
|
||||||
|
# clearing the in-memory ciphertext. The surrounding transaction keeps the
|
||||||
|
# two changes atomic at commit time.
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
def _profile_username(profile: MailServerProfile, protocol: str) -> str | None:
|
def _profile_username(profile: MailServerProfile, protocol: str) -> str | None:
|
||||||
@@ -1307,28 +1683,100 @@ def imap_config_from_profile(profile: MailServerProfile) -> ImapConfig | None:
|
|||||||
return ImapConfig.model_validate(payload)
|
return ImapConfig.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
def inherit_profile_credentials(server: dict[str, Any], protocol: str) -> bool:
|
def campaign_profile_transport_revisions(profile: MailServerProfile) -> dict[str, str | None]:
|
||||||
return _legacy_profile_credentials_inherited(server, protocol)
|
"""Return Mail-owned opaque revisions without exposing transport identity.
|
||||||
|
|
||||||
|
Revisions are random persisted values, backfilled by migration and rotated
|
||||||
|
whenever the corresponding transport or account identity changes.
|
||||||
|
They cannot be dictionary-tested to recover a host or account name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
smtp_revision = str(getattr(profile, "smtp_transport_revision", "") or "")
|
||||||
|
if not smtp_revision:
|
||||||
|
raise MailProfileError("Mail profile has no SMTP transport revision; run the Mail database migrations")
|
||||||
|
imap_revision = None
|
||||||
|
if profile.imap_config:
|
||||||
|
imap_revision = str(getattr(profile, "imap_transport_revision", "") or "")
|
||||||
|
if not imap_revision:
|
||||||
|
raise MailProfileError("Mail profile has no IMAP transport revision; run the Mail database migrations")
|
||||||
|
return {"smtp": smtp_revision, "imap": imap_revision}
|
||||||
|
|
||||||
|
|
||||||
def _transport_credentials_from_server(server: dict[str, Any], protocol: str) -> tuple[str | None, str | None]:
|
def delete_mail_profile_credentials(
|
||||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
session: Session,
|
||||||
config = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else None
|
*,
|
||||||
legacy_config = server.get(protocol) if isinstance(server.get(protocol), dict) else None
|
profile: MailServerProfile,
|
||||||
username = config.get("username") if config is not None else None
|
deletion_reason: str,
|
||||||
password = config.get("password") if config is not None else None
|
user_id: str | None = None,
|
||||||
if username in (None, "") and legacy_config is not None:
|
api_key_id: str | None = None,
|
||||||
username = legacy_config.get("username")
|
) -> tuple[str, ...]:
|
||||||
if password in (None, "") and legacy_config is not None:
|
"""Immediately scrub Mail-owned profile secrets and record the deletion.
|
||||||
password = legacy_config.get("password")
|
|
||||||
return (str(username) if username not in (None, "") else None, str(password) if password not in (None, "") else None)
|
The audit record contains only protocol names and non-secret profile scope
|
||||||
|
metadata. Any audit/storage failure aborts the caller's transaction, so a
|
||||||
|
profile cannot appear deleted while its encrypted passwords remain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
deleted_protocols = tuple(
|
||||||
|
protocol
|
||||||
|
for protocol, encrypted in (
|
||||||
|
("smtp", profile.smtp_password_encrypted),
|
||||||
|
("imap", profile.imap_password_encrypted),
|
||||||
|
)
|
||||||
|
if encrypted
|
||||||
|
)
|
||||||
|
if not deleted_protocols:
|
||||||
|
return ()
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=profile.tenant_id,
|
||||||
|
scope="system" if profile.scope_type == "system" else "tenant",
|
||||||
|
user_id=user_id,
|
||||||
|
api_key_id=api_key_id,
|
||||||
|
action="mail.profile_credentials_deleted",
|
||||||
|
object_type="mail_server_profile",
|
||||||
|
object_id=profile.id,
|
||||||
|
details={
|
||||||
|
"scope_type": profile.scope_type,
|
||||||
|
"scope_id": profile.scope_id,
|
||||||
|
"deleted_protocols": list(deleted_protocols),
|
||||||
|
"deletion_reason": deletion_reason,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Fail before mutating the ORM object if the audit record cannot be stored.
|
||||||
|
session.flush()
|
||||||
|
profile.smtp_password_encrypted = None
|
||||||
|
profile.imap_password_encrypted = None
|
||||||
|
session.add(profile)
|
||||||
|
session.flush()
|
||||||
|
return deleted_protocols
|
||||||
|
|
||||||
|
|
||||||
def apply_campaign_credentials(payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]:
|
def delete_mail_profile_credentials_for_retirement(session: Session) -> int:
|
||||||
username, password = _transport_credentials_from_server(server, protocol)
|
"""Scrub and audit every remaining Mail-owned secret before table drop."""
|
||||||
payload["username"] = username
|
|
||||||
payload["password"] = password
|
profiles = (
|
||||||
return payload
|
session.query(MailServerProfile)
|
||||||
|
.filter(
|
||||||
|
or_(
|
||||||
|
MailServerProfile.smtp_password_encrypted.is_not(None),
|
||||||
|
MailServerProfile.imap_password_encrypted.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(MailServerProfile.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
deleted = 0
|
||||||
|
for profile in profiles:
|
||||||
|
deleted += len(
|
||||||
|
delete_mail_profile_credentials(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
deletion_reason="module_data_retired",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
def _server_config_payload(value: dict[str, Any] | None) -> dict[str, Any]:
|
def _server_config_payload(value: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
@@ -1530,87 +1978,9 @@ def set_mail_profile_policy(
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _write_transport_to_server(server: dict[str, Any], protocol: str, payload: dict[str, Any]) -> None:
|
|
||||||
server_payload = dict(payload)
|
|
||||||
username = server_payload.pop("username", None)
|
|
||||||
password = server_payload.pop("password", None)
|
|
||||||
server_payload.pop("enabled", None)
|
|
||||||
server[protocol] = server_payload
|
|
||||||
if username not in (None, "") or password not in (None, ""):
|
|
||||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
|
||||||
protocol_credentials = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
|
|
||||||
if username not in (None, ""):
|
|
||||||
protocol_credentials["username"] = username
|
|
||||||
if password not in (None, ""):
|
|
||||||
protocol_credentials["password"] = password
|
|
||||||
credentials[protocol] = protocol_credentials
|
|
||||||
server["credentials"] = credentials
|
|
||||||
|
|
||||||
|
|
||||||
def materialize_campaign_mail_profile_config(
|
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
raw_json: dict[str, Any],
|
|
||||||
campaign_id: str | None = None,
|
|
||||||
owner_user_id: str | None = None,
|
|
||||||
owner_group_id: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
data = copy.deepcopy(raw_json if isinstance(raw_json, dict) else {})
|
|
||||||
server = data.get("server")
|
|
||||||
if not isinstance(server, dict):
|
|
||||||
return data
|
|
||||||
profile_id = server.get("mail_profile_id")
|
|
||||||
if not profile_id:
|
|
||||||
assert_campaign_mail_policy_allows_json(
|
|
||||||
session,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
raw_json=data,
|
|
||||||
campaign_id=campaign_id,
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
owner_group_id=owner_group_id,
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
if campaign_id:
|
|
||||||
profile = ensure_mail_profile_allowed_for_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=str(profile_id), require_active=True)
|
|
||||||
else:
|
|
||||||
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
|
|
||||||
assert_campaign_mail_policy_allows_json(
|
|
||||||
session,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
raw_json=data,
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
owner_group_id=owner_group_id,
|
|
||||||
)
|
|
||||||
policy = effective_mail_profile_policy(
|
|
||||||
session,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
campaign_id=campaign_id,
|
|
||||||
owner_user_id=owner_user_id,
|
|
||||||
owner_group_id=owner_group_id,
|
|
||||||
)
|
|
||||||
_assert_profile_credentials_allowed_for_server(profile, policy, server)
|
|
||||||
resolved_server = dict(server)
|
|
||||||
smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json")
|
|
||||||
if not profile_credentials_inherited_for_server(policy, server, "smtp"):
|
|
||||||
smtp_payload = apply_campaign_credentials(smtp_payload, server, "smtp")
|
|
||||||
_write_transport_to_server(resolved_server, "smtp", smtp_payload)
|
|
||||||
imap = imap_config_from_profile(profile)
|
|
||||||
if imap is not None:
|
|
||||||
imap_payload = imap.model_dump(mode="json")
|
|
||||||
if not profile_credentials_inherited_for_server(policy, server, "imap"):
|
|
||||||
imap_payload = apply_campaign_credentials(imap_payload, server, "imap")
|
|
||||||
_write_transport_to_server(resolved_server, "imap", imap_payload)
|
|
||||||
else:
|
|
||||||
resolved_server.pop("imap", None)
|
|
||||||
data["server"] = resolved_server
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def mail_profile_id_from_campaign_json(raw_json: dict[str, Any] | None) -> str | None:
|
def mail_profile_id_from_campaign_json(raw_json: dict[str, Any] | None) -> str | None:
|
||||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||||
value = server.get("mail_profile_id") if isinstance(server, dict) else None
|
return _campaign_mail_profile_reference_id(server) if isinstance(server, dict) else None
|
||||||
return str(value) if value else None
|
|
||||||
|
|
||||||
|
|
||||||
def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]:
|
def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]:
|
||||||
|
|||||||
@@ -36,6 +36,27 @@ class CachedMessagePage:
|
|||||||
stale: bool
|
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:
|
def begin_mailbox_refresh(tenant_id: str, profile_id: str, folder: str) -> bool:
|
||||||
key = (tenant_id, profile_id, folder)
|
key = (tenant_id, profile_id, folder)
|
||||||
with _refresh_lock:
|
with _refresh_lock:
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.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.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
@@ -23,6 +26,40 @@ from govoplan_mail.backend.documentation import documentation_topics
|
|||||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
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.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:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
@@ -89,13 +126,19 @@ def _mail_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="mail",
|
id="mail",
|
||||||
name="Mail",
|
name="Mail",
|
||||||
version="0.1.8",
|
version="0.1.9",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("campaigns", "addresses"),
|
optional_dependencies=("campaigns", "addresses"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.1.6"),
|
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="campaigns.access",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name="campaigns.mail_policy_context",
|
name="campaigns.mail_policy_context",
|
||||||
version_min="0.1.0",
|
version_min="0.1.0",
|
||||||
@@ -123,14 +166,8 @@ manifest = ModuleManifest(
|
|||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=_mail_retirement_provider,
|
||||||
mail_models.MailServerProfile,
|
retirement_notes="Destructive retirement first scrubs and audits Mail-owned credentials, then drops Mail-owned database tables after the installer captures a database snapshot.",
|
||||||
mail_models.MailProfilePolicy,
|
|
||||||
mail_models.MailMailboxFolderIndex,
|
|
||||||
mail_models.MailMailboxMessageIndex,
|
|
||||||
label="Mail",
|
|
||||||
),
|
|
||||||
retirement_notes="Destructive retirement drops mail-owned database tables after the installer captures a database snapshot.",
|
|
||||||
),
|
),
|
||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
@@ -149,7 +186,7 @@ manifest = ModuleManifest(
|
|||||||
id="mail.profiles-and-policy",
|
id="mail.profiles-and-policy",
|
||||||
title="Mail profiles and policy hierarchy",
|
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.",
|
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 their own mail-server settings. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
|
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",
|
layer="configured",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
||||||
@@ -165,11 +202,210 @@ manifest = ModuleManifest(
|
|||||||
links=(
|
links=(
|
||||||
DocumentationLink(label="Mail profiles", href="/api/v1/mail/profiles", kind="api"),
|
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="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"),
|
DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
|
||||||
),
|
),
|
||||||
related_modules=("campaigns",),
|
related_modules=("campaigns",),
|
||||||
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
|
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
|
||||||
configuration_keys=("mail_profile_policy",),
|
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"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
"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",
|
||||||
|
"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,),
|
documentation_providers=(documentation_topics,),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -41,23 +40,27 @@ from govoplan_core.core.change_sequence import (
|
|||||||
sequence_watermark_is_expired,
|
sequence_watermark_is_expired,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
|
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
|
||||||
from govoplan_core.db.session import get_database, get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_mail.backend.mailbox_index import (
|
from govoplan_mail.backend.mailbox_index import (
|
||||||
begin_mailbox_refresh,
|
|
||||||
cache_mailbox_folders,
|
cache_mailbox_folders,
|
||||||
cache_mailbox_messages,
|
cache_mailbox_messages,
|
||||||
cached_mailbox_folders,
|
cached_mailbox_folders,
|
||||||
cached_mailbox_message_page,
|
cached_mailbox_message_page,
|
||||||
finish_mailbox_refresh,
|
clear_mailbox_index,
|
||||||
)
|
)
|
||||||
from govoplan_mail.backend.mail_profiles import (
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
MailProfileError,
|
MailProfileError,
|
||||||
|
campaign_mail_context_visible_to_actor,
|
||||||
create_mail_server_profile,
|
create_mail_server_profile,
|
||||||
|
delete_mail_profile_credentials,
|
||||||
effective_mail_profile_policy_for_scope,
|
effective_mail_profile_policy_for_scope,
|
||||||
get_mail_profile_policy,
|
get_mail_profile_policy,
|
||||||
get_mail_server_profile,
|
get_mail_server_profile,
|
||||||
|
get_mail_server_profile_for_actor,
|
||||||
imap_config_from_profile,
|
imap_config_from_profile,
|
||||||
list_mail_server_profiles,
|
list_mail_server_profiles,
|
||||||
|
mail_profile_visible_to_actor,
|
||||||
|
mail_profile_scope_visible_to_actor,
|
||||||
parent_mail_profile_policy,
|
parent_mail_profile_policy,
|
||||||
profile_response_payload,
|
profile_response_payload,
|
||||||
set_mail_profile_policy,
|
set_mail_profile_policy,
|
||||||
@@ -70,7 +73,6 @@ from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfiguratio
|
|||||||
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
||||||
|
|
||||||
router = APIRouter(prefix="/mail", tags=["mail"])
|
router = APIRouter(prefix="/mail", tags=["mail"])
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MAIL_MODULE_ID = "mail"
|
MAIL_MODULE_ID = "mail"
|
||||||
MAIL_PROFILES_COLLECTION = "mail.profiles"
|
MAIL_PROFILES_COLLECTION = "mail.profiles"
|
||||||
@@ -144,6 +146,55 @@ def _require_mailbox_read_scope(principal: ApiPrincipal) -> None:
|
|||||||
_require_scope(principal, "mail:profile:use")
|
_require_scope(principal, "mail:profile:use")
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_is_tenant_admin(principal: ApiPrincipal) -> bool:
|
||||||
|
return has_scope(principal, "tenant:*")
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_can_manage_tenant_profiles(principal: ApiPrincipal) -> bool:
|
||||||
|
return has_scope(principal, "mail:profile:write")
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_can_manage_system_profiles(principal: ApiPrincipal) -> bool:
|
||||||
|
return has_scope(principal, "system:settings:write")
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_actor_kwargs(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
administrative_visibility: bool,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"actor_user_id": principal.user.id,
|
||||||
|
"actor_group_ids": principal.group_ids,
|
||||||
|
"actor_can_manage_tenant_profiles": _principal_can_manage_tenant_profiles(principal),
|
||||||
|
"actor_can_manage_system_profiles": _principal_can_manage_system_profiles(principal),
|
||||||
|
"actor_tenant_admin": _principal_is_tenant_admin(principal),
|
||||||
|
"actor_administrative_visibility": administrative_visibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_profile_for_principal(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
profile_id: str,
|
||||||
|
require_active: bool = False,
|
||||||
|
administrative_visibility: bool = False,
|
||||||
|
):
|
||||||
|
return get_mail_server_profile_for_actor(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
profile_id=profile_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
group_ids=principal.group_ids,
|
||||||
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||||
|
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
||||||
|
tenant_admin=_principal_is_tenant_admin(principal),
|
||||||
|
administrative_visibility=administrative_visibility,
|
||||||
|
require_active=require_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
||||||
if scope_type == "system":
|
if scope_type == "system":
|
||||||
_require_scope(principal, "system:settings:read")
|
_require_scope(principal, "system:settings:read")
|
||||||
@@ -162,6 +213,86 @@ def _require_policy_write_scope(principal: ApiPrincipal, scope_type: str) -> Non
|
|||||||
_require_any_scope(principal, "admin:policies:write", "mail:profile:write")
|
_require_any_scope(principal, "admin:policies:write", "mail:profile:write")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_policy_scope_visibility(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
if scope_type in {"system", "tenant"}:
|
||||||
|
return
|
||||||
|
if has_scope(principal, "admin:policies:read"):
|
||||||
|
return
|
||||||
|
if not mail_profile_scope_visible_to_actor(
|
||||||
|
session,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
profile_tenant_id=principal.tenant_id,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
group_ids=principal.group_ids,
|
||||||
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||||
|
tenant_admin=_principal_is_tenant_admin(principal),
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mail profile policy not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_campaign_context_visibility(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
campaign_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
if not campaign_id:
|
||||||
|
return
|
||||||
|
if not campaign_mail_context_visible_to_actor(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
group_ids=principal.group_ids,
|
||||||
|
tenant_admin=_principal_is_tenant_admin(principal),
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_policy_campaign_context(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
campaign_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
if scope_type == "campaign" and campaign_id and campaign_id != scope_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="campaign_id must match the campaign policy scope_id",
|
||||||
|
)
|
||||||
|
_require_campaign_context_visibility(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
campaign_id=campaign_id or (scope_id if scope_type == "campaign" else None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_campaign_profile_mutation_access(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
if scope_type != "campaign":
|
||||||
|
return
|
||||||
|
_require_campaign_context_visibility(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
campaign_id=scope_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _profile_response(profile) -> MailServerProfileResponse:
|
def _profile_response(profile) -> MailServerProfileResponse:
|
||||||
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
|
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
|
||||||
|
|
||||||
@@ -282,6 +413,45 @@ def _mail_deleted_item(entry) -> DeltaDeletedItem:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_change_visible_to_principal(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
entry: ChangeSequenceEntry,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
profile = get_mail_server_profile(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
profile_id=entry.resource_id,
|
||||||
|
)
|
||||||
|
except MailProfileError:
|
||||||
|
payload = entry.payload if isinstance(entry.payload, dict) else {}
|
||||||
|
return mail_profile_scope_visible_to_actor(
|
||||||
|
session,
|
||||||
|
scope_type=str(payload.get("scope_type") or "tenant"),
|
||||||
|
scope_id=str(payload["scope_id"]) if payload.get("scope_id") else None,
|
||||||
|
profile_tenant_id=entry.tenant_id,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
group_ids=principal.group_ids,
|
||||||
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||||
|
tenant_admin=_principal_is_tenant_admin(principal),
|
||||||
|
)
|
||||||
|
return mail_profile_visible_to_actor(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
group_ids=principal.group_ids,
|
||||||
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
||||||
|
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
||||||
|
tenant_admin=_principal_is_tenant_admin(principal),
|
||||||
|
administrative_visibility=True,
|
||||||
|
require_active=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
|
def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
|
||||||
return MailMailboxMessageSummaryResponse(
|
return MailMailboxMessageSummaryResponse(
|
||||||
uid=message.uid,
|
uid=message.uid,
|
||||||
@@ -450,65 +620,6 @@ def _mailbox_messages_response(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _schedule_mailbox_refresh(
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
profile_id: str,
|
|
||||||
folder: str,
|
|
||||||
limit: int,
|
|
||||||
offset: int = 0,
|
|
||||||
include_folder_status: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
if not begin_mailbox_refresh(tenant_id, profile_id, folder):
|
|
||||||
return False
|
|
||||||
background_tasks.add_task(
|
|
||||||
_refresh_mailbox_index_task,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
profile_id=profile_id,
|
|
||||||
folder=folder,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
include_folder_status=include_folder_status,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_mailbox_index_task(
|
|
||||||
*,
|
|
||||||
tenant_id: str,
|
|
||||||
profile_id: str,
|
|
||||||
folder: str,
|
|
||||||
limit: int,
|
|
||||||
offset: int,
|
|
||||||
include_folder_status: bool,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
with get_database().session() as session:
|
|
||||||
imap = _imap_config_for_profile(session, tenant_id=tenant_id, profile_id=profile_id)
|
|
||||||
result = load_imap_mailbox_bootstrap(
|
|
||||||
imap_config=imap,
|
|
||||||
folder=folder,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
include_folder_status=include_folder_status,
|
|
||||||
)
|
|
||||||
cache_mailbox_folders(session, tenant_id=tenant_id, profile_id=profile_id, result=result.folders)
|
|
||||||
cache_mailbox_messages(session, tenant_id=tenant_id, profile_id=profile_id, result=result.messages)
|
|
||||||
session.commit()
|
|
||||||
except Exception:
|
|
||||||
# Background refresh is opportunistic. Foreground requests will surface
|
|
||||||
# concrete IMAP errors when no usable cache is available.
|
|
||||||
logger.debug(
|
|
||||||
"Background mailbox index refresh failed (profile_id=%r, folder=%r)",
|
|
||||||
profile_id,
|
|
||||||
folder,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
finish_mailbox_refresh(tenant_id, profile_id, folder)
|
|
||||||
|
|
||||||
|
|
||||||
def _cached_folder_selection(folders, requested: str, detected_sent_folder: str | None = None) -> str:
|
def _cached_folder_selection(folders, requested: str, detected_sent_folder: str | None = None) -> str:
|
||||||
names = {folder.name for folder in folders}
|
names = {folder.name for folder in folders}
|
||||||
if requested in names:
|
if requested in names:
|
||||||
@@ -520,8 +631,13 @@ def _cached_folder_selection(folders, requested: str, detected_sent_folder: str
|
|||||||
return folders[0].name if folders else requested
|
return folders[0].name if folders else requested
|
||||||
|
|
||||||
|
|
||||||
def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str):
|
def _imap_config_for_principal(session: Session, *, principal: ApiPrincipal, profile_id: str):
|
||||||
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
|
profile = _get_profile_for_principal(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
require_active=True,
|
||||||
|
)
|
||||||
imap = imap_config_from_profile(profile)
|
imap = imap_config_from_profile(profile)
|
||||||
if imap is None:
|
if imap is None:
|
||||||
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||||
@@ -537,7 +653,7 @@ def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, sco
|
|||||||
def _full_mail_settings_delta_response(
|
def _full_mail_settings_delta_response(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
principal: ApiPrincipal,
|
||||||
scope_type: str,
|
scope_type: str,
|
||||||
scope_id: str | None,
|
scope_id: str | None,
|
||||||
include_inactive: bool,
|
include_inactive: bool,
|
||||||
@@ -545,16 +661,17 @@ def _full_mail_settings_delta_response(
|
|||||||
) -> MailSettingsDeltaResponse:
|
) -> MailSettingsDeltaResponse:
|
||||||
profiles = list_mail_server_profiles(
|
profiles = list_mail_server_profiles(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
include_inactive=include_inactive,
|
include_inactive=include_inactive,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
|
**_profile_actor_kwargs(principal, administrative_visibility=True),
|
||||||
)
|
)
|
||||||
return MailSettingsDeltaResponse(
|
return MailSettingsDeltaResponse(
|
||||||
profiles=[_profile_response(profile) for profile in profiles],
|
profiles=[_profile_response(profile) for profile in profiles],
|
||||||
policy=_policy_response(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id),
|
policy=_policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id),
|
||||||
changed_sections=["profiles", "policy"],
|
changed_sections=["profiles", "policy"],
|
||||||
deleted=[],
|
deleted=[],
|
||||||
watermark=_mail_settings_watermark(session, tenant_id=tenant_id),
|
watermark=_mail_settings_watermark(session, tenant_id=principal.tenant_id),
|
||||||
has_more=False,
|
has_more=False,
|
||||||
full=True,
|
full=True,
|
||||||
)
|
)
|
||||||
@@ -592,10 +709,23 @@ def mail_settings_delta(
|
|||||||
clean_scope_type = scope_type.strip().casefold()
|
clean_scope_type = scope_type.strip().casefold()
|
||||||
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
||||||
_require_policy_read_scope(principal, clean_scope_type)
|
_require_policy_read_scope(principal, clean_scope_type)
|
||||||
|
_require_policy_scope_visibility(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
scope_type=clean_scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
_require_policy_campaign_context(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
scope_type=clean_scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
)
|
||||||
if since is None:
|
if since is None:
|
||||||
return _full_mail_settings_delta_response(
|
return _full_mail_settings_delta_response(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
principal=principal,
|
||||||
scope_type=clean_scope_type,
|
scope_type=clean_scope_type,
|
||||||
scope_id=scope_id,
|
scope_id=scope_id,
|
||||||
include_inactive=include_inactive,
|
include_inactive=include_inactive,
|
||||||
@@ -605,16 +735,22 @@ def mail_settings_delta(
|
|||||||
if entries is None:
|
if entries is None:
|
||||||
return _full_mail_settings_delta_response(
|
return _full_mail_settings_delta_response(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
principal=principal,
|
||||||
scope_type=clean_scope_type,
|
scope_type=clean_scope_type,
|
||||||
scope_id=scope_id,
|
scope_id=scope_id,
|
||||||
include_inactive=include_inactive,
|
include_inactive=include_inactive,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
)
|
)
|
||||||
|
visible_profile_entries = [
|
||||||
|
entry
|
||||||
|
for entry in entries
|
||||||
|
if entry.collection == MAIL_PROFILES_COLLECTION
|
||||||
|
and entry.resource_type == MAIL_PROFILE_RESOURCE
|
||||||
|
and _profile_change_visible_to_principal(session, entry=entry, principal=principal)
|
||||||
|
]
|
||||||
changed_profile_ids = {
|
changed_profile_ids = {
|
||||||
entry.resource_id
|
entry.resource_id
|
||||||
for entry in entries
|
for entry in visible_profile_entries
|
||||||
if entry.collection == MAIL_PROFILES_COLLECTION and entry.resource_type == MAIL_PROFILE_RESOURCE
|
|
||||||
}
|
}
|
||||||
visible_profiles = {
|
visible_profiles = {
|
||||||
profile.id: profile
|
profile.id: profile
|
||||||
@@ -623,6 +759,7 @@ def mail_settings_delta(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
include_inactive=include_inactive,
|
include_inactive=include_inactive,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
|
**_profile_actor_kwargs(principal, administrative_visibility=True),
|
||||||
)
|
)
|
||||||
if profile.id in changed_profile_ids
|
if profile.id in changed_profile_ids
|
||||||
}
|
}
|
||||||
@@ -634,10 +771,8 @@ def mail_settings_delta(
|
|||||||
changed_sections.append("policy")
|
changed_sections.append("policy")
|
||||||
deleted = [
|
deleted = [
|
||||||
_mail_deleted_item(entry)
|
_mail_deleted_item(entry)
|
||||||
for entry in entries
|
for entry in visible_profile_entries
|
||||||
if entry.collection == MAIL_PROFILES_COLLECTION
|
if entry.resource_id not in visible_profiles
|
||||||
and entry.resource_type == MAIL_PROFILE_RESOURCE
|
|
||||||
and entry.resource_id not in visible_profiles
|
|
||||||
]
|
]
|
||||||
return MailSettingsDeltaResponse(
|
return MailSettingsDeltaResponse(
|
||||||
profiles=[_profile_response(profile) for profile in visible_profiles.values()],
|
profiles=[_profile_response(profile) for profile in visible_profiles.values()],
|
||||||
@@ -664,6 +799,7 @@ def list_profiles(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
include_inactive=include_inactive,
|
include_inactive=include_inactive,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
|
**_profile_actor_kwargs(principal, administrative_visibility=True),
|
||||||
)
|
)
|
||||||
return MailServerProfileListResponse(profiles=[_profile_response(profile) for profile in profiles])
|
return MailServerProfileListResponse(profiles=[_profile_response(profile) for profile in profiles])
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
@@ -677,6 +813,12 @@ def create_profile(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_profile_write_scope(principal, payload.scope_type)
|
_require_profile_write_scope(principal, payload.scope_type)
|
||||||
|
_require_campaign_profile_mutation_access(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
)
|
||||||
if payload.credentials_supplied():
|
if payload.credentials_supplied():
|
||||||
_require_profile_credentials_scope(principal, payload.scope_type)
|
_require_profile_credentials_scope(principal, payload.scope_type)
|
||||||
try:
|
try:
|
||||||
@@ -700,7 +842,7 @@ def create_profile(
|
|||||||
resource_id=profile.id,
|
resource_id=profile.id,
|
||||||
operation="created",
|
operation="created",
|
||||||
principal=principal,
|
principal=principal,
|
||||||
tenant_id=profile.tenant_id or principal.tenant_id,
|
tenant_id=profile.tenant_id,
|
||||||
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
|
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -719,7 +861,14 @@ def get_profile(
|
|||||||
):
|
):
|
||||||
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
||||||
try:
|
try:
|
||||||
return _profile_response(get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id))
|
return _profile_response(
|
||||||
|
_get_profile_for_principal(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
administrative_visibility=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
|
||||||
@@ -734,6 +883,12 @@ def update_profile(
|
|||||||
try:
|
try:
|
||||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||||
|
_require_campaign_profile_mutation_access(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
scope_type=profile.scope_type or "tenant",
|
||||||
|
scope_id=profile.scope_id,
|
||||||
|
)
|
||||||
if payload.credentials_supplied() or payload.clear_imap:
|
if payload.credentials_supplied() or payload.clear_imap:
|
||||||
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
||||||
smtp_config = payload.smtp_config()
|
smtp_config = payload.smtp_config()
|
||||||
@@ -751,6 +906,7 @@ def update_profile(
|
|||||||
profile,
|
profile,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
slug=payload.slug,
|
slug=payload.slug,
|
||||||
description=payload.description if "description" in payload.model_fields_set else None,
|
description=payload.description if "description" in payload.model_fields_set else None,
|
||||||
@@ -766,7 +922,7 @@ def update_profile(
|
|||||||
resource_id=profile.id,
|
resource_id=profile.id,
|
||||||
operation="updated",
|
operation="updated",
|
||||||
principal=principal,
|
principal=principal,
|
||||||
tenant_id=profile.tenant_id or principal.tenant_id,
|
tenant_id=profile.tenant_id,
|
||||||
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
|
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -775,6 +931,9 @@ def update_profile(
|
|||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
||||||
@@ -786,24 +945,52 @@ def deactivate_profile(
|
|||||||
try:
|
try:
|
||||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||||
profile.is_active = False
|
_require_campaign_profile_mutation_access(
|
||||||
session.add(profile)
|
|
||||||
_record_mail_change(
|
|
||||||
session,
|
session,
|
||||||
collection=MAIL_PROFILES_COLLECTION,
|
|
||||||
resource_type=MAIL_PROFILE_RESOURCE,
|
|
||||||
resource_id=profile.id,
|
|
||||||
operation="deleted",
|
|
||||||
principal=principal,
|
principal=principal,
|
||||||
tenant_id=profile.tenant_id or principal.tenant_id,
|
scope_type=profile.scope_type or "tenant",
|
||||||
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
|
scope_id=profile.scope_id,
|
||||||
)
|
)
|
||||||
|
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
||||||
|
was_active = bool(profile.is_active)
|
||||||
|
profile.is_active = False
|
||||||
|
deleted_protocols = delete_mail_profile_credentials(
|
||||||
|
session,
|
||||||
|
profile=profile,
|
||||||
|
deletion_reason="profile_deactivated",
|
||||||
|
user_id=principal.user.id,
|
||||||
|
api_key_id=principal.api_key_id,
|
||||||
|
)
|
||||||
|
# Deactivation invalidates unauthenticated IMAP profiles too. Keep the
|
||||||
|
# cache deletion in this transaction so a failed audit/change record
|
||||||
|
# cannot leave profile state and cached mailbox data out of sync.
|
||||||
|
clear_mailbox_index(session, profile_id=profile.id)
|
||||||
|
session.add(profile)
|
||||||
|
if was_active or deleted_protocols:
|
||||||
|
_record_mail_change(
|
||||||
|
session,
|
||||||
|
collection=MAIL_PROFILES_COLLECTION,
|
||||||
|
resource_type=MAIL_PROFILE_RESOURCE,
|
||||||
|
resource_id=profile.id,
|
||||||
|
operation="deleted",
|
||||||
|
principal=principal,
|
||||||
|
tenant_id=profile.tenant_id,
|
||||||
|
payload={
|
||||||
|
"scope_type": profile.scope_type,
|
||||||
|
"scope_id": profile.scope_id,
|
||||||
|
"credentials_deleted": bool(deleted_protocols),
|
||||||
|
"deleted_credential_protocols": list(deleted_protocols),
|
||||||
|
},
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(profile)
|
session.refresh(profile)
|
||||||
return _profile_response(profile)
|
return _profile_response(profile)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.get("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
|
@router.get("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
|
||||||
@@ -816,6 +1003,19 @@ def read_mail_profile_policy(
|
|||||||
):
|
):
|
||||||
scope_type = scope_type.strip().casefold()
|
scope_type = scope_type.strip().casefold()
|
||||||
_require_policy_read_scope(principal, scope_type)
|
_require_policy_read_scope(principal, scope_type)
|
||||||
|
_require_policy_scope_visibility(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
_require_policy_campaign_context(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id)
|
return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
@@ -832,6 +1032,19 @@ def write_mail_profile_policy(
|
|||||||
):
|
):
|
||||||
scope_type = scope_type.strip().casefold()
|
scope_type = scope_type.strip().casefold()
|
||||||
_require_policy_write_scope(principal, scope_type)
|
_require_policy_write_scope(principal, scope_type)
|
||||||
|
_require_policy_scope_visibility(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
_require_policy_campaign_context(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
campaign_id=None,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
set_mail_profile_policy(
|
set_mail_profile_policy(
|
||||||
session,
|
session,
|
||||||
@@ -866,7 +1079,12 @@ def test_profile_smtp(
|
|||||||
_require_scope(principal, "mail:profile:test")
|
_require_scope(principal, "mail:profile:test")
|
||||||
_require_scope(principal, "mail:profile:use")
|
_require_scope(principal, "mail:profile:use")
|
||||||
try:
|
try:
|
||||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
profile = _get_profile_for_principal(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
require_active=True,
|
||||||
|
)
|
||||||
smtp = smtp_config_from_profile(profile)
|
smtp = smtp_config_from_profile(profile)
|
||||||
result = test_smtp_login(smtp_config=smtp)
|
result = test_smtp_login(smtp_config=smtp)
|
||||||
return MailConnectionTestResponse(ok=True, protocol="smtp", host=result.host, port=result.port, security=result.security, message="SMTP connection successful.", details={"authenticated": result.authenticated})
|
return MailConnectionTestResponse(ok=True, protocol="smtp", host=result.host, port=result.port, security=result.security, message="SMTP connection successful.", details={"authenticated": result.authenticated})
|
||||||
@@ -885,7 +1103,12 @@ def test_profile_imap(
|
|||||||
_require_scope(principal, "mail:profile:test")
|
_require_scope(principal, "mail:profile:test")
|
||||||
_require_scope(principal, "mail:profile:use")
|
_require_scope(principal, "mail:profile:use")
|
||||||
try:
|
try:
|
||||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
profile = _get_profile_for_principal(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
require_active=True,
|
||||||
|
)
|
||||||
imap = imap_config_from_profile(profile)
|
imap = imap_config_from_profile(profile)
|
||||||
if imap is None:
|
if imap is None:
|
||||||
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||||
@@ -906,7 +1129,12 @@ def list_profile_imap_folders(
|
|||||||
_require_scope(principal, "mail:profile:test")
|
_require_scope(principal, "mail:profile:test")
|
||||||
_require_scope(principal, "mail:profile:use")
|
_require_scope(principal, "mail:profile:use")
|
||||||
try:
|
try:
|
||||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id, require_active=True)
|
profile = _get_profile_for_principal(
|
||||||
|
session,
|
||||||
|
principal=principal,
|
||||||
|
profile_id=profile_id,
|
||||||
|
require_active=True,
|
||||||
|
)
|
||||||
imap = imap_config_from_profile(profile)
|
imap = imap_config_from_profile(profile)
|
||||||
if imap is None:
|
if imap is None:
|
||||||
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||||
@@ -921,7 +1149,6 @@ def list_profile_imap_folders(
|
|||||||
@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse)
|
@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse)
|
||||||
def list_profile_mailbox_folders(
|
def list_profile_mailbox_folders(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
include_status: bool = Query(default=False),
|
include_status: bool = Query(default=False),
|
||||||
refresh: bool = False,
|
refresh: bool = False,
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
@@ -929,18 +1156,10 @@ def list_profile_mailbox_folders(
|
|||||||
):
|
):
|
||||||
_require_mailbox_read_scope(principal)
|
_require_mailbox_read_scope(principal)
|
||||||
try:
|
try:
|
||||||
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
|
||||||
if not include_status and not refresh:
|
if not include_status and not refresh:
|
||||||
cached = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
cached = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
if cached is not None:
|
if cached is not None and not cached.stale:
|
||||||
refreshing = cached.stale and _schedule_mailbox_refresh(
|
|
||||||
background_tasks,
|
|
||||||
tenant_id=principal.tenant_id,
|
|
||||||
profile_id=profile_id,
|
|
||||||
folder="INBOX",
|
|
||||||
limit=DEFAULT_MAILBOX_MESSAGE_LIMIT,
|
|
||||||
include_folder_status=include_status,
|
|
||||||
)
|
|
||||||
result = SimpleNamespace(
|
result = SimpleNamespace(
|
||||||
host=imap.host,
|
host=imap.host,
|
||||||
port=imap.port,
|
port=imap.port,
|
||||||
@@ -948,7 +1167,7 @@ def list_profile_mailbox_folders(
|
|||||||
folders=cached.folders,
|
folders=cached.folders,
|
||||||
detected_sent_folder=None,
|
detected_sent_folder=None,
|
||||||
)
|
)
|
||||||
return _mailbox_folder_response(result, from_cache=True, refreshing=refreshing, indexed_at=cached.indexed_at)
|
return _mailbox_folder_response(result, from_cache=True, refreshing=False, indexed_at=cached.indexed_at)
|
||||||
result = list_imap_folders(imap_config=imap, include_status=include_status)
|
result = list_imap_folders(imap_config=imap, include_status=include_status)
|
||||||
cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result)
|
cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -964,7 +1183,6 @@ def list_profile_mailbox_folders(
|
|||||||
@router.get("/profiles/{profile_id}/mailbox/bootstrap", response_model=MailMailboxBootstrapResponse)
|
@router.get("/profiles/{profile_id}/mailbox/bootstrap", response_model=MailMailboxBootstrapResponse)
|
||||||
def bootstrap_profile_mailbox(
|
def bootstrap_profile_mailbox(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
||||||
limit: int = Query(default=DEFAULT_MAILBOX_MESSAGE_LIMIT, ge=1, le=100),
|
limit: int = Query(default=DEFAULT_MAILBOX_MESSAGE_LIMIT, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0, le=100000),
|
offset: int = Query(default=0, ge=0, le=100000),
|
||||||
@@ -975,10 +1193,10 @@ def bootstrap_profile_mailbox(
|
|||||||
) -> MailMailboxBootstrapResponse:
|
) -> MailMailboxBootstrapResponse:
|
||||||
_require_mailbox_read_scope(principal)
|
_require_mailbox_read_scope(principal)
|
||||||
try:
|
try:
|
||||||
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
|
||||||
if not refresh:
|
if not refresh:
|
||||||
cached_folders = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
cached_folders = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
if cached_folders is not None:
|
if cached_folders is not None and not cached_folders.stale:
|
||||||
selected_folder = _cached_folder_selection(cached_folders.folders, folder)
|
selected_folder = _cached_folder_selection(cached_folders.folders, folder)
|
||||||
cached_messages = cached_mailbox_message_page(
|
cached_messages = cached_mailbox_message_page(
|
||||||
session,
|
session,
|
||||||
@@ -988,16 +1206,7 @@ def bootstrap_profile_mailbox(
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
if cached_messages is not None:
|
if cached_messages is not None and not cached_messages.stale:
|
||||||
refreshing = (cached_folders.stale or cached_messages.stale) and _schedule_mailbox_refresh(
|
|
||||||
background_tasks,
|
|
||||||
tenant_id=principal.tenant_id,
|
|
||||||
profile_id=profile_id,
|
|
||||||
folder=selected_folder,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
include_folder_status=include_status,
|
|
||||||
)
|
|
||||||
folder_result = SimpleNamespace(
|
folder_result = SimpleNamespace(
|
||||||
host=imap.host,
|
host=imap.host,
|
||||||
port=imap.port,
|
port=imap.port,
|
||||||
@@ -1021,7 +1230,7 @@ def bootstrap_profile_mailbox(
|
|||||||
folders=_mailbox_folder_response(
|
folders=_mailbox_folder_response(
|
||||||
folder_result,
|
folder_result,
|
||||||
from_cache=True,
|
from_cache=True,
|
||||||
refreshing=refreshing,
|
refreshing=False,
|
||||||
indexed_at=cached_folders.indexed_at,
|
indexed_at=cached_folders.indexed_at,
|
||||||
),
|
),
|
||||||
messages=_mailbox_messages_response(
|
messages=_mailbox_messages_response(
|
||||||
@@ -1039,7 +1248,7 @@ def bootstrap_profile_mailbox(
|
|||||||
full=not cursor_stable,
|
full=not cursor_stable,
|
||||||
messages=cached_messages.messages,
|
messages=cached_messages.messages,
|
||||||
from_cache=True,
|
from_cache=True,
|
||||||
refreshing=refreshing,
|
refreshing=False,
|
||||||
indexed_at=cached_messages.indexed_at,
|
indexed_at=cached_messages.indexed_at,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1095,7 +1304,6 @@ def bootstrap_profile_mailbox(
|
|||||||
@router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse)
|
@router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse)
|
||||||
def list_profile_mailbox_messages(
|
def list_profile_mailbox_messages(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
||||||
limit: int | None = Query(default=None, ge=1, le=100),
|
limit: int | None = Query(default=None, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0, le=100000),
|
offset: int = Query(default=0, ge=0, le=100000),
|
||||||
@@ -1106,7 +1314,7 @@ def list_profile_mailbox_messages(
|
|||||||
):
|
):
|
||||||
_require_mailbox_read_scope(principal)
|
_require_mailbox_read_scope(principal)
|
||||||
try:
|
try:
|
||||||
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
|
||||||
effective_limit = _mailbox_cursor_limit(cursor, limit)
|
effective_limit = _mailbox_cursor_limit(cursor, limit)
|
||||||
fingerprint = _mailbox_cursor_fingerprint(tenant_id=principal.tenant_id, profile_id=profile_id, folder=folder, limit=effective_limit)
|
fingerprint = _mailbox_cursor_fingerprint(tenant_id=principal.tenant_id, profile_id=profile_id, folder=folder, limit=effective_limit)
|
||||||
effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset)
|
effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset)
|
||||||
@@ -1122,15 +1330,7 @@ def list_profile_mailbox_messages(
|
|||||||
cache_matches_cursor = cached is not None and (
|
cache_matches_cursor = cached is not None and (
|
||||||
cursor is None or (expected_uidvalidity is not None and cached.uidvalidity == expected_uidvalidity)
|
cursor is None or (expected_uidvalidity is not None and cached.uidvalidity == expected_uidvalidity)
|
||||||
)
|
)
|
||||||
if cached is not None and cache_matches_cursor:
|
if cached is not None and cache_matches_cursor and not cached.stale:
|
||||||
refreshing = cached.stale and _schedule_mailbox_refresh(
|
|
||||||
background_tasks,
|
|
||||||
tenant_id=principal.tenant_id,
|
|
||||||
profile_id=profile_id,
|
|
||||||
folder=folder,
|
|
||||||
limit=effective_limit,
|
|
||||||
offset=effective_offset,
|
|
||||||
)
|
|
||||||
next_cursor, cursor_stable = _next_mailbox_cursor(
|
next_cursor, cursor_stable = _next_mailbox_cursor(
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
@@ -1156,7 +1356,7 @@ def list_profile_mailbox_messages(
|
|||||||
full=not cursor_stable,
|
full=not cursor_stable,
|
||||||
messages=cached.messages,
|
messages=cached.messages,
|
||||||
from_cache=True,
|
from_cache=True,
|
||||||
refreshing=refreshing,
|
refreshing=False,
|
||||||
indexed_at=cached.indexed_at,
|
indexed_at=cached.indexed_at,
|
||||||
)
|
)
|
||||||
result = list_imap_messages(
|
result = list_imap_messages(
|
||||||
@@ -1213,7 +1413,7 @@ def get_profile_mailbox_message(
|
|||||||
):
|
):
|
||||||
_require_mailbox_read_scope(principal)
|
_require_mailbox_read_scope(principal)
|
||||||
try:
|
try:
|
||||||
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
|
||||||
result = get_imap_message(imap_config=imap, folder=folder, uid=message_uid)
|
result = get_imap_message(imap_config=imap, folder=folder, uid=message_uid)
|
||||||
return MailMailboxMessageResponse(
|
return MailMailboxMessageResponse(
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
|
|||||||
@@ -68,9 +68,16 @@ class ImapAppendError(RuntimeError):
|
|||||||
configuration or mailbox choice probably needs user/admin attention.
|
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)
|
super().__init__(message)
|
||||||
self.temporary = temporary
|
self.temporary = temporary
|
||||||
|
self.outcome_unknown = outcome_unknown
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -1100,10 +1107,12 @@ def append_message_to_sent(
|
|||||||
)
|
)
|
||||||
|
|
||||||
client: imaplib.IMAP4 | None = None
|
client: imaplib.IMAP4 | None = None
|
||||||
|
append_started = False
|
||||||
try:
|
try:
|
||||||
client = _open_imap(imap_config)
|
client = _open_imap(imap_config)
|
||||||
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
|
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
|
||||||
internal_date = imaplib.Time2Internaldate(time.time())
|
internal_date = imaplib.Time2Internaldate(time.time())
|
||||||
|
append_started = True
|
||||||
typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes)
|
typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes)
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
|
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
|
||||||
@@ -1119,9 +1128,17 @@ def append_message_to_sent(
|
|||||||
except ImapAppendError:
|
except ImapAppendError:
|
||||||
raise
|
raise
|
||||||
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
|
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:
|
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:
|
finally:
|
||||||
if client is not None:
|
if client is not None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
322
tests/test_campaign_capability.py
Normal file
322
tests/test_campaign_capability.py
Normal 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()
|
||||||
@@ -247,6 +247,22 @@ class ImapMailboxCommandTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.folder, "Gesendete Elemente")
|
self.assertEqual(result.folder, "Gesendete Elemente")
|
||||||
self.assertEqual(result.bytes_appended, len(b"Subject: test\r\n\r\nBody"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,21 +2,205 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||||
from govoplan_mail.backend.mail_profiles import (
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
|
EffectiveCredentialPolicy,
|
||||||
EffectiveMailProfilePolicy,
|
EffectiveMailProfilePolicy,
|
||||||
|
MailProfileError,
|
||||||
|
_assert_campaign_inherits_profile_credentials,
|
||||||
|
_campaign_mail_profile_reference_id,
|
||||||
_apply_profile_transport_update,
|
_apply_profile_transport_update,
|
||||||
|
campaign_profile_transport_revisions,
|
||||||
|
create_mail_server_profile,
|
||||||
_merge_policy,
|
_merge_policy,
|
||||||
_next_profile_transport_state,
|
_next_profile_transport_state,
|
||||||
_policy_parent_lock_message,
|
_policy_parent_lock_message,
|
||||||
_policy_parent_lock_violations,
|
_policy_parent_lock_violations,
|
||||||
|
delete_mail_profile_credentials,
|
||||||
|
update_mail_server_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MailProfileTransportHelperTests(unittest.TestCase):
|
class MailProfileTransportHelperTests(unittest.TestCase):
|
||||||
def test_next_transport_state_uses_saved_profile_credentials(self):
|
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(
|
profile = SimpleNamespace(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
scope_type="tenant",
|
scope_type="tenant",
|
||||||
@@ -29,32 +213,51 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
|||||||
imap_password_encrypted=encrypt_secret("imap-secret"),
|
imap_password_encrypted=encrypt_secret("imap-secret"),
|
||||||
)
|
)
|
||||||
|
|
||||||
smtp, imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=False)
|
with patch(
|
||||||
self.assertEqual(smtp.username, "saved-smtp")
|
"govoplan_mail.backend.mail_profiles.decrypt_secret",
|
||||||
self.assertEqual(smtp.password, "smtp-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)
|
self.assertIsNotNone(imap)
|
||||||
self.assertEqual(imap.username, "saved-imap")
|
assert imap is not None
|
||||||
self.assertEqual(imap.password, "imap-secret")
|
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)
|
_, cleared_imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=True)
|
||||||
self.assertIsNone(cleared_imap)
|
self.assertIsNone(cleared_imap)
|
||||||
|
|
||||||
def test_apply_transport_update_preserves_unsupplied_passwords(self):
|
def test_apply_transport_update_preserves_unsupplied_passwords(self):
|
||||||
profile = SimpleNamespace(
|
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_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
|
||||||
smtp_username="saved-smtp",
|
smtp_username="saved-smtp",
|
||||||
smtp_password_encrypted=encrypt_secret("smtp-secret"),
|
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_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
|
||||||
imap_username="saved-imap",
|
imap_username="saved-imap",
|
||||||
imap_password_encrypted=encrypt_secret("imap-secret"),
|
imap_password_encrypted=encrypt_secret("imap-secret"),
|
||||||
|
imap_transport_revision="imap-before",
|
||||||
)
|
)
|
||||||
|
session = SimpleNamespace(flush=lambda: None)
|
||||||
|
|
||||||
_apply_profile_transport_update(
|
with patch("govoplan_mail.backend.mail_profiles.clear_mailbox_index") as clear_index:
|
||||||
profile,
|
_apply_profile_transport_update(
|
||||||
smtp=SmtpConfig(host="smtp2.example.org", username="new-smtp"),
|
session, # type: ignore[arg-type]
|
||||||
imap=ImapConfig(host="imap2.example.org", username="new-imap"),
|
profile,
|
||||||
clear_imap=False,
|
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_config["host"], "smtp2.example.org")
|
||||||
self.assertEqual(profile.smtp_username, "new-smtp")
|
self.assertEqual(profile.smtp_username, "new-smtp")
|
||||||
@@ -62,14 +265,255 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
|||||||
self.assertEqual(profile.imap_config["host"], "imap2.example.org")
|
self.assertEqual(profile.imap_config["host"], "imap2.example.org")
|
||||||
self.assertEqual(profile.imap_username, "new-imap")
|
self.assertEqual(profile.imap_username, "new-imap")
|
||||||
self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "imap-secret")
|
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")
|
||||||
|
|
||||||
_apply_profile_transport_update(profile, smtp=None, imap=None, clear_imap=True)
|
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_config)
|
||||||
self.assertIsNone(profile.imap_username)
|
self.assertIsNone(profile.imap_username)
|
||||||
self.assertIsNone(profile.imap_password_encrypted)
|
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):
|
class MailProfilePolicyHelperTests(unittest.TestCase):
|
||||||
|
def test_campaign_contract_accepts_only_mail_profile_reference(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}),
|
||||||
|
"profile-1",
|
||||||
|
)
|
||||||
|
for legacy in ("smtp", "imap", "credentials", "inherit_smtp_credentials"):
|
||||||
|
with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select a Mail profile"):
|
||||||
|
_campaign_mail_profile_reference_id({"mail_profile_id": "profile-1", legacy: {}})
|
||||||
|
|
||||||
|
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, "Store the credentials on a Mail profile"):
|
||||||
|
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||||
|
|
||||||
def test_merge_policy_respects_locked_lower_level_limits(self):
|
def test_merge_policy_respects_locked_lower_level_limits(self):
|
||||||
policy = EffectiveMailProfilePolicy()
|
policy = EffectiveMailProfilePolicy()
|
||||||
_merge_policy(
|
_merge_policy(
|
||||||
|
|||||||
49
tests/test_mailbox_index.py
Normal file
49
tests/test_mailbox_index.py
Normal 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()
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleManifest
|
||||||
from govoplan_mail.backend.manifest import get_manifest
|
from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manifest
|
||||||
|
|
||||||
|
|
||||||
class MailManifestTests(unittest.TestCase):
|
class MailManifestTests(unittest.TestCase):
|
||||||
@@ -14,6 +16,97 @@ class MailManifestTests(unittest.TestCase):
|
|||||||
self.assertEqual(manifest.id, "mail")
|
self.assertEqual(manifest.id, "mail")
|
||||||
self.assertIn("addresses", manifest.optional_dependencies)
|
self.assertIn("addresses", manifest.optional_dependencies)
|
||||||
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
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
|
||||||
|
],
|
||||||
|
)
|
||||||
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
419
tests/test_profile_actor_authorization.py
Normal file
419
tests/test_profile_actor_authorization.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_mail.backend import router
|
||||||
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
|
EffectiveMailProfilePolicy,
|
||||||
|
MailProfileError,
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class _Principal:
|
||||||
|
tenant_id = "tenant-1"
|
||||||
|
user = SimpleNamespace(id="user-1")
|
||||||
|
group_ids = frozenset({"group-1"})
|
||||||
|
|
||||||
|
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_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_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()
|
||||||
173
tests/test_router_profile_deletion.py
Normal file
173
tests/test_router_profile_deletion.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
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"),
|
||||||
|
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()
|
||||||
|
|
||||||
|
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._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()
|
||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -17,15 +17,15 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
||||||
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
||||||
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
||||||
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-local settings",
|
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-scoped profiles",
|
||||||
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-local settings",
|
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles",
|
||||||
"i18n:govoplan-mail.campaigns": "Campaigns",
|
"i18n:govoplan-mail.campaigns": "Campaigns",
|
||||||
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
|
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
|
||||||
"i18n:govoplan-mail.cancel.77dfd213": "Cancel",
|
"i18n:govoplan-mail.cancel.77dfd213": "Cancel",
|
||||||
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
|
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
|
||||||
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
|
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
|
||||||
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
|
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
|
||||||
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Controls whether campaigns may use inline SMTP/IMAP settings instead of reusable profiles.",
|
"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_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.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.create_mail_profile.4d2f8f9f": "Create mail profile",
|
||||||
@@ -37,8 +37,8 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"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_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_value.a276a667": "Deactivate {value0}",
|
||||||
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
|
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
|
||||||
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Decides whether lower scopes inherit saved IMAP credentials from a selected profile or must provide local IMAP credentials.",
|
"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": "Decides whether lower scopes inherit saved SMTP credentials from a selected profile or must provide local SMTP credentials.",
|
"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.deny.53577bb5": "Deny",
|
||||||
"i18n:govoplan-mail.description.55f8ebc8": "Description",
|
"i18n:govoplan-mail.description.55f8ebc8": "Description",
|
||||||
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Development mock mailbox",
|
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Development mock mailbox",
|
||||||
@@ -214,15 +214,15 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
"i18n:govoplan-mail.bytes_b": "{value0} B",
|
||||||
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
|
||||||
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
|
||||||
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-local settings",
|
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "kampagnenbezogene Profile",
|
||||||
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-local settings",
|
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Kampagnenbezogene Profile",
|
||||||
"i18n:govoplan-mail.campaigns": "Kampagnen",
|
"i18n:govoplan-mail.campaigns": "Kampagnen",
|
||||||
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
|
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
|
||||||
"i18n:govoplan-mail.cancel.77dfd213": "Abbrechen",
|
"i18n:govoplan-mail.cancel.77dfd213": "Abbrechen",
|
||||||
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
|
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
|
||||||
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
|
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
|
||||||
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
|
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
|
||||||
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Controls whether campaigns may use inline SMTP/IMAP settings instead of reusable profiles.",
|
"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_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.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.create_mail_profile.4d2f8f9f": "Create mail profile",
|
||||||
@@ -234,8 +234,8 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"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_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_value.a276a667": "Deactivate {value0}",
|
||||||
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
|
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
|
||||||
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Decides whether lower scopes inherit saved IMAP credentials from a selected profile or must provide local IMAP credentials.",
|
"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": "Decides whether lower scopes inherit saved SMTP credentials from a selected profile or must provide local SMTP credentials.",
|
"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.deny.53577bb5": "Deny",
|
||||||
"i18n:govoplan-mail.description.55f8ebc8": "Beschreibung",
|
"i18n:govoplan-mail.description.55f8ebc8": "Beschreibung",
|
||||||
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox",
|
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox",
|
||||||
|
|||||||
Reference in New Issue
Block a user