11 Commits
v0.1.3 ... main

50 changed files with 4812 additions and 1142 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

18
.gitignore vendored
View File

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

View File

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

View File

@@ -1,5 +1,9 @@
# govoplan-mail
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile management, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
## Ownership
@@ -50,7 +54,20 @@ Frontend package:
@govoplan/mail-webui
```
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection.
The campaign module consumes `mail.campaign_delivery` for sending,
append-to-Sent behavior, profile selection, and policy checks. Mail does not
import campaign internals; campaign-scoped policy and owner context are resolved
through the core `campaigns.mailPolicyContext` capability when the campaign
module is installed.
Development mailbox routes are registered by the mail module only when the
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
does not contribute these routes directly.
POP3 and JMAP are deferred. The protocol decision is documented in
[docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md): stabilize
SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3
only for explicit legacy-download requirements.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.

View File

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

View File

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

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-mail"
version = "0.1.3"
version = "0.1.8"
description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.3",
"govoplan-core>=0.1.8",
"pydantic>=2,<3",
"redis>=5,<6",
"SQLAlchemy>=2,<3",

View File

@@ -41,6 +41,12 @@ class MailCampaignCapability:
append_message_to_sent = staticmethod(append_message_to_sent)
send_email_message = staticmethod(send_email_message)
@staticmethod
def mock_mailbox():
from govoplan_mail.backend.dev import mock_mailbox
return mock_mailbox
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
configure_runtime(settings=context.settings)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,259 @@
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationContext, DocumentationLink, DocumentationTopic
from govoplan_mail.backend.mail_profiles import MailProfileError, effective_mail_profile_policy_for_scope
MAIL_POLICY_DOC_SCOPES = ("mail:profile:read", "admin:policies:read", "system:settings:read")
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
topic = _tenant_mail_policy_topic(context)
return (topic,) if topic is not None else ()
def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTopic | None:
principal = context.principal
tenant_id = str(getattr(principal, "tenant_id", "") or "")
if not tenant_id:
return None
if context.documentation_type == "admin" and not _has_any_scope(principal, MAIL_POLICY_DOC_SCOPES):
return None
session = context.session
if not isinstance(session, Session):
return None
try:
policy = effective_mail_profile_policy_for_scope(session, tenant_id=tenant_id, scope_type="tenant")
except MailProfileError as exc:
return DocumentationTopic(
id="mail.tenant-profile-policy-unavailable",
title="Mail server policy could not be evaluated",
summary="Mail profile documentation is installed, but the tenant-level effective policy could not be read for this request.",
body=str(exc),
layer="available",
documentation_types=(context.documentation_type,),
source_module_id="mail",
order=41,
links=(_mail_policy_api_link(),),
metadata={"error_type": type(exc).__name__},
)
effective = policy.as_dict()
if context.documentation_type == "user":
summary, body = _mail_policy_user_text(effective)
return DocumentationTopic(
id="mail.tenant-profile-policy-user",
title="Choosing a mail server",
summary=summary,
body=body,
layer="configured",
documentation_types=("user",),
audience=("mail_user", "campaign_user"),
order=40,
links=(
DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("Campaigns can use these mail choices when the campaign module is available.",),
configuration_keys=("mail_profile_policy",),
i18n_key="mail.topic.tenant_profile_policy_user",
translations=_user_mail_policy_translations(effective),
source_module_id="mail",
metadata=_mail_policy_user_metadata(effective),
)
summary, body = _mail_policy_admin_text(effective, source_count=len(policy.source_policies))
return DocumentationTopic(
id="mail.tenant-profile-policy-admin",
title="Mail server choices for this tenant",
summary=summary,
body=body,
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"),
order=40,
links=(
DocumentationLink(label="Mail profiles API", href="/api/v1/mail/profiles", kind="api"),
_mail_policy_api_link(),
DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("Campaign delivery can use reusable mail profiles when govoplan-campaign is installed.",),
configuration_keys=("mail_profile_policy",),
i18n_key="mail.topic.tenant_profile_policy_admin",
source_module_id="mail",
metadata=_mail_policy_metadata(effective, source_count=len(policy.source_policies)),
)
def _mail_policy_admin_text(policy: dict[str, Any], *, source_count: int) -> tuple[str, str]:
allowed_profile_ids = policy.get("allowed_profile_ids")
lower_scopes = _allowed_lower_scopes(policy)
approved_profile_limit = isinstance(allowed_profile_ids, list)
pattern_counts = _pattern_counts(policy)
locked_limit_count = sum(1 for value in _lower_limit_values(policy) if value is False)
if approved_profile_limit and not lower_scopes:
summary = "This tenant is in approved-profile mode: users can choose configured mail profiles, but lower scopes cannot bring arbitrary SMTP or IMAP servers."
elif approved_profile_limit:
summary = "This tenant limits mail sending to approved profile ids, while selected lower scopes can still define profiles within policy limits."
elif lower_scopes:
summary = "This tenant permits lower-scope mail profiles subject to the effective host, sender, recipient, and credential policy."
else:
summary = "This tenant uses centrally managed mail profiles; lower-scope profile creation is disabled by the effective policy."
approved_line = _approved_profile_line(allowed_profile_ids)
lower_scope_line = _lower_scope_line(lower_scopes)
credential_line = _credential_line(policy)
pattern_line = f"Allow-list pattern groups: {pattern_counts['whitelist']}. Deny-list patterns: {pattern_counts['blacklist']}."
lower_limit_line = f"Locked lower-level limits: {locked_limit_count}. Policy sources applied: {source_count}."
body = "\n".join([approved_line, lower_scope_line, credential_line, pattern_line, lower_limit_line])
return summary, body
def _mail_policy_user_text(policy: dict[str, Any]) -> tuple[str, str]:
allowed_profile_ids = policy.get("allowed_profile_ids")
lower_scopes = _allowed_lower_scopes(policy)
approved_profile_limit = isinstance(allowed_profile_ids, list)
if approved_profile_limit and not lower_scopes:
summary = "You can choose from approved mail servers, but you cannot add your own mail server for this tenant."
body = "This is set by tenant policy. If the mail server you need is not offered, ask an administrator to add it as an approved mail profile."
elif approved_profile_limit:
summary = "You can use approved mail servers. Some local mail-server settings may also be allowed, depending on where you work."
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."
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."
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."
else:
summary = "Mail servers are managed centrally for this tenant."
body = "You cannot add a personal, group, or campaign mail server here. Choose one of the configured options or ask an administrator to add another approved profile."
return summary, body
def _user_mail_policy_translations(policy: dict[str, Any]) -> dict[str, dict[str, str]]:
allowed_profile_ids = policy.get("allowed_profile_ids")
lower_scopes = _allowed_lower_scopes(policy)
approved_profile_limit = isinstance(allowed_profile_ids, list)
if approved_profile_limit and not lower_scopes:
return {
"de": {
"title": "Mailserver auswaehlen",
"summary": "Sie koennen aus freigegebenen Mailservern waehlen, aber keinen eigenen Mailserver fuer diesen Tenant eintragen.",
"body": "Das wird durch die Tenant-Regeln festgelegt. Wenn der benoetigte Mailserver nicht angeboten wird, bitten Sie eine Administratorin oder einen Administrator, ihn als freigegebenes Mailprofil anzulegen.",
},
}
if approved_profile_limit:
return {
"de": {
"title": "Mailserver auswaehlen",
"summary": "Sie koennen freigegebene Mailserver nutzen. In manchen Bereichen koennen zusaetzliche lokale Einstellungen erlaubt sein.",
"body": "Die Auswahl wird durch Tenant-Regeln begrenzt. Auch wenn lokale Einstellungen erlaubt sind, prueft GovOPlaN Server, Absender, Empfaenger und Zugangsdaten.",
},
}
if lower_scopes:
return {
"de": {
"title": "Mailserver auswaehlen",
"summary": "In bestimmten Bereichen koennen Sie einen Mailserver eintragen, 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.",
},
}
return {
"de": {
"title": "Mailserver auswaehlen",
"summary": "Mailserver werden fuer diesen Tenant zentral verwaltet.",
"body": "Sie koennen hier keinen persoenlichen, Gruppen- oder Kampagnen-Mailserver eintragen. Waehlen Sie eine konfigurierte Option oder bitten Sie eine Administratorin oder einen Administrator um ein weiteres freigegebenes Profil.",
},
}
def _approved_profile_line(allowed_profile_ids: Any) -> str:
if isinstance(allowed_profile_ids, list):
count = len(allowed_profile_ids)
if count == 0:
return "Approved profiles: the active policy currently allows no reusable profile ids."
return f"Approved profiles: users are limited to {count} reusable profile id(s) selected by policy."
return "Approved profiles: no explicit approved-profile list is active."
def _lower_scope_line(lower_scopes: tuple[str, ...]) -> str:
if not lower_scopes:
return "Lower scopes: user, group, and campaign-local profile creation are disabled."
return "Lower scopes allowed to define profiles: " + ", ".join(lower_scopes) + "."
def _credential_line(policy: dict[str, Any]) -> str:
smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True))
imap_inherit = bool((policy.get("imap_credentials") or {}).get("inherit", True))
return f"Credential inheritance: SMTP {'inherits' if smtp_inherit else 'requires local credentials'}; IMAP {'inherits' if imap_inherit else 'requires local credentials'}."
def _allowed_lower_scopes(policy: dict[str, Any]) -> tuple[str, ...]:
scopes: list[str] = []
if policy.get("allow_user_profiles") is not False:
scopes.append("user")
if policy.get("allow_group_profiles") is not False:
scopes.append("group")
if policy.get("allow_campaign_profiles") is not False:
scopes.append("campaign")
return tuple(scopes)
def _pattern_counts(policy: dict[str, Any]) -> dict[str, int]:
return {
"whitelist": _pattern_count(policy.get("whitelist")),
"blacklist": _pattern_count(policy.get("blacklist")),
}
def _pattern_count(payload: Any) -> int:
if not isinstance(payload, dict):
return 0
return sum(len(value) for value in payload.values() if isinstance(value, list))
def _lower_limit_values(policy: dict[str, Any]) -> tuple[bool, ...]:
lower_limits = policy.get("allow_lower_level_limits")
if not isinstance(lower_limits, dict):
return ()
return tuple(value for value in lower_limits.values() if isinstance(value, bool))
def _mail_policy_metadata(policy: dict[str, Any], *, source_count: int) -> dict[str, Any]:
allowed_profile_ids = policy.get("allowed_profile_ids")
pattern_counts = _pattern_counts(policy)
return {
"approved_profile_limit_active": isinstance(allowed_profile_ids, list),
"approved_profile_count": len(allowed_profile_ids) if isinstance(allowed_profile_ids, list) else None,
"lower_scopes_allowed": list(_allowed_lower_scopes(policy)),
"whitelist_pattern_count": pattern_counts["whitelist"],
"blacklist_pattern_count": pattern_counts["blacklist"],
"locked_lower_level_limit_count": sum(1 for value in _lower_limit_values(policy) if value is False),
"policy_source_count": source_count,
}
def _mail_policy_user_metadata(policy: dict[str, Any]) -> dict[str, Any]:
allowed_profile_ids = policy.get("allowed_profile_ids")
return {
"approved_profile_limit_active": isinstance(allowed_profile_ids, list),
"can_add_local_mail_server": bool(_allowed_lower_scopes(policy)),
"lower_scopes_allowed": list(_allowed_lower_scopes(policy)),
}
def _has_any_scope(principal: object | None, scopes: tuple[str, ...]) -> bool:
has = getattr(principal, "has", None)
if callable(has):
return any(bool(has(scope)) for scope in scopes)
principal_scopes = set(getattr(principal, "scopes", ()) or ())
return "*" in principal_scopes or any(scope in principal_scopes for scope in scopes)
def _mail_policy_api_link() -> DocumentationLink:
return DocumentationLink(label="Tenant mail policy API", href="/api/v1/mail/policies/tenant", kind="api")

View File

@@ -2,17 +2,24 @@ from __future__ import annotations
import copy
import fnmatch
import json
import re
from dataclasses import dataclass, field
from typing import Any, Iterable
from sqlalchemy import and_, or_
from sqlalchemy import and_, or_, text
from sqlalchemy.orm import Session
from govoplan_core.admin.governance import get_system_settings
from govoplan_core.core.optional import reraise_unless_missing_package
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
from govoplan_mail.backend.db.models import MailServerProfile
from govoplan_core.admin.settings import get_system_settings
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
CampaignMailPolicyContext,
CampaignMailPolicyContextProvider,
)
from govoplan_core.core.policy import policy_source_step
from govoplan_core.core.runtime import get_registry
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
@@ -49,15 +56,6 @@ def default_mail_policy_lower_level_limits() -> dict[str, bool]:
return {key: True for key in MAIL_POLICY_LIMIT_KEYS}
def _campaign_model():
try:
from govoplan_campaign.backend.db.models import Campaign
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_campaign")
raise MailProfileError("Campaign module is not installed") from exc
return Campaign
@dataclass(slots=True)
class EffectiveCredentialPolicy:
inherit: bool = True
@@ -240,6 +238,178 @@ def _policy_from_attr(value: Any) -> dict[str, Any]:
return normalize_mail_profile_policy(value if isinstance(value, dict) else {})
def _json_object(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _access_directory() -> AccessDirectory | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
return None
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise MailProfileError("Access directory capability is invalid")
return capability
def _campaign_policy_provider() -> CampaignMailPolicyContextProvider | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT):
return None
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
if not isinstance(capability, CampaignMailPolicyContextProvider):
raise MailProfileError("Campaign mail policy context capability is invalid")
return capability
def _campaign_policy_context(session: Session, *, tenant_id: str, campaign_id: str) -> CampaignMailPolicyContext:
provider = _campaign_policy_provider()
if provider is None:
raise MailProfileError("Campaign module is not available")
context = provider.get_campaign_mail_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
if context is None:
raise MailProfileError("Campaign not found for mail-server profile policy")
return context
def _legacy_tenant_settings(session: Session, tenant_id: str) -> dict[str, Any] | None:
row = session.execute(text("select settings from core_scopes where id = :tenant_id"), {"tenant_id": tenant_id}).first()
return _json_object(row[0]) if row else None
def _legacy_subject_policy(session: Session, *, subject_type: str, tenant_id: str, scope_id: str) -> dict[str, Any] | None:
if subject_type == "user":
statement = text("select mail_profile_policy from access_users where id = :scope_id and tenant_id = :tenant_id")
elif subject_type == "group":
statement = text("select mail_profile_policy from access_groups where id = :scope_id and tenant_id = :tenant_id")
else:
raise MailProfileError("Unsupported mail policy subject")
row = session.execute(
statement,
{"scope_id": scope_id, "tenant_id": tenant_id},
).first()
return _json_object(row[0]) if row else None
def _tenant_exists(session: Session, tenant_id: str) -> bool:
return _legacy_tenant_settings(session, tenant_id) is not None
def _user_exists(session: Session, *, tenant_id: str, user_id: str) -> bool:
directory = _access_directory()
if directory is not None:
user = directory.get_user(user_id)
return bool(user and user.tenant_id == tenant_id and user.status == "active")
return _legacy_subject_policy(session, subject_type="user", tenant_id=tenant_id, scope_id=user_id) is not None
def _group_exists(session: Session, *, tenant_id: str, group_id: str) -> bool:
directory = _access_directory()
if directory is not None:
group = directory.get_group(group_id)
return bool(group and group.tenant_id == tenant_id and group.status == "active")
return _legacy_subject_policy(session, subject_type="group", tenant_id=tenant_id, scope_id=group_id) is not None
def _policy_scope_key(*, tenant_id: str, scope_type: str, scope_id: str | None) -> tuple[str | None, str, str | None]:
clean_scope = _normalize_scope_type(scope_type)
if clean_scope == "system":
return None, clean_scope, None
if clean_scope == "tenant":
return tenant_id, clean_scope, scope_id or tenant_id
return tenant_id, clean_scope, scope_id
def _mail_policy_row(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> MailProfilePolicy | None:
row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
query = session.query(MailProfilePolicy).filter(MailProfilePolicy.scope_type == row_scope_type)
query = query.filter(MailProfilePolicy.tenant_id.is_(None) if row_tenant_id is None else MailProfilePolicy.tenant_id == row_tenant_id)
query = query.filter(MailProfilePolicy.scope_id.is_(None) if row_scope_id is None else MailProfilePolicy.scope_id == row_scope_id)
return query.one_or_none()
def _legacy_mail_profile_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
) -> dict[str, Any] | None:
clean_scope = _normalize_scope_type(scope_type)
if clean_scope == "system":
return _policy_from_settings(get_system_settings(session).settings or {})
if clean_scope == "tenant":
if scope_id and scope_id != tenant_id:
return None
settings = _legacy_tenant_settings(session, tenant_id)
return _policy_from_settings(settings or {}) if settings is not None else None
if not scope_id:
return None
if clean_scope == "user":
legacy = _legacy_subject_policy(session, subject_type="user", tenant_id=tenant_id, scope_id=scope_id)
return _policy_from_attr(legacy) if legacy is not None else None
if clean_scope == "group":
legacy = _legacy_subject_policy(session, subject_type="group", tenant_id=tenant_id, scope_id=scope_id)
return _policy_from_attr(legacy) if legacy is not None else None
try:
context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
except MailProfileError:
return None
return _policy_from_attr(context.mail_profile_policy)
def _read_mail_profile_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None = None,
) -> dict[str, Any] | None:
clean_scope = _normalize_scope_type(scope_type)
if clean_scope != "campaign":
row = _mail_policy_row(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id)
if row is not None:
return _policy_from_attr(row.policy)
return _legacy_mail_profile_policy(session, tenant_id=tenant_id, scope_type=clean_scope, scope_id=scope_id)
def _write_mail_profile_policy(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
policy: dict[str, Any],
) -> dict[str, Any]:
row_tenant_id, row_scope_type, row_scope_id = _policy_scope_key(tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
row = _mail_policy_row(session, tenant_id=tenant_id, scope_type=row_scope_type, scope_id=row_scope_id)
if row is None:
row = MailProfilePolicy(
tenant_id=row_tenant_id,
scope_type=row_scope_type,
scope_id=row_scope_id,
policy=policy,
)
else:
row.policy = policy
session.add(row)
return policy
def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False) -> list[str]:
fields: list[str] = []
@@ -298,13 +468,13 @@ def _mail_policy_source_policy(policy: dict[str, Any], *, baseline: bool = False
def _mail_policy_source_step(source: str, source_id: str | None, label: str, policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
return {
"scope_type": source,
"scope_id": source_id,
"label": label,
"applied_fields": _mail_policy_source_fields(policy, baseline=baseline),
"policy": _mail_policy_source_policy(policy, baseline=baseline),
}
return policy_source_step(
source,
label,
source_id,
applied_fields=_mail_policy_source_fields(policy, baseline=baseline),
policy=_mail_policy_source_policy(policy, baseline=baseline),
)
def _merge_credential_policy(
@@ -326,15 +496,7 @@ def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, s
normalized = normalize_mail_profile_policy(data)
policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline))
lower_limits = dict(policy.allow_lower_level_limits)
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
if allowed_ids and lower_limits.get("allowed_profile_ids", True):
policy.allowed_profile_id_sets.append(set(allowed_ids))
if normalized.get("allow_user_profiles") is False and lower_limits.get("allow_user_profiles", True):
policy.allow_user_profiles = False
if normalized.get("allow_group_profiles") is False and lower_limits.get("allow_group_profiles", True):
policy.allow_group_profiles = False
if normalized.get("allow_campaign_profiles") is False and lower_limits.get("allow_campaign_profiles", True):
policy.allow_campaign_profiles = False
_merge_profile_definition_policy(policy, normalized, lower_limits)
_merge_credential_policy(
policy.smtp_credentials,
normalized.get("smtp_credentials") or {},
@@ -349,19 +511,43 @@ def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, s
lower_limits=lower_limits,
inherit_limit_key="imap_credentials.inherit",
)
_merge_pattern_policy(policy, normalized, lower_limits)
_merge_lower_level_limits(policy, normalized, lower_limits)
def _policy_limit_enabled(lower_limits: dict[str, bool], key: str) -> bool:
return lower_limits.get(key, True)
def _merge_profile_definition_policy(policy: EffectiveMailProfilePolicy, normalized: dict[str, Any], lower_limits: dict[str, bool]) -> None:
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
if allowed_ids and _policy_limit_enabled(lower_limits, "allowed_profile_ids"):
policy.allowed_profile_id_sets.append(set(allowed_ids))
if normalized.get("allow_user_profiles") is False and _policy_limit_enabled(lower_limits, "allow_user_profiles"):
policy.allow_user_profiles = False
if normalized.get("allow_group_profiles") is False and _policy_limit_enabled(lower_limits, "allow_group_profiles"):
policy.allow_group_profiles = False
if normalized.get("allow_campaign_profiles") is False and _policy_limit_enabled(lower_limits, "allow_campaign_profiles"):
policy.allow_campaign_profiles = False
def _merge_pattern_policy(policy: EffectiveMailProfilePolicy, normalized: dict[str, Any], lower_limits: dict[str, bool]) -> None:
whitelist = normalized.get("whitelist") or {}
blacklist = normalized.get("blacklist") or {}
for key in PROFILE_PATTERN_KEYS:
allow_patterns = _meaningful_allow_patterns(whitelist.get(key, []))
if allow_patterns and lower_limits.get(f"whitelist.{key}", True):
if allow_patterns and _policy_limit_enabled(lower_limits, f"whitelist.{key}"):
policy.whitelist_groups.setdefault(key, []).append(allow_patterns)
deny_patterns = _clean_string_list(blacklist.get(key, []))
if deny_patterns and lower_limits.get(f"blacklist.{key}", True):
if deny_patterns and _policy_limit_enabled(lower_limits, f"blacklist.{key}"):
policy.blacklist_patterns.setdefault(key, []).extend(deny_patterns)
def _merge_lower_level_limits(policy: EffectiveMailProfilePolicy, normalized: dict[str, Any], lower_limits: dict[str, bool]) -> None:
local_lower_limits = normalized.get("allow_lower_level_limits") or {}
if local_lower_limits:
policy.allow_lower_level_limits = {
key: lower_limits.get(key, True) and local_lower_limits.get(key, lower_limits.get(key, True))
key: _policy_limit_enabled(lower_limits, key) and local_lower_limits.get(key, _policy_limit_enabled(lower_limits, key))
for key in MAIL_POLICY_LIMIT_KEYS
}
@@ -373,15 +559,13 @@ def _owner_context(
campaign_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> tuple[Any | None, str | None, str | None]:
campaign = None
) -> tuple[CampaignMailPolicyContext | None, str | None, str | None]:
campaign_context = None
if campaign_id:
campaign = session.get(_campaign_model(), campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profile policy")
owner_user_id = campaign.owner_user_id
owner_group_id = campaign.owner_group_id
return campaign, owner_user_id, owner_group_id
campaign_context = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
owner_user_id = campaign_context.owner_user_id
owner_group_id = campaign_context.owner_group_id
return campaign_context, owner_user_id, owner_group_id
def effective_mail_profile_policy(
@@ -399,22 +583,22 @@ def effective_mail_profile_policy(
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
tenant = session.get(Tenant, tenant_id)
if tenant is None:
tenant_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id)
if tenant_policy is None:
raise MailProfileError("Tenant not found for mail-server profile policy")
policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session)
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True)
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant")
system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, system_policy, source="system", label="System", baseline=True)
_merge_policy(policy, tenant_policy, source="tenant", source_id=tenant_id, label="Tenant")
if owner_user_id:
user = session.get(User, owner_user_id)
if user and user.tenant_id == tenant_id:
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user")
user_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=owner_user_id)
if user_policy is not None:
_merge_policy(policy, user_policy, source="user", source_id=owner_user_id, label="Owner user")
if owner_group_id:
group = session.get(Group, owner_group_id)
if group and group.tenant_id == tenant_id:
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group")
group_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=owner_group_id)
if group_policy is not None:
_merge_policy(policy, group_policy, source="group", source_id=owner_group_id, label="Owner group")
if campaign is not None:
_merge_policy(policy, _policy_from_attr(campaign.mail_profile_policy), source="campaign", source_id=campaign.id, label="Campaign")
return policy
@@ -434,8 +618,8 @@ def effective_mail_profile_policy_for_scope(
return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
if clean_scope == "system":
policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session)
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True)
system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, system_policy, source="system", label="System", baseline=True)
return policy
if clean_scope == "tenant":
return effective_mail_profile_policy(session, tenant_id=tenant_id)
@@ -460,33 +644,31 @@ def parent_mail_profile_policy(
scope_id: str | None = None,
) -> EffectiveMailProfilePolicy:
clean_scope = _normalize_scope_type(scope_type)
tenant = session.get(Tenant, tenant_id)
if tenant is None:
tenant_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id)
if tenant_policy is None:
raise MailProfileError("Tenant not found for mail-server profile policy")
policy = EffectiveMailProfilePolicy()
system_settings = get_system_settings(session)
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True)
system_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
_merge_policy(policy, system_policy, source="system", label="System", baseline=True)
if clean_scope == "tenant":
return policy
_merge_policy(policy, _policy_from_settings(tenant.settings or {}), source="tenant", source_id=tenant.id, label="Tenant")
_merge_policy(policy, tenant_policy, source="tenant", source_id=tenant_id, label="Tenant")
if clean_scope in {"user", "group"}:
return policy
if clean_scope != "campaign" or not scope_id:
return policy
campaign = session.get(_campaign_model(), scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
if campaign.owner_user_id:
user = session.get(User, campaign.owner_user_id)
if user and user.tenant_id == tenant_id:
_merge_policy(policy, _policy_from_attr(user.mail_profile_policy), source="user", source_id=user.id, label="Owner user")
user_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=campaign.owner_user_id)
if user_policy is not None:
_merge_policy(policy, user_policy, source="user", source_id=campaign.owner_user_id, label="Owner user")
if campaign.owner_group_id:
group = session.get(Group, campaign.owner_group_id)
if group and group.tenant_id == tenant_id:
_merge_policy(policy, _policy_from_attr(group.mail_profile_policy), source="group", source_id=group.id, label="Owner group")
group_policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=campaign.owner_group_id)
if group_policy is not None:
_merge_policy(policy, group_policy, source="group", source_id=campaign.owner_group_id, label="Owner group")
return policy
@@ -580,22 +762,18 @@ def _scope_tuple_for_create(
target_id = clean_scope_id or user_id
if not target_id:
raise MailProfileError("User-scoped mail-server profiles require a user scope_id")
user = session.get(User, target_id)
if user is None or user.tenant_id != tenant_id:
if not _user_exists(session, tenant_id=tenant_id, user_id=target_id):
raise MailProfileError("User scope is not part of the active tenant")
return tenant_id, clean_scope, target_id
if clean_scope == "group":
if not clean_scope_id:
raise MailProfileError("Group-scoped mail-server profiles require a group scope_id")
group = session.get(Group, clean_scope_id)
if group is None or group.tenant_id != tenant_id:
if not _group_exists(session, tenant_id=tenant_id, group_id=clean_scope_id):
raise MailProfileError("Group scope is not part of the active tenant")
return tenant_id, clean_scope, clean_scope_id
if not clean_scope_id:
raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id")
campaign = session.get(_campaign_model(), clean_scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign scope is not part of the active tenant")
_campaign_policy_context(session, tenant_id=tenant_id, campaign_id=clean_scope_id)
return tenant_id, clean_scope, clean_scope_id
@@ -647,7 +825,7 @@ def _profile_visible_filter(tenant_id: str):
return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id)
def _context_profile_clauses(campaign: Any, policy: EffectiveMailProfilePolicy) -> list[Any]:
def _context_profile_clauses(campaign: CampaignMailPolicyContext, policy: EffectiveMailProfilePolicy) -> list[Any]:
clauses: list[Any] = [
MailServerProfile.scope_type == "system",
and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"),
@@ -674,7 +852,7 @@ def _profile_allowed_for_context(
*,
tenant_id: str,
policy: EffectiveMailProfilePolicy,
campaign: Any | None = None,
campaign: CampaignMailPolicyContext | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> bool:
@@ -711,9 +889,7 @@ def list_mail_server_profiles(
campaign_id: str | None = None,
) -> list[MailServerProfile]:
if campaign_id:
campaign = session.get(_campaign_model(), campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profiles")
campaign = _campaign_policy_context(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)))
if not include_inactive:
@@ -762,9 +938,7 @@ def ensure_mail_profile_allowed_for_campaign(
profile_id: str,
require_active: bool = True,
) -> MailServerProfile:
campaign = session.get(_campaign_model(), campaign_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign not found for mail-server profile policy")
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=campaign_id)
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active)
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
if not _profile_allowed_for_context(
@@ -1029,49 +1203,81 @@ def update_mail_server_profile(
if is_active is not None:
profile.is_active = is_active
next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "username": _profile_username(profile, "smtp"), "password": decrypt_secret(profile.smtp_password_encrypted)})
if clear_imap:
next_imap = None
elif imap is not None:
next_imap = imap
elif profile.imap_config:
next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "username": _profile_username(profile, "imap"), "password": decrypt_secret(profile.imap_password_encrypted)})
else:
next_imap = None
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)
_apply_profile_transport_update(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
profile.updated_by_user_id = user_id
session.add(profile)
session.flush()
return profile
def _next_profile_transport_state(
profile: MailServerProfile,
*,
smtp: SmtpConfig | None,
imap: ImapConfig | None,
clear_imap: bool,
) -> tuple[SmtpConfig, ImapConfig | None]:
next_smtp = smtp or smtp_config_from_profile(profile)
if clear_imap:
return next_smtp, None
if imap is not None:
return next_smtp, imap
return next_smtp, imap_config_from_profile(profile)
def _assert_profile_transport_allowed(
session: Session,
*,
tenant_id: str,
profile: MailServerProfile,
smtp: SmtpConfig,
imap: ImapConfig | None,
) -> None:
scope_type = _profile_scope_type(profile)
scope_id = _profile_scope_id(profile)
if scope_type == "campaign":
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, campaign_id=scope_id)
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, campaign_id=scope_id)
elif scope_type == "user":
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, owner_user_id=scope_id)
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, owner_user_id=scope_id)
elif scope_type == "group":
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap, owner_group_id=scope_id)
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap, owner_group_id=scope_id)
elif scope_type == "tenant":
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap)
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap)
def _apply_profile_transport_update(
profile: MailServerProfile,
*,
smtp: SmtpConfig | None,
imap: ImapConfig | None,
clear_imap: bool,
) -> None:
if smtp is not None:
smtp_payload, smtp_username, smtp_password, username_supplied, password_supplied = _transport_payload(smtp)
profile.smtp_config = smtp_payload
if username_supplied:
profile.smtp_username = smtp_username
if password_supplied:
profile.smtp_password_encrypted = encrypt_secret(smtp_password)
_apply_profile_transport_payload(profile, "smtp", smtp)
if clear_imap:
profile.imap_config = None
profile.imap_username = None
profile.imap_password_encrypted = None
elif imap is not None:
imap_payload, imap_username, imap_password, username_supplied, password_supplied = _transport_payload(imap)
profile.imap_config = imap_payload
_apply_profile_transport_payload(profile, "imap", imap)
def _apply_profile_transport_payload(profile: MailServerProfile, protocol: str, config: SmtpConfig | ImapConfig) -> None:
payload, username, password, username_supplied, password_supplied = _transport_payload(config)
if protocol == "smtp":
profile.smtp_config = payload
if username_supplied:
profile.imap_username = imap_username
profile.smtp_username = username
if password_supplied:
profile.imap_password_encrypted = encrypt_secret(imap_password)
profile.updated_by_user_id = user_id
session.add(profile)
session.flush()
return profile
profile.smtp_password_encrypted = encrypt_secret(password)
return
profile.imap_config = payload
if username_supplied:
profile.imap_username = username
if password_supplied:
profile.imap_password_encrypted = encrypt_secret(password)
def _profile_username(profile: MailServerProfile, protocol: str) -> str | None:
@@ -1165,54 +1371,73 @@ def get_mail_profile_policy(
) -> dict[str, Any]:
clean_scope = _normalize_scope_type(scope_type)
if clean_scope == "system":
return _policy_from_settings(get_system_settings(session).settings or {})
return _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system") or {}
if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id:
policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=scope_id or tenant_id)
if policy is None:
raise MailProfileError("Tenant policy not found")
return _policy_from_settings(tenant.settings or {})
return policy
if not scope_id:
raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id")
if clean_scope == "user":
user = session.get(User, scope_id)
if user is None or user.tenant_id != tenant_id:
policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=scope_id)
if policy is None:
raise MailProfileError("User policy not found")
return _policy_from_attr(user.mail_profile_policy)
return policy
if clean_scope == "group":
group = session.get(Group, scope_id)
if group is None or group.tenant_id != tenant_id:
policy = _read_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=scope_id)
if policy is None:
raise MailProfileError("Group policy not found")
return _policy_from_attr(group.mail_profile_policy)
campaign = session.get(_campaign_model(), scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
return policy
campaign = _campaign_policy_context(session, tenant_id=tenant_id, campaign_id=scope_id)
return _policy_from_attr(campaign.mail_profile_policy)
def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None:
parent_limits = parent.allow_lower_level_limits
violations = _policy_parent_lock_violations(parent.allow_lower_level_limits, normalized)
if violations:
raise MailProfileError(_policy_parent_lock_message(violations[0]))
def _policy_parent_lock_violations(parent_limits: dict[str, bool], normalized: dict[str, Any]) -> list[str]:
violations: list[str] = []
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
if allowed_ids and not parent_limits.get("allowed_profile_ids", True):
raise MailProfileError("Mail profile allow-list is locked by an ancestor policy")
if allowed_ids and not _policy_limit_enabled(parent_limits, "allowed_profile_ids"):
violations.append("allowed_profile_ids")
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
if isinstance(normalized.get(key), bool) and not parent_limits.get(key, True):
raise MailProfileError(f"{key} is locked by an ancestor policy")
if isinstance(normalized.get(key), bool) and not _policy_limit_enabled(parent_limits, key):
violations.append(key)
for kind in ("whitelist", "blacklist"):
rules = normalized.get(kind) or {}
if isinstance(rules, dict):
for key in PROFILE_PATTERN_KEYS:
if _clean_string_list(rules.get(key, [])) and not parent_limits.get(f"{kind}.{key}", True):
raise MailProfileError(f"{kind}.{key} is locked by an ancestor policy")
limit_key = f"{kind}.{key}"
if _clean_string_list(rules.get(key, [])) and not _policy_limit_enabled(parent_limits, limit_key):
violations.append(limit_key)
for protocol in ("smtp", "imap"):
local = normalized.get(f"{protocol}_credentials") or {}
local_inherit = local.get("inherit")
if isinstance(local_inherit, bool) and not parent_limits.get(f"{protocol}_credentials.inherit", True):
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by an ancestor policy")
limit_key = f"{protocol}_credentials.inherit"
if isinstance(local_inherit, bool) and not _policy_limit_enabled(parent_limits, limit_key):
violations.append(limit_key)
local_lower_limits = normalized.get("allow_lower_level_limits") or {}
if isinstance(local_lower_limits, dict):
for key, allowed in local_lower_limits.items():
if allowed is True and not parent_limits.get(key, True):
raise MailProfileError(f"{key} lower-level limiting cannot be re-enabled below a locked ancestor policy")
if allowed is True and not _policy_limit_enabled(parent_limits, key):
violations.append(f"allow_lower_level_limits.{key}")
return violations
def _policy_parent_lock_message(field: str) -> str:
if field == "allowed_profile_ids":
return "Mail profile allow-list is locked by an ancestor policy"
if field.startswith("allow_lower_level_limits."):
key = field.removeprefix("allow_lower_level_limits.")
return f"{key} lower-level limiting cannot be re-enabled below a locked ancestor policy"
if field.endswith("_credentials.inherit"):
protocol = field.split("_", 1)[0].upper()
return f"{protocol} credential inheritance is locked by an ancestor policy"
return f"{field} is locked by an ancestor policy"
def set_mail_profile_policy(
@@ -1231,42 +1456,28 @@ def set_mail_profile_policy(
normalized,
)
if clean_scope == "system":
item = get_system_settings(session)
settings_payload = dict(item.settings or {})
settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized
item.settings = settings_payload
session.add(item)
return normalized
return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="system", scope_id=None, policy=normalized)
if clean_scope == "tenant":
tenant = session.get(Tenant, scope_id or tenant_id)
if tenant is None or tenant.id != tenant_id:
if (scope_id and scope_id != tenant_id) or not _tenant_exists(session, tenant_id):
raise MailProfileError("Tenant policy not found")
settings_payload = dict(tenant.settings or {})
settings_payload[MAIL_PROFILE_POLICY_SETTINGS_KEY] = normalized
tenant.settings = settings_payload
session.add(tenant)
return normalized
return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="tenant", scope_id=tenant_id, policy=normalized)
if not scope_id:
raise MailProfileError(f"{clean_scope.capitalize()} mail profile policy requires scope_id")
if clean_scope == "user":
user = session.get(User, scope_id)
if user is None or user.tenant_id != tenant_id:
if not _user_exists(session, tenant_id=tenant_id, user_id=scope_id):
raise MailProfileError("User policy not found")
user.mail_profile_policy = normalized
session.add(user)
return normalized
return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="user", scope_id=scope_id, policy=normalized)
if clean_scope == "group":
group = session.get(Group, scope_id)
if group is None or group.tenant_id != tenant_id:
if not _group_exists(session, tenant_id=tenant_id, group_id=scope_id):
raise MailProfileError("Group policy not found")
group.mail_profile_policy = normalized
session.add(group)
return normalized
campaign = session.get(_campaign_model(), scope_id)
if campaign is None or campaign.tenant_id != tenant_id:
raise MailProfileError("Campaign policy not found")
campaign.mail_profile_policy = normalized
session.add(campaign)
return _write_mail_profile_policy(session, tenant_id=tenant_id, scope_type="group", scope_id=scope_id, policy=normalized)
provider = _campaign_policy_provider()
if provider is None:
raise MailProfileError("Campaign module is not available")
try:
provider.set_campaign_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id, policy=normalized)
except ValueError as exc:
raise MailProfileError("Campaign policy not found") from exc
return normalized
@@ -1354,10 +1565,11 @@ def mail_profile_id_from_campaign_json(raw_json: dict[str, Any] | None) -> str |
def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]:
return [
row.group_id
for row in session.query(UserGroupMembership).filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id == user_id,
)
]
directory = _access_directory()
if directory is not None:
return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
rows = session.execute(
text("select group_id from access_user_group_memberships where tenant_id = :tenant_id and user_id = :user_id"),
{"tenant_id": tenant_id, "user_id": user_id},
).all()
return [row[0] for row in rows]

View File

@@ -0,0 +1,287 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.security.time import ensure_aware_utc, utc_now
from govoplan_mail.backend.db.models import MailMailboxFolderIndex, MailMailboxMessageIndex
from govoplan_mail.backend.sending.imap import ImapFolderListResult, ImapMailboxInfo, ImapMailboxMessageListResult, ImapMailboxMessageSummary
MAILBOX_INDEX_TTL_SECONDS = 30
_refresh_lock = Lock()
_refreshing_keys: set[tuple[str, str, str]] = set()
@dataclass(frozen=True, slots=True)
class CachedFolderList:
folders: list[ImapMailboxInfo]
indexed_at: datetime | None
stale: bool
@dataclass(frozen=True, slots=True)
class CachedMessagePage:
folder: str
messages: list[ImapMailboxMessageSummary]
total_count: int
offset: int
limit: int
uidvalidity: str | None
indexed_at: datetime | None
stale: bool
def begin_mailbox_refresh(tenant_id: str, profile_id: str, folder: str) -> bool:
key = (tenant_id, profile_id, folder)
with _refresh_lock:
if key in _refreshing_keys:
return False
_refreshing_keys.add(key)
return True
def finish_mailbox_refresh(tenant_id: str, profile_id: str, folder: str) -> None:
with _refresh_lock:
_refreshing_keys.discard((tenant_id, profile_id, folder))
def cache_mailbox_folders(
session: Session,
*,
tenant_id: str,
profile_id: str,
result: ImapFolderListResult,
indexed_at: datetime | None = None,
) -> None:
indexed_at = indexed_at or utc_now()
existing = {
row.folder: row
for row in session.query(MailMailboxFolderIndex)
.filter(MailMailboxFolderIndex.tenant_id == tenant_id, MailMailboxFolderIndex.profile_id == profile_id)
.all()
}
for folder in result.folders:
row = existing.get(folder.name)
if row is None:
row = MailMailboxFolderIndex(tenant_id=tenant_id, profile_id=profile_id, folder=folder.name)
row.flags = list(folder.flags or [])
row.message_count = folder.message_count
row.unseen_count = folder.unseen_count
row.indexed_at = indexed_at
session.add(row)
def cache_mailbox_messages(
session: Session,
*,
tenant_id: str,
profile_id: str,
result: ImapMailboxMessageListResult,
indexed_at: datetime | None = None,
) -> None:
indexed_at = indexed_at or utc_now()
session.flush()
folder_row = (
session.query(MailMailboxFolderIndex)
.filter(
MailMailboxFolderIndex.tenant_id == tenant_id,
MailMailboxFolderIndex.profile_id == profile_id,
MailMailboxFolderIndex.folder == result.folder,
)
.one_or_none()
)
if folder_row is None:
folder_row = MailMailboxFolderIndex(tenant_id=tenant_id, profile_id=profile_id, folder=result.folder)
folder_row.message_count = result.total_count
folder_row.uidvalidity = result.uidvalidity
folder_row.message_indexed_at = indexed_at
session.add(folder_row)
uids = [message.uid for message in result.messages]
if result.total_count <= 0:
(
session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == result.folder,
)
.delete(synchronize_session=False)
)
return
window_count = max(0, min(result.limit, result.total_count - result.offset))
if window_count:
stale_query = session.query(MailMailboxMessageIndex).filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == result.folder,
MailMailboxMessageIndex.sort_position >= result.offset,
MailMailboxMessageIndex.sort_position < result.offset + window_count,
)
if uids:
stale_query = stale_query.filter(MailMailboxMessageIndex.uid.notin_(uids))
for row in stale_query.all():
session.delete(row)
existing = {}
if uids:
existing = {
row.uid: row
for row in session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == result.folder,
MailMailboxMessageIndex.uid.in_(uids),
)
.all()
}
for index, message in enumerate(result.messages):
row = existing.get(message.uid)
if row is None:
row = MailMailboxMessageIndex(
tenant_id=tenant_id,
profile_id=profile_id,
folder=result.folder,
uid=message.uid,
)
row.uid_int = _uid_int(message.uid)
row.sort_position = result.offset + index
row.subject = message.subject
row.from_header = message.from_header
row.to_header = message.to_header
row.cc_header = message.cc_header
row.date = message.date
row.message_id = message.message_id
row.flags = list(message.flags or [])
row.size_bytes = message.size_bytes
row.body_preview = message.body_preview
row.attachment_count = message.attachment_count
row.indexed_at = indexed_at
session.add(row)
def cached_mailbox_folders(
session: Session,
*,
tenant_id: str,
profile_id: str,
max_age_seconds: int = MAILBOX_INDEX_TTL_SECONDS,
) -> CachedFolderList | None:
rows = (
session.query(MailMailboxFolderIndex)
.filter(
MailMailboxFolderIndex.tenant_id == tenant_id,
MailMailboxFolderIndex.profile_id == profile_id,
MailMailboxFolderIndex.indexed_at.isnot(None),
)
.order_by(MailMailboxFolderIndex.folder.asc())
.all()
)
if not rows:
return None
indexed_at = max((row.indexed_at for row in rows if row.indexed_at), default=None)
return CachedFolderList(
folders=[
ImapMailboxInfo(name=row.folder, flags=list(row.flags or []), message_count=row.message_count, unseen_count=row.unseen_count)
for row in rows
],
indexed_at=indexed_at,
stale=_is_stale(indexed_at, max_age_seconds=max_age_seconds),
)
def cached_mailbox_message_page(
session: Session,
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int,
max_age_seconds: int = MAILBOX_INDEX_TTL_SECONDS,
) -> CachedMessagePage | None:
folder_row = (
session.query(MailMailboxFolderIndex)
.filter(
MailMailboxFolderIndex.tenant_id == tenant_id,
MailMailboxFolderIndex.profile_id == profile_id,
MailMailboxFolderIndex.folder == folder,
)
.one_or_none()
)
if folder_row is None or folder_row.message_indexed_at is None:
return None
total_count = folder_row.message_count
if total_count is None:
total_count = int(
session.query(func.count(MailMailboxMessageIndex.id))
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == folder,
)
.scalar()
or 0
)
rows = (
session.query(MailMailboxMessageIndex)
.filter(
MailMailboxMessageIndex.tenant_id == tenant_id,
MailMailboxMessageIndex.profile_id == profile_id,
MailMailboxMessageIndex.folder == folder,
)
.order_by(MailMailboxMessageIndex.sort_position.asc(), MailMailboxMessageIndex.uid_int.desc(), MailMailboxMessageIndex.uid.desc())
.offset(offset)
.limit(limit)
.all()
)
expected_count = max(0, min(limit, total_count - offset))
if len(rows) < expected_count:
return None
indexed_at = min((row.indexed_at for row in rows if row.indexed_at), default=folder_row.message_indexed_at)
return CachedMessagePage(
folder=folder,
messages=[_message_from_index(row) for row in rows],
total_count=total_count,
offset=offset,
limit=limit,
uidvalidity=folder_row.uidvalidity,
indexed_at=indexed_at,
stale=_is_stale(indexed_at, max_age_seconds=max_age_seconds),
)
def _message_from_index(row: MailMailboxMessageIndex) -> ImapMailboxMessageSummary:
return ImapMailboxMessageSummary(
uid=row.uid,
folder=row.folder,
subject=row.subject,
from_header=row.from_header,
to_header=row.to_header,
cc_header=row.cc_header,
date=row.date,
message_id=row.message_id,
flags=list(row.flags or []),
size_bytes=row.size_bytes,
body_preview=row.body_preview,
attachment_count=row.attachment_count,
)
def _uid_int(uid: str) -> int:
try:
return int(str(uid))
except ValueError:
return 0
def _is_stale(indexed_at: datetime | None, *, max_age_seconds: int) -> bool:
aware = ensure_aware_utc(indexed_at)
if aware is None:
return True
return (utc_now() - aware).total_seconds() > max_age_seconds

View File

@@ -2,8 +2,24 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base
from govoplan_mail.backend.documentation import documentation_topics
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
@@ -56,18 +72,43 @@ ROLE_TEMPLATES = (
def _mail_router(context: ModuleContext):
from govoplan_mail.backend.runtime import configure_runtime
configure_runtime(settings=context.settings)
configure_runtime(registry=context.registry, settings=context.settings)
from fastapi import APIRouter
from govoplan_mail.backend.router import router
return router
aggregate = APIRouter()
aggregate.include_router(router)
app_env = str(getattr(context.settings, "app_env", "dev")).lower()
if app_env == "dev" and bool(getattr(context.settings, "dev_mailbox_api_enabled", False)):
from govoplan_mail.backend.dev_router import router as dev_router
aggregate.include_router(dev_router)
return aggregate
manifest = ModuleManifest(
id="mail",
name="Mail",
version="0.1.2",
dependencies=("access",),
optional_dependencies=(),
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses"),
provides_interfaces=(
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.1.6"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="campaigns.mail_policy_context",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.lookup",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_mail_router,
role_templates=ROLE_TEMPLATES,
@@ -81,10 +122,57 @@ manifest = ModuleManifest(
module_id="mail",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
mail_models.MailServerProfile,
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=(
persistent_table_uninstall_guard(
mail_models.MailServerProfile,
mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex,
label="Mail",
),
),
capability_factories={
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
},
documentation=(
DocumentationTopic(
id="mail.profiles-and-policy",
title="Mail profiles and policy hierarchy",
summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.",
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define their own mail-server settings. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"),
order=39,
i18n_key="mail.topic.profiles_and_policy",
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:profile:read", "admin:policies:read", "system:settings:read"),
configuration_keys=("mail_profile_policy",),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/api/v1/mail/profiles", kind="api"),
DocumentationLink(label="Tenant mail policy", href="/api/v1/mail/policies/tenant", kind="api"),
DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
configuration_keys=("mail_profile_policy",),
),
),
documentation_providers=(documentation_topics,),
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,20 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
import dataclasses
from types import SimpleNamespace
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_mail.backend.schemas import (
MailAddressLookupCandidate,
MailAddressLookupResponse,
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapFolderResponse,
MailMailboxBootstrapResponse,
MailImapTestRequest,
MailMailboxAttachmentResponse,
MailMailboxMessageDetailResponse,
@@ -14,14 +23,32 @@ from govoplan_mail.backend.schemas import (
MailMailboxMessageSummaryResponse,
MailProfilePolicyResponse,
MailProfilePolicyUpdateRequest,
MailSettingsDeltaResponse,
MailServerProfileCreateRequest,
MailServerProfileListResponse,
MailServerProfileResponse,
MailServerProfileUpdateRequest,
MailSmtpTestRequest,
)
from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
decode_sequence_watermark,
encode_sequence_watermark,
record_change,
sequence_watermark_is_expired,
)
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_mail.backend.mailbox_index import (
begin_mailbox_refresh,
cache_mailbox_folders,
cache_mailbox_messages,
cached_mailbox_folders,
cached_mailbox_message_page,
finish_mailbox_refresh,
)
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
create_mail_server_profile,
@@ -37,12 +64,54 @@ from govoplan_mail.backend.mail_profiles import (
update_mail_server_profile,
)
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
from govoplan_mail.backend.runtime import get_registry
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, load_imap_mailbox_bootstrap, test_imap_login
from govoplan_mail.backend.sending.smtp import test_smtp_login
from sqlalchemy.orm import Session
router = APIRouter(prefix="/mail", tags=["mail"])
MAIL_MODULE_ID = "mail"
MAIL_PROFILES_COLLECTION = "mail.profiles"
MAIL_POLICIES_COLLECTION = "mail.profile_policies"
MAIL_SETTINGS_COLLECTIONS = (MAIL_PROFILES_COLLECTION, MAIL_POLICIES_COLLECTION)
MAIL_PROFILE_RESOURCE = "mail_profile"
MAIL_POLICY_RESOURCE = "mail_profile_policy"
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
def _capability_payload(value: object) -> dict[str, Any]:
if dataclasses.is_dataclass(value):
return dataclasses.asdict(value)
if isinstance(value, dict):
return dict(value)
payload: dict[str, Any] = {}
for key in (
"contact_id",
"address_book_id",
"display_name",
"email",
"email_label",
"organization",
"role_title",
"tags",
"source_kind",
"source_ref",
"source_revision",
"provenance",
):
if hasattr(value, key):
payload[key] = getattr(value, key)
return payload
def _registry_capability(name: str) -> object | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name):
return None
return registry.capability(name)
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
@@ -95,6 +164,122 @@ def _profile_response(profile) -> MailServerProfileResponse:
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
def _policy_response(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
campaign_id: str | None = None,
) -> MailProfilePolicyResponse:
policy = get_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
effective_policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
campaign_id=campaign_id,
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(
scope_type=scope_type,
scope_id=scope_id,
policy=policy,
effective_policy=effective,
parent_policy=parent,
effective_policy_sources=effective_sources,
parent_policy_sources=parent_sources,
)
def _record_mail_change(
session: Session,
*,
collection: str,
resource_type: str,
resource_id: str | None,
operation: str,
principal: ApiPrincipal,
tenant_id: str | None,
payload: dict[str, object] | None = None,
) -> None:
if not resource_id:
return
record_change(
session,
module_id=MAIL_MODULE_ID,
collection=collection,
resource_type=resource_type,
resource_id=resource_id,
operation=operation,
tenant_id=tenant_id,
actor_type="user",
actor_id=principal.user.id,
payload=payload or {},
)
def _mail_delta_query(session: Session, *, tenant_id: str, since_sequence: int):
return session.query(ChangeSequenceEntry).filter(
ChangeSequenceEntry.id > since_sequence,
ChangeSequenceEntry.module_id == MAIL_MODULE_ID,
ChangeSequenceEntry.collection.in_(MAIL_SETTINGS_COLLECTIONS),
or_(ChangeSequenceEntry.tenant_id == tenant_id, ChangeSequenceEntry.tenant_id.is_(None)),
)
def _mail_settings_watermark(session: Session, *, tenant_id: str) -> str:
sequence_id = _mail_delta_query(session, tenant_id=tenant_id, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar()
return encode_sequence_watermark(int(sequence_id or 0))
def _mail_settings_entries(session: Session, *, tenant_id: str, since: str, limit: int):
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
expired_for_tenant = sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=tenant_id,
module_id=MAIL_MODULE_ID,
collections=MAIL_SETTINGS_COLLECTIONS,
)
expired_for_system = sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=None,
module_id=MAIL_MODULE_ID,
collections=MAIL_SETTINGS_COLLECTIONS,
)
if expired_for_tenant or expired_for_system:
return None, False
entries_plus_one = _mail_delta_query(
session,
tenant_id=tenant_id,
since_sequence=since_sequence,
).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all()
has_more = len(entries_plus_one) > limit
return entries_plus_one[:limit], has_more
def _mail_settings_response_watermark(session: Session, *, tenant_id: str, entries, has_more: bool) -> str:
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _mail_settings_watermark(session, tenant_id=tenant_id)
def _mail_deleted_item(entry) -> DeltaDeletedItem:
return DeltaDeletedItem(
id=entry.resource_id,
resource_type=entry.resource_type,
revision=encode_sequence_watermark(entry.id),
deleted_at=entry.created_at if entry.operation == "deleted" else None,
)
def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
return MailMailboxMessageSummaryResponse(
uid=message.uid,
@@ -132,6 +317,202 @@ def _mailbox_detail_response(message) -> MailMailboxMessageDetailResponse:
)
def _mailbox_cursor_fingerprint(*, tenant_id: str, profile_id: str, folder: str, limit: int) -> str:
return keyset_query_fingerprint(
MAILBOX_MESSAGES_CURSOR_SCOPE,
{"tenant_id": tenant_id, "profile_id": profile_id, "folder": folder, "limit": limit, "sort": "imap.uid.desc"},
)
def _mailbox_cursor_values(cursor: str | None, *, fingerprint: str | None = None) -> dict[str, object] | None:
try:
return decode_keyset_cursor(MAILBOX_MESSAGES_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
def _mailbox_cursor_limit(cursor: str | None, explicit_limit: int | None) -> int:
if explicit_limit is not None:
return explicit_limit
values = _mailbox_cursor_values(cursor) if cursor else None
raw_limit = values.get("limit") if values else DEFAULT_MAILBOX_MESSAGE_LIMIT
if not isinstance(raw_limit, int) or raw_limit < 1 or raw_limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
return raw_limit
def _mailbox_cursor_position(cursor: str | None, *, fingerprint: str, offset: int) -> tuple[int, str | None, str | None, dict[str, object] | None]:
values = _mailbox_cursor_values(cursor, fingerprint=fingerprint) if cursor else None
if values is None:
return offset, None, None, None
raw_offset = values.get("offset")
if not isinstance(raw_offset, int) or raw_offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
after_uid = values.get("after_uid") or values.get("anchor_uid")
uidvalidity = values.get("uidvalidity")
if not isinstance(after_uid, str) or not after_uid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
if uidvalidity is not None and not isinstance(uidvalidity, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
return raw_offset, after_uid, uidvalidity, values
def _next_mailbox_cursor(
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int,
total_count: int,
uidvalidity: str | None,
messages,
) -> tuple[str | None, bool]:
cursor_stable = bool(uidvalidity)
next_offset = offset + len(messages)
if not cursor_stable or next_offset >= total_count or not messages:
return None, cursor_stable
return (
encode_keyset_cursor(
MAILBOX_MESSAGES_CURSOR_SCOPE,
fingerprint=_mailbox_cursor_fingerprint(tenant_id=tenant_id, profile_id=profile_id, folder=folder, limit=limit),
values={
"offset": next_offset,
"limit": limit,
"uidvalidity": uidvalidity,
"after_uid": messages[-1].uid,
},
),
cursor_stable,
)
def _mailbox_folder_response(
result,
*,
from_cache: bool = False,
refreshing: bool = False,
indexed_at=None,
) -> MailImapFolderListResponse:
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(
ok=True,
host=result.host,
port=result.port,
security=result.security,
message=f"Found {len(folders)} IMAP folder(s).",
folders=folders,
detected_sent_folder=result.detected_sent_folder,
from_cache=from_cache,
refreshing=refreshing,
indexed_at=indexed_at,
)
def _mailbox_messages_response(
*,
profile_id: str,
folder: str,
host: str | None,
port: int | None,
security: str | None,
total_count: int,
offset: int,
limit: int,
cursor: str | None,
next_cursor: str | None,
cursor_stable: bool,
full: bool,
messages,
from_cache: bool = False,
refreshing: bool = False,
indexed_at=None,
) -> MailMailboxMessageListResponse:
return MailMailboxMessageListResponse(
profile_id=profile_id,
folder=folder,
host=host,
port=port,
security=security,
total_count=total_count,
offset=offset,
limit=limit,
cursor=cursor,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=full,
from_cache=from_cache,
refreshing=refreshing,
indexed_at=indexed_at,
messages=[_mailbox_summary_response(message) for message in messages],
)
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.
pass
finally:
finish_mailbox_refresh(tenant_id, profile_id, folder)
def _cached_folder_selection(folders, requested: str, detected_sent_folder: str | None = None) -> str:
names = {folder.name for folder in folders}
if requested in names:
return requested
if "INBOX" in names:
return "INBOX"
if detected_sent_folder and detected_sent_folder in names:
return detected_sent_folder
return folders[0].name if folders else requested
def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str):
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
imap = imap_config_from_profile(profile)
@@ -146,6 +527,122 @@ def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, sco
return parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id).as_dict()
def _full_mail_settings_delta_response(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
include_inactive: bool,
campaign_id: str | None,
) -> MailSettingsDeltaResponse:
profiles = list_mail_server_profiles(
session,
tenant_id=tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
return MailSettingsDeltaResponse(
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),
changed_sections=["profiles", "policy"],
deleted=[],
watermark=_mail_settings_watermark(session, tenant_id=tenant_id),
has_more=False,
full=True,
)
@router.get("/address-lookup", response_model=MailAddressLookupResponse)
def lookup_mail_addresses(
query: str = Query(min_length=1),
limit: int = Query(default=25, ge=1, le=100),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MailAddressLookupResponse:
_require_scope(principal, "mail:profile:use")
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
if capability is None or not hasattr(capability, "lookup"):
return MailAddressLookupResponse(available=False, candidates=[])
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit)
return MailAddressLookupResponse(
available=True,
candidates=[MailAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates],
)
@router.get("/settings/delta", response_model=MailSettingsDeltaResponse)
def mail_settings_delta(
scope_type: str = Query(default="tenant"),
scope_id: str | None = Query(default=None),
include_inactive: bool = False,
campaign_id: str | None = Query(default=None),
since: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
clean_scope_type = scope_type.strip().casefold()
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
_require_policy_read_scope(principal, clean_scope_type)
if since is None:
return _full_mail_settings_delta_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope_type,
scope_id=scope_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
entries, has_more = _mail_settings_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit)
if entries is None:
return _full_mail_settings_delta_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope_type,
scope_id=scope_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
changed_profile_ids = {
entry.resource_id
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION and entry.resource_type == MAIL_PROFILE_RESOURCE
}
visible_profiles = {
profile.id: profile
for profile in list_mail_server_profiles(
session,
tenant_id=principal.tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
if profile.id in changed_profile_ids
}
policy_changed = any(entry.collection == MAIL_POLICIES_COLLECTION for entry in entries)
changed_sections = []
if changed_profile_ids:
changed_sections.append("profiles")
if policy_changed:
changed_sections.append("policy")
deleted = [
_mail_deleted_item(entry)
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION
and entry.resource_type == MAIL_PROFILE_RESOURCE
and entry.resource_id not in visible_profiles
]
return MailSettingsDeltaResponse(
profiles=[_profile_response(profile) for profile in visible_profiles.values()],
policy=_policy_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope_type, scope_id=scope_id, campaign_id=campaign_id) if policy_changed else None,
changed_sections=changed_sections,
deleted=deleted,
watermark=_mail_settings_response_watermark(session, tenant_id=principal.tenant_id, entries=entries, has_more=has_more),
has_more=has_more,
full=False,
)
@router.get("/profiles", response_model=MailServerProfileListResponse)
def list_profiles(
include_inactive: bool = False,
@@ -189,12 +686,22 @@ def create_profile(
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
_record_mail_change(
session,
collection=MAIL_PROFILES_COLLECTION,
resource_type=MAIL_PROFILE_RESOURCE,
resource_id=profile.id,
operation="created",
principal=principal,
tenant_id=profile.tenant_id or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse)
@@ -245,12 +752,22 @@ def update_profile(
imap=imap_config,
clear_imap=payload.clear_imap,
)
_record_mail_change(
session,
collection=MAIL_PROFILES_COLLECTION,
resource_type=MAIL_PROFILE_RESOURCE,
resource_id=profile.id,
operation="updated",
principal=principal,
tenant_id=profile.tenant_id or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
@@ -264,6 +781,16 @@ def deactivate_profile(
_require_profile_write_scope(principal, profile.scope_type or "tenant")
profile.is_active = False
session.add(profile)
_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 or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
@@ -283,20 +810,7 @@ def read_mail_profile_policy(
scope_type = scope_type.strip().casefold()
_require_policy_read_scope(principal, scope_type)
try:
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
effective_policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
campaign_id=campaign_id,
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -319,22 +833,21 @@ def write_mail_profile_policy(
scope_id=scope_id,
policy=payload.policy.model_dump(mode="json"),
)
session.commit()
effective_policy = effective_mail_profile_policy_for_scope(
_record_mail_change(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
collection=MAIL_POLICIES_COLLECTION,
resource_type=MAIL_POLICY_RESOURCE,
resource_id=f"{scope_type}:{scope_id or ''}",
operation="updated",
principal=principal,
tenant_id=None if scope_type == "system" else principal.tenant_id,
payload={"scope_type": scope_type, "scope_id": scope_id},
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
session.commit()
return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse)
@@ -391,8 +904,7 @@ def list_profile_imap_folders(
if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration")
result = list_imap_folders(imap_config=imap)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
return _mailbox_folder_response(result)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc:
@@ -402,19 +914,173 @@ def list_profile_imap_folders(
@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse)
def list_profile_mailbox_folders(
profile_id: str,
background_tasks: BackgroundTasks,
include_status: bool = Query(default=False),
refresh: bool = False,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_folders(imap_config=imap)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
if not include_status and not refresh:
cached = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
if cached is not None:
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(
host=imap.host,
port=imap.port,
security=imap.security.value,
folders=cached.folders,
detected_sent_folder=None,
)
return _mailbox_folder_response(result, from_cache=True, refreshing=refreshing, indexed_at=cached.indexed_at)
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)
session.commit()
return _mailbox_folder_response(result)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@router.get("/profiles/{profile_id}/mailbox/bootstrap", response_model=MailMailboxBootstrapResponse)
def bootstrap_profile_mailbox(
profile_id: str,
background_tasks: BackgroundTasks,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int = Query(default=DEFAULT_MAILBOX_MESSAGE_LIMIT, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
refresh: bool = False,
include_status: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
) -> MailMailboxBootstrapResponse:
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
if not refresh:
cached_folders = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
if cached_folders is not None:
selected_folder = _cached_folder_selection(cached_folders.folders, folder)
cached_messages = cached_mailbox_message_page(
session,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=selected_folder,
limit=limit,
offset=offset,
)
if cached_messages is not None:
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(
host=imap.host,
port=imap.port,
security=imap.security.value,
folders=cached_folders.folders,
detected_sent_folder=None,
)
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=cached_messages.folder,
limit=cached_messages.limit,
offset=cached_messages.offset,
total_count=cached_messages.total_count,
uidvalidity=cached_messages.uidvalidity,
messages=cached_messages.messages,
)
return MailMailboxBootstrapResponse(
profile_id=profile_id,
folder=cached_messages.folder,
folders=_mailbox_folder_response(
folder_result,
from_cache=True,
refreshing=refreshing,
indexed_at=cached_folders.indexed_at,
),
messages=_mailbox_messages_response(
profile_id=profile_id,
folder=cached_messages.folder,
host=imap.host,
port=imap.port,
security=imap.security.value,
total_count=cached_messages.total_count,
offset=cached_messages.offset,
limit=cached_messages.limit,
cursor=None,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=not cursor_stable,
messages=cached_messages.messages,
from_cache=True,
refreshing=refreshing,
indexed_at=cached_messages.indexed_at,
),
)
result = load_imap_mailbox_bootstrap(
imap_config=imap,
folder=folder,
limit=limit,
offset=offset,
include_folder_status=include_status,
)
cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result.folders)
cache_mailbox_messages(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result.messages)
session.commit()
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=result.messages.folder,
limit=result.messages.limit,
offset=result.messages.offset,
total_count=result.messages.total_count,
uidvalidity=result.messages.uidvalidity,
messages=result.messages.messages,
)
return MailMailboxBootstrapResponse(
profile_id=profile_id,
folder=result.messages.folder,
folders=_mailbox_folder_response(result.folders),
messages=_mailbox_messages_response(
profile_id=profile_id,
folder=result.messages.folder,
host=result.messages.host,
port=result.messages.port,
security=result.messages.security,
total_count=result.messages.total_count,
offset=result.messages.offset,
limit=result.messages.limit,
cursor=None,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=not cursor_stable,
messages=result.messages.messages,
),
)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -422,17 +1088,92 @@ def list_profile_mailbox_folders(
@router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse)
def list_profile_mailbox_messages(
profile_id: str,
background_tasks: BackgroundTasks,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int = Query(default=50, ge=1, le=100),
limit: int | None = Query(default=None, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
cursor: str | None = None,
refresh: bool = False,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit, offset=offset)
return MailMailboxMessageListResponse(
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)
effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset)
if not refresh:
cached = cached_mailbox_message_page(
session,
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=folder,
limit=effective_limit,
offset=effective_offset,
)
cache_matches_cursor = cached is not None and (
cursor is None or (expected_uidvalidity is not None and cached.uidvalidity == expected_uidvalidity)
)
if cached is not None and cache_matches_cursor:
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(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=cached.folder,
limit=cached.limit,
offset=cached.offset,
total_count=cached.total_count,
uidvalidity=cached.uidvalidity,
messages=cached.messages,
)
return _mailbox_messages_response(
profile_id=profile_id,
folder=cached.folder,
host=imap.host,
port=imap.port,
security=imap.security.value,
total_count=cached.total_count,
offset=cached.offset,
limit=cached.limit,
cursor=cursor,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=not cursor_stable,
messages=cached.messages,
from_cache=True,
refreshing=refreshing,
indexed_at=cached.indexed_at,
)
result = list_imap_messages(
imap_config=imap,
folder=folder,
limit=effective_limit,
offset=effective_offset,
after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
)
cache_mailbox_messages(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result)
session.commit()
full = bool(cursor_values is not None and result.cursor_reset)
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=result.folder,
limit=result.limit,
offset=result.offset,
total_count=result.total_count,
uidvalidity=result.uidvalidity,
messages=result.messages,
)
return _mailbox_messages_response(
profile_id=profile_id,
folder=result.folder,
host=result.host,
@@ -441,12 +1182,16 @@ def list_profile_mailbox_messages(
total_count=result.total_count,
offset=result.offset,
limit=result.limit,
messages=[_mailbox_summary_response(message) for message in result.messages],
cursor=cursor,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=full or not cursor_stable,
messages=result.messages,
)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -474,7 +1219,7 @@ def get_profile_mailbox_message(
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
status_code = status.HTTP_404_NOT_FOUND if "not found" in str(exc).casefold() else status.HTTP_502_BAD_GATEWAY
raise HTTPException(status_code=status_code, detail=str(exc)) from exc

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import imaplib
import logging
import re
import socket
import ssl
@@ -21,6 +22,8 @@ from govoplan_mail.backend.dev.mock_mailbox import (
record_imap_append,
)
logger = logging.getLogger(__name__)
class ImapConfigurationError(ValueError):
"""Raised when IMAP settings are incomplete or inconsistent."""
@@ -70,6 +73,10 @@ class ImapMailboxAttachmentInfo:
size_bytes: int
def _log_imap_cleanup_failure(action: str, exc: BaseException) -> None:
logger.debug("IMAP cleanup failed while %s: %s", action, exc, exc_info=True)
@dataclass(frozen=True, slots=True)
class ImapMailboxMessageSummary:
uid: str
@@ -115,6 +122,14 @@ class ImapMailboxMessageListResult:
total_count: int
offset: int
limit: int
uidvalidity: str | None = None
cursor_reset: bool = False
@dataclass(frozen=True, slots=True)
class ImapMailboxBootstrapResult:
folders: ImapFolderListResult
messages: ImapMailboxMessageListResult
@dataclass(frozen=True, slots=True)
@@ -168,8 +183,8 @@ def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
except Exception:
try:
client.logout() # type: ignore[possibly-undefined]
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("opening connection", cleanup_exc)
raise
@@ -304,58 +319,200 @@ def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
finally:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("testing login", cleanup_exc)
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
host, port = _require_imap_config(imap_config)
records = list_records(limit=500)
folders = []
for item in MOCK_IMAP_FOLDERS:
name = str(item["name"])
count = sum(1 for record in records if _mock_folder_matches(record, name))
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder="Sent",
)
def _list_imap_folders_on_client(
client: imaplib.IMAP4,
*,
host: str,
port: int,
security: str,
include_status: bool,
) -> ImapFolderListResult:
typ, data = client.list()
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
message_count, unseen_count = (
(None, None)
if not include_status or _has_folder_flag(flags, "noselect")
else _imap_folder_status(client, name)
)
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
return ImapFolderListResult(
host=host,
port=port,
security=security,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
)
def list_imap_folders(*, imap_config: ImapConfig, include_status: bool = True) -> ImapFolderListResult:
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
records = list_records(limit=500)
folders = []
for item in MOCK_IMAP_FOLDERS:
name = str(item["name"])
count = sum(1 for record in records if _mock_folder_matches(record, name))
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder="Sent",
)
return _mock_imap_folders(imap_config=imap_config)
client = _open_imap(imap_config)
try:
typ, data = client.list()
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
message_count, unseen_count = (None, None) if _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name)
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
return ImapFolderListResult(
return _list_imap_folders_on_client(
client,
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
include_status=include_status,
)
finally:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("listing folders", cleanup_exc)
def _preferred_mailbox_folder(folders: list[ImapMailboxInfo], requested: str | None, detected_sent_folder: str | None) -> str:
folder_names = {folder.name for folder in folders}
requested = (requested or "").strip()
if requested and requested in folder_names:
return requested
if "INBOX" in folder_names:
return "INBOX"
if detected_sent_folder and detected_sent_folder in folder_names:
return detected_sent_folder
return folders[0].name if folders else (requested or "INBOX")
def load_imap_mailbox_bootstrap(
*,
imap_config: ImapConfig,
folder: str = "INBOX",
limit: int = 50,
offset: int = 0,
include_folder_status: bool = False,
) -> ImapMailboxBootstrapResult:
"""Load folders and one message page through a single IMAP connection."""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
folders = _mock_imap_folders(imap_config=imap_config)
selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder)
messages = list_imap_messages(imap_config=imap_config, folder=selected_folder, limit=limit, offset=offset)
return ImapMailboxBootstrapResult(folders=folders, messages=messages)
client = _open_imap(imap_config)
try:
folders = _list_imap_folders_on_client(
client,
host=host,
port=port,
security=imap_config.security.value,
include_status=include_folder_status,
)
selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder)
selected_folder, limit, offset = _normalize_mailbox_page(folder=selected_folder, limit=limit, offset=offset)
messages = _list_imap_messages_on_client(
client,
host=host,
port=port,
security=imap_config.security.value,
folder=selected_folder,
limit=limit,
offset=offset,
after_uid=None,
expected_uidvalidity=None,
)
return ImapMailboxBootstrapResult(folders=folders, messages=messages)
finally:
try:
client.logout()
except Exception as cleanup_exc:
_log_imap_cleanup_failure("bootstrapping mailbox", cleanup_exc)
def _list_imap_messages_on_client(
client: imaplib.IMAP4,
*,
host: str,
port: int,
security: str,
folder: str,
limit: int,
offset: int,
after_uid: str | None,
expected_uidvalidity: str | None,
) -> ImapMailboxMessageListResult:
total_count, uidvalidity = _select_readonly(client, folder)
if after_uid is None and expected_uidvalidity is None:
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=security,
folder=folder,
messages=messages,
total_count=total_count,
offset=offset,
limit=limit,
uidvalidity=None,
cursor_reset=False,
)
uids = _search_message_uids(client)
cursor_reset = False
effective_after_uid = after_uid
effective_offset = offset
if expected_uidvalidity and expected_uidvalidity != uidvalidity:
effective_after_uid = None
effective_offset = 0
cursor_reset = True
page_uids, effective_offset, anchor_missing = _paged_descending_uids(
uids,
offset=effective_offset,
limit=limit,
after_uid=effective_after_uid,
)
messages = _fetch_message_summaries_by_uid(client, page_uids, folder)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=security,
folder=folder,
messages=messages,
total_count=len(uids) if uids else total_count,
offset=effective_offset,
limit=limit,
uidvalidity=uidvalidity,
cursor_reset=cursor_reset or anchor_missing,
)
def _has_folder_flag(flags: set[str], flag: str) -> bool:
@@ -527,15 +684,23 @@ def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -
return parts
def _select_readonly(client: imaplib.IMAP4, folder: str) -> int:
def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]:
typ, data = client.select(folder, readonly=True)
if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
selected_count = _decode_item(data[0] if data else None).strip()
try:
return max(0, int(selected_count))
total_count = max(0, int(selected_count))
except ValueError:
return 0
total_count = 0
uidvalidity = None
try:
_, uidvalidity_data = client.response("UIDVALIDITY")
if uidvalidity_data:
uidvalidity = _decode_item(uidvalidity_data[0]).strip() or None
except Exception:
uidvalidity = None
return total_count, uidvalidity
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
@@ -590,6 +755,63 @@ def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[
return summaries
def _search_message_uids(client: imaplib.IMAP4) -> list[str]:
typ, data = client.uid("search", None, "ALL")
if typ != "OK":
raise ImapAppendError(f"IMAP UID search failed: {data!r}", temporary=True)
uids: set[int] = set()
for item in data or []:
for token in _decode_item(item).split():
if token.isdigit():
uids.add(int(token))
return [str(uid) for uid in sorted(uids, reverse=True)]
def _paged_descending_uids(
uids: list[str],
*,
offset: int,
limit: int,
after_uid: str | None = None,
) -> tuple[list[str], int, bool]:
if not uids:
return [], 0, False
cursor_reset = False
if after_uid:
try:
start = uids.index(str(after_uid)) + 1
except ValueError:
start = 0
cursor_reset = True
else:
start = min(max(0, offset), len(uids))
return uids[start:start + limit], start, cursor_reset
def _fetch_message_summaries_by_uid(client: imaplib.IMAP4, uids: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not uids:
return []
typ, data = client.uid("fetch", _sequence_set(uids), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_uid: dict[str, ImapMailboxMessageSummary] = {}
for _sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
if not fetched_uid:
continue
by_uid[fetched_uid] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
summaries: list[ImapMailboxMessageSummary] = []
for uid in uids:
summary = by_uid.get(str(uid))
if summary is not None:
summaries.append(summary)
continue
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
return summaries
def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip()
if folder:
@@ -612,16 +834,35 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
return "\n".join(lines).encode("utf-8", errors="replace")
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult:
def list_imap_messages(
*,
imap_config: ImapConfig,
folder: str = "INBOX",
limit: int = 50,
offset: int = 0,
after_uid: str | None = None,
expected_uidvalidity: str | None = None,
) -> ImapMailboxMessageListResult:
"""List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX"
limit = max(1, min(limit, 100))
offset = max(0, offset)
folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset)
if is_mock_imap_host(imap_config.host):
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
page_records = records[offset:offset + limit]
cursor_reset = False
if expected_uidvalidity and expected_uidvalidity != "mock-v1":
effective_offset = 0
cursor_reset = True
elif after_uid:
record_ids = [str(record.get("id") or "") for record in records]
try:
effective_offset = record_ids.index(str(after_uid)) + 1
except ValueError:
effective_offset = 0
cursor_reset = True
else:
effective_offset = min(offset, len(records))
page_records = records[effective_offset:effective_offset + limit]
messages = [
_message_summary_from_raw(
uid=str(record.get("id") or ""),
@@ -632,19 +873,73 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
)
for record in page_records
]
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
messages=messages,
total_count=len(records),
offset=effective_offset,
limit=limit,
uidvalidity="mock-v1",
cursor_reset=cursor_reset,
)
client = _open_imap(imap_config)
try:
total_count = _select_readonly(client, folder)
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit)
total_count, uidvalidity = _select_readonly(client, folder)
if after_uid is None and expected_uidvalidity is None:
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
messages=messages,
total_count=total_count,
offset=offset,
limit=limit,
uidvalidity=None,
cursor_reset=False,
)
uids = _search_message_uids(client)
cursor_reset = False
effective_after_uid = after_uid
effective_offset = offset
if expected_uidvalidity and expected_uidvalidity != uidvalidity:
effective_after_uid = None
effective_offset = 0
cursor_reset = True
page_uids, effective_offset, anchor_missing = _paged_descending_uids(
uids,
offset=effective_offset,
limit=limit,
after_uid=effective_after_uid,
)
messages = _fetch_message_summaries_by_uid(client, page_uids, folder)
return ImapMailboxMessageListResult(
host=host,
port=port,
security=imap_config.security.value,
folder=folder,
messages=messages,
total_count=len(uids) if uids else total_count,
offset=effective_offset,
limit=limit,
uidvalidity=uidvalidity,
cursor_reset=cursor_reset or anchor_missing,
)
finally:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("listing messages", cleanup_exc)
def _normalize_mailbox_page(*, folder: str | None, limit: int, offset: int) -> tuple[str, int, int]:
return (folder or "INBOX").strip() or "INBOX", max(1, min(limit, 100)), max(0, offset)
def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]:
@@ -686,8 +981,8 @@ def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapM
finally:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("reading message", cleanup_exc)
def append_message_to_sent(
@@ -744,5 +1039,5 @@ def append_message_to_sent(
if client is not None:
try:
client.logout()
except Exception:
pass
except Exception as cleanup_exc:
_log_imap_cleanup_failure("appending sent message", cleanup_exc)

View File

@@ -40,8 +40,8 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
if not enabled or not _distributed_rate_limit_enabled():
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
redis_key = f"multimailer:ratelimit:{key}:next_allowed"
lock_key = f"multimailer:ratelimit:{key}:lock"
redis_key = f"govoplan:ratelimit:{key}:next_allowed"
lock_key = f"govoplan:ratelimit:{key}:lock"
waited = 0.0
try:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import copy
import logging
import smtplib
import ssl
from dataclasses import dataclass
@@ -15,6 +16,8 @@ from govoplan_mail.backend.dev.mock_mailbox import (
record_smtp_delivery,
)
logger = logging.getLogger(__name__)
class SmtpConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or inconsistent."""
@@ -56,6 +59,10 @@ class SmtpSendResult:
return len(self.envelope_recipients) - len(self.refused_recipients)
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
if not config.host:
raise SmtpConfigurationError("SMTP host is required")
@@ -89,8 +96,8 @@ def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
# on GC, but explicit cleanup is safer when the variable exists.
try:
smtp.quit() # type: ignore[possibly-undefined]
except Exception:
pass
except Exception as cleanup_exc:
_log_smtp_cleanup_failure("opening connection", cleanup_exc)
raise
@@ -134,11 +141,12 @@ def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
finally:
try:
smtp.quit()
except Exception:
except Exception as quit_exc:
_log_smtp_cleanup_failure("testing login quit", quit_exc)
try:
smtp.close()
except Exception:
pass
except Exception as close_exc:
_log_smtp_cleanup_failure("testing login close", close_exc)
def prepare_test_message(
@@ -161,12 +169,12 @@ def prepare_test_message(
del test_message[header]
# Replace potential previous marker headers if the user test-sends an EML twice.
for header in ["X-MultiMailer-Test-Send"]:
for header in ["X-GovOPlaN-Test-Send"]:
if header in test_message:
del test_message[header]
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient))
test_message["X-MultiMailer-Test-Send"] = "true"
test_message["X-GovOPlaN-Test-Send"] = "true"
return test_message
@@ -177,43 +185,97 @@ def _send_smtp_payload(
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
host, port, recipients = _prepare_smtp_send(
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
if is_mock_smtp_host(smtp_config.host):
_accepted, refused = _send_mock_smtp_payload(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=recipients,
)
return _smtp_send_result(
smtp_config=smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
)
refused = _send_network_smtp_payload(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=recipients,
)
return _smtp_send_result(
smtp_config=smtp_config,
host=host,
port=port,
envelope_from=envelope_from,
envelope_recipients=recipients,
refused=refused,
)
def _prepare_smtp_send(
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> tuple[str, int, list[str]]:
host, port = _require_smtp_config(smtp_config)
if not envelope_from:
raise SmtpConfigurationError("SMTP envelope sender is required")
if not envelope_recipients:
recipients = [recipient for recipient in envelope_recipients if recipient]
if not recipients:
raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
return host, port, recipients
if is_mock_smtp_host(smtp_config.host):
if consume_fail_next_smtp():
raise SmtpSendError("Mock SMTP configured to fail the next send")
failures = get_failures()
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
refused: dict[str, tuple[int, bytes]] = {}
accepted = list(envelope_recipients)
if reject_text:
refused = {
recipient: (550, b"mock recipient rejected")
for recipient in envelope_recipients
if reject_text in recipient.lower()
}
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
if not accepted:
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
record_smtp_delivery(
message,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host=smtp_config.host,
)
return SmtpSendResult(
host=host,
port=port,
security=smtp_config.security.value,
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
)
def _send_mock_smtp_payload(
message: EmailMessage | bytes,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> tuple[list[str], dict[str, tuple[int, bytes]]]:
if consume_fail_next_smtp():
raise SmtpSendError("Mock SMTP configured to fail the next send")
failures = get_failures()
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
refused: dict[str, tuple[int, bytes]] = {}
accepted = list(envelope_recipients)
if reject_text:
refused = {
recipient: (550, b"mock recipient rejected")
for recipient in envelope_recipients
if reject_text in recipient.lower()
}
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
if not accepted:
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
record_smtp_delivery(
message,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host=smtp_config.host,
)
return accepted, refused
def _send_network_smtp_payload(
message: EmailMessage | bytes,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> dict[str, tuple[int, bytes]]:
try:
smtp = _open_smtp(smtp_config)
except smtplib.SMTPAuthenticationError as exc:
@@ -266,12 +328,24 @@ def _send_smtp_payload(
finally:
try:
smtp.quit()
except Exception:
except Exception as quit_exc:
_log_smtp_cleanup_failure("sending message quit", quit_exc)
try:
smtp.close()
except Exception:
pass
except Exception as close_exc:
_log_smtp_cleanup_failure("sending message close", close_exc)
return refused
def _smtp_send_result(
*,
smtp_config: SmtpConfig,
host: str,
port: int,
envelope_from: str,
envelope_recipients: list[str],
refused: dict[str, tuple[int, bytes]],
) -> SmtpSendResult:
return SmtpSendResult(
host=host,
port=port,

View File

@@ -5,6 +5,7 @@ import unittest
from govoplan_mail.backend.sending.imap import (
_detect_sent_folder,
_extract_mailbox_name,
_normalize_mailbox_page,
_paged_descending_sequences,
_parse_fetch_sequence,
_sequence_set,
@@ -63,6 +64,10 @@ class ImapMessagePaginationTests(unittest.TestCase):
)
self.assertIsNone(_parse_fetch_sequence("UID 123 FLAGS ()"))
def test_normalizes_mailbox_pagination_request(self):
self.assertEqual(_normalize_mailbox_page(folder="", limit=0, offset=-5), ("INBOX", 1, 0))
self.assertEqual(_normalize_mailbox_page(folder=" Sent ", limit=500, offset=3), ("Sent", 100, 3))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.mail_profiles import (
EffectiveMailProfilePolicy,
_apply_profile_transport_update,
_merge_policy,
_next_profile_transport_state,
_policy_parent_lock_message,
_policy_parent_lock_violations,
)
class MailProfileTransportHelperTests(unittest.TestCase):
def test_next_transport_state_uses_saved_profile_credentials(self):
profile = SimpleNamespace(
tenant_id="tenant-1",
scope_type="tenant",
scope_id=None,
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypt_secret("smtp-secret"),
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
imap_username="saved-imap",
imap_password_encrypted=encrypt_secret("imap-secret"),
)
smtp, imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=False)
self.assertEqual(smtp.username, "saved-smtp")
self.assertEqual(smtp.password, "smtp-secret")
self.assertIsNotNone(imap)
self.assertEqual(imap.username, "saved-imap")
self.assertEqual(imap.password, "imap-secret")
_, cleared_imap = _next_profile_transport_state(profile, smtp=None, imap=None, clear_imap=True)
self.assertIsNone(cleared_imap)
def test_apply_transport_update_preserves_unsupplied_passwords(self):
profile = SimpleNamespace(
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypt_secret("smtp-secret"),
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
imap_username="saved-imap",
imap_password_encrypted=encrypt_secret("imap-secret"),
)
_apply_profile_transport_update(
profile,
smtp=SmtpConfig(host="smtp2.example.org", username="new-smtp"),
imap=ImapConfig(host="imap2.example.org", username="new-imap"),
clear_imap=False,
)
self.assertEqual(profile.smtp_config["host"], "smtp2.example.org")
self.assertEqual(profile.smtp_username, "new-smtp")
self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "smtp-secret")
self.assertEqual(profile.imap_config["host"], "imap2.example.org")
self.assertEqual(profile.imap_username, "new-imap")
self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "imap-secret")
_apply_profile_transport_update(profile, smtp=None, imap=None, clear_imap=True)
self.assertIsNone(profile.imap_config)
self.assertIsNone(profile.imap_username)
self.assertIsNone(profile.imap_password_encrypted)
class MailProfilePolicyHelperTests(unittest.TestCase):
def test_merge_policy_respects_locked_lower_level_limits(self):
policy = EffectiveMailProfilePolicy()
_merge_policy(
policy,
{
"blacklist": {"smtp_hosts": ["*.blocked.example"]},
"allow_lower_level_limits": {"blacklist.smtp_hosts": False},
},
source="system",
)
_merge_policy(
policy,
{"blacklist": {"smtp_hosts": ["*.tenant.example"]}},
source="tenant",
source_id="tenant-1",
)
self.assertEqual(policy.blacklist_patterns["smtp_hosts"], ["*.blocked.example"])
self.assertFalse(policy.allow_lower_level_limits["blacklist.smtp_hosts"])
def test_parent_lock_violations_are_reported_per_field(self):
violations = _policy_parent_lock_violations(
{
"allow_user_profiles": False,
"smtp_credentials.inherit": False,
"blacklist.smtp_hosts": False,
},
{
"allow_user_profiles": True,
"smtp_credentials": {"inherit": False},
"blacklist": {"smtp_hosts": ["*.blocked.example"]},
"allow_lower_level_limits": {"allow_user_profiles": True},
},
)
self.assertEqual(
violations,
[
"allow_user_profiles",
"blacklist.smtp_hosts",
"smtp_credentials.inherit",
"allow_lower_level_limits.allow_user_profiles",
],
)
self.assertEqual(
_policy_parent_lock_message("smtp_credentials.inherit"),
"SMTP credential inheritance is locked by an ancestor policy",
)
if __name__ == "__main__":
unittest.main()

20
tests/test_manifest.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleManifest
from govoplan_mail.backend.manifest import get_manifest
class MailManifestTests(unittest.TestCase):
def test_manifest_declares_optional_addresses_lookup(self) -> None:
manifest = get_manifest()
self.assertIsInstance(manifest, ModuleManifest)
self.assertEqual(manifest.id, "mail")
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import unittest
from govoplan_mail.backend.config import SmtpConfig
from govoplan_mail.backend.sending.smtp import (
SmtpConfigurationError,
_prepare_smtp_send,
_smtp_send_result,
)
class SmtpSendHelperTests(unittest.TestCase):
def test_prepare_smtp_send_validates_envelope(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
with self.assertRaisesRegex(SmtpConfigurationError, "envelope sender"):
_prepare_smtp_send(smtp_config=config, envelope_from="", envelope_recipients=["user@example.org"])
with self.assertRaisesRegex(SmtpConfigurationError, "recipient"):
_prepare_smtp_send(smtp_config=config, envelope_from="sender@example.org", envelope_recipients=[""])
def test_prepare_smtp_send_filters_blank_recipients(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
self.assertEqual(
_prepare_smtp_send(
smtp_config=config,
envelope_from="sender@example.org",
envelope_recipients=["", "user@example.org"],
),
("smtp.example.org", 587, ["user@example.org"]),
)
def test_smtp_send_result_decodes_refused_recipients(self):
config = SmtpConfig(host="smtp.example.org", port=587, security="starttls")
result = _smtp_send_result(
smtp_config=config,
host="smtp.example.org",
port=587,
envelope_from="sender@example.org",
envelope_recipients=["ok@example.org", "blocked@example.org"],
refused={"blocked@example.org": (550, b"blocked")},
)
self.assertEqual(result.accepted_count, 1)
self.assertEqual(result.refused_recipients["blocked@example.org"], (550, "blocked"))
if __name__ == "__main__":
unittest.main()

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

@@ -0,0 +1,142 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.8",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/lucide-react": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
"license": "ISC",
"peer": true,
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.7"
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT",
"peer": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.2",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,8 +14,8 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
@@ -26,7 +26,7 @@
}
},
"scripts": {
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-policy-validation.test.js"
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js"
},
"devDependencies": {
"typescript": "^5.7.2"

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
export type MailProfileProtocol = "smtp" | "imap";
export type MailProfileEditSection = MailProfileProtocol | "advanced";
export type MailProfilePanelMode = "all" | "server" | "credentials";
export type MailProfileEditTarget =
| {kind: "create";}
| {kind: "profile";}
| {kind: "server";protocol: MailProfileProtocol;}
| {kind: "credentials";protocol: MailProfileProtocol;};
export type MailProfileTransportLike = {
host?: string | null;
};
export type MailProfileTreeProfileLike = {
id: string;
imap?: MailProfileTransportLike | null;
};
export type MailProfileChildDescriptor = {
kind: "server" | "credential";
id: string;
protocol: MailProfileProtocol;
};
export function mailProfileChildDescriptors(profile: MailProfileTreeProfileLike): MailProfileChildDescriptor[] {
const children: MailProfileChildDescriptor[] = [
{ kind: "server", id: `server:${profile.id}:smtp`, protocol: "smtp" },
{ kind: "credential", id: `credential:${profile.id}:smtp`, protocol: "smtp" },
{ kind: "server", id: `server:${profile.id}:imap`, protocol: "imap" }
];
if (profile.imap) children.push({ kind: "credential", id: `credential:${profile.id}:imap`, protocol: "imap" });
return children;
}
export function mailProfileEditTargetInitialSection(target: MailProfileEditTarget): MailProfileEditSection {
if (target.kind === "server" || target.kind === "credentials") return target.protocol;
return "smtp";
}
export function mailProfileEditTargetPanelMode(target: MailProfileEditTarget): MailProfilePanelMode | null {
if (target.kind === "create") return "all";
if (target.kind === "server") return "server";
if (target.kind === "credentials") return "credentials";
return null;
}
export function mailProfileEditTargetVisibleSections(target: MailProfileEditTarget): MailProfileEditSection[] {
if (target.kind === "server" || target.kind === "credentials") return [target.protocol];
if (target.kind === "create") return ["smtp", "imap", "advanced"];
return [];
}
export function mailProfileEditTargetShowsProfileFields(target: MailProfileEditTarget): boolean {
return target.kind === "create" || target.kind === "profile";
}
export function mailProfileEditTargetShowsSettingsPanel(target: MailProfileEditTarget): boolean {
return target.kind !== "profile";
}

View File

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

View File

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

View File

@@ -2,24 +2,32 @@ import { createElement, lazy } from "react";
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/mail-profiles.css";
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const mailboxRead = ["mail:mailbox:read"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const mailModule: PlatformWebModule = {
id: "mail",
label: "Mail",
label: "i18n:govoplan-mail.mail.92379cbb",
version: "1.0.0",
dependencies: ["access"],
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
optionalDependencies: ["addresses"],
translations,
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
routes: [
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
],
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
uiCapabilities: {
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
},
runtimeUiCapabilities: {
"mail.devMailbox": { enabled: true, label: "Development mock mailbox" } satisfies MailDevMailboxUiCapability
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
}
};

View File

@@ -18,30 +18,6 @@
max-width: 520px;
}
.mail-profile-table-surface {
width: 100%;
max-width: 100%;
min-width: 0;
}
.mail-profile-table-surface > .data-grid-shell {
width: 100%;
max-width: 100%;
min-width: 0;
}
.card-body > .mail-profile-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
}
.card-body > .mail-profile-table-surface:only-child > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.mail-profile-dialog .dialog-body {
max-height: min(76vh, 820px);
overflow: auto;
@@ -52,8 +28,40 @@
gap: 18px;
}
.mail-profile-transport-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.mail-profile-transport-summary > div {
display: grid;
gap: 4px;
min-width: 0;
padding: 10px 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
.mail-profile-transport-summary span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.mail-profile-transport-summary strong,
.mail-profile-transport-summary small {
min-width: 0;
overflow-wrap: anywhere;
}
.mail-profile-transport-summary small {
color: var(--muted);
}
.mail-profile-form h3,
.mail-policy-section h3,
.mail-policy-pattern-grid h4 {
margin: 0;
color: var(--text-strong);
@@ -83,12 +91,6 @@
margin: 0;
}
.mail-policy-section {
display: grid;
gap: 12px;
padding-top: 4px;
}
.mail-profile-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -101,7 +103,7 @@
align-items: start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
}
@@ -172,7 +174,7 @@
}
.mail-policy-effective {
border-top: 1px solid var(--line);
border-top: var(--border-line);
padding-top: 14px;
}
@@ -186,7 +188,7 @@
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
@@ -208,14 +210,15 @@
}
.status-badge.neutral {
background: #e7e4df;
color: #666;
background: var(--status-neutral-bg);
color: var(--text-soft);
}
@media (max-width: 900px) {
.admin-form-grid.three-columns,
.mail-policy-pattern-grid,
.mail-policy-effective-grid,
.mail-profile-transport-summary,
.mail-policy-row,
.mail-policy-table.with-effective-column .mail-policy-row,
.mail-policy-table.with-allow-column .mail-policy-row,
@@ -292,7 +295,7 @@
gap: 8px;
align-items: center;
width: 100%;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
@@ -387,7 +390,7 @@
margin: 0;
padding: 12px;
overflow: auto;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
color: var(--text);
@@ -411,7 +414,7 @@
gap: 8px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
}
@@ -526,7 +529,7 @@
flex: 0 0 auto;
min-width: 20px;
padding: 1px 6px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 999px;
background: var(--surface-subtle);
color: var(--text);
@@ -577,7 +580,7 @@
}
.mailbox-message-list-panel {
border-right: 1px solid var(--line);
border-right: var(--border-line);
}
.mailbox-message-table {
@@ -599,7 +602,7 @@
width: min(430px, 100%);
min-height: 34px;
padding: 0 8px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
@@ -633,7 +636,7 @@
}
.mailbox-error-state {
color: var(--danger-text, #8f2e2e);
color: var(--danger-text);
}
.mailbox-pagination {
@@ -703,7 +706,7 @@
.mailbox-preview-panel {
grid-column: 1 / -1;
min-height: 320px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
}
}

View File

@@ -0,0 +1,48 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
}
import {
mailProfileChildDescriptors,
mailProfileEditTargetInitialSection,
mailProfileEditTargetPanelMode,
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
mailProfileEditTargetVisibleSections
} from "../src/features/mail/mailProfileEditorModel";
const smtpOnlyChildren = mailProfileChildDescriptors({ id: "profile-1", imap: null });
assertDeepEqual(
smtpOnlyChildren.map((child) => `${child.kind}:${child.protocol}`),
["server:smtp", "credential:smtp", "server:imap"],
"SMTP-only profiles expose SMTP server/credentials and an addable IMAP server"
);
assert(!smtpOnlyChildren.some((child) => child.kind === "credential" && child.protocol === "imap"), "IMAP credentials are hidden until an IMAP server exists");
const fullChildren = mailProfileChildDescriptors({ id: "profile-1", imap: { host: "imap.example.org" } });
assertDeepEqual(
fullChildren.map((child) => `${child.kind}:${child.protocol}`),
["server:smtp", "credential:smtp", "server:imap", "credential:imap"],
"profiles with IMAP expose focused rows for both transports and credentials"
);
assertEqual(mailProfileEditTargetInitialSection({ kind: "server", protocol: "imap" }), "imap");
assertEqual(mailProfileEditTargetPanelMode({ kind: "server", protocol: "smtp" }), "server");
assertEqual(mailProfileEditTargetPanelMode({ kind: "credentials", protocol: "imap" }), "credentials");
assertEqual(mailProfileEditTargetPanelMode({ kind: "profile" }), null);
assertDeepEqual(mailProfileEditTargetVisibleSections({ kind: "credentials", protocol: "imap" }), ["imap"]);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "profile" }), true);
assertEqual(mailProfileEditTargetShowsProfileFields({ kind: "server", protocol: "smtp" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "profile" }), false);
assertEqual(mailProfileEditTargetShowsSettingsPanel({ kind: "create" }), true);

View File

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