Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0337b2d26 | |||
| 4865de5007 | |||
| 5371cb3ad4 | |||
| 286b6cafd9 | |||
| 47e3f9ec44 | |||
| 30fd6e7e5f | |||
| caaace14f3 | |||
| d5d5b89603 | |||
| b4b0c76455 | |||
| 5f800a24db | |||
| 3743b23613 | |||
| e6e243c14d | |||
| 3d82d7b32d |
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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:
|
||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal 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?
|
||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal 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:
|
||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal 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:
|
||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal 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:
|
||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## Issue
|
||||
|
||||
Closes #
|
||||
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
Follow-up issues:
|
||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -326,3 +326,24 @@ cython_debug/
|
||||
# Built Visual Studio Code Extensions
|
||||
*.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
44
AGENTS.md
Normal 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.
|
||||
23
README.md
23
README.md
@@ -1,5 +1,9 @@
|
||||
# govoplan-mail
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
GovOPlaN Mail is the mail transport module. It owns reusable SMTP/IMAP profile management, mail profile policy enforcement, mock mail infrastructure, and the mail WebUI package.
|
||||
|
||||
## Ownership
|
||||
@@ -15,6 +19,10 @@ This repository owns:
|
||||
|
||||
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
|
||||
|
||||
Install through the core environment:
|
||||
@@ -46,7 +54,20 @@ Frontend package:
|
||||
@govoplan/mail-webui
|
||||
```
|
||||
|
||||
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection.
|
||||
The campaign module consumes `mail.campaign_delivery` for sending,
|
||||
append-to-Sent behavior, profile selection, and policy checks. Mail does not
|
||||
import campaign internals; campaign-scoped policy and owner context are resolved
|
||||
through the core `campaigns.mailPolicyContext` capability when the campaign
|
||||
module is installed.
|
||||
|
||||
Development mailbox routes are registered by the mail module only when the
|
||||
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
|
||||
does not contribute these routes directly.
|
||||
|
||||
POP3 and JMAP are deferred. The protocol decision is documented in
|
||||
[docs/MAIL_PROTOCOL_ROADMAP.md](docs/MAIL_PROTOCOL_ROADMAP.md): stabilize
|
||||
SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3
|
||||
only for explicit legacy-download requirements.
|
||||
|
||||
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||
|
||||
|
||||
50
docs/MAIL_PROTOCOL_ROADMAP.md
Normal file
50
docs/MAIL_PROTOCOL_ROADMAP.md
Normal 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -19,8 +19,8 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
|
||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-mail"
|
||||
version = "0.1.1"
|
||||
version = "0.1.8"
|
||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.1",
|
||||
"govoplan-core>=0.1.8",
|
||||
"pydantic>=2,<3",
|
||||
"redis>=5,<6",
|
||||
"SQLAlchemy>=2,<3",
|
||||
|
||||
53
src/govoplan_mail/backend/capabilities.py
Normal file
53
src/govoplan_mail/backend/capabilities.py
Normal 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()
|
||||
@@ -1,35 +1,23 @@
|
||||
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
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
|
||||
class TransportSecurity(StrEnum):
|
||||
PLAIN = "plain"
|
||||
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)
|
||||
__all__ = [
|
||||
"ImapConfig",
|
||||
"ImapServerConfig",
|
||||
"SmtpConfig",
|
||||
"SmtpServerConfig",
|
||||
"StrictModel",
|
||||
"TransportCredentials",
|
||||
"TransportSecurity",
|
||||
"normalize_split_transport_credentials",
|
||||
]
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
@@ -21,7 +23,7 @@ class MailServerProfile(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
@@ -29,12 +31,70 @@ class MailServerProfile(Base, TimestampMixin):
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
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_username: Mapped[str | None] = mapped_column(String(320))
|
||||
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
imap_username: Mapped[str | None] = mapped_column(String(320))
|
||||
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
tenant: Mapped["Tenant"] = relationship()
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
|
||||
class MailProfilePolicy(Base, TimestampMixin):
|
||||
__tablename__ = "mail_profile_policies"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"),
|
||||
Index("ix_mail_profile_policies_scope", "scope_type", "scope_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class MailMailboxFolderIndex(Base, TimestampMixin):
|
||||
__tablename__ = "mail_mailbox_folder_index"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("profile_id", "folder", name="uq_mail_mailbox_folder_index_profile_folder"),
|
||||
Index("ix_mail_mailbox_folder_index_tenant_profile", "tenant_id", "profile_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
profile_id: Mapped[str] = mapped_column(ForeignKey("mail_server_profiles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
folder: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
flags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
message_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
unseen_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
uidvalidity: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
indexed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
message_indexed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
|
||||
class MailMailboxMessageIndex(Base, TimestampMixin):
|
||||
__tablename__ = "mail_mailbox_message_index"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("profile_id", "folder", "uid", name="uq_mail_mailbox_message_index_profile_folder_uid"),
|
||||
Index("ix_mail_mailbox_message_index_page", "tenant_id", "profile_id", "folder", "sort_position"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
profile_id: Mapped[str] = mapped_column(ForeignKey("mail_server_profiles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
folder: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
uid: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
uid_int: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False, index=True)
|
||||
sort_position: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False, index=True)
|
||||
subject: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
from_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
to_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cc_header: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
date: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
message_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
flags: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
body_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
attachment_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
indexed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||
clear_records,
|
||||
get_failures,
|
||||
|
||||
259
src/govoplan_mail/backend/documentation.py
Normal file
259
src/govoplan_mail/backend/documentation.py
Normal 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
287
src/govoplan_mail/backend/mailbox_index.py
Normal file
287
src/govoplan_mail/backend/mailbox_index.py
Normal 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
|
||||
@@ -2,8 +2,24 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_mail.backend.documentation import documentation_topics
|
||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||
|
||||
|
||||
@@ -56,18 +72,43 @@ ROLE_TEMPLATES = (
|
||||
def _mail_router(context: ModuleContext):
|
||||
from govoplan_mail.backend.runtime import configure_runtime
|
||||
|
||||
configure_runtime(settings=context.settings)
|
||||
configure_runtime(registry=context.registry, settings=context.settings)
|
||||
from fastapi import APIRouter
|
||||
from govoplan_mail.backend.router import router
|
||||
|
||||
return router
|
||||
aggregate = APIRouter()
|
||||
aggregate.include_router(router)
|
||||
app_env = str(getattr(context.settings, "app_env", "dev")).lower()
|
||||
if app_env == "dev" and bool(getattr(context.settings, "dev_mailbox_api_enabled", False)):
|
||||
from govoplan_mail.backend.dev_router import router as dev_router
|
||||
|
||||
aggregate.include_router(dev_router)
|
||||
return aggregate
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="mail",
|
||||
name="Mail",
|
||||
version="1.0.0",
|
||||
dependencies=("access",),
|
||||
optional_dependencies=(),
|
||||
version="0.1.8",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns", "addresses"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.1.6"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name="campaigns.mail_policy_context",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="addresses.lookup",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_mail_router,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
@@ -81,10 +122,59 @@ manifest = ModuleManifest(
|
||||
module_id="mail",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
mail_models.MailServerProfile,
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
mail_models.MailMailboxMessageIndex,
|
||||
label="Mail",
|
||||
),
|
||||
retirement_notes="Destructive retirement drops mail-owned database tables after the installer captures a database snapshot.",
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
mail_models.MailServerProfile,
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
mail_models.MailMailboxMessageIndex,
|
||||
label="Mail",
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="mail.profiles-and-policy",
|
||||
title="Mail profiles and policy hierarchy",
|
||||
summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.",
|
||||
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define their own mail-server settings. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
||||
order=39,
|
||||
i18n_key="mail.topic.profiles_and_policy",
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail",),
|
||||
any_scopes=("mail:profile:read", "admin:policies:read", "system:settings:read"),
|
||||
configuration_keys=("mail_profile_policy",),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Mail profiles", href="/api/v1/mail/profiles", kind="api"),
|
||||
DocumentationLink(label="Tenant mail policy", href="/api/v1/mail/policies/tenant", kind="api"),
|
||||
DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
|
||||
),
|
||||
related_modules=("campaigns",),
|
||||
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
|
||||
configuration_keys=("mail_profile_policy",),
|
||||
),
|
||||
),
|
||||
documentation_providers=(documentation_topics,),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
@@ -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
@@ -1,29 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from govoplan_core.core.runtime import ModuleRuntimeState
|
||||
|
||||
_runtime_settings: object | None = None
|
||||
_runtime = ModuleRuntimeState("Mail")
|
||||
|
||||
|
||||
def configure_runtime(*, settings: object | None = None) -> None:
|
||||
global _runtime_settings
|
||||
if settings is not None:
|
||||
_runtime_settings = settings
|
||||
|
||||
|
||||
def get_settings() -> object:
|
||||
if _runtime_settings is not None:
|
||||
return _runtime_settings
|
||||
try:
|
||||
from govoplan_core.settings import settings as legacy_settings
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError("GovOPlaN Mail runtime settings are not configured") from exc
|
||||
return legacy_settings
|
||||
|
||||
|
||||
class SettingsProxy:
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(get_settings(), name)
|
||||
|
||||
|
||||
settings = SettingsProxy()
|
||||
configure_runtime = _runtime.configure_runtime
|
||||
get_registry = _runtime.get_registry
|
||||
get_settings = _runtime.get_settings
|
||||
settings = _runtime.settings
|
||||
|
||||
@@ -3,9 +3,17 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
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):
|
||||
@@ -15,7 +23,29 @@ class MailSmtpTestRequest(SmtpConfig):
|
||||
class MailImapTestRequest(ImapConfig):
|
||||
"""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"]
|
||||
@@ -25,7 +55,6 @@ class MailCredentialPolicyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
inherit: bool | None = None
|
||||
allow_override: bool | None = None
|
||||
|
||||
|
||||
class MailProfilePolicyPayload(BaseModel):
|
||||
@@ -39,6 +68,7 @@ class MailProfilePolicyPayload(BaseModel):
|
||||
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
||||
whitelist: 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):
|
||||
@@ -50,8 +80,10 @@ class MailProfilePolicyUpdateRequest(BaseModel):
|
||||
class PolicySourceStepResponse(BaseModel):
|
||||
scope_type: str
|
||||
scope_id: str | None = None
|
||||
path: str
|
||||
label: str
|
||||
applied_fields: list[str] = Field(default_factory=list)
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailProfilePolicyResponse(BaseModel):
|
||||
@@ -73,8 +105,29 @@ class MailServerProfileCreateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
scope_type: MailProfileScope = "tenant"
|
||||
scope_id: str | None = None
|
||||
smtp: SmtpConfig
|
||||
imap: ImapConfig | None = None
|
||||
smtp: SmtpServerConfig
|
||||
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):
|
||||
@@ -84,10 +137,35 @@ class MailServerProfileUpdateRequest(BaseModel):
|
||||
slug: str | None = Field(default=None, max_length=100)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
smtp: SmtpConfig | None = None
|
||||
imap: ImapConfig | None = None
|
||||
smtp: SmtpServerConfig | None = None
|
||||
imap: ImapServerConfig | None = None
|
||||
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
|
||||
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):
|
||||
id: str
|
||||
@@ -100,6 +178,7 @@ class MailServerProfileResponse(BaseModel):
|
||||
is_active: bool
|
||||
smtp: dict[str, Any]
|
||||
imap: dict[str, Any] | None = None
|
||||
credentials: dict[str, Any] = Field(default_factory=dict)
|
||||
smtp_password_configured: bool = False
|
||||
imap_password_configured: bool = False
|
||||
created_at: datetime
|
||||
@@ -110,6 +189,36 @@ class MailServerProfileListResponse(BaseModel):
|
||||
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailSettingsDeltaResponse(BaseModel):
|
||||
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
|
||||
policy: MailProfilePolicyResponse | None = None
|
||||
changed_sections: list[str] = Field(default_factory=list)
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class MailAddressLookupCandidate(BaseModel):
|
||||
contact_id: str
|
||||
address_book_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
email_label: str | None = None
|
||||
organization: str | None = None
|
||||
role_title: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
source_kind: str = "local"
|
||||
source_ref: str | None = None
|
||||
source_revision: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailAddressLookupResponse(BaseModel):
|
||||
available: bool = False
|
||||
candidates: list[MailAddressLookupCandidate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailConnectionTestResponse(BaseModel):
|
||||
ok: bool
|
||||
protocol: Literal["smtp", "imap"]
|
||||
@@ -123,6 +232,8 @@ class MailConnectionTestResponse(BaseModel):
|
||||
class MailImapFolderResponse(BaseModel):
|
||||
name: str
|
||||
flags: list[str] = Field(default_factory=list)
|
||||
message_count: int | None = None
|
||||
unseen_count: int | None = None
|
||||
|
||||
|
||||
class MailImapFolderListResponse(BaseModel):
|
||||
@@ -134,6 +245,9 @@ class MailImapFolderListResponse(BaseModel):
|
||||
message: str
|
||||
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
||||
detected_sent_folder: str | None = None
|
||||
from_cache: bool = False
|
||||
refreshing: bool = False
|
||||
indexed_at: datetime | None = None
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
class MailMailboxAttachmentResponse(BaseModel):
|
||||
@@ -171,9 +285,25 @@ class MailMailboxMessageListResponse(BaseModel):
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
total_count: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = None
|
||||
cursor_stable: bool = False
|
||||
full: bool = False
|
||||
from_cache: bool = False
|
||||
refreshing: bool = False
|
||||
indexed_at: datetime | None = None
|
||||
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailMailboxBootstrapResponse(BaseModel):
|
||||
profile_id: str
|
||||
folder: str
|
||||
folders: MailImapFolderListResponse
|
||||
messages: MailMailboxMessageListResponse
|
||||
|
||||
|
||||
class MailMailboxMessageResponse(BaseModel):
|
||||
profile_id: str
|
||||
folder: str
|
||||
@@ -181,4 +311,3 @@ class MailMailboxMessageResponse(BaseModel):
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
message: MailMailboxMessageDetailResponse
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
@@ -21,6 +22,8 @@ from govoplan_mail.backend.dev.mock_mailbox import (
|
||||
record_imap_append,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImapConfigurationError(ValueError):
|
||||
"""Raised when IMAP settings are incomplete or inconsistent."""
|
||||
@@ -50,6 +53,8 @@ class ImapLoginTestResult:
|
||||
class ImapMailboxInfo:
|
||||
name: str
|
||||
flags: list[str]
|
||||
message_count: int | None = None
|
||||
unseen_count: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -68,6 +73,10 @@ class ImapMailboxAttachmentInfo:
|
||||
size_bytes: int
|
||||
|
||||
|
||||
def _log_imap_cleanup_failure(action: str, exc: BaseException) -> None:
|
||||
logger.debug("IMAP cleanup failed while %s: %s", action, exc, exc_info=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImapMailboxMessageSummary:
|
||||
uid: str
|
||||
@@ -111,6 +120,16 @@ class ImapMailboxMessageListResult:
|
||||
folder: str
|
||||
messages: list[ImapMailboxMessageSummary]
|
||||
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)
|
||||
@@ -133,8 +152,6 @@ class ImapAppendResult:
|
||||
|
||||
|
||||
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
|
||||
if not config.enabled:
|
||||
raise ImapConfigurationError("IMAP is disabled")
|
||||
if not config.host:
|
||||
raise ImapConfigurationError("IMAP host is required")
|
||||
if not config.port:
|
||||
@@ -166,8 +183,8 @@ def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
|
||||
except Exception:
|
||||
try:
|
||||
client.logout() # type: ignore[possibly-undefined]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as cleanup_exc:
|
||||
_log_imap_cleanup_failure("opening connection", cleanup_exc)
|
||||
raise
|
||||
|
||||
|
||||
@@ -302,52 +319,225 @@ def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as cleanup_exc:
|
||||
_log_imap_cleanup_failure("testing login", cleanup_exc)
|
||||
|
||||
|
||||
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||
def _mock_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||
host, port = _require_imap_config(imap_config)
|
||||
records = list_records(limit=500)
|
||||
folders = []
|
||||
for item in MOCK_IMAP_FOLDERS:
|
||||
name = str(item["name"])
|
||||
count = sum(1 for record in records if _mock_folder_matches(record, name))
|
||||
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folders=folders,
|
||||
detected_sent_folder="Sent",
|
||||
)
|
||||
|
||||
|
||||
def _list_imap_folders_on_client(
|
||||
client: imaplib.IMAP4,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
security: str,
|
||||
include_status: bool,
|
||||
) -> ImapFolderListResult:
|
||||
typ, data = client.list()
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
|
||||
|
||||
parsed: list[tuple[str, set[str]]] = []
|
||||
folders: list[ImapMailboxInfo] = []
|
||||
for item in data or []:
|
||||
extracted = _extract_mailbox_name(item)
|
||||
if not extracted:
|
||||
continue
|
||||
name, flags = extracted
|
||||
parsed.append((name, flags))
|
||||
message_count, unseen_count = (
|
||||
(None, None)
|
||||
if not include_status or _has_folder_flag(flags, "noselect")
|
||||
else _imap_folder_status(client, name)
|
||||
)
|
||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
|
||||
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=security,
|
||||
folders=folders,
|
||||
detected_sent_folder=_detect_sent_folder(parsed),
|
||||
)
|
||||
|
||||
|
||||
def list_imap_folders(*, imap_config: ImapConfig, include_status: bool = True) -> ImapFolderListResult:
|
||||
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
|
||||
|
||||
host, port = _require_imap_config(imap_config)
|
||||
if is_mock_imap_host(imap_config.host):
|
||||
folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS]
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folders=folders,
|
||||
detected_sent_folder="Sent",
|
||||
)
|
||||
return _mock_imap_folders(imap_config=imap_config)
|
||||
|
||||
client = _open_imap(imap_config)
|
||||
try:
|
||||
typ, data = client.list()
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
|
||||
|
||||
parsed: list[tuple[str, set[str]]] = []
|
||||
folders: list[ImapMailboxInfo] = []
|
||||
for item in data or []:
|
||||
extracted = _extract_mailbox_name(item)
|
||||
if not extracted:
|
||||
continue
|
||||
name, flags = extracted
|
||||
parsed.append((name, flags))
|
||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
|
||||
|
||||
return ImapFolderListResult(
|
||||
return _list_imap_folders_on_client(
|
||||
client,
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folders=folders,
|
||||
detected_sent_folder=_detect_sent_folder(parsed),
|
||||
include_status=include_status,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as cleanup_exc:
|
||||
_log_imap_cleanup_failure("listing folders", cleanup_exc)
|
||||
|
||||
|
||||
def _preferred_mailbox_folder(folders: list[ImapMailboxInfo], requested: str | None, detected_sent_folder: str | None) -> str:
|
||||
folder_names = {folder.name for folder in folders}
|
||||
requested = (requested or "").strip()
|
||||
if requested and requested in folder_names:
|
||||
return requested
|
||||
if "INBOX" in folder_names:
|
||||
return "INBOX"
|
||||
if detected_sent_folder and detected_sent_folder in folder_names:
|
||||
return detected_sent_folder
|
||||
return folders[0].name if folders else (requested or "INBOX")
|
||||
|
||||
|
||||
def load_imap_mailbox_bootstrap(
|
||||
*,
|
||||
imap_config: ImapConfig,
|
||||
folder: str = "INBOX",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
include_folder_status: bool = False,
|
||||
) -> ImapMailboxBootstrapResult:
|
||||
"""Load folders and one message page through a single IMAP connection."""
|
||||
|
||||
host, port = _require_imap_config(imap_config)
|
||||
if is_mock_imap_host(imap_config.host):
|
||||
folders = _mock_imap_folders(imap_config=imap_config)
|
||||
selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder)
|
||||
messages = list_imap_messages(imap_config=imap_config, folder=selected_folder, limit=limit, offset=offset)
|
||||
return ImapMailboxBootstrapResult(folders=folders, messages=messages)
|
||||
|
||||
client = _open_imap(imap_config)
|
||||
try:
|
||||
folders = _list_imap_folders_on_client(
|
||||
client,
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
include_status=include_folder_status,
|
||||
)
|
||||
selected_folder = _preferred_mailbox_folder(folders.folders, folder, folders.detected_sent_folder)
|
||||
selected_folder, limit, offset = _normalize_mailbox_page(folder=selected_folder, limit=limit, offset=offset)
|
||||
messages = _list_imap_messages_on_client(
|
||||
client,
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folder=selected_folder,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
after_uid=None,
|
||||
expected_uidvalidity=None,
|
||||
)
|
||||
return ImapMailboxBootstrapResult(folders=folders, messages=messages)
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception as cleanup_exc:
|
||||
_log_imap_cleanup_failure("bootstrapping mailbox", cleanup_exc)
|
||||
|
||||
|
||||
def _list_imap_messages_on_client(
|
||||
client: imaplib.IMAP4,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
security: str,
|
||||
folder: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
after_uid: str | None,
|
||||
expected_uidvalidity: str | None,
|
||||
) -> ImapMailboxMessageListResult:
|
||||
total_count, uidvalidity = _select_readonly(client, folder)
|
||||
if after_uid is None and expected_uidvalidity is None:
|
||||
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
|
||||
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
|
||||
return ImapMailboxMessageListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=security,
|
||||
folder=folder,
|
||||
messages=messages,
|
||||
total_count=total_count,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
uidvalidity=None,
|
||||
cursor_reset=False,
|
||||
)
|
||||
uids = _search_message_uids(client)
|
||||
cursor_reset = False
|
||||
effective_after_uid = after_uid
|
||||
effective_offset = offset
|
||||
if expected_uidvalidity and expected_uidvalidity != uidvalidity:
|
||||
effective_after_uid = None
|
||||
effective_offset = 0
|
||||
cursor_reset = True
|
||||
page_uids, effective_offset, anchor_missing = _paged_descending_uids(
|
||||
uids,
|
||||
offset=effective_offset,
|
||||
limit=limit,
|
||||
after_uid=effective_after_uid,
|
||||
)
|
||||
messages = _fetch_message_summaries_by_uid(client, page_uids, folder)
|
||||
return ImapMailboxMessageListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=security,
|
||||
folder=folder,
|
||||
messages=messages,
|
||||
total_count=len(uids) if uids else total_count,
|
||||
offset=effective_offset,
|
||||
limit=limit,
|
||||
uidvalidity=uidvalidity,
|
||||
cursor_reset=cursor_reset or anchor_missing,
|
||||
)
|
||||
|
||||
|
||||
def _has_folder_flag(flags: set[str], flag: str) -> bool:
|
||||
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:
|
||||
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:
|
||||
@@ -393,6 +583,24 @@ def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]:
|
||||
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:
|
||||
message = _parse_message(raw)
|
||||
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]:
|
||||
metadata_parts: list[str] = []
|
||||
raw: bytes | None = None
|
||||
@@ -445,24 +661,46 @@ def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str
|
||||
else:
|
||||
metadata_parts.append(_decode_item(item))
|
||||
metadata = " ".join(part for part in metadata_parts if part)
|
||||
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 []
|
||||
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
|
||||
if raw is None:
|
||||
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
|
||||
return (
|
||||
uid_match.group(1) if uid_match else None,
|
||||
flags,
|
||||
int(size_match.group(1)) if size_match else None,
|
||||
raw,
|
||||
)
|
||||
return uid, flags, size_bytes, 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)
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
|
||||
selected_count = _decode_item(data[0] if data else None).strip()
|
||||
try:
|
||||
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]:
|
||||
@@ -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
|
||||
|
||||
|
||||
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:
|
||||
folder = str(record.get("folder") or "").strip()
|
||||
if folder:
|
||||
@@ -495,14 +834,35 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
|
||||
return "\n".join(lines).encode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> ImapMailboxMessageListResult:
|
||||
def list_imap_messages(
|
||||
*,
|
||||
imap_config: ImapConfig,
|
||||
folder: str = "INBOX",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
after_uid: str | None = None,
|
||||
expected_uidvalidity: str | None = None,
|
||||
) -> ImapMailboxMessageListResult:
|
||||
"""List mailbox messages without mutating read/unread state."""
|
||||
|
||||
host, port = _require_imap_config(imap_config)
|
||||
folder = (folder or "INBOX").strip() or "INBOX"
|
||||
limit = max(1, min(limit, 100))
|
||||
folder, limit, offset = _normalize_mailbox_page(folder=folder, limit=limit, offset=offset)
|
||||
if is_mock_imap_host(imap_config.host):
|
||||
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
|
||||
cursor_reset = False
|
||||
if expected_uidvalidity and expected_uidvalidity != "mock-v1":
|
||||
effective_offset = 0
|
||||
cursor_reset = True
|
||||
elif after_uid:
|
||||
record_ids = [str(record.get("id") or "") for record in records]
|
||||
try:
|
||||
effective_offset = record_ids.index(str(after_uid)) + 1
|
||||
except ValueError:
|
||||
effective_offset = 0
|
||||
cursor_reset = True
|
||||
else:
|
||||
effective_offset = min(offset, len(records))
|
||||
page_records = records[effective_offset:effective_offset + limit]
|
||||
messages = [
|
||||
_message_summary_from_raw(
|
||||
uid=str(record.get("id") or ""),
|
||||
@@ -511,28 +871,83 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
|
||||
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
|
||||
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)
|
||||
try:
|
||||
_select_readonly(client, folder)
|
||||
typ, data = client.uid("search", None, "ALL")
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True)
|
||||
uid_bytes = data[0] if data else b""
|
||||
uids = _decode_item(uid_bytes).split()
|
||||
messages: list[ImapMailboxMessageSummary] = []
|
||||
for uid in reversed(uids[-limit:]):
|
||||
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
|
||||
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
|
||||
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(uids))
|
||||
total_count, uidvalidity = _select_readonly(client, folder)
|
||||
if after_uid is None and expected_uidvalidity is None:
|
||||
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
|
||||
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
|
||||
return ImapMailboxMessageListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folder=folder,
|
||||
messages=messages,
|
||||
total_count=total_count,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
uidvalidity=None,
|
||||
cursor_reset=False,
|
||||
)
|
||||
uids = _search_message_uids(client)
|
||||
cursor_reset = False
|
||||
effective_after_uid = after_uid
|
||||
effective_offset = offset
|
||||
if expected_uidvalidity and expected_uidvalidity != uidvalidity:
|
||||
effective_after_uid = None
|
||||
effective_offset = 0
|
||||
cursor_reset = True
|
||||
page_uids, effective_offset, anchor_missing = _paged_descending_uids(
|
||||
uids,
|
||||
offset=effective_offset,
|
||||
limit=limit,
|
||||
after_uid=effective_after_uid,
|
||||
)
|
||||
messages = _fetch_message_summaries_by_uid(client, page_uids, folder)
|
||||
return ImapMailboxMessageListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folder=folder,
|
||||
messages=messages,
|
||||
total_count=len(uids) if uids else total_count,
|
||||
offset=effective_offset,
|
||||
limit=limit,
|
||||
uidvalidity=uidvalidity,
|
||||
cursor_reset=cursor_reset or anchor_missing,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as cleanup_exc:
|
||||
_log_imap_cleanup_failure("listing messages", cleanup_exc)
|
||||
|
||||
|
||||
def _normalize_mailbox_page(*, folder: str | None, limit: int, offset: int) -> tuple[str, int, int]:
|
||||
return (folder or "INBOX").strip() or "INBOX", max(1, min(limit, 100)), max(0, offset)
|
||||
|
||||
|
||||
def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]:
|
||||
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:
|
||||
@@ -566,8 +981,8 @@ def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapM
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as cleanup_exc:
|
||||
_log_imap_cleanup_failure("reading message", cleanup_exc)
|
||||
|
||||
|
||||
def append_message_to_sent(
|
||||
@@ -624,5 +1039,5 @@ def append_message_to_sent(
|
||||
if client is not None:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as cleanup_exc:
|
||||
_log_imap_cleanup_failure("appending sent message", cleanup_exc)
|
||||
|
||||
@@ -40,8 +40,8 @@ def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = T
|
||||
if not enabled or not _distributed_rate_limit_enabled():
|
||||
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
|
||||
|
||||
redis_key = f"multimailer:ratelimit:{key}:next_allowed"
|
||||
lock_key = f"multimailer:ratelimit:{key}:lock"
|
||||
redis_key = f"govoplan:ratelimit:{key}:next_allowed"
|
||||
lock_key = f"govoplan:ratelimit:{key}:lock"
|
||||
waited = 0.0
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
@@ -15,6 +16,8 @@ from govoplan_mail.backend.dev.mock_mailbox import (
|
||||
record_smtp_delivery,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmtpConfigurationError(ValueError):
|
||||
"""Raised when SMTP settings are incomplete or inconsistent."""
|
||||
@@ -56,6 +59,10 @@ class SmtpSendResult:
|
||||
return len(self.envelope_recipients) - len(self.refused_recipients)
|
||||
|
||||
|
||||
def _log_smtp_cleanup_failure(action: str, exc: BaseException) -> None:
|
||||
logger.debug("SMTP cleanup failed while %s: %s", action, exc, exc_info=True)
|
||||
|
||||
|
||||
def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
|
||||
if not config.host:
|
||||
raise SmtpConfigurationError("SMTP host is required")
|
||||
@@ -89,8 +96,8 @@ def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
|
||||
# on GC, but explicit cleanup is safer when the variable exists.
|
||||
try:
|
||||
smtp.quit() # type: ignore[possibly-undefined]
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as cleanup_exc:
|
||||
_log_smtp_cleanup_failure("opening connection", cleanup_exc)
|
||||
raise
|
||||
|
||||
|
||||
@@ -134,11 +141,12 @@ def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
except Exception as quit_exc:
|
||||
_log_smtp_cleanup_failure("testing login quit", quit_exc)
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as close_exc:
|
||||
_log_smtp_cleanup_failure("testing login close", close_exc)
|
||||
|
||||
|
||||
def prepare_test_message(
|
||||
@@ -161,12 +169,12 @@ def prepare_test_message(
|
||||
del test_message[header]
|
||||
|
||||
# Replace potential previous marker headers if the user test-sends an EML twice.
|
||||
for header in ["X-MultiMailer-Test-Send"]:
|
||||
for header in ["X-GovOPlaN-Test-Send"]:
|
||||
if header in test_message:
|
||||
del test_message[header]
|
||||
|
||||
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient))
|
||||
test_message["X-MultiMailer-Test-Send"] = "true"
|
||||
test_message["X-GovOPlaN-Test-Send"] = "true"
|
||||
return test_message
|
||||
|
||||
|
||||
@@ -177,43 +185,97 @@ def _send_smtp_payload(
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
) -> SmtpSendResult:
|
||||
host, port, recipients = _prepare_smtp_send(
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
|
||||
if is_mock_smtp_host(smtp_config.host):
|
||||
_accepted, refused = _send_mock_smtp_payload(
|
||||
message,
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
)
|
||||
return _smtp_send_result(
|
||||
smtp_config=smtp_config,
|
||||
host=host,
|
||||
port=port,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
refused=refused,
|
||||
)
|
||||
|
||||
refused = _send_network_smtp_payload(
|
||||
message,
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
)
|
||||
return _smtp_send_result(
|
||||
smtp_config=smtp_config,
|
||||
host=host,
|
||||
port=port,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=recipients,
|
||||
refused=refused,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_smtp_send(
|
||||
*,
|
||||
smtp_config: SmtpConfig,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
) -> tuple[str, int, list[str]]:
|
||||
host, port = _require_smtp_config(smtp_config)
|
||||
if not envelope_from:
|
||||
raise SmtpConfigurationError("SMTP envelope sender is required")
|
||||
if not envelope_recipients:
|
||||
recipients = [recipient for recipient in envelope_recipients if recipient]
|
||||
if not recipients:
|
||||
raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
|
||||
return host, port, recipients
|
||||
|
||||
if is_mock_smtp_host(smtp_config.host):
|
||||
if consume_fail_next_smtp():
|
||||
raise SmtpSendError("Mock SMTP configured to fail the next send")
|
||||
failures = get_failures()
|
||||
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
|
||||
refused: dict[str, tuple[int, bytes]] = {}
|
||||
accepted = list(envelope_recipients)
|
||||
if reject_text:
|
||||
refused = {
|
||||
recipient: (550, b"mock recipient rejected")
|
||||
for recipient in envelope_recipients
|
||||
if reject_text in recipient.lower()
|
||||
}
|
||||
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
|
||||
if not accepted:
|
||||
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
|
||||
record_smtp_delivery(
|
||||
message,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=accepted,
|
||||
smtp_host=smtp_config.host,
|
||||
)
|
||||
return SmtpSendResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=smtp_config.security.value,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=list(envelope_recipients),
|
||||
refused_recipients=_decode_refused(refused),
|
||||
)
|
||||
|
||||
def _send_mock_smtp_payload(
|
||||
message: EmailMessage | bytes,
|
||||
*,
|
||||
smtp_config: SmtpConfig,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
) -> tuple[list[str], dict[str, tuple[int, bytes]]]:
|
||||
if consume_fail_next_smtp():
|
||||
raise SmtpSendError("Mock SMTP configured to fail the next send")
|
||||
failures = get_failures()
|
||||
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
|
||||
refused: dict[str, tuple[int, bytes]] = {}
|
||||
accepted = list(envelope_recipients)
|
||||
if reject_text:
|
||||
refused = {
|
||||
recipient: (550, b"mock recipient rejected")
|
||||
for recipient in envelope_recipients
|
||||
if reject_text in recipient.lower()
|
||||
}
|
||||
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
|
||||
if not accepted:
|
||||
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
|
||||
record_smtp_delivery(
|
||||
message,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=accepted,
|
||||
smtp_host=smtp_config.host,
|
||||
)
|
||||
return accepted, refused
|
||||
|
||||
|
||||
def _send_network_smtp_payload(
|
||||
message: EmailMessage | bytes,
|
||||
*,
|
||||
smtp_config: SmtpConfig,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
) -> dict[str, tuple[int, bytes]]:
|
||||
try:
|
||||
smtp = _open_smtp(smtp_config)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
@@ -266,12 +328,24 @@ def _send_smtp_payload(
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
except Exception as quit_exc:
|
||||
_log_smtp_cleanup_failure("sending message quit", quit_exc)
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as close_exc:
|
||||
_log_smtp_cleanup_failure("sending message close", close_exc)
|
||||
return refused
|
||||
|
||||
|
||||
def _smtp_send_result(
|
||||
*,
|
||||
smtp_config: SmtpConfig,
|
||||
host: str,
|
||||
port: int,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
refused: dict[str, tuple[int, bytes]],
|
||||
) -> SmtpSendResult:
|
||||
return SmtpSendResult(
|
||||
host=host,
|
||||
port=port,
|
||||
|
||||
73
tests/test_imap_parser.py
Normal file
73
tests/test_imap_parser.py
Normal 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()
|
||||
124
tests/test_mail_profile_helpers.py
Normal file
124
tests/test_mail_profile_helpers.py
Normal 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
20
tests/test_manifest.py
Normal 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()
|
||||
48
tests/test_smtp_helpers.py
Normal file
48
tests/test_smtp_helpers.py
Normal 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
142
webui/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -14,8 +14,8 @@
|
||||
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.1",
|
||||
"lucide-react": "^0.555.0",
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
@@ -24,5 +24,11 @@
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,51 +1,66 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
import type {
|
||||
ApiSettings,
|
||||
DeltaDeletedItem,
|
||||
MailConnectionTestResponse,
|
||||
MailImapFolderListResponse,
|
||||
MailImapTestPayload,
|
||||
MailProfilePolicy,
|
||||
MailProfilePolicyResponse,
|
||||
MailProfileScope,
|
||||
MailSecurity,
|
||||
MailServerProfile,
|
||||
MailServerProfilePayload,
|
||||
MailSmtpTestPayload,
|
||||
MockMailboxMessage,
|
||||
MockMailboxMessageResponse
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
|
||||
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
|
||||
export type {
|
||||
MailConnectionTestResponse,
|
||||
MailCredentialPolicy,
|
||||
MailImapFolderListResponse,
|
||||
MailImapFolderResponse,
|
||||
MailImapTestPayload,
|
||||
MailProfilePatternKey,
|
||||
MailProfilePatternRules,
|
||||
MailProfilePolicy,
|
||||
MailProfilePolicyLimitKey,
|
||||
MailProfilePolicyLimitPermissions,
|
||||
MailProfilePolicyResponse,
|
||||
MailProfileScope,
|
||||
MailSecurity,
|
||||
MailServerProfile,
|
||||
MailServerProfileCredentialsPayload,
|
||||
MailServerProfileListResponse,
|
||||
MailServerProfilePayload,
|
||||
MailSmtpTestPayload,
|
||||
MailTransportCredentialsPayload,
|
||||
MockMailboxMessage,
|
||||
MockMailboxMessageResponse
|
||||
} from "@govoplan/core-webui";
|
||||
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";
|
||||
|
||||
export type MailSecurity = "plain" | "tls" | "starttls";
|
||||
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
||||
|
||||
export type MailSmtpTestPayload = {
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
security?: MailSecurity;
|
||||
timeout_seconds?: number;
|
||||
export type MailAddressLookupCandidate = {
|
||||
contact_id: string;
|
||||
address_book_id: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
email_label?: string | null;
|
||||
organization?: string | null;
|
||||
role_title?: string | null;
|
||||
tags: string[];
|
||||
source_kind: string;
|
||||
source_ref?: string | null;
|
||||
source_revision?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MailImapTestPayload = MailSmtpTestPayload & {
|
||||
enabled?: boolean;
|
||||
sent_folder?: string | null;
|
||||
export type MailAddressLookupResponse = {
|
||||
available: boolean;
|
||||
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 = {
|
||||
filename?: string | null;
|
||||
content_type: string;
|
||||
@@ -59,6 +74,7 @@ export type MailMailboxMessageSummary = {
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
cc_header?: string | null;
|
||||
bcc_header?: string | null;
|
||||
date?: string | null;
|
||||
message_id?: string | null;
|
||||
flags?: string[];
|
||||
@@ -81,9 +97,25 @@ export type MailMailboxMessageListResponse = {
|
||||
port?: number | null;
|
||||
security?: MailSecurity | string | null;
|
||||
total_count?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
next_cursor?: string | null;
|
||||
cursor_stable?: boolean;
|
||||
full?: boolean;
|
||||
from_cache?: boolean;
|
||||
refreshing?: boolean;
|
||||
indexed_at?: string | null;
|
||||
messages: MailMailboxMessageSummary[];
|
||||
};
|
||||
|
||||
export type MailMailboxBootstrapResponse = {
|
||||
profile_id: string;
|
||||
folder: string;
|
||||
folders: MailImapFolderListResponse;
|
||||
messages: MailMailboxMessageListResponse;
|
||||
};
|
||||
|
||||
export type MailMailboxMessageResponse = {
|
||||
profile_id: string;
|
||||
folder: string;
|
||||
@@ -93,87 +125,46 @@ export type MailMailboxMessageResponse = {
|
||||
message: MailMailboxMessageDetail;
|
||||
};
|
||||
|
||||
export type MailServerProfile = {
|
||||
id: string;
|
||||
tenant_id?: string | null;
|
||||
scope_type: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
is_active: boolean;
|
||||
smtp: MailSmtpTestPayload;
|
||||
imap?: MailImapTestPayload | null;
|
||||
smtp_password_configured: boolean;
|
||||
imap_password_configured: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
export type MailSettingsDeltaResponse = {
|
||||
profiles: MailServerProfile[];
|
||||
policy?: MailProfilePolicyResponse | null;
|
||||
changed_sections?: string[];
|
||||
deleted: DeltaDeletedItem[];
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
||||
|
||||
export const mailProfilePatternKeys = [
|
||||
"smtp_hosts",
|
||||
"imap_hosts",
|
||||
"envelope_senders",
|
||||
"from_headers",
|
||||
"recipient_domains"
|
||||
] as const;
|
||||
|
||||
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
|
||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||
|
||||
export 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 lookupMailAddresses(settings: ApiSettings, query: string, limit = 25): Promise<MailAddressLookupResponse> {
|
||||
return apiFetch<MailAddressLookupResponse>(settings, apiPath("/api/v1/mail/address-lookup", { query, limit }));
|
||||
}
|
||||
|
||||
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeInactive) params.set("include_inactive", "true");
|
||||
if (campaignId) params.set("campaign_id", campaignId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
|
||||
return response.profiles ?? [];
|
||||
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
|
||||
include_inactive: includeInactive ? true : undefined,
|
||||
campaign_id: campaignId
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchMailSettingsDelta(
|
||||
settings: ApiSettings,
|
||||
params: {
|
||||
scope_type?: MailProfileScope;
|
||||
scope_id?: string | null;
|
||||
include_inactive?: boolean;
|
||||
campaign_id?: string | null;
|
||||
since?: string | null;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Promise<MailSettingsDeltaResponse> {
|
||||
return apiFetch<MailSettingsDeltaResponse>(settings, apiPath("/api/v1/mail/settings/delta", {
|
||||
scope_type: params.scope_type,
|
||||
scope_id: params.scope_id,
|
||||
include_inactive: params.include_inactive ? true : undefined,
|
||||
campaign_id: params.campaign_id,
|
||||
since: params.since,
|
||||
limit: params.limit
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
|
||||
@@ -202,11 +193,10 @@ export async function getMailProfilePolicy(
|
||||
scopeId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailProfilePolicyResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
if (campaignId) params.set("campaign_id", campaignId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
|
||||
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
|
||||
scope_id: scopeId,
|
||||
campaign_id: campaignId
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateMailProfilePolicy(
|
||||
@@ -215,39 +205,63 @@ export async function updateMailProfilePolicy(
|
||||
policy: MailProfilePolicy,
|
||||
scopeId?: string | null
|
||||
): Promise<MailProfilePolicyResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (scopeId) params.set("scope_id", scopeId);
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, {
|
||||
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, { scope_id: scopeId }), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ policy })
|
||||
});
|
||||
}
|
||||
|
||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
|
||||
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`);
|
||||
}
|
||||
|
||||
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
|
||||
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`);
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
|
||||
return apiPost<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`);
|
||||
}
|
||||
|
||||
export async function listMailboxFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`);
|
||||
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {
|
||||
return apiFetch<MailImapFolderListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, {
|
||||
include_status: includeStatus ? true : undefined,
|
||||
refresh: refresh ? true : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
export async function bootstrapMailbox(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
folder = "INBOX",
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
refresh = false
|
||||
): Promise<MailMailboxBootstrapResponse> {
|
||||
return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, {
|
||||
folder,
|
||||
limit,
|
||||
offset,
|
||||
refresh: refresh ? true : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listMailboxMessages(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
folder = "INBOX",
|
||||
limit = 50
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
cursor?: string | null,
|
||||
refresh = false
|
||||
): Promise<MailMailboxMessageListResponse> {
|
||||
const params = new URLSearchParams({ folder, limit: String(limit) });
|
||||
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
|
||||
return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
|
||||
folder,
|
||||
limit,
|
||||
offset,
|
||||
cursor,
|
||||
refresh: refresh ? true : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getMailboxMessage(
|
||||
@@ -256,69 +270,34 @@ export async function getMailboxMessage(
|
||||
folder: string,
|
||||
uid: string
|
||||
): Promise<MailMailboxMessageResponse> {
|
||||
const params = new URLSearchParams({ folder });
|
||||
return apiFetch<MailMailboxMessageResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}?${params.toString()}`);
|
||||
return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder }));
|
||||
}
|
||||
|
||||
export async function testSmtpSettings(
|
||||
settings: ApiSettings,
|
||||
payload: MailSmtpTestPayload
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return apiPostJson<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", payload);
|
||||
}
|
||||
|
||||
export async function testImapSettings(
|
||||
settings: ApiSettings,
|
||||
payload: MailImapTestPayload
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return apiPostJson<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", payload);
|
||||
}
|
||||
|
||||
export async function listImapFolders(
|
||||
settings: ApiSettings,
|
||||
payload: MailImapTestPayload
|
||||
): Promise<MailImapFolderListResponse> {
|
||||
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return apiPostJson<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", payload);
|
||||
}
|
||||
|
||||
export type MockMailboxMessage = {
|
||||
id: string;
|
||||
kind: "smtp" | "imap_append" | string;
|
||||
created_at: string;
|
||||
envelope_from?: string | null;
|
||||
envelope_recipients?: string[];
|
||||
subject?: string | null;
|
||||
from_header?: string | null;
|
||||
to_header?: string | null;
|
||||
cc_header?: string | null;
|
||||
bcc_header?: string | null;
|
||||
message_id?: string | null;
|
||||
size_bytes?: number;
|
||||
body_preview?: string | null;
|
||||
attachment_count?: number;
|
||||
folder?: string | null;
|
||||
raw_eml?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
|
||||
};
|
||||
|
||||
export type MockMailboxListResponse = {
|
||||
messages: MockMailboxMessage[];
|
||||
};
|
||||
|
||||
export type MockMailboxMessageResponse = {
|
||||
message: MockMailboxMessage;
|
||||
};
|
||||
|
||||
export type MockMailboxFailureConfig = {
|
||||
fail_next_smtp?: boolean | null;
|
||||
fail_next_imap?: boolean | null;
|
||||
@@ -326,8 +305,7 @@ export type MockMailboxFailureConfig = {
|
||||
};
|
||||
|
||||
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
|
||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
||||
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
|
||||
return apiFetch<MockMailboxListResponse>(settings, apiPath("/api/v1/dev/mailbox/messages", { kind }));
|
||||
}
|
||||
|
||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default Button;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
export default Card;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
export default ConfirmDialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
export default Dialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
export default DismissibleAlert;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
export default FormField;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
export default LoadingFrame;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoadingIndicator } from "@govoplan/core-webui";
|
||||
export default LoadingIndicator;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
export default MetricCard;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
export default PageTitle;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
export default StatusBadge;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Stepper } from "@govoplan/core-webui";
|
||||
export default Stepper;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
export default ToggleSwitch;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { EmailAddressInput } from "@govoplan/core-webui";
|
||||
export default EmailAddressInput;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
export default FieldLabel;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { InlineHelp } from "@govoplan/core-webui";
|
||||
export default InlineHelp;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
@@ -7,28 +7,22 @@ import {
|
||||
LoadingIndicator,
|
||||
MessageDisplayPanel,
|
||||
formatDateTime,
|
||||
i18nMessage,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
bootstrapMailbox,
|
||||
getMailboxMessage,
|
||||
listMailboxFolders,
|
||||
listMailboxMessages,
|
||||
listMailServerProfiles,
|
||||
type MailImapFolderResponse,
|
||||
type MailMailboxMessageDetail,
|
||||
type MailMailboxMessageSummary,
|
||||
type MailServerProfile
|
||||
} from "../../api/mail";
|
||||
type MailServerProfile } from
|
||||
"../../api/mail";
|
||||
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
|
||||
|
||||
type MailFolderNode = {
|
||||
id: string;
|
||||
label: string;
|
||||
folderName: string | null;
|
||||
flags: string[];
|
||||
children: MailFolderNode[];
|
||||
};
|
||||
|
||||
export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
export default function MailboxPage({ settings }: {settings: ApiSettings;}) {
|
||||
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
|
||||
@@ -37,34 +31,86 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||
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 [selectedMessageKeyState, setSelectedMessageKeyState] = useState("");
|
||||
const [pendingMessageKey, setPendingMessageKey] = useState("");
|
||||
const [loadingProfiles, setLoadingProfiles] = useState(true);
|
||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [folderError, setFolderError] = useState("");
|
||||
const [messageError, setMessageError] = useState("");
|
||||
const [detailError, setDetailError] = useState("");
|
||||
const selectedMessageKeyRef = useRef("");
|
||||
const folderRequestRef = useRef(0);
|
||||
const messageListRequestRef = useRef(0);
|
||||
const messageDetailRequestRef = useRef(0);
|
||||
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
|
||||
const skipNextMessageLoadRef = useRef(false);
|
||||
|
||||
const selectedProfile = profiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
|
||||
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 noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
|
||||
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 folderEmptyText = noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.";
|
||||
const messageEmptyText = !selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : "No messages in this folder.";
|
||||
const previewEmptyText = loadingMessage ? "Loading message..." : "Select a message to inspect its content.";
|
||||
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
|
||||
const folderEmptyText = folderError || (noImapProfiles ? "i18n:govoplan-mail.no_imap_enabled_mail_profiles.61ae44d8" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
|
||||
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
|
||||
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
|
||||
const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094";
|
||||
|
||||
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
|
||||
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder); }, [foldersReady, selectedProfileId, selectedFolder]);
|
||||
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
|
||||
useEffect(() => {if (messagePage > messagePageCount) setMessagePage(messagePageCount);}, [messagePage, messagePageCount]);
|
||||
useEffect(() => {if (selectedProfileId) void loadMailboxBootstrap(selectedProfileId);}, [selectedProfileId]);
|
||||
useEffect(() => {
|
||||
if (!selectedProfileId || !selectedFolder || !foldersReady) return;
|
||||
if (skipNextMessageLoadRef.current) {
|
||||
skipNextMessageLoadRef.current = false;
|
||||
return;
|
||||
}
|
||||
void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize);
|
||||
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePreviewShortcut = (event: KeyboardEvent) => {
|
||||
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(() => {
|
||||
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
|
||||
@@ -90,7 +136,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
mailboxPageCursorsRef.current = {};
|
||||
skipNextMessageLoadRef.current = false;
|
||||
}
|
||||
} catch (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;
|
||||
const requestId = ++folderRequestRef.current;
|
||||
messageListRequestRef.current += 1;
|
||||
const folderRequestId = ++folderRequestRef.current;
|
||||
const messageRequestId = ++messageListRequestRef.current;
|
||||
messageDetailRequestRef.current += 1;
|
||||
setLoadingFolders(true);
|
||||
setLoadingMessages(true);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessageTotalCount(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
selectedMessageKeyRef.current = "";
|
||||
setPendingMessageKey("");
|
||||
setFolderError("");
|
||||
setMessageError("");
|
||||
setDetailError("");
|
||||
setError("");
|
||||
try {
|
||||
const response = await listMailboxFolders(settings, profileId);
|
||||
if (requestId !== folderRequestRef.current) return;
|
||||
if (!response.ok) throw new Error(response.message || "Mailbox folders could not be loaded.");
|
||||
const loaded = response.folders?.length ? response.folders : [{ name: "INBOX", flags: [] }];
|
||||
setFolders(loaded);
|
||||
setExpandedFolders(new Set());
|
||||
let nextFolder = selectedFolder;
|
||||
if (!nextFolder || !loaded.some((folder) => folder.name === nextFolder)) {
|
||||
nextFolder = loaded.some((folder) => folder.name === "INBOX") ? "INBOX" : response.detected_sent_folder || loaded[0]?.name || "INBOX";
|
||||
const response = await bootstrapMailbox(settings, profileId, selectedFolder || "INBOX", messagePageSize, 0, refresh);
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
|
||||
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
|
||||
const loadedMessages = response.messages.messages ?? [];
|
||||
const total = response.messages.total_count ?? loadedMessages.length;
|
||||
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
|
||||
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
|
||||
nextFolder = loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
|
||||
}
|
||||
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
|
||||
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize);
|
||||
mailboxPageCursorsRef.current = {
|
||||
[`${cursorKey}:1`]: null,
|
||||
[`${cursorKey}:2`]: response.messages.next_cursor ?? null
|
||||
};
|
||||
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== 1;
|
||||
setFolders(foldersWithCounts);
|
||||
setExpandedFolders(new Set());
|
||||
setSelectedFolder(nextFolder);
|
||||
setFoldersLoadedForProfile(profileId);
|
||||
setMessages(loadedMessages);
|
||||
setMessageTotalCount(total);
|
||||
} catch (err) {
|
||||
if (requestId !== folderRequestRef.current) return;
|
||||
setError(errorText(err));
|
||||
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
|
||||
const message = errorText(err);
|
||||
skipNextMessageLoadRef.current = false;
|
||||
setFolderError(message);
|
||||
setMessageError(message);
|
||||
setError(message);
|
||||
setFolders([]);
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
selectedMessageKeyRef.current = "";
|
||||
setPendingMessageKey("");
|
||||
} finally {
|
||||
if (requestId === folderRequestRef.current) setLoadingFolders(false);
|
||||
if (folderRequestId === folderRequestRef.current) setLoadingFolders(false);
|
||||
if (messageRequestId === messageListRequestRef.current) setLoadingMessages(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
|
||||
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize, refresh = false) {
|
||||
if (!profileId || !folder) return;
|
||||
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);
|
||||
setMessageTotalCount(null);
|
||||
setSelectedMessage(null);
|
||||
setPendingMessageKey("");
|
||||
setMessageError("");
|
||||
setDetailError("");
|
||||
setError("");
|
||||
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;
|
||||
const loaded = response.messages ?? [];
|
||||
const total = response.total_count ?? loaded.length;
|
||||
if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null;
|
||||
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
|
||||
setMessages(loaded);
|
||||
setMessageTotalCount(response.total_count ?? loaded.length);
|
||||
setSelectedMessage(null);
|
||||
setMessageTotalCount(total);
|
||||
setFolderMessageCount(folder, total);
|
||||
const rememberedKey = selectedMessageKeyRef.current;
|
||||
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== messageListRequestRef.current) return;
|
||||
setError(errorText(err));
|
||||
const message = errorText(err);
|
||||
setMessageError(message);
|
||||
setError(message);
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
setPendingMessageKey("");
|
||||
} finally {
|
||||
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
|
||||
@@ -168,18 +256,25 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
if (!selectedProfileId) return;
|
||||
const folderName = message.folder || selectedFolder;
|
||||
const requestId = ++messageDetailRequestRef.current;
|
||||
setPendingMessageKey(mailboxMessageKey(folderName, message.uid));
|
||||
const nextKey = mailboxMessageKey(folderName, message.uid);
|
||||
setSelectedMessageKeyState(nextKey);
|
||||
selectedMessageKeyRef.current = nextKey;
|
||||
setPendingMessageKey(nextKey);
|
||||
setSelectedMessage(null);
|
||||
setDetailError("");
|
||||
setLoadingMessage(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
|
||||
if (requestId !== messageDetailRequestRef.current) return;
|
||||
setSelectedMessage(response.message);
|
||||
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
|
||||
setPendingMessageKey("");
|
||||
} catch (err) {
|
||||
if (requestId !== messageDetailRequestRef.current) return;
|
||||
setError(errorText(err));
|
||||
const messageText = errorText(err);
|
||||
setDetailError(messageText);
|
||||
setError(messageText);
|
||||
setPendingMessageKey("");
|
||||
} finally {
|
||||
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
|
||||
@@ -195,18 +290,31 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
setFoldersLoadedForProfile("");
|
||||
setMessages([]);
|
||||
setMessageTotalCount(null);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
selectedMessageKeyRef.current = "";
|
||||
setPendingMessageKey("");
|
||||
setExpandedFolders(new Set());
|
||||
setLoadingFolders(false);
|
||||
setLoadingMessages(false);
|
||||
setLoadingMessage(false);
|
||||
mailboxPageCursorsRef.current = {};
|
||||
skipNextMessageLoadRef.current = false;
|
||||
}
|
||||
|
||||
function openFolderNode(node: MailFolderNode) {
|
||||
if (node.folderName) {
|
||||
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName);
|
||||
else setSelectedFolder(node.folderName);
|
||||
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else
|
||||
{
|
||||
setSelectedFolder(node.folderName);
|
||||
setMessagePage(1);
|
||||
setSelectedMessage(null);
|
||||
setSelectedMessageKeyState("");
|
||||
selectedMessageKeyRef.current = "";
|
||||
setPendingMessageKey("");
|
||||
setDetailError("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
toggleFolderNode(node);
|
||||
@@ -215,19 +323,33 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
function toggleFolderNode(node: MailFolderNode) {
|
||||
setExpandedFolders((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(node.id)) next.delete(node.id);
|
||||
else next.add(node.id);
|
||||
if (next.has(node.id)) next.delete(node.id);else
|
||||
next.add(node.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<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>}
|
||||
|
||||
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
|
||||
<aside className="file-tree-panel" aria-label="Mailbox folders">
|
||||
<div className="file-tree-heading">Folders</div>
|
||||
<aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
|
||||
<div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
|
||||
<div className="file-tree-list">
|
||||
{folderTree.length === 0 && <p className="muted small-text mailbox-tree-empty">{folderEmptyText}</p>}
|
||||
<ExplorerTree
|
||||
@@ -242,71 +364,80 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
disabled={shellBusy}
|
||||
renderNodeContent={(node) => {
|
||||
const flagText = folderFlagLabel(node.flags);
|
||||
const showCount = foldersReady && node.folderName === selectedFolder && messageTotalCount !== null;
|
||||
const showCount = foldersReady && node.messageCount !== null;
|
||||
return (
|
||||
<span className="mailbox-tree-node-label">
|
||||
<span className="mailbox-tree-node-main">
|
||||
<span>{node.label}</span>
|
||||
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>}
|
||||
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
|
||||
</span>
|
||||
{flagText && <small>{flagText}</small>}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</span>);
|
||||
|
||||
}} />
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="file-list-panel mailbox-message-list-panel" aria-label="Mailbox messages">
|
||||
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
||||
<div className="file-list-sticky">
|
||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
|
||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
|
||||
<label className="mailbox-profile-field">
|
||||
<span>IMAP profile</span>
|
||||
<select value={selectedProfileId} disabled={shellBusy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>}
|
||||
<span>i18n:govoplan-mail.imap_profile.5165df81</span>
|
||||
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||
{imapProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_imap_profiles_available.d64589f8</option>}
|
||||
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
|
||||
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
|
||||
<div className="mailbox-toolbar-actions">
|
||||
<Button onClick={() => void loadProfiles()} disabled={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" />
|
||||
Profiles
|
||||
i18n:govoplan-mail.profiles.0c2a9300
|
||||
</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" />
|
||||
Folders
|
||||
i18n:govoplan-mail.folders.19adc47b
|
||||
</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" />
|
||||
Messages
|
||||
i18n:govoplan-mail.messages.f1702b46
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="file-breadcrumbs" aria-label="Current mailbox folder">
|
||||
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "Mail"}</span>
|
||||
<nav className="file-breadcrumbs" aria-label="i18n:govoplan-mail.current_mailbox_folder.55e2aea5">
|
||||
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "i18n:govoplan-mail.mail.92379cbb"}</span>
|
||||
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
|
||||
</nav>
|
||||
|
||||
<div className="mailbox-filter-row">
|
||||
<label className="mailbox-search-field">
|
||||
<Search size={15} aria-hidden="true" />
|
||||
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="i18n:govoplan-mail.search_messages.abea65ae" />
|
||||
{messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="i18n:govoplan-mail.clear_message_search.cc9f2800"><X size={14} /></button>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="file-list-meta">
|
||||
<span>{messageCountLabel}</span>
|
||||
<span>{selectedFolder}</span>
|
||||
{shellBusy && <span>Working...</span>}
|
||||
{loadingMessage && <span>Loading preview...</span>}
|
||||
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} i18n:govoplan-mail.on_page.ca7166f4</span>}
|
||||
{shellBusy && <span>i18n:govoplan-mail.working.049ac820</span>}
|
||||
{loadingMessage && <span>{i18nMessage("i18n:govoplan-mail.loading_preview.ebd86225")}</span>}
|
||||
</div>
|
||||
|
||||
<div className="file-list-table-head mailbox-message-head" role="row">
|
||||
<span>Subject</span>
|
||||
<span>Date</span>
|
||||
<span>Size</span>
|
||||
<span>i18n:govoplan-mail.subject.8d183dbd</span>
|
||||
<span>i18n:govoplan-mail.date.eb9a4bc1</span>
|
||||
<span>i18n:govoplan-mail.size.b7152342</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="file-list-drop-target mailbox-message-scroll">
|
||||
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
|
||||
{messages.length === 0 && <div className="file-list-empty">{messageEmptyText}</div>}
|
||||
{messages.map((message) => {
|
||||
<div className="file-list-table mailbox-message-table" role="table" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
|
||||
{filteredMessages.length === 0 && <div className={`file-list-empty${messageError ? " mailbox-error-state" : ""}`}>{messageEmptyText}</div>}
|
||||
{filteredMessages.map((message) => {
|
||||
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
||||
const selected = key === selectedMessageKey;
|
||||
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
||||
@@ -322,13 +453,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
event.preventDefault();
|
||||
void openMessage(message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
|
||||
<div className="file-list-name-cell">
|
||||
<div className="file-list-name">
|
||||
<Mail className="file-row-icon" size={20} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{message.subject || "(no subject)"}</strong>
|
||||
<strong>{message.subject || "i18n:govoplan-mail.no_subject.49b20da0"}</strong>
|
||||
<small>{message.from_header || "-"}</small>
|
||||
</span>
|
||||
</div>
|
||||
@@ -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}
|
||||
<span>{formatBytes(message.size_bytes)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<MailboxPagination
|
||||
page={messagePage}
|
||||
pageSize={messagePageSize}
|
||||
totalRows={messageTotalCount ?? 0}
|
||||
disabled={loadingMessages || !foldersReady}
|
||||
onPageChange={changeMessagePage}
|
||||
onPageSizeChange={changeMessagePageSize} />
|
||||
|
||||
</section>
|
||||
|
||||
<section className="mailbox-preview-panel" aria-label="Message preview">
|
||||
<div className="file-tree-heading">Preview</div>
|
||||
<section className="mailbox-preview-panel" aria-label="i18n:govoplan-mail.message_preview.58de1450">
|
||||
<div className="file-tree-heading">i18n:govoplan-mail.preview.f1fbb2b4</div>
|
||||
<div className="mailbox-preview-scroll">
|
||||
<MessageDisplayPanel
|
||||
title={selectedMessage?.subject}
|
||||
fields={selectedMessage ? [
|
||||
{ label: "From", value: selectedMessage.from_header || "-" },
|
||||
{ label: "To", value: selectedMessage.to_header || "-" },
|
||||
{ label: "Cc", value: selectedMessage.cc_header || null },
|
||||
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
|
||||
] : []}
|
||||
{ label: "i18n:govoplan-mail.from.3f66052a", value: selectedMessage.from_header || "-" },
|
||||
{ label: "i18n:govoplan-mail.to.ae79ea1e", value: selectedMessage.to_header || "-" },
|
||||
{ label: "i18n:govoplan-mail.cc.1fd6a880", value: selectedMessage.cc_header || null },
|
||||
{ label: "i18n:govoplan-mail.bcc.8431acad", value: selectedMessage.bcc_header || null },
|
||||
{ label: "i18n:govoplan-mail.date.eb9a4bc1", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }] :
|
||||
[]}
|
||||
bodyText={selectedMessage?.body_text}
|
||||
bodyHtml={selectedMessage?.body_html}
|
||||
bodyPreview={selectedMessage?.body_preview}
|
||||
@@ -365,102 +505,74 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
||||
contentType: attachment.content_type,
|
||||
sizeBytes: attachment.size_bytes
|
||||
}))}
|
||||
emptyText={previewEmptyText}
|
||||
/>
|
||||
emptyText={previewEmptyText} />
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{shellBusy && (
|
||||
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
|
||||
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{loadingLabel}</span></div>
|
||||
{shellBusy &&
|
||||
<div className="file-manager-loading-overlay" aria-live="polite" aria-busy="true">
|
||||
<div className="loading-frame-panel"><LoadingIndicator label={loadingLabel} /> <span>{i18nMessage(loadingLabel)}</span></div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function 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;
|
||||
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>);
|
||||
|
||||
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 {
|
||||
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 {
|
||||
if (!ready) return "No folder loaded";
|
||||
if (loading) return "Loading messages";
|
||||
if (totalCount !== null && totalCount > visibleCount) return `${visibleCount} of ${totalCount} messages`;
|
||||
if (!ready) return "i18n:govoplan-mail.no_folder_loaded.889bdb1b";
|
||||
if (loading) return "i18n:govoplan-mail.loading_messages.4294022c";
|
||||
if (totalCount !== null && totalCount > visibleCount) return i18nMessage("i18n:govoplan-mail.value_of_value_messages.981a1618", { value0: visibleCount, value1: totalCount });
|
||||
const count = totalCount ?? visibleCount;
|
||||
return `${count} message${count === 1 ? "" : "s"}`;
|
||||
return i18nMessage(count === 1 ? "i18n:govoplan-mail.value_message" : "i18n:govoplan-mail.value_messages", { value0: count });
|
||||
}
|
||||
|
||||
function folderFlagLabel(flags: string[]): string {
|
||||
@@ -472,17 +584,17 @@ function displayFolderFlag(flag: string): string | null {
|
||||
const normalized = flag.replace(/^\\/, "").toLowerCase();
|
||||
switch (normalized) {
|
||||
case "sent":
|
||||
return "Sent";
|
||||
return "i18n:govoplan-mail.sent.35f49dcf";
|
||||
case "drafts":
|
||||
return "Drafts";
|
||||
return "i18n:govoplan-mail.drafts.22a31d86";
|
||||
case "trash":
|
||||
return "Trash";
|
||||
return "i18n:govoplan-mail.trash.e3bf62bb";
|
||||
case "archive":
|
||||
return "Archive";
|
||||
return "i18n:govoplan-mail.archive.2621c6fd";
|
||||
case "junk":
|
||||
return "Junk";
|
||||
return "i18n:govoplan-mail.junk.86c7d94c";
|
||||
case "flagged":
|
||||
return "Flagged";
|
||||
return "i18n:govoplan-mail.flagged.f8db8a17";
|
||||
case "haschildren":
|
||||
case "hasnochildren":
|
||||
case "noselect":
|
||||
@@ -494,17 +606,22 @@ function displayFolderFlag(flag: string): string | null {
|
||||
|
||||
function transportLabel(profile: MailServerProfile): string {
|
||||
const imap = profile.imap;
|
||||
if (!imap?.host) return "IMAP not configured";
|
||||
if (!imap?.host) return "i18n:govoplan-mail.imap_not_configured.b2892af3";
|
||||
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
|
||||
}
|
||||
|
||||
function formatBytes(value?: number | null): string {
|
||||
if (!value) return "-";
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (value < 1024) return i18nMessage("i18n:govoplan-mail.bytes_b", { value0: value });
|
||||
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-mail.bytes_kb", { value0: (value / 1024).toFixed(1) });
|
||||
return i18nMessage("i18n:govoplan-mail.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
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);
|
||||
}
|
||||
|
||||
149
webui/src/features/mail/mailPolicyValidation.ts
Normal file
149
webui/src/features/mail/mailPolicyValidation.ts
Normal 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(/^@+/, "");
|
||||
}
|
||||
60
webui/src/features/mail/mailProfileEditorModel.ts
Normal file
60
webui/src/features/mail/mailProfileEditorModel.ts
Normal 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";
|
||||
}
|
||||
88
webui/src/features/mail/mailboxFolders.ts
Normal file
88
webui/src/features/mail/mailboxFolders.ts
Normal 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()}`;
|
||||
}
|
||||
398
webui/src/i18n/generatedTranslations.ts
Normal file
398
webui/src/i18n/generatedTranslations.ts
Normal 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..."
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import "./styles/mail-profiles.css";
|
||||
export { default } from "./module";
|
||||
export * from "./module";
|
||||
export * from "./api/mail";
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
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 mailboxRead = ["mail:mailbox:read"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
};
|
||||
|
||||
export const mailModule: PlatformWebModule = {
|
||||
id: "mail",
|
||||
label: "Mail",
|
||||
label: "i18n:govoplan-mail.mail.92379cbb",
|
||||
version: "1.0.0",
|
||||
dependencies: ["access"],
|
||||
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||
optionalDependencies: ["addresses"],
|
||||
translations,
|
||||
navItems: [{ to: "/mail", label: "i18n:govoplan-mail.mail.92379cbb", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||
routes: [
|
||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
|
||||
]
|
||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }],
|
||||
|
||||
uiCapabilities: {
|
||||
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
|
||||
},
|
||||
runtimeUiCapabilities: {
|
||||
"mail.devMailbox": { enabled: true, label: "i18n:govoplan-mail.development_mock_mailbox.1a379865" } satisfies MailDevMailboxUiCapability
|
||||
}
|
||||
};
|
||||
|
||||
export default mailModule;
|
||||
|
||||
@@ -18,30 +18,6 @@
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.mail-profile-table-surface {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-profile-table-surface > .data-grid-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-body > .mail-profile-table-surface:only-child {
|
||||
margin: -22px -24px;
|
||||
width: calc(100% + 48px);
|
||||
max-width: inherit;
|
||||
}
|
||||
|
||||
.card-body > .mail-profile-table-surface:only-child > .data-grid-shell {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mail-profile-dialog .dialog-body {
|
||||
max-height: min(76vh, 820px);
|
||||
overflow: auto;
|
||||
@@ -52,8 +28,40 @@
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mail-profile-transport-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mail-profile-transport-summary > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.mail-profile-transport-summary span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mail-profile-transport-summary strong,
|
||||
.mail-profile-transport-summary small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.mail-profile-transport-summary small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mail-profile-form h3,
|
||||
.mail-policy-section h3,
|
||||
.mail-policy-pattern-grid h4 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
@@ -83,12 +91,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mail-policy-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.mail-profile-checkbox-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
@@ -101,7 +103,7 @@
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
}
|
||||
@@ -123,6 +125,42 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -136,7 +174,7 @@
|
||||
}
|
||||
|
||||
.mail-policy-effective {
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
@@ -150,7 +188,7 @@
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
@@ -172,17 +210,27 @@
|
||||
}
|
||||
|
||||
.status-badge.neutral {
|
||||
background: #e7e4df;
|
||||
color: #666;
|
||||
background: var(--status-neutral-bg);
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-form-grid.three-columns,
|
||||
.mail-policy-pattern-grid,
|
||||
.mail-policy-effective-grid {
|
||||
.mail-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;
|
||||
}
|
||||
|
||||
.mail-policy-row-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.mail-credential-inheritance-row {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -247,7 +295,7 @@
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
@@ -342,7 +390,7 @@
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
@@ -366,7 +414,7 @@
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@@ -481,7 +529,7 @@
|
||||
flex: 0 0 auto;
|
||||
min-width: 20px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--line);
|
||||
border: var(--border-line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text);
|
||||
@@ -532,13 +580,69 @@
|
||||
}
|
||||
|
||||
.mailbox-message-list-panel {
|
||||
border-right: 1px solid var(--line);
|
||||
border-right: var(--border-line);
|
||||
}
|
||||
|
||||
.mailbox-message-table {
|
||||
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-row {
|
||||
grid-template-columns: minmax(260px, 1fr) 190px 130px;
|
||||
@@ -602,7 +706,7 @@
|
||||
.mailbox-preview-panel {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 320px;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
webui/tests/mail-policy-validation.test.ts
Normal file
46
webui/tests/mail-policy-validation.test.ts
Normal 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");
|
||||
48
webui/tests/mail-profile-editor-model.test.ts
Normal file
48
webui/tests/mail-profile-editor-model.test.ts
Normal 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);
|
||||
|
||||
30
webui/tests/mailbox-folders.test.ts
Normal file
30
webui/tests/mailbox-folders.test.ts
Normal 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");
|
||||
27
webui/tsconfig.mail-tests.json
Normal file
27
webui/tsconfig.mail-tests.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user