14 Commits
v0.1.0 ... main

69 changed files with 6318 additions and 1246 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:

21
.gitignore vendored
View File

@@ -326,3 +326,24 @@ cython_debug/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
# 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/

44
AGENTS.md Normal file
View File

@@ -0,0 +1,44 @@
# GovOPlaN Mail Codex Guide
## Scope
This repository owns the `mail` module: SMTP/IMAP profiles, mail profile policy, encrypted mail credentials, SMTP sending, IMAP append and mailbox access, mock mail infrastructure, backend module manifest, and `@govoplan/mail-webui`.
Core owns auth, tenants, RBAC, database/session primitives, CSRF/API helpers, shared components, and shell layout. Campaign/files integrations must remain optional and go through core module metadata or capabilities.
## Local Commands
Install and run through core:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -r requirements-dev.txt
```
Focused backend tests:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
```
Focused WebUI tests:
```bash
cd /mnt/DATA/git/govoplan-mail/webui
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:mail-ui
```
For combined checks, run:
```bash
cd /mnt/DATA/git/govoplan
tools/checks/check-focused.sh
```
## Working Rules
- Keep mail behavior in this module, not core.
- Do not require campaign or files imports for mail-only startup.
- Shared WebUI components belong in core; mail WebUI should consume them through `@govoplan/core-webui`.
- Prefer mailbox/IMAP tests and targeted API smoke tests before full-suite runs.

View File

@@ -1,5 +1,9 @@
# govoplan-mail # 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. 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 ## Ownership
@@ -15,6 +19,10 @@ This repository owns:
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout. Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
## Credential inheritance policy
SMTP and IMAP each have one credential inheritance decision: descendants inherit profile credentials, may inherit profile credentials, or must provide local credentials. The lower-level override switch for `smtp_credentials.inherit` and `imap_credentials.inherit` decides whether child scopes may change that decision. There is no separate "override the override" credential policy field.
## Development ## Development
Install through the core environment: Install through the core environment:
@@ -46,7 +54,20 @@ Frontend package:
@govoplan/mail-webui @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/`. 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", "name": "@govoplan/mail-webui",
"version": "0.1.0", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -19,10 +19,15 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.0", "@govoplan/core-webui": "^0.1.8",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
} }
} }

View File

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

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from govoplan_core.core.modules import ModuleContext
from govoplan_mail.backend.runtime import configure_runtime
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
apply_campaign_credentials,
assert_campaign_mail_policy_allows_json,
assert_mail_policy_allows_send,
effective_profile_credentials_inherited,
ensure_mail_profile_allowed_for_campaign,
imap_config_from_profile,
mail_profile_id_from_campaign_json,
materialize_campaign_mail_profile_config,
smtp_config_from_profile,
)
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes, send_email_message
class MailCampaignCapability:
MailProfileError = MailProfileError
SmtpConfigurationError = SmtpConfigurationError
SmtpSendError = SmtpSendError
ImapConfigurationError = ImapConfigurationError
ImapAppendError = ImapAppendError
materialize_campaign_mail_profile_config = staticmethod(materialize_campaign_mail_profile_config)
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
assert_mail_policy_allows_send = staticmethod(assert_mail_policy_allows_send)
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
ensure_mail_profile_allowed_for_campaign = staticmethod(ensure_mail_profile_allowed_for_campaign)
smtp_config_from_profile = staticmethod(smtp_config_from_profile)
imap_config_from_profile = staticmethod(imap_config_from_profile)
effective_profile_credentials_inherited = staticmethod(effective_profile_credentials_inherited)
apply_campaign_credentials = staticmethod(apply_campaign_credentials)
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
send_email_bytes = staticmethod(send_email_bytes)
append_message_to_sent = staticmethod(append_message_to_sent)
send_email_message = staticmethod(send_email_message)
@staticmethod
def mock_mailbox():
from govoplan_mail.backend.dev import mock_mailbox
return mock_mailbox
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
configure_runtime(settings=context.settings)
return MailCampaignCapability()

View File

@@ -1,35 +1,23 @@
from __future__ import annotations from __future__ import annotations
from enum import StrEnum from govoplan_core.mail.config import (
ImapConfig,
ImapServerConfig,
SmtpConfig,
SmtpServerConfig,
StrictModel,
TransportCredentials,
TransportSecurity,
normalize_split_transport_credentials,
)
from pydantic import BaseModel, ConfigDict, Field __all__ = [
"ImapConfig",
"ImapServerConfig",
class StrictModel(BaseModel): "SmtpConfig",
model_config = ConfigDict(extra="forbid", populate_by_name=True) "SmtpServerConfig",
"StrictModel",
"TransportCredentials",
class TransportSecurity(StrEnum): "TransportSecurity",
PLAIN = "plain" "normalize_split_transport_credentials",
TLS = "tls" ]
STARTTLS = "starttls"
class SmtpConfig(StrictModel):
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = None
password: str | None = None
security: TransportSecurity = TransportSecurity.STARTTLS
timeout_seconds: int = Field(default=30, ge=1)
class ImapConfig(StrictModel):
enabled: bool = False
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = None
password: str | None = None
security: TransportSecurity = TransportSecurity.TLS
sent_folder: str = "auto"
timeout_seconds: int = Field(default=30, ge=1)

View File

@@ -3,8 +3,10 @@ from __future__ import annotations
import uuid import uuid
from typing import Any from typing import Any
from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, Text, UniqueConstraint from datetime import datetime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin from govoplan_core.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) 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_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) scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -29,12 +31,70 @@ class MailServerProfile(Base, TimestampMixin):
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
smtp_username: Mapped[str | None] = mapped_column(String(320))
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text) smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
imap_username: Mapped[str | None] = mapped_column(String(320))
imap_password_encrypted: Mapped[str | None] = mapped_column(Text) 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) 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("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)
tenant: Mapped["Tenant"] = relationship()
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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field 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 ( from govoplan_mail.backend.dev.mock_mailbox import (
clear_records, clear_records,
get_failures, 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")

File diff suppressed because it is too large Load Diff

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 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_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 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): def _mail_router(context: ModuleContext):
from govoplan_mail.backend.runtime import configure_runtime 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 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( manifest = ModuleManifest(
id="mail", id="mail",
name="Mail", name="Mail",
version="1.0.0", version="0.1.8",
dependencies=("access",), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=(), 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, permissions=PERMISSIONS,
route_factory=_mail_router, route_factory=_mail_router,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
@@ -81,10 +122,59 @@ manifest = ModuleManifest(
module_id="mail", module_id="mail",
metadata=Base.metadata, metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"), script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_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,),
) )
def get_manifest() -> ModuleManifest: def get_manifest() -> ModuleManifest:
return manifest return manifest

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")

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from govoplan_core.core.runtime import ModuleRuntimeState
_runtime_settings: object | None = None _runtime = ModuleRuntimeState("Mail")
configure_runtime = _runtime.configure_runtime
def configure_runtime(*, settings: object | None = None) -> None: get_registry = _runtime.get_registry
global _runtime_settings get_settings = _runtime.get_settings
if settings is not None: settings = _runtime.settings
_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()

View File

@@ -3,9 +3,17 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, model_validator
from govoplan_mail.backend.config import ImapConfig, SmtpConfig 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): class MailSmtpTestRequest(SmtpConfig):
@@ -15,7 +23,29 @@ class MailSmtpTestRequest(SmtpConfig):
class MailImapTestRequest(ImapConfig): class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form.""" """IMAP settings supplied directly from the WebUI mail settings form."""
enabled: bool = True
class MailTransportCredentialsPayload(TransportCredentials):
"""Credentials supplied separately from saved server settings."""
class MailServerProfileCredentialsPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
smtp: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload)
imap: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload)
def _normalize_profile_transport_payload(value: object) -> object:
return normalize_split_transport_credentials(value)
def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]:
payload = server.model_dump(mode="json")
if "username" in credentials.model_fields_set:
payload["username"] = credentials.username
if "password" in credentials.model_fields_set:
payload["password"] = credentials.password
return payload
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"] MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
@@ -25,7 +55,6 @@ class MailCredentialPolicyPayload(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
inherit: bool | None = None inherit: bool | None = None
allow_override: bool | None = None
class MailProfilePolicyPayload(BaseModel): class MailProfilePolicyPayload(BaseModel):
@@ -39,6 +68,7 @@ class MailProfilePolicyPayload(BaseModel):
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
whitelist: dict[str, list[str]] = Field(default_factory=dict) whitelist: dict[str, list[str]] = Field(default_factory=dict)
blacklist: dict[str, list[str]] = Field(default_factory=dict) blacklist: dict[str, list[str]] = Field(default_factory=dict)
allow_lower_level_limits: dict[str, bool] = Field(default_factory=dict)
class MailProfilePolicyUpdateRequest(BaseModel): class MailProfilePolicyUpdateRequest(BaseModel):
@@ -50,8 +80,10 @@ class MailProfilePolicyUpdateRequest(BaseModel):
class PolicySourceStepResponse(BaseModel): class PolicySourceStepResponse(BaseModel):
scope_type: str scope_type: str
scope_id: str | None = None scope_id: str | None = None
path: str
label: str label: str
applied_fields: list[str] = Field(default_factory=list) applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
class MailProfilePolicyResponse(BaseModel): class MailProfilePolicyResponse(BaseModel):
@@ -73,8 +105,29 @@ class MailServerProfileCreateRequest(BaseModel):
is_active: bool = True is_active: bool = True
scope_type: MailProfileScope = "tenant" scope_type: MailProfileScope = "tenant"
scope_id: str | None = None scope_id: str | None = None
smtp: SmtpConfig smtp: SmtpServerConfig
imap: ImapConfig | None = None imap: ImapServerConfig | None = None
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
@model_validator(mode="before")
@classmethod
def normalize_legacy_transport_payload(cls, value: object) -> object:
return _normalize_profile_transport_payload(value)
def smtp_config(self) -> SmtpConfig:
return SmtpConfig.model_validate(_merge_transport_credentials(self.smtp, self.credentials.smtp))
def imap_config(self) -> ImapConfig | None:
if self.imap is None:
return None
return ImapConfig.model_validate(_merge_transport_credentials(self.imap, self.credentials.imap))
def credentials_supplied(self) -> bool:
return any(
field in credentials.model_fields_set
for credentials in (self.credentials.smtp, self.credentials.imap)
for field in ("username", "password")
)
class MailServerProfileUpdateRequest(BaseModel): class MailServerProfileUpdateRequest(BaseModel):
@@ -84,10 +137,35 @@ class MailServerProfileUpdateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100) slug: str | None = Field(default=None, max_length=100)
description: str | None = None description: str | None = None
is_active: bool | None = None is_active: bool | None = None
smtp: SmtpConfig | None = None smtp: SmtpServerConfig | None = None
imap: ImapConfig | None = None imap: ImapServerConfig | None = None
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
clear_imap: bool = False clear_imap: bool = False
@model_validator(mode="before")
@classmethod
def normalize_legacy_transport_payload(cls, value: object) -> object:
return _normalize_profile_transport_payload(value)
def smtp_config(self) -> SmtpConfig | None:
if self.smtp is None and "smtp" not in self.credentials.model_fields_set:
return None
server = self.smtp or SmtpServerConfig()
return SmtpConfig.model_validate(_merge_transport_credentials(server, self.credentials.smtp))
def imap_config(self) -> ImapConfig | None:
if self.imap is None and "imap" not in self.credentials.model_fields_set:
return None
server = self.imap or ImapServerConfig()
return ImapConfig.model_validate(_merge_transport_credentials(server, self.credentials.imap))
def credentials_supplied(self) -> bool:
return any(
field in credentials.model_fields_set
for credentials in (self.credentials.smtp, self.credentials.imap)
for field in ("username", "password")
)
class MailServerProfileResponse(BaseModel): class MailServerProfileResponse(BaseModel):
id: str id: str
@@ -100,6 +178,7 @@ class MailServerProfileResponse(BaseModel):
is_active: bool is_active: bool
smtp: dict[str, Any] smtp: dict[str, Any]
imap: dict[str, Any] | None = None imap: dict[str, Any] | None = None
credentials: dict[str, Any] = Field(default_factory=dict)
smtp_password_configured: bool = False smtp_password_configured: bool = False
imap_password_configured: bool = False imap_password_configured: bool = False
created_at: datetime created_at: datetime
@@ -110,6 +189,36 @@ class MailServerProfileListResponse(BaseModel):
profiles: list[MailServerProfileResponse] = Field(default_factory=list) 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): class MailConnectionTestResponse(BaseModel):
ok: bool ok: bool
protocol: Literal["smtp", "imap"] protocol: Literal["smtp", "imap"]
@@ -123,6 +232,8 @@ class MailConnectionTestResponse(BaseModel):
class MailImapFolderResponse(BaseModel): class MailImapFolderResponse(BaseModel):
name: str name: str
flags: list[str] = Field(default_factory=list) flags: list[str] = Field(default_factory=list)
message_count: int | None = None
unseen_count: int | None = None
class MailImapFolderListResponse(BaseModel): class MailImapFolderListResponse(BaseModel):
@@ -134,6 +245,9 @@ class MailImapFolderListResponse(BaseModel):
message: str message: str
folders: list[MailImapFolderResponse] = Field(default_factory=list) folders: list[MailImapFolderResponse] = Field(default_factory=list)
detected_sent_folder: str | None = None 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) details: dict[str, Any] = Field(default_factory=dict)
class MailMailboxAttachmentResponse(BaseModel): class MailMailboxAttachmentResponse(BaseModel):
@@ -171,9 +285,25 @@ class MailMailboxMessageListResponse(BaseModel):
port: int | None = None port: int | None = None
security: str | None = None security: str | None = None
total_count: int = 0 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) messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
class MailMailboxBootstrapResponse(BaseModel):
profile_id: str
folder: str
folders: MailImapFolderListResponse
messages: MailMailboxMessageListResponse
class MailMailboxMessageResponse(BaseModel): class MailMailboxMessageResponse(BaseModel):
profile_id: str profile_id: str
folder: str folder: str
@@ -181,4 +311,3 @@ class MailMailboxMessageResponse(BaseModel):
port: int | None = None port: int | None = None
security: str | None = None security: str | None = None
message: MailMailboxMessageDetailResponse message: MailMailboxMessageDetailResponse

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import imaplib import imaplib
import logging
import re import re
import socket import socket
import ssl import ssl
@@ -21,6 +22,8 @@ from govoplan_mail.backend.dev.mock_mailbox import (
record_imap_append, record_imap_append,
) )
logger = logging.getLogger(__name__)
class ImapConfigurationError(ValueError): class ImapConfigurationError(ValueError):
"""Raised when IMAP settings are incomplete or inconsistent.""" """Raised when IMAP settings are incomplete or inconsistent."""
@@ -50,6 +53,8 @@ class ImapLoginTestResult:
class ImapMailboxInfo: class ImapMailboxInfo:
name: str name: str
flags: list[str] flags: list[str]
message_count: int | None = None
unseen_count: int | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -68,6 +73,10 @@ class ImapMailboxAttachmentInfo:
size_bytes: int 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) @dataclass(frozen=True, slots=True)
class ImapMailboxMessageSummary: class ImapMailboxMessageSummary:
uid: str uid: str
@@ -111,6 +120,16 @@ class ImapMailboxMessageListResult:
folder: str folder: str
messages: list[ImapMailboxMessageSummary] messages: list[ImapMailboxMessageSummary]
total_count: int 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) @dataclass(frozen=True, slots=True)
@@ -133,8 +152,6 @@ class ImapAppendResult:
def _require_imap_config(config: ImapConfig) -> tuple[str, int]: def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
if not config.enabled:
raise ImapConfigurationError("IMAP is disabled")
if not config.host: if not config.host:
raise ImapConfigurationError("IMAP host is required") raise ImapConfigurationError("IMAP host is required")
if not config.port: if not config.port:
@@ -166,8 +183,8 @@ def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
except Exception: except Exception:
try: try:
client.logout() # type: ignore[possibly-undefined] client.logout() # type: ignore[possibly-undefined]
except Exception: except Exception as cleanup_exc:
pass _log_imap_cleanup_failure("opening connection", cleanup_exc)
raise raise
@@ -302,16 +319,18 @@ def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
finally: finally:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _log_imap_cleanup_failure("testing login", cleanup_exc)
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult: def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config) host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host): records = list_records(limit=500)
folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS] 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( return ImapFolderListResult(
host=host, host=host,
port=port, port=port,
@@ -320,8 +339,15 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
detected_sent_folder="Sent", detected_sent_folder="Sent",
) )
client = _open_imap(imap_config)
try: def _list_imap_folders_on_client(
client: imaplib.IMAP4,
*,
host: str,
port: int,
security: str,
include_status: bool,
) -> ImapFolderListResult:
typ, data = client.list() typ, data = client.list()
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True) raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
@@ -334,20 +360,184 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
continue continue
name, flags = extracted name, flags = extracted
parsed.append((name, flags)) parsed.append((name, flags))
folders.append(ImapMailboxInfo(name=name, flags=sorted(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( return ImapFolderListResult(
host=host, host=host,
port=port, port=port,
security=imap_config.security.value, security=security,
folders=folders, folders=folders,
detected_sent_folder=_detect_sent_folder(parsed), 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):
return _mock_imap_folders(imap_config=imap_config)
client = _open_imap(imap_config)
try:
return _list_imap_folders_on_client(
client,
host=host,
port=port,
security=imap_config.security.value,
include_status=include_status,
)
finally: finally:
try: try:
client.logout() client.logout()
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:
wanted = flag.casefold().lstrip("\\")
return any(item.casefold().lstrip("\\") == wanted for item in flags)
def _quote_mailbox_name(name: str) -> str:
return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]:
try:
typ, data = client.status(_quote_mailbox_name(folder), "(MESSAGES UNSEEN)")
except Exception: except Exception:
pass return None, None
if typ != "OK":
return None, None
text = " ".join(_decode_item(item) for item in data or [])
messages_match = re.search(r"\bMESSAGES\s+(\d+)", text, re.IGNORECASE)
unseen_match = re.search(r"\bUNSEEN\s+(\d+)", text, re.IGNORECASE)
return (
int(messages_match.group(1)) if messages_match else None,
int(unseen_match.group(1)) if unseen_match else None,
)
def _parse_message(raw: bytes) -> EmailMessage: def _parse_message(raw: bytes) -> EmailMessage:
@@ -393,6 +583,24 @@ def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]:
return attachments return attachments
def _message_summary_from_headers(*, uid: str, folder: str, headers: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
message = _parse_message(headers.rstrip() + b"\r\n\r\n")
return ImapMailboxMessageSummary(
uid=uid,
folder=folder,
subject=_header_text(message, "Subject"),
from_header=_header_text(message, "From"),
to_header=_header_text(message, "To"),
cc_header=_header_text(message, "Cc"),
date=_header_text(message, "Date"),
message_id=_header_text(message, "Message-ID"),
flags=flags,
size_bytes=size_bytes,
body_preview=None,
attachment_count=0,
)
def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary: def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
message = _parse_message(raw) message = _parse_message(raw)
attachments = _attachment_infos(message) attachments = _attachment_infos(message)
@@ -434,6 +642,14 @@ def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[s
) )
def _parse_fetch_metadata(metadata: str) -> tuple[str | None, list[str], int | None]:
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
flags = flags_match.group(1).split() if flags_match else []
return uid_match.group(1) if uid_match else None, flags, int(size_match.group(1)) if size_match else None
def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]: def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]:
metadata_parts: list[str] = [] metadata_parts: list[str] = []
raw: bytes | None = None raw: bytes | None = None
@@ -445,24 +661,46 @@ def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str
else: else:
metadata_parts.append(_decode_item(item)) metadata_parts.append(_decode_item(item))
metadata = " ".join(part for part in metadata_parts if part) metadata = " ".join(part for part in metadata_parts if part)
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE) uid, flags, size_bytes = _parse_fetch_metadata(metadata)
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
flags = flags_match.group(1).split() if flags_match else []
if raw is None: if raw is None:
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True) raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
return ( return uid, flags, size_bytes, raw
uid_match.group(1) if uid_match else None,
flags,
int(size_match.group(1)) if size_match else None,
raw,
)
def _select_readonly(client: imaplib.IMAP4, folder: str) -> None: def _parse_fetch_sequence(metadata: str) -> str | None:
match = re.match(r"\s*(\d+)\s+\(", metadata)
return match.group(1) if match else None
def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -> list[tuple[str | None, str | None, list[str], int | None, bytes]]:
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]] = []
for item in data or []:
if not isinstance(item, tuple) or not isinstance(item[1], bytes):
continue
metadata = _decode_item(item[0])
sequence = _parse_fetch_sequence(metadata)
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
parts.append((sequence, uid, flags, size_bytes, item[1]))
return parts
def _select_readonly(client: imaplib.IMAP4, folder: str) -> tuple[int, str | None]:
typ, data = client.select(folder, readonly=True) typ, data = client.select(folder, readonly=True)
if typ != "OK": if typ != "OK":
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False) 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:
total_count = max(0, int(selected_count))
except ValueError:
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]: def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
@@ -473,6 +711,107 @@ def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[st
return fetched_uid or str(uid), flags, size_bytes, raw return fetched_uid or str(uid), flags, size_bytes, raw
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
if typ != "OK":
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
if not fetched_uid:
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
return fetched_uid, flags, size_bytes, raw
def _sequence_set(sequences: list[str]) -> str:
sequence_numbers = sorted({int(sequence) for sequence in sequences if str(sequence).isdigit()})
if not sequence_numbers:
return ",".join(str(sequence) for sequence in sequences)
expected_range = list(range(sequence_numbers[0], sequence_numbers[-1] + 1))
if sequence_numbers == expected_range:
return f"{sequence_numbers[0]}:{sequence_numbers[-1]}"
return ",".join(str(sequence) for sequence in sequence_numbers)
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
if not sequences:
return []
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
if typ != "OK":
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
if not sequence or not fetched_uid:
continue
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
summaries: list[ImapMailboxMessageSummary] = []
for sequence in sequences:
summary = by_sequence.get(str(sequence))
if summary is not None:
summaries.append(summary)
continue
fetched_uid, flags, size_bytes, raw = _fetch_message_by_sequence(client, sequence)
summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
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: def _mock_record_folder(record: dict[str, Any]) -> str:
folder = str(record.get("folder") or "").strip() folder = str(record.get("folder") or "").strip()
if folder: if folder:
@@ -495,14 +834,35 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
return "\n".join(lines).encode("utf-8", errors="replace") return "\n".join(lines).encode("utf-8", errors="replace")
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> 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.""" """List mailbox messages without mutating read/unread state."""
host, port = _require_imap_config(imap_config) host, port = _require_imap_config(imap_config)
folder = (folder or "INBOX").strip() or "INBOX" folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset)
limit = max(1, min(limit, 100))
if is_mock_imap_host(imap_config.host): if is_mock_imap_host(imap_config.host):
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)] records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
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 = [ messages = [
_message_summary_from_raw( _message_summary_from_raw(
uid=str(record.get("id") or ""), uid=str(record.get("id") or ""),
@@ -511,28 +871,83 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
flags=["\\Seen"] if record.get("kind") == "imap_append" else [], flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
size_bytes=int(record.get("size_bytes") or 0) or None, size_bytes=int(record.get("size_bytes") or 0) or None,
) )
for record in records[: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)) 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) client = _open_imap(imap_config)
try: try:
_select_readonly(client, folder) total_count, uidvalidity = _select_readonly(client, folder)
typ, data = client.uid("search", None, "ALL") if after_uid is None and expected_uidvalidity is None:
if typ != "OK": page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True) messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
uid_bytes = data[0] if data else b"" return ImapMailboxMessageListResult(
uids = _decode_item(uid_bytes).split() host=host,
messages: list[ImapMailboxMessageSummary] = [] port=port,
for uid in reversed(uids[-limit:]): security=imap_config.security.value,
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid) folder=folder,
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes)) messages=messages,
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(uids)) 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: finally:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _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]:
if total_count <= 0 or offset >= total_count:
return []
end = total_count - offset
start = max(1, end - limit + 1)
return [str(sequence) for sequence in range(end, start - 1, -1)]
def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult: def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult:
@@ -566,8 +981,8 @@ def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapM
finally: finally:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _log_imap_cleanup_failure("reading message", cleanup_exc)
def append_message_to_sent( def append_message_to_sent(
@@ -624,5 +1039,5 @@ def append_message_to_sent(
if client is not None: if client is not None:
try: try:
client.logout() client.logout()
except Exception: except Exception as cleanup_exc:
pass _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(): 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) 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" redis_key = f"govoplan:ratelimit:{key}:next_allowed"
lock_key = f"multimailer:ratelimit:{key}:lock" lock_key = f"govoplan:ratelimit:{key}:lock"
waited = 0.0 waited = 0.0
try: try:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
import logging
import smtplib import smtplib
import ssl import ssl
from dataclasses import dataclass from dataclasses import dataclass
@@ -15,6 +16,8 @@ from govoplan_mail.backend.dev.mock_mailbox import (
record_smtp_delivery, record_smtp_delivery,
) )
logger = logging.getLogger(__name__)
class SmtpConfigurationError(ValueError): class SmtpConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or inconsistent.""" """Raised when SMTP settings are incomplete or inconsistent."""
@@ -56,6 +59,10 @@ class SmtpSendResult:
return len(self.envelope_recipients) - len(self.refused_recipients) 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]: def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
if not config.host: if not config.host:
raise SmtpConfigurationError("SMTP host is required") 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. # on GC, but explicit cleanup is safer when the variable exists.
try: try:
smtp.quit() # type: ignore[possibly-undefined] smtp.quit() # type: ignore[possibly-undefined]
except Exception: except Exception as cleanup_exc:
pass _log_smtp_cleanup_failure("opening connection", cleanup_exc)
raise raise
@@ -134,11 +141,12 @@ def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
finally: finally:
try: try:
smtp.quit() smtp.quit()
except Exception: except Exception as quit_exc:
_log_smtp_cleanup_failure("testing login quit", quit_exc)
try: try:
smtp.close() smtp.close()
except Exception: except Exception as close_exc:
pass _log_smtp_cleanup_failure("testing login close", close_exc)
def prepare_test_message( def prepare_test_message(
@@ -161,12 +169,12 @@ def prepare_test_message(
del test_message[header] del test_message[header]
# Replace potential previous marker headers if the user test-sends an EML twice. # 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: if header in test_message:
del test_message[header] del test_message[header]
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient)) 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 return test_message
@@ -177,13 +185,66 @@ def _send_smtp_payload(
envelope_from: str, envelope_from: str,
envelope_recipients: list[str], envelope_recipients: list[str],
) -> SmtpSendResult: ) -> 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) host, port = _require_smtp_config(smtp_config)
if not envelope_from: if not envelope_from:
raise SmtpConfigurationError("SMTP envelope sender is required") 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") raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
return host, port, recipients
if is_mock_smtp_host(smtp_config.host):
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(): if consume_fail_next_smtp():
raise SmtpSendError("Mock SMTP configured to fail the next send") raise SmtpSendError("Mock SMTP configured to fail the next send")
failures = get_failures() failures = get_failures()
@@ -205,15 +266,16 @@ def _send_smtp_payload(
envelope_recipients=accepted, envelope_recipients=accepted,
smtp_host=smtp_config.host, smtp_host=smtp_config.host,
) )
return SmtpSendResult( return accepted, refused
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_network_smtp_payload(
message: EmailMessage | bytes,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> dict[str, tuple[int, bytes]]:
try: try:
smtp = _open_smtp(smtp_config) smtp = _open_smtp(smtp_config)
except smtplib.SMTPAuthenticationError as exc: except smtplib.SMTPAuthenticationError as exc:
@@ -266,12 +328,24 @@ def _send_smtp_payload(
finally: finally:
try: try:
smtp.quit() smtp.quit()
except Exception: except Exception as quit_exc:
_log_smtp_cleanup_failure("sending message quit", quit_exc)
try: try:
smtp.close() smtp.close()
except Exception: except Exception as close_exc:
pass _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( return SmtpSendResult(
host=host, host=host,
port=port, port=port,

73
tests/test_imap_parser.py Normal file
View File

@@ -0,0 +1,73 @@
from __future__ import annotations
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,
)
class ImapFolderParserTests(unittest.TestCase):
def test_extracts_quoted_mailbox_after_quoted_delimiter(self):
self.assertEqual(
_extract_mailbox_name(b'(\\HasNoChildren \\Sent) "/" "Sent Items"'),
("Sent Items", {"\\hasnochildren", "\\sent"}),
)
def test_extracts_atom_mailbox(self):
self.assertEqual(
_extract_mailbox_name('(\\HasNoChildren) "/" INBOX'),
("INBOX", {"\\hasnochildren"}),
)
def test_extracts_nil_delimiter_mailbox(self):
self.assertEqual(
_extract_mailbox_name('(\\HasNoChildren) NIL "INBOX.Sent"'),
("INBOX.Sent", {"\\hasnochildren"}),
)
def test_detects_sent_by_flag_before_name(self):
self.assertEqual(
_detect_sent_folder([("Archive", {"\\sent"}), ("Sent", set())]),
"Archive",
)
def test_detects_common_sent_name(self):
self.assertEqual(
_detect_sent_folder([("INBOX", set()), ("Gesendet", set())]),
"Gesendet",
)
class ImapMessagePaginationTests(unittest.TestCase):
def test_paginates_sequence_numbers_newest_first(self):
self.assertEqual(
_paged_descending_sequences(120, offset=0, limit=5),
["120", "119", "118", "117", "116"],
)
self.assertEqual(_paged_descending_sequences(120, offset=118, limit=5), ["2", "1"])
self.assertEqual(_paged_descending_sequences(120, offset=120, limit=5), [])
def test_compacts_contiguous_sequence_sets(self):
self.assertEqual(_sequence_set(["5", "4", "3"]), "3:5")
self.assertEqual(_sequence_set(["9", "7", "5"]), "5,7,9")
def test_extracts_fetch_sequence_number(self):
self.assertEqual(
_parse_fetch_sequence("42 (UID 123 FLAGS (\\Seen) RFC822.SIZE 100)"),
"42",
)
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", "name": "@govoplan/mail-webui",
"version": "0.1.0", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,10 +14,21 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css" "./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.0", "@govoplan/core-webui": "^0.1.8",
"lucide-react": "^0.555.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"scripts": {
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js"
},
"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,51 +1,66 @@
import type { ApiSettings } from "../types"; import type {
import { apiFetch } from "./client"; 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 MailAddressLookupCandidate = {
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign"; contact_id: string;
address_book_id: string;
export type MailSmtpTestPayload = { display_name: string;
host?: string | null; email?: string | null;
port?: number | null; email_label?: string | null;
username?: string | null; organization?: string | null;
password?: string | null; role_title?: string | null;
security?: MailSecurity; tags: string[];
timeout_seconds?: number; source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
}; };
export type MailImapTestPayload = MailSmtpTestPayload & { export type MailAddressLookupResponse = {
enabled?: boolean; available: boolean;
sent_folder?: string | null; candidates: MailAddressLookupCandidate[];
}; };
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[];
};
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 = { export type MailMailboxAttachment = {
filename?: string | null; filename?: string | null;
content_type: string; content_type: string;
@@ -59,6 +74,7 @@ export type MailMailboxMessageSummary = {
from_header?: string | null; from_header?: string | null;
to_header?: string | null; to_header?: string | null;
cc_header?: string | null; cc_header?: string | null;
bcc_header?: string | null;
date?: string | null; date?: string | null;
message_id?: string | null; message_id?: string | null;
flags?: string[]; flags?: string[];
@@ -81,9 +97,25 @@ export type MailMailboxMessageListResponse = {
port?: number | null; port?: number | null;
security?: MailSecurity | string | null; security?: MailSecurity | string | null;
total_count?: number; 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[]; messages: MailMailboxMessageSummary[];
}; };
export type MailMailboxBootstrapResponse = {
profile_id: string;
folder: string;
folders: MailImapFolderListResponse;
messages: MailMailboxMessageListResponse;
};
export type MailMailboxMessageResponse = { export type MailMailboxMessageResponse = {
profile_id: string; profile_id: string;
folder: string; folder: string;
@@ -93,87 +125,46 @@ export type MailMailboxMessageResponse = {
message: MailMailboxMessageDetail; message: MailMailboxMessageDetail;
}; };
export type MailServerProfile = { export type MailSettingsDeltaResponse = {
id: string; profiles: MailServerProfile[];
tenant_id?: string | null; policy?: MailProfilePolicyResponse | null;
scope_type: MailProfileScope; changed_sections?: string[];
scope_id?: string | null; deleted: DeltaDeletedItem[];
name: string; watermark?: string | null;
slug: string; has_more: boolean;
description?: string | null; full: boolean;
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
updated_at: string;
}; };
export type MailServerProfileListResponse = { profiles: MailServerProfile[] }; 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 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 type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: 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;
};
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
};
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;
};
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> { export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams(); return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
if (includeInactive) params.set("include_inactive", "true"); include_inactive: includeInactive ? true : undefined,
if (campaignId) params.set("campaign_id", campaignId); campaign_id: campaignId
const suffix = params.toString() ? `?${params.toString()}` : ""; });
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`); }
return response.profiles ?? [];
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> { export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
@@ -202,11 +193,10 @@ export async function getMailProfilePolicy(
scopeId?: string | null, scopeId?: string | null,
campaignId?: string | null campaignId?: string | null
): Promise<MailProfilePolicyResponse> { ): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams(); return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
if (scopeId) params.set("scope_id", scopeId); scope_id: scopeId,
if (campaignId) params.set("campaign_id", campaignId); campaign_id: campaignId
const suffix = params.toString() ? `?${params.toString()}` : ""; }));
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
} }
export async function updateMailProfilePolicy( export async function updateMailProfilePolicy(
@@ -215,39 +205,63 @@ export async function updateMailProfilePolicy(
policy: MailProfilePolicy, policy: MailProfilePolicy,
scopeId?: string | null scopeId?: string | null
): Promise<MailProfilePolicyResponse> { ): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams(); return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, { scope_id: scopeId }), {
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ policy }) body: JSON.stringify({ policy })
}); });
} }
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> { 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> { 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> { 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> { export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`); 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( export async function listMailboxMessages(
settings: ApiSettings, settings: ApiSettings,
profileId: string, profileId: string,
folder = "INBOX", folder = "INBOX",
limit = 50 limit = 50,
offset = 0,
cursor?: string | null,
refresh = false
): Promise<MailMailboxMessageListResponse> { ): Promise<MailMailboxMessageListResponse> {
const params = new URLSearchParams({ folder, limit: String(limit) }); return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`); folder,
limit,
offset,
cursor,
refresh: refresh ? true : undefined
}));
} }
export async function getMailboxMessage( export async function getMailboxMessage(
@@ -256,69 +270,34 @@ export async function getMailboxMessage(
folder: string, folder: string,
uid: string uid: string
): Promise<MailMailboxMessageResponse> { ): Promise<MailMailboxMessageResponse> {
const params = new URLSearchParams({ folder }); return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder }));
return apiFetch<MailMailboxMessageResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`);
} }
export async function testSmtpSettings( export async function testSmtpSettings(
settings: ApiSettings, settings: ApiSettings,
payload: MailSmtpTestPayload payload: MailSmtpTestPayload
): Promise<MailConnectionTestResponse> { ): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", { return apiPostJson<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", payload);
method: "POST",
body: JSON.stringify(payload)
});
} }
export async function testImapSettings( export async function testImapSettings(
settings: ApiSettings, settings: ApiSettings,
payload: MailImapTestPayload payload: MailImapTestPayload
): Promise<MailConnectionTestResponse> { ): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", { return apiPostJson<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", payload);
method: "POST",
body: JSON.stringify(payload)
});
} }
export async function listImapFolders( export async function listImapFolders(
settings: ApiSettings, settings: ApiSettings,
payload: MailImapTestPayload payload: MailImapTestPayload
): Promise<MailImapFolderListResponse> { ): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", { return apiPostJson<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", payload);
method: "POST",
body: JSON.stringify(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 = { export type MockMailboxListResponse = {
messages: MockMailboxMessage[]; messages: MockMailboxMessage[];
}; };
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export type MockMailboxFailureConfig = { export type MockMailboxFailureConfig = {
fail_next_smtp?: boolean | null; fail_next_smtp?: boolean | null;
fail_next_imap?: boolean | null; fail_next_imap?: boolean | null;
@@ -326,8 +305,7 @@ export type MockMailboxFailureConfig = {
}; };
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> { export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; return apiFetch<MockMailboxListResponse>(settings, apiPath("/api/v1/dev/mailbox/messages", { kind }));
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
} }
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> { export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {

View File

@@ -1,2 +0,0 @@
import { Button } from "@govoplan/core-webui";
export default Button;

View File

@@ -1,2 +0,0 @@
import { Card } from "@govoplan/core-webui";
export default Card;

View File

@@ -1,2 +0,0 @@
import { ConfirmDialog } from "@govoplan/core-webui";
export default ConfirmDialog;

View File

@@ -1,2 +0,0 @@
import { Dialog } from "@govoplan/core-webui";
export default Dialog;

View File

@@ -1,2 +0,0 @@
import { DismissibleAlert } from "@govoplan/core-webui";
export default DismissibleAlert;

View File

@@ -1,2 +0,0 @@
import { FormField } from "@govoplan/core-webui";
export default FormField;

View File

@@ -1,2 +0,0 @@
import { LoadingFrame } from "@govoplan/core-webui";
export default LoadingFrame;

View File

@@ -1,2 +0,0 @@
import { LoadingIndicator } from "@govoplan/core-webui";
export default LoadingIndicator;

View File

@@ -1,2 +0,0 @@
import { MetricCard } from "@govoplan/core-webui";
export default MetricCard;

View File

@@ -1,2 +0,0 @@
import { PageTitle } from "@govoplan/core-webui";
export default PageTitle;

View File

@@ -1,2 +0,0 @@
import { StatusBadge } from "@govoplan/core-webui";
export default StatusBadge;

View File

@@ -1,2 +0,0 @@
import { Stepper } from "@govoplan/core-webui";
export default Stepper;

View File

@@ -1,2 +0,0 @@
import { ToggleSwitch } from "@govoplan/core-webui";
export default ToggleSwitch;

View File

@@ -1,2 +0,0 @@
import { EmailAddressInput } from "@govoplan/core-webui";
export default EmailAddressInput;

View File

@@ -1,2 +0,0 @@
import { FieldLabel } from "@govoplan/core-webui";
export default FieldLabel;

View File

@@ -1,2 +0,0 @@
import { InlineHelp } from "@govoplan/core-webui";
export default InlineHelp;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { import {
Button, Button,
DismissibleAlert, DismissibleAlert,
@@ -7,28 +7,22 @@ import {
LoadingIndicator, LoadingIndicator,
MessageDisplayPanel, MessageDisplayPanel,
formatDateTime, formatDateTime,
i18nMessage,
type ApiSettings type ApiSettings
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
bootstrapMailbox,
getMailboxMessage, getMailboxMessage,
listMailboxFolders,
listMailboxMessages, listMailboxMessages,
listMailServerProfiles, listMailServerProfiles,
type MailImapFolderResponse, type MailImapFolderResponse,
type MailMailboxMessageDetail, type MailMailboxMessageDetail,
type MailMailboxMessageSummary, type MailMailboxMessageSummary,
type MailServerProfile type MailServerProfile } from
} from "../../api/mail"; "../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
type MailFolderNode = { export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
id: string;
label: string;
folderName: string | null;
flags: string[];
children: MailFolderNode[];
};
export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [profiles, setProfiles] = useState<MailServerProfile[]>([]); const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState(""); const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]); const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
@@ -37,34 +31,86 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set()); const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]); const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null); const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
const [messagePage, setMessagePage] = useState(1);
const [messagePageSize, setMessagePageSize] = useState(10);
const [messageQuery, setMessageQuery] = useState("");
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null); const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
const [selectedMessageKeyState, setSelectedMessageKeyState] = useState("");
const [pendingMessageKey, setPendingMessageKey] = useState(""); const [pendingMessageKey, setPendingMessageKey] = useState("");
const [loadingProfiles, setLoadingProfiles] = useState(true); const [loadingProfiles, setLoadingProfiles] = useState(true);
const [loadingFolders, setLoadingFolders] = useState(false); const [loadingFolders, setLoadingFolders] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false);
const [loadingMessage, setLoadingMessage] = useState(false); const [loadingMessage, setLoadingMessage] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [folderError, setFolderError] = useState("");
const [messageError, setMessageError] = useState("");
const [detailError, setDetailError] = useState("");
const selectedMessageKeyRef = useRef("");
const folderRequestRef = useRef(0); const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0); const messageListRequestRef = useRef(0);
const messageDetailRequestRef = 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 selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]); const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]); const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]); const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
const filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]);
const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize));
const shellBusy = loadingProfiles || loadingFolders || loadingMessages; const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0; const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId; const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : pendingMessageKey; const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady); const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const folderEmptyText = noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available."; 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 = !selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : "No messages in this folder."; 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 = loadingMessage ? "Loading message..." : "Select a message to inspect its content."; const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message..."; 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(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]); useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder); }, [foldersReady, selectedProfileId, selectedFolder]); 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) => {
if (isEditableTarget(event.target)) return;
if (event.key === "Escape") {
if (selectedMessage || selectedMessageKey || pendingMessageKey || detailError) {
event.preventDefault();
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setDetailError("");
}
return;
}
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 (nextIndex === null) return;
event.preventDefault();
const nextMessage = filteredMessages[nextIndex];
if (nextMessage) void openMessage(nextMessage);
};
window.addEventListener("keydown", handlePreviewShortcut);
return () => window.removeEventListener("keydown", handlePreviewShortcut);
}, [detailError, filteredMessages, loadingMessage, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]);
useEffect(() => { useEffect(() => {
const ancestorIds = folderAncestorIds(folderTree, selectedFolder); const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
@@ -90,7 +136,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
} }
} catch (err) { } catch (err) {
setError(errorText(err)); setError(errorText(err));
@@ -99,65 +148,104 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
} }
} }
async function loadFolders(profileId = selectedProfileId) { async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
if (!profileId) return; if (!profileId) return;
const requestId = ++folderRequestRef.current; const folderRequestId = ++folderRequestRef.current;
messageListRequestRef.current += 1; const messageRequestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1; messageDetailRequestRef.current += 1;
setLoadingFolders(true); setLoadingFolders(true);
setLoadingMessages(true);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessageTotalCount(null); setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey(""); setPendingMessageKey("");
setFolderError("");
setMessageError("");
setDetailError("");
setError(""); setError("");
try { try {
const response = await listMailboxFolders(settings, profileId); const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
if (requestId !== folderRequestRef.current) return; if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded."); if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }]; const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
setFolders(loaded); const loadedMessages = response.messages.messages ?? [];
setExpandedFolders(new Set()); const total = response.messages.total_count ?? loadedMessages.length;
let nextFolder = selectedFolder; let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
if (!nextFolder || !loaded.some((folder) => folder.name === nextFolder)) { if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
nextFolder = loaded.some((folder) => folder.name === "INBOX") ? "INBOX" : response.detected_sent_folder || loaded[0]?.name || "INBOX"; 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); setSelectedFolder(nextFolder);
setFoldersLoadedForProfile(profileId); setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages);
setMessageTotalCount(total);
} catch (err) { } catch (err) {
if (requestId !== folderRequestRef.current) return; if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
setError(errorText(err)); const message = errorText(err);
skipNextMessageLoadRef.current = false;
setFolderError(message);
setMessageError(message);
setError(message);
setFolders([]); setFolders([]);
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } 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) { async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) {
if (!profileId || !folder) return; if (!profileId || !folder) return;
const requestId = ++messageListRequestRef.current; const requestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1; 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); setLoadingMessages(true);
setMessageTotalCount(null); setMessageError("");
setSelectedMessage(null); setDetailError("");
setPendingMessageKey("");
setError(""); setError("");
try { try {
const response = await listMailboxMessages(settings, profileId, folder, 50); const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset, cursor, refresh);
if (requestId !== messageListRequestRef.current) return; if (requestId !== messageListRequestRef.current) return;
const loaded = response.messages ?? []; 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); setMessages(loaded);
setMessageTotalCount(response.total_count ?? loaded.length); setMessageTotalCount(total);
setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
}
} catch (err) { } catch (err) {
if (requestId !== messageListRequestRef.current) return; if (requestId !== messageListRequestRef.current) return;
setError(errorText(err)); const message = errorText(err);
setMessageError(message);
setError(message);
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } finally {
if (requestId === messageListRequestRef.current) setLoadingMessages(false); if (requestId === messageListRequestRef.current) setLoadingMessages(false);
@@ -168,18 +256,25 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
if (!selectedProfileId) return; if (!selectedProfileId) return;
const folderName = message.folder || selectedFolder; const folderName = message.folder || selectedFolder;
const requestId = ++messageDetailRequestRef.current; const requestId = ++messageDetailRequestRef.current;
setPendingMessageKey(mailboxMessageKey(folderName, message.uid)); const nextKey = mailboxMessageKey(folderName, message.uid);
setSelectedMessageKeyState(nextKey);
selectedMessageKeyRef.current = nextKey;
setPendingMessageKey(nextKey);
setSelectedMessage(null); setSelectedMessage(null);
setDetailError("");
setLoadingMessage(true); setLoadingMessage(true);
setError(""); setError("");
try { try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid); const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
if (requestId !== messageDetailRequestRef.current) return; if (requestId !== messageDetailRequestRef.current) return;
setSelectedMessage(response.message); setSelectedMessage(response.message);
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
setPendingMessageKey(""); setPendingMessageKey("");
} catch (err) { } catch (err) {
if (requestId !== messageDetailRequestRef.current) return; if (requestId !== messageDetailRequestRef.current) return;
setError(errorText(err)); const messageText = errorText(err);
setDetailError(messageText);
setError(messageText);
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } finally {
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false); if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
@@ -195,18 +290,31 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
setFoldersLoadedForProfile(""); setFoldersLoadedForProfile("");
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setMessagePage(1);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey(""); setPendingMessageKey("");
setExpandedFolders(new Set()); setExpandedFolders(new Set());
setLoadingFolders(false); setLoadingFolders(false);
setLoadingMessages(false); setLoadingMessages(false);
setLoadingMessage(false); setLoadingMessage(false);
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
} }
function openFolderNode(node: MailFolderNode) { function openFolderNode(node: MailFolderNode) {
if (node.folderName) { if (node.folderName) {
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName); if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else
else setSelectedFolder(node.folderName); {
setSelectedFolder(node.folderName);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setDetailError("");
}
return; return;
} }
toggleFolderNode(node); toggleFolderNode(node);
@@ -215,19 +323,33 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
function toggleFolderNode(node: MailFolderNode) { function toggleFolderNode(node: MailFolderNode) {
setExpandedFolders((current) => { setExpandedFolders((current) => {
const next = new Set(current); const next = new Set(current);
if (next.has(node.id)) next.delete(node.id); if (next.has(node.id)) next.delete(node.id);else
else next.add(node.id); next.add(node.id);
return next; return next;
}); });
} }
function setFolderMessageCount(folderName: string, count: number) {
setFolders((current) => current.map((folder) => folder.name === folderName ? { ...folder, message_count: count } : folder));
}
function changeMessagePage(page: number) {
const nextPage = Math.min(messagePageCount, Math.max(1, page));
if (nextPage !== messagePage) setMessagePage(nextPage);
}
function changeMessagePageSize(pageSize: number) {
setMessagePageSize(pageSize);
setMessagePage(1);
}
return ( return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page"> <div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}> <div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="Mailbox folders"> <aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
<div className="file-tree-heading">Folders</div> <div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
<div className="file-tree-list"> <div className="file-tree-list">
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>} {folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>}
<ExplorerTree <ExplorerTree
@@ -242,71 +364,80 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
disabled={shellBusy} disabled={shellBusy}
renderNodeContent={(node) => { renderNodeContent={(node) => {
const flagText = folderFlagLabel(node.flags); const flagText = folderFlagLabel(node.flags);
const showCount = foldersReady && node.folderName === selectedFolder && messageTotalCount !== null; const showCount = foldersReady && node.messageCount !== null;
return ( return (
<span className="mailbox-tree-node-label"> <span className="mailbox-tree-node-label">
<span className="mailbox-tree-node-main"> <span className="mailbox-tree-node-main">
<span>{node.label}</span> <span>{node.label}</span>
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>} {showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
</span> </span>
{flagText && <small>{flagText}</small>} {flagText && <small>{flagText}</small>}
</span> </span>);
);
}} }} />
/>
</div> </div>
</aside> </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-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"> <label className="mailbox-profile-field">
<span>IMAP profile</span> <span>i18n:govoplan-mail.imap_profile.5165df81</span>
<select value={selectedProfileId} disabled={shellBusy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}> <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>)} {imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select> </select>
</label> </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"> <div className="mailbox-toolbar-actions">
<Button onClick={() => void loadProfiles()} disabled={shellBusy} title="Reload IMAP profiles"> <Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
Profiles i18n:govoplan-mail.profiles.0c2a9300
</Button> </Button>
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || shellBusy} 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" /> <RefreshCw size={16} aria-hidden="true" />
Folders i18n:govoplan-mail.folders.19adc47b
</Button> </Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || shellBusy} 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" /> <RefreshCw size={16} aria-hidden="true" />
Messages i18n:govoplan-mail.messages.f1702b46
</Button> </Button>
</div> </div>
</div> </div>
<nav className="file-breadcrumbs" aria-label="Current mailbox folder"> <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 || "Mail"}</span> <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> <span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
</nav> </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="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"> <div className="file-list-meta">
<span>{messageCountLabel}</span> <span>{messageCountLabel}</span>
<span>{selectedFolder}</span> <span>{selectedFolder}</span>
{shellBusy && <span>Working...</span>} {messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
{loadingMessage && <span>Loading preview...</span>} {shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
</div> </div>
<div className="file-list-table-head mailbox-message-head" role="row"> <div className="file-list-table-head mailbox-message-head" role="row">
<span>Subject</span> <span>i18n:govoplan-mail.subject.8d183dbd</span>
<span>Date</span> <span>i18n:govoplan-mail.date.eb9a4bc1</span>
<span>Size</span> <span>i18n:govoplan-mail.size.b7152342</span>
</div> </div>
</div> </div>
<div className="file-list-drop-target mailbox-message-scroll"> <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">
{messages.length === 0 && <div className="file-list-empty">{messageEmptyText}</div>} {filteredMessages.length === 0 && <div className={`file-list-empty${messageError ? " mailbox-error-state" : ""}`}>{messageEmptyText}</div>}
{messages.map((message) => { {filteredMessages.map((message) => {
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid); const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
const selected = key === selectedMessageKey; const selected = key === selectedMessageKey;
const loadingSelected = loadingMessage && key === pendingMessageKey; const loadingSelected = loadingMessage && key === pendingMessageKey;
@@ -322,13 +453,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
event.preventDefault(); event.preventDefault();
void openMessage(message); void openMessage(message);
} }
}} }}>
>
<div className="file-list-name-cell"> <div className="file-list-name-cell">
<div className="file-list-name"> <div className="file-list-name">
<Mail className="file-row-icon" size={20} aria-hidden="true" /> <Mail className="file-row-icon" size={20} aria-hidden="true" />
<span> <span>
<strong>{message.subject || "(no subject)"}</strong> <strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
<small>{message.from_header || "-"}</small> <small>{message.from_header || "-"}</small>
</span> </span>
</div> </div>
@@ -338,24 +469,33 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
{message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null} {message.attachment_count ? <span><Paperclip size={14} aria-hidden="true" /> {message.attachment_count}</span> : null}
<span>{formatBytes(message.size_bytes)}</span> <span>{formatBytes(message.size_bytes)}</span>
</span> </span>
</div> </div>);
);
})} })}
</div> </div>
</div> </div>
<MailboxPagination
page={messagePage}
pageSize={messagePageSize}
totalRows={messageTotalCount ?? 0}
disabled={loadingMessages || !foldersReady}
onPageChange={changeMessagePage}
onPageSizeChange={changeMessagePageSize} />
</section> </section>
<section className="mailbox-preview-panel" aria-label="Message preview"> <section className="mailbox-preview-panel" aria-label="i18n:govoplan-mail.message_preview.58de1450">
<div className="file-tree-heading">Preview</div> <div className="file-tree-heading">i18n:govoplan-mail.preview.f1fbb2b4</div>
<div className="mailbox-preview-scroll"> <div className="mailbox-preview-scroll">
<MessageDisplayPanel <MessageDisplayPanel
title={selectedMessage?.subject} title={selectedMessage?.subject}
fields={selectedMessage ? [ fields={selectedMessage ? [
{ label: "From", value: selectedMessage.from_header || "-" }, { label: "i18n:govoplan-mail.from.3f66052a", value: selectedMessage.from_header || "-" },
{ label: "To", value: selectedMessage.to_header || "-" }, { label: "i18n:govoplan-mail.to.ae79ea1e", value: selectedMessage.to_header || "-" },
{ label: "Cc", value: selectedMessage.cc_header || null }, { label: "i18n:govoplan-mail.cc.1fd6a880", value: selectedMessage.cc_header || null },
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) } { 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} bodyText={selectedMessage?.body_text}
bodyHtml={selectedMessage?.body_html} bodyHtml={selectedMessage?.body_html}
bodyPreview={selectedMessage?.body_preview} bodyPreview={selectedMessage?.body_preview}
@@ -365,102 +505,74 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
contentType: attachment.content_type, contentType: attachment.content_type,
sizeBytes: attachment.size_bytes sizeBytes: attachment.size_bytes
}))} }))}
emptyText={previewEmptyText} emptyText={previewEmptyText} />
/>
</div> </div>
</section> </section>
{shellBusy && ( {shellBusy &&
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true"> <div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div> <div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{i18nMessage(loadingLabel)}</span></div>
</div> </div>
)} }
</div> </div>
</div>);
}
function MailboxPagination({ page, pageSize, totalRows, disabled, onPageChange, onPageSizeChange }: {page: number;pageSize: number;totalRows: number;disabled: boolean;onPageChange: (page: number) => void;onPageSizeChange: (pageSize: number) => void;}) {
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
const last = Math.min(totalRows, page * pageSize);
const options = [10, 25, 50, 100];
return (
<div className="data-grid-pagination mailbox-pagination" aria-label="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>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="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 buildMailboxFolderTree(folders: MailImapFolderResponse[]): MailFolderNode[] {
const roots: MailFolderNode[] = [];
const byId = new Map<string, MailFolderNode>();
function ensureNode(parts: string[], index: number): MailFolderNode | null {
if (index < 0 || index >= parts.length) return null;
const id = parts.slice(0, index + 1).join("/");
const existing = byId.get(id);
if (existing) return existing;
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], children: [] };
byId.set(id, node);
if (index === 0) {
roots.push(node);
} else {
ensureNode(parts, index - 1)?.children.push(node);
}
return node;
}
for (const folder of folders) {
const parts = folderParts(folder.name);
const node = ensureNode(parts, parts.length - 1);
if (node) {
node.folderName = folder.name;
node.flags = folder.flags ?? [];
}
}
sortFolderNodes(roots);
return roots;
}
function folderParts(folderName: string): string[] {
const normalized = folderName.trim();
if (!normalized) return [];
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
return [normalized];
}
function sortFolderNodes(nodes: MailFolderNode[]) {
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
nodes.forEach((node) => sortFolderNodes(node.children));
}
function folderSortKey(label: string): string {
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
}
function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
for (const node of nodes) {
if (node.folderName === folderName) return node.id;
const child = findFolderNodeId(node.children, folderName);
if (child) return child;
}
return null;
}
function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
for (const node of nodes) {
const path = [...parents, node.id];
if (node.folderName === folderName) return path;
const child = folderAncestorIds(node.children, folderName, path);
if (child.length > 0) return child;
}
return [];
}
function mailboxMessageKey(folder: string, uid: string): string { function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`; 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)));
}
function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string { function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
if (!ready) return "No folder loaded"; if (!ready) return "i18n:govoplan-mail.no_folder_loaded.889bdb1b";
if (loading) return "Loading messages"; if (loading) return "i18n:govoplan-mail.loading_messages.4294022c";
if (totalCount !== null && totalCount > visibleCount) return `${visibleCount} of ${totalCount} messages`; if (totalCount !== null && totalCount > visibleCount) return i18nMessage("i18n:govoplan-mail.value_of_value_messages.981a1618", { value0: visibleCount, value1: totalCount });
const count = totalCount ?? visibleCount; 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 { function folderFlagLabel(flags: string[]): string {
@@ -472,17 +584,17 @@ function displayFolderFlag(flag: string): string | null {
const normalized = flag.replace(/^\\/, "").toLowerCase(); const normalized = flag.replace(/^\\/, "").toLowerCase();
switch (normalized) { switch (normalized) {
case "sent": case "sent":
return "Sent"; return "i18n:govoplan-mail.sent.35f49dcf";
case "drafts": case "drafts":
return "Drafts"; return "i18n:govoplan-mail.drafts.22a31d86";
case "trash": case "trash":
return "Trash"; return "i18n:govoplan-mail.trash.e3bf62bb";
case "archive": case "archive":
return "Archive"; return "i18n:govoplan-mail.archive.2621c6fd";
case "junk": case "junk":
return "Junk"; return "i18n:govoplan-mail.junk.86c7d94c";
case "flagged": case "flagged":
return "Flagged"; return "i18n:govoplan-mail.flagged.f8db8a17";
case "haschildren": case "haschildren":
case "hasnochildren": case "hasnochildren":
case "noselect": case "noselect":
@@ -494,17 +606,22 @@ function displayFolderFlag(flag: string): string | null {
function transportLabel(profile: MailServerProfile): string { function transportLabel(profile: MailServerProfile): string {
const imap = profile.imap; 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(); return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
} }
function formatBytes(value?: number | null): string { function formatBytes(value?: number | null): string {
if (!value) return "-"; if (!value) return "-";
if (value < 1024) return `${value} B`; if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-mail.bytes_kb", { value0: (value / 1024).toFixed(1) });
return `${(value / 1024 / 1024).toFixed(1)} MB`; return i18nMessage("i18n:govoplan-mail.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
} }
function errorText(error: unknown): string { function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
}

View File

@@ -0,0 +1,149 @@
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePolicy = {
whitelist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
blacklist?: Partial<Record<MailProfilePatternKey, string[]>> | null;
};
export type MailPolicyValidationInput = {
smtpHost?: string | null;
imapHost?: string | null;
envelopeSender?: string | null;
fromHeader?: string | null;
recipientDomains?: Array<string | null | undefined> | null;
};
export type MailPolicyValidationMessage = {
key: MailProfilePatternKey;
label: string;
value: string;
message: string;
severity?: "warning" | "danger";
};
const patternLabels: Record<MailProfilePatternKey, string> = {
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 = {
key: MailProfilePatternKey;
value: string;
};
export function validateMailPolicy(
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 }))];
return checks.flatMap(({ key, value }) => {
const result = mailPolicyValueAllowed(policy, key, value);
if (result.allowed) return [];
const label = patternLabels[key];
return [{
key,
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.`
}];
});
}
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;} {
const value = normalizePolicyValue(key, rawValue);
if (!value || !policy) return { allowed: true, value };
const deniedBy = patternsFor(policy.blacklist, key).find((pattern) => wildcardPatternMatches(pattern, value));
if (deniedBy) return { allowed: false, value, reason: "blacklist", pattern: deniedBy };
const allowPatterns = meaningfulAllowPatterns(patternsFor(policy.whitelist, key));
if (allowPatterns.length > 0 && !allowPatterns.some((pattern) => wildcardPatternMatches(pattern, value))) {
return { allowed: false, value, reason: "whitelist" };
}
return { allowed: true, value };
}
export function wildcardPatternMatches(pattern: string, rawValue: string): boolean {
const normalizedPattern = pattern.trim();
const value = rawValue.trim();
if (!normalizedPattern || !value) return false;
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[] {
return (rules?.[key] ?? []).map((pattern) => String(pattern).trim()).filter(Boolean);
}
function meaningfulAllowPatterns(patterns: string[]): string[] {
return patterns.filter((pattern) => pattern !== "*");
}
function normalizePolicyValue(key: MailProfilePatternKey, value: string | null | undefined): string {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "";
if (key === "recipient_domains") return normalizeDomain(trimmed);
return trimmed;
}
function normalizeDomain(value: string | null | undefined): string {
const trimmed = String(value ?? "").trim().toLowerCase();
if (!trimmed) return "";
const emailDomain = trimmed.includes("@") ? trimmed.split("@").pop() ?? "" : trimmed;
return emailDomain.replace(/^@+/, "");
}

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,88 @@
export type MailboxFolderInfo = {
name: string;
flags?: string[];
message_count?: number | null;
unseen_count?: number | null;
};
export type MailFolderNode = {
id: string;
label: string;
folderName: string | null;
flags: string[];
messageCount: number | null;
unseenCount: number | null;
children: MailFolderNode[];
};
export function buildMailboxFolderTree(folders: MailboxFolderInfo[]): MailFolderNode[] {
const roots: MailFolderNode[] = [];
const byId = new Map<string, MailFolderNode>();
function ensureNode(parts: string[], index: number): MailFolderNode | null {
if (index < 0 || index >= parts.length) return null;
const id = parts.slice(0, index + 1).join("/");
const existing = byId.get(id);
if (existing) return existing;
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], messageCount: null, unseenCount: null, children: [] };
byId.set(id, node);
if (index === 0) {
roots.push(node);
} else {
ensureNode(parts, index - 1)?.children.push(node);
}
return node;
}
for (const folder of folders) {
const parts = folderParts(folder.name);
const node = ensureNode(parts, parts.length - 1);
if (node) {
node.folderName = folder.name;
node.flags = folder.flags ?? [];
node.messageCount = typeof folder.message_count === "number" ? folder.message_count : null;
node.unseenCount = typeof folder.unseen_count === "number" ? folder.unseen_count : null;
}
}
sortFolderNodes(roots);
return roots;
}
export function folderParts(folderName: string): string[] {
const normalized = folderName.trim();
if (!normalized) return [];
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
return [normalized];
}
export function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
for (const node of nodes) {
if (node.folderName === folderName) return node.id;
const child = findFolderNodeId(node.children, folderName);
if (child) return child;
}
return null;
}
export function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
for (const node of nodes) {
const path = [...parents, node.id];
if (node.folderName === folderName) return path;
const child = folderAncestorIds(node.children, folderName, path);
if (child.length > 0) return child;
}
return [];
}
function sortFolderNodes(nodes: MailFolderNode[]) {
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
nodes.forEach((node) => sortFolderNodes(node.children));
}
function folderSortKey(label: string): string {
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
}

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 { default } from "./module";
export * from "./module"; export * from "./module";
export * from "./api/mail"; export * from "./api/mail";

View File

@@ -1,18 +1,34 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; 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 MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
const mailboxRead = ["mail:mailbox:read"]; const mailboxRead = ["mail:mailbox:read"];
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
export const mailModule: PlatformWebModule = { export const mailModule: PlatformWebModule = {
id: "mail", id: "mail",
label: "Mail", label: "i18n:govoplan-mail.mail.92379cbb",
version: "1.0.0", version: "1.0.0",
dependencies: ["access"], 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: [ 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: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
}
}; };
export default mailModule; export default mailModule;

View File

@@ -18,30 +18,6 @@
max-width: 520px; 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 { .mail-profile-dialog .dialog-body {
max-height: min(76vh, 820px); max-height: min(76vh, 820px);
overflow: auto; overflow: auto;
@@ -52,8 +28,40 @@
gap: 18px; 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-profile-form h3,
.mail-policy-section h3,
.mail-policy-pattern-grid h4 { .mail-policy-pattern-grid h4 {
margin: 0; margin: 0;
color: var(--text-strong); color: var(--text-strong);
@@ -83,12 +91,6 @@
margin: 0; margin: 0;
} }
.mail-policy-section {
display: grid;
gap: 12px;
padding-top: 4px;
}
.mail-profile-checkbox-grid { .mail-profile-checkbox-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@@ -101,7 +103,7 @@
align-items: start; align-items: start;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
} }
@@ -123,6 +125,42 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.mail-policy-row {
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr);
}
.mail-policy-table.with-effective-column .mail-policy-row {
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr) minmax(220px, 1fr);
}
.mail-policy-table.with-allow-column .mail-policy-row {
grid-template-columns: minmax(170px, .8fr) minmax(200px, .75fr) minmax(150px, .55fr);
}
.mail-policy-table.with-effective-column.with-allow-column .mail-policy-row {
grid-template-columns: minmax(170px, .8fr) minmax(200px, .75fr) minmax(210px, .95fr) minmax(150px, .55fr);
}
.mail-policy-pattern-row {
grid-template-columns: minmax(190px, .8fr) minmax(220px, 1fr) minmax(220px, 1fr);
align-items: start;
}
.mail-policy-pattern-table.with-allow-column .mail-policy-pattern-row {
grid-template-columns: minmax(170px, .7fr) minmax(200px, 1fr) minmax(200px, 1fr) minmax(165px, .6fr);
}
.mail-policy-pattern-limits {
display: grid;
gap: 8px;
min-width: 0;
}
.mail-policy-lower-cell .toggle-switch-row,
.mail-policy-pattern-limits .toggle-switch-row {
margin: 0;
}
.mail-policy-pattern-grid { .mail-policy-pattern-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -136,7 +174,7 @@
} }
.mail-policy-effective { .mail-policy-effective {
border-top: 1px solid var(--line); border-top: var(--border-line);
padding-top: 14px; padding-top: 14px;
} }
@@ -150,7 +188,7 @@
display: grid; display: grid;
gap: 3px; gap: 3px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-subtle); background: var(--surface-subtle);
} }
@@ -172,17 +210,27 @@
} }
.status-badge.neutral { .status-badge.neutral {
background: #e7e4df; background: var(--status-neutral-bg);
color: #666; color: var(--text-soft);
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.admin-form-grid.three-columns, .admin-form-grid.three-columns,
.mail-policy-pattern-grid, .mail-policy-pattern-grid,
.mail-policy-effective-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,
.mail-policy-table.with-effective-column.with-allow-column .mail-policy-row,
.mail-policy-pattern-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.mail-policy-row-header {
display: none;
}
.mail-credential-inheritance-row { .mail-credential-inheritance-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -247,7 +295,7 @@
gap: 8px; gap: 8px;
align-items: center; align-items: center;
width: 100%; width: 100%;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
@@ -342,7 +390,7 @@
margin: 0; margin: 0;
padding: 12px; padding: 12px;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--surface-subtle); background: var(--surface-subtle);
color: var(--text); color: var(--text);
@@ -366,7 +414,7 @@
gap: 8px; gap: 8px;
align-items: center; align-items: center;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
@@ -481,7 +529,7 @@
flex: 0 0 auto; flex: 0 0 auto;
min-width: 20px; min-width: 20px;
padding: 1px 6px; padding: 1px 6px;
border: 1px solid var(--line); border: var(--border-line);
border-radius: 999px; border-radius: 999px;
background: var(--surface-subtle); background: var(--surface-subtle);
color: var(--text); color: var(--text);
@@ -532,13 +580,69 @@
} }
.mailbox-message-list-panel { .mailbox-message-list-panel {
border-right: 1px solid var(--line); border-right: var(--border-line);
} }
.mailbox-message-table { .mailbox-message-table {
min-width: 660px; min-width: 660px;
} }
.mailbox-filter-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px 0;
}
.mailbox-search-field {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: min(430px, 100%);
min-height: 34px;
padding: 0 8px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--muted);
}
.mailbox-search-field input {
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
padding: 6px 0;
}
.mailbox-search-field button {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--radius-xs, 5px);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.mailbox-search-field button:hover,
.mailbox-search-field button:focus-visible {
background: var(--surface-subtle);
color: var(--text-strong);
}
.mailbox-error-state {
color: var(--danger-text);
}
.mailbox-pagination {
flex: 0 0 auto;
}
.mailbox-message-head, .mailbox-message-head,
.mailbox-message-row { .mailbox-message-row {
grid-template-columns: minmax(260px, 1fr) 190px 130px; grid-template-columns: minmax(260px, 1fr) 190px 130px;
@@ -602,7 +706,7 @@
.mailbox-preview-panel { .mailbox-preview-panel {
grid-column: 1 / -1; grid-column: 1 / -1;
min-height: 320px; min-height: 320px;
border-top: 1px solid var(--line); border-top: var(--border-line);
} }
} }

View File

@@ -0,0 +1,46 @@
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 { mailPolicyValueAllowed, validateMailPolicy, wildcardPatternMatches } from "../src/features/mail/mailPolicyValidation";
assertEqual(wildcardPatternMatches("*.example.org", "smtp.example.org"), true);
assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp1.example.org"), true);
assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp12.example.org"), false);
const policy = {
whitelist: {
smtp_hosts: ["smtp.allowed.test"],
from_headers: ["*@allowed.test"],
recipient_domains: ["allowed.test"]
},
blacklist: {
smtp_hosts: ["smtp.blocked.test"],
envelope_senders: ["blocked@*"]
}
};
assertDeepEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.allowed.test"), { allowed: true, value: "smtp.allowed.test" });
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.blocked.test").allowed, false, "blacklist wins for SMTP host");
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.other.test").allowed, false, "whitelist blocks unknown SMTP host");
const messages = validateMailPolicy(policy, {
smtpHost: "smtp.other.test",
envelopeSender: "blocked@allowed.test",
fromHeader: "sender@other.test",
recipientDomains: ["allowed.test", "denied.test", "user@denied.test"]
});
assert(messages.some((item) => item.key === "smtp_hosts" && item.value === "smtp.other.test"));
assert(messages.some((item) => item.key === "envelope_senders" && item.value === "blocked@allowed.test"));
assert(messages.some((item) => item.key === "from_headers" && item.value === "sender@other.test"));
assertEqual(messages.filter((item) => item.key === "recipient_domains" && item.value === "denied.test").length, 1, "recipient domains are normalized and de-duplicated");

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

@@ -0,0 +1,30 @@
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 { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, folderParts } from "../src/features/mail/mailboxFolders";
assertDeepEqual(folderParts("INBOX.Sent.Archive"), ["INBOX", "Sent", "Archive"]);
assertDeepEqual(folderParts("Projects/2026/Inbox"), ["Projects", "2026", "Inbox"]);
const tree = buildMailboxFolderTree([
{ name: "Archive/2026", flags: [] },
{ name: "INBOX", flags: [] },
{ name: "INBOX.Sent", flags: ["\\Sent"] },
{ name: "Projects/2026/Inbox", flags: [] }
]);
assertEqual(tree[0].id, "INBOX", "INBOX sorts first");
assertEqual(findFolderNodeId(tree, "INBOX.Sent"), "INBOX/Sent");
assertDeepEqual(folderAncestorIds(tree, "Projects/2026/Inbox"), ["Projects", "Projects/2026", "Projects/2026/Inbox"]);
assertEqual(tree.find((node) => node.id === "INBOX")?.children[0]?.folderName, "INBOX.Sent");

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020",
"DOM"
],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".mail-test-build",
"rootDir": "."
},
"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"
]
}