Compare commits
6 Commits
v0.1.0
...
b4b0c76455
| Author | SHA1 | Date | |
|---|---|---|---|
| b4b0c76455 | |||
| 5f800a24db | |||
| 3743b23613 | |||
| e6e243c14d | |||
| 3d82d7b32d | |||
| 3cd8c45c42 |
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:
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -326,3 +326,6 @@ cython_debug/
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
|
||||||
|
# GovOPlaN WebUI test output
|
||||||
|
webui/.mail-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-core
|
||||||
|
./scripts/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.
|
||||||
14
README.md
14
README.md
@@ -15,6 +15,10 @@ This repository owns:
|
|||||||
|
|
||||||
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
|
||||||
|
|
||||||
|
## Credential inheritance policy
|
||||||
|
|
||||||
|
SMTP and IMAP each have one credential inheritance decision: descendants inherit profile credentials, may inherit profile credentials, or must provide local credentials. The lower-level override switch for `smtp_credentials.inherit` and `imap_credentials.inherit` decides whether child scopes may change that decision. There is no separate "override the override" credential policy field.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Install through the core environment:
|
Install through the core environment:
|
||||||
@@ -46,7 +50,15 @@ Frontend package:
|
|||||||
@govoplan/mail-webui
|
@govoplan/mail-webui
|
||||||
```
|
```
|
||||||
|
|
||||||
The campaign module depends on this module for sending, append-to-Sent behavior, profile selection, and read-only mailbox inspection.
|
The campaign module consumes `mail.campaign_delivery` for sending,
|
||||||
|
append-to-Sent behavior, profile selection, and policy checks. Mail does not
|
||||||
|
import campaign internals; campaign-scoped policy and owner context are resolved
|
||||||
|
through the core `campaigns.mailPolicyContext` capability when the campaign
|
||||||
|
module is installed.
|
||||||
|
|
||||||
|
Development mailbox routes are registered by the mail module only when the
|
||||||
|
core runtime is in `dev` mode and `dev_mailbox_api_enabled` is enabled. Core
|
||||||
|
does not contribute these routes directly.
|
||||||
|
|
||||||
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -19,10 +19,15 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.0",
|
"@govoplan/core-webui": "^0.1.6",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-mail"
|
name = "govoplan-mail"
|
||||||
version = "0.1.0"
|
version = "0.1.6"
|
||||||
description = "GovOPlaN mail module with backend and WebUI integration."
|
description = "GovOPlaN mail module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.0",
|
"govoplan-core>=0.1.6",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"redis>=5,<6",
|
"redis>=5,<6",
|
||||||
"SQLAlchemy>=2,<3",
|
"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,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import StrEnum
|
from govoplan_core.mail.config import (
|
||||||
|
ImapConfig,
|
||||||
|
ImapServerConfig,
|
||||||
|
SmtpConfig,
|
||||||
|
SmtpServerConfig,
|
||||||
|
StrictModel,
|
||||||
|
TransportCredentials,
|
||||||
|
TransportSecurity,
|
||||||
|
)
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
__all__ = [
|
||||||
|
"ImapConfig",
|
||||||
|
"ImapServerConfig",
|
||||||
class StrictModel(BaseModel):
|
"SmtpConfig",
|
||||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
"SmtpServerConfig",
|
||||||
|
"StrictModel",
|
||||||
|
"TransportCredentials",
|
||||||
class TransportSecurity(StrEnum):
|
"TransportSecurity",
|
||||||
PLAIN = "plain"
|
]
|
||||||
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)
|
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ class MailServerProfile(Base, TimestampMixin):
|
|||||||
description: Mapped[str | None] = mapped_column(Text)
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
smtp_username: Mapped[str | None] = mapped_column(String(320))
|
||||||
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||||
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
imap_username: Mapped[str | None] = mapped_column(String(320))
|
||||||
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
@@ -38,3 +40,16 @@ class MailServerProfile(Base, TimestampMixin):
|
|||||||
tenant: Mapped["Tenant"] = relationship()
|
tenant: Mapped["Tenant"] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class MailProfilePolicy(Base, TimestampMixin):
|
||||||
|
__tablename__ = "mail_profile_policies"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "scope_type", "scope_id", name="uq_mail_profile_policies_scope"),
|
||||||
|
Index("ix_mail_profile_policies_scope", "scope_type", "scope_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), 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)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, require_scope
|
||||||
from govoplan_mail.backend.dev.mock_mailbox import (
|
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||||
clear_records,
|
clear_records,
|
||||||
get_failures,
|
get_failures,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||||
@@ -57,15 +58,23 @@ def _mail_router(context: ModuleContext):
|
|||||||
from govoplan_mail.backend.runtime import configure_runtime
|
from govoplan_mail.backend.runtime import configure_runtime
|
||||||
|
|
||||||
configure_runtime(settings=context.settings)
|
configure_runtime(settings=context.settings)
|
||||||
|
from fastapi import APIRouter
|
||||||
from govoplan_mail.backend.router import router
|
from govoplan_mail.backend.router import router
|
||||||
|
|
||||||
return router
|
aggregate = APIRouter()
|
||||||
|
aggregate.include_router(router)
|
||||||
|
app_env = str(getattr(context.settings, "app_env", "dev")).lower()
|
||||||
|
if app_env == "dev" and bool(getattr(context.settings, "dev_mailbox_api_enabled", False)):
|
||||||
|
from govoplan_mail.backend.dev_router import router as dev_router
|
||||||
|
|
||||||
|
aggregate.include_router(dev_router)
|
||||||
|
return aggregate
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="mail",
|
id="mail",
|
||||||
name="Mail",
|
name="Mail",
|
||||||
version="1.0.0",
|
version="0.1.6",
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=(),
|
optional_dependencies=(),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
@@ -81,10 +90,26 @@ manifest = ModuleManifest(
|
|||||||
module_id="mail",
|
module_id="mail",
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||||
|
retirement_supported=True,
|
||||||
|
retirement_provider=drop_table_retirement_provider(
|
||||||
|
mail_models.MailServerProfile,
|
||||||
|
mail_models.MailProfilePolicy,
|
||||||
|
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,
|
||||||
|
label="Mail",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""mail profile policies
|
||||||
|
|
||||||
|
Revision ID: 3d4e5f708192
|
||||||
|
Revises: 1b2c3d4e5f70
|
||||||
|
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 = "1b2c3d4e5f70"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "mail_profile_policies" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
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"], ["tenants.id"], name=op.f("fk_mail_profile_policies_tenant_id_tenants"), 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")
|
||||||
@@ -20,12 +20,12 @@ from govoplan_mail.backend.schemas import (
|
|||||||
MailServerProfileUpdateRequest,
|
MailServerProfileUpdateRequest,
|
||||||
MailSmtpTestRequest,
|
MailSmtpTestRequest,
|
||||||
)
|
)
|
||||||
from govoplan_core.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_mail.backend.mail_profiles import (
|
from govoplan_mail.backend.mail_profiles import (
|
||||||
MailProfileError,
|
MailProfileError,
|
||||||
create_mail_server_profile,
|
create_mail_server_profile,
|
||||||
effective_mail_profile_policy,
|
effective_mail_profile_policy_for_scope,
|
||||||
get_mail_profile_policy,
|
get_mail_profile_policy,
|
||||||
get_mail_server_profile,
|
get_mail_server_profile,
|
||||||
imap_config_from_profile,
|
imap_config_from_profile,
|
||||||
@@ -36,6 +36,7 @@ from govoplan_mail.backend.mail_profiles import (
|
|||||||
smtp_config_from_profile,
|
smtp_config_from_profile,
|
||||||
update_mail_server_profile,
|
update_mail_server_profile,
|
||||||
)
|
)
|
||||||
|
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||||
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
|
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
|
||||||
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -172,7 +173,7 @@ def create_profile(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_profile_write_scope(principal, payload.scope_type)
|
_require_profile_write_scope(principal, payload.scope_type)
|
||||||
if payload.smtp.password or (payload.imap and payload.imap.password):
|
if payload.credentials_supplied():
|
||||||
_require_profile_credentials_scope(principal, payload.scope_type)
|
_require_profile_credentials_scope(principal, payload.scope_type)
|
||||||
try:
|
try:
|
||||||
profile = create_mail_server_profile(
|
profile = create_mail_server_profile(
|
||||||
@@ -182,8 +183,8 @@ def create_profile(
|
|||||||
name=payload.name,
|
name=payload.name,
|
||||||
slug=payload.slug,
|
slug=payload.slug,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
smtp=payload.smtp,
|
smtp=payload.smtp_config(),
|
||||||
imap=payload.imap,
|
imap=payload.imap_config(),
|
||||||
is_active=payload.is_active,
|
is_active=payload.is_active,
|
||||||
scope_type=payload.scope_type,
|
scope_type=payload.scope_type,
|
||||||
scope_id=payload.scope_id,
|
scope_id=payload.scope_id,
|
||||||
@@ -219,8 +220,18 @@ def update_profile(
|
|||||||
try:
|
try:
|
||||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||||
if (payload.smtp and "password" in payload.smtp.model_fields_set) or (payload.imap and "password" in payload.imap.model_fields_set) or payload.clear_imap:
|
if payload.credentials_supplied() or payload.clear_imap:
|
||||||
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
||||||
|
smtp_config = payload.smtp_config()
|
||||||
|
if payload.smtp is None and "smtp" in payload.credentials.model_fields_set:
|
||||||
|
smtp_data = dict(profile.smtp_config or {})
|
||||||
|
smtp_data.update(payload.credentials.smtp.model_dump(mode="json", exclude_unset=True))
|
||||||
|
smtp_config = SmtpConfig.model_validate(smtp_data)
|
||||||
|
imap_config = payload.imap_config()
|
||||||
|
if payload.imap is None and "imap" in payload.credentials.model_fields_set and profile.imap_config:
|
||||||
|
imap_data = dict(profile.imap_config or {})
|
||||||
|
imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True))
|
||||||
|
imap_config = ImapConfig.model_validate(imap_data)
|
||||||
update_mail_server_profile(
|
update_mail_server_profile(
|
||||||
session,
|
session,
|
||||||
profile,
|
profile,
|
||||||
@@ -230,8 +241,8 @@ def update_profile(
|
|||||||
slug=payload.slug,
|
slug=payload.slug,
|
||||||
description=payload.description if "description" in payload.model_fields_set else None,
|
description=payload.description if "description" in payload.model_fields_set else None,
|
||||||
is_active=payload.is_active,
|
is_active=payload.is_active,
|
||||||
smtp=payload.smtp,
|
smtp=smtp_config,
|
||||||
imap=payload.imap,
|
imap=imap_config,
|
||||||
clear_imap=payload.clear_imap,
|
clear_imap=payload.clear_imap,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -273,16 +284,15 @@ def read_mail_profile_policy(
|
|||||||
_require_policy_read_scope(principal, scope_type)
|
_require_policy_read_scope(principal, scope_type)
|
||||||
try:
|
try:
|
||||||
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
|
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||||
effective = None
|
effective_policy = effective_mail_profile_policy_for_scope(
|
||||||
effective_sources = []
|
session,
|
||||||
if campaign_id:
|
tenant_id=principal.tenant_id,
|
||||||
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
|
scope_type=scope_type,
|
||||||
effective = effective_policy.as_dict()
|
scope_id=scope_id,
|
||||||
effective_sources = effective_policy.source_policies
|
campaign_id=campaign_id,
|
||||||
elif scope_type == "campaign" and scope_id:
|
)
|
||||||
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
|
effective = effective_policy.as_dict()
|
||||||
effective = effective_policy.as_dict()
|
effective_sources = effective_policy.source_policies
|
||||||
effective_sources = effective_policy.source_policies
|
|
||||||
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
|
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
|
||||||
parent = parent_policy.as_dict() if parent_policy else None
|
parent = parent_policy.as_dict() if parent_policy else None
|
||||||
parent_sources = parent_policy.source_policies if parent_policy else []
|
parent_sources = parent_policy.source_policies if parent_policy else []
|
||||||
@@ -310,12 +320,14 @@ def write_mail_profile_policy(
|
|||||||
policy=payload.policy.model_dump(mode="json"),
|
policy=payload.policy.model_dump(mode="json"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
effective = None
|
effective_policy = effective_mail_profile_policy_for_scope(
|
||||||
effective_sources = []
|
session,
|
||||||
if scope_type == "campaign" and scope_id:
|
tenant_id=principal.tenant_id,
|
||||||
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
|
scope_type=scope_type,
|
||||||
effective = effective_policy.as_dict()
|
scope_id=scope_id,
|
||||||
effective_sources = effective_policy.source_policies
|
)
|
||||||
|
effective = effective_policy.as_dict()
|
||||||
|
effective_sources = effective_policy.source_policies
|
||||||
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
|
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
|
||||||
parent = parent_policy.as_dict() if parent_policy else None
|
parent = parent_policy.as_dict() if parent_policy else None
|
||||||
parent_sources = parent_policy.source_policies if parent_policy else []
|
parent_sources = parent_policy.source_policies if parent_policy else []
|
||||||
@@ -379,7 +391,7 @@ def list_profile_imap_folders(
|
|||||||
if imap is None:
|
if imap is None:
|
||||||
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||||
result = list_imap_folders(imap_config=imap)
|
result = list_imap_folders(imap_config=imap)
|
||||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
|
||||||
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
@@ -397,7 +409,7 @@ def list_profile_mailbox_folders(
|
|||||||
try:
|
try:
|
||||||
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
result = list_imap_folders(imap_config=imap)
|
result = list_imap_folders(imap_config=imap)
|
||||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
|
||||||
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
@@ -412,13 +424,14 @@ def list_profile_mailbox_messages(
|
|||||||
profile_id: str,
|
profile_id: str,
|
||||||
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
||||||
limit: int = Query(default=50, ge=1, le=100),
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0, le=100000),
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_require_mailbox_read_scope(principal)
|
_require_mailbox_read_scope(principal)
|
||||||
try:
|
try:
|
||||||
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||||
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit)
|
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit, offset=offset)
|
||||||
return MailMailboxMessageListResponse(
|
return MailMailboxMessageListResponse(
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
folder=result.folder,
|
folder=result.folder,
|
||||||
@@ -426,6 +439,8 @@ def list_profile_mailbox_messages(
|
|||||||
port=result.port,
|
port=result.port,
|
||||||
security=result.security,
|
security=result.security,
|
||||||
total_count=result.total_count,
|
total_count=result.total_count,
|
||||||
|
offset=result.offset,
|
||||||
|
limit=result.limit,
|
||||||
messages=[_mailbox_summary_response(message) for message in result.messages],
|
messages=[_mailbox_summary_response(message) for message in result.messages],
|
||||||
)
|
)
|
||||||
except MailProfileError as exc:
|
except MailProfileError as exc:
|
||||||
@@ -551,7 +566,7 @@ def list_imap_folder_settings(
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
||||||
try:
|
try:
|
||||||
result = list_imap_folders(imap_config=payload)
|
result = list_imap_folders(imap_config=payload)
|
||||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
|
||||||
return MailImapFolderListResponse(
|
return MailImapFolderListResponse(
|
||||||
ok=True,
|
ok=True,
|
||||||
host=result.host,
|
host=result.host,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
from govoplan_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials
|
||||||
|
|
||||||
|
|
||||||
class MailSmtpTestRequest(SmtpConfig):
|
class MailSmtpTestRequest(SmtpConfig):
|
||||||
@@ -15,7 +15,50 @@ class MailSmtpTestRequest(SmtpConfig):
|
|||||||
class MailImapTestRequest(ImapConfig):
|
class MailImapTestRequest(ImapConfig):
|
||||||
"""IMAP settings supplied directly from the WebUI mail settings form."""
|
"""IMAP settings supplied directly from the WebUI mail settings form."""
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
|
class MailTransportCredentialsPayload(TransportCredentials):
|
||||||
|
"""Credentials supplied separately from saved server settings."""
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerProfileCredentialsPayload(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
smtp: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload)
|
||||||
|
imap: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_profile_transport_payload(value: object) -> object:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
data = dict(value)
|
||||||
|
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
|
||||||
|
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
|
||||||
|
for protocol in ("smtp", "imap"):
|
||||||
|
transport = data.get(protocol)
|
||||||
|
if not isinstance(transport, dict):
|
||||||
|
continue
|
||||||
|
next_transport = dict(transport)
|
||||||
|
next_credentials = dict(credentials.get(protocol) or {})
|
||||||
|
for field in ("username", "password"):
|
||||||
|
if field in next_transport and field not in next_credentials:
|
||||||
|
next_credentials[field] = next_transport[field]
|
||||||
|
next_transport.pop(field, None)
|
||||||
|
next_transport.pop("enabled", None)
|
||||||
|
data[protocol] = next_transport
|
||||||
|
if next_credentials:
|
||||||
|
credentials[protocol] = next_credentials
|
||||||
|
if credentials:
|
||||||
|
data["credentials"] = credentials
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]:
|
||||||
|
payload = server.model_dump(mode="json")
|
||||||
|
if "username" in credentials.model_fields_set:
|
||||||
|
payload["username"] = credentials.username
|
||||||
|
if "password" in credentials.model_fields_set:
|
||||||
|
payload["password"] = credentials.password
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
|
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
|
||||||
@@ -25,7 +68,6 @@ class MailCredentialPolicyPayload(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
inherit: bool | None = None
|
inherit: bool | None = None
|
||||||
allow_override: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class MailProfilePolicyPayload(BaseModel):
|
class MailProfilePolicyPayload(BaseModel):
|
||||||
@@ -39,6 +81,7 @@ class MailProfilePolicyPayload(BaseModel):
|
|||||||
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
||||||
whitelist: dict[str, list[str]] = Field(default_factory=dict)
|
whitelist: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
blacklist: dict[str, list[str]] = Field(default_factory=dict)
|
blacklist: dict[str, list[str]] = Field(default_factory=dict)
|
||||||
|
allow_lower_level_limits: dict[str, bool] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class MailProfilePolicyUpdateRequest(BaseModel):
|
class MailProfilePolicyUpdateRequest(BaseModel):
|
||||||
@@ -52,6 +95,7 @@ class PolicySourceStepResponse(BaseModel):
|
|||||||
scope_id: str | None = None
|
scope_id: str | None = None
|
||||||
label: str
|
label: str
|
||||||
applied_fields: list[str] = Field(default_factory=list)
|
applied_fields: list[str] = Field(default_factory=list)
|
||||||
|
policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class MailProfilePolicyResponse(BaseModel):
|
class MailProfilePolicyResponse(BaseModel):
|
||||||
@@ -73,8 +117,29 @@ class MailServerProfileCreateRequest(BaseModel):
|
|||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
scope_type: MailProfileScope = "tenant"
|
scope_type: MailProfileScope = "tenant"
|
||||||
scope_id: str | None = None
|
scope_id: str | None = None
|
||||||
smtp: SmtpConfig
|
smtp: SmtpServerConfig
|
||||||
imap: ImapConfig | None = None
|
imap: ImapServerConfig | None = None
|
||||||
|
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_legacy_transport_payload(cls, value: object) -> object:
|
||||||
|
return _normalize_profile_transport_payload(value)
|
||||||
|
|
||||||
|
def smtp_config(self) -> SmtpConfig:
|
||||||
|
return SmtpConfig.model_validate(_merge_transport_credentials(self.smtp, self.credentials.smtp))
|
||||||
|
|
||||||
|
def imap_config(self) -> ImapConfig | None:
|
||||||
|
if self.imap is None:
|
||||||
|
return None
|
||||||
|
return ImapConfig.model_validate(_merge_transport_credentials(self.imap, self.credentials.imap))
|
||||||
|
|
||||||
|
def credentials_supplied(self) -> bool:
|
||||||
|
return any(
|
||||||
|
field in credentials.model_fields_set
|
||||||
|
for credentials in (self.credentials.smtp, self.credentials.imap)
|
||||||
|
for field in ("username", "password")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MailServerProfileUpdateRequest(BaseModel):
|
class MailServerProfileUpdateRequest(BaseModel):
|
||||||
@@ -84,10 +149,35 @@ class MailServerProfileUpdateRequest(BaseModel):
|
|||||||
slug: str | None = Field(default=None, max_length=100)
|
slug: str | None = Field(default=None, max_length=100)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
smtp: SmtpConfig | None = None
|
smtp: SmtpServerConfig | None = None
|
||||||
imap: ImapConfig | None = None
|
imap: ImapServerConfig | None = None
|
||||||
|
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
|
||||||
clear_imap: bool = False
|
clear_imap: bool = False
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def normalize_legacy_transport_payload(cls, value: object) -> object:
|
||||||
|
return _normalize_profile_transport_payload(value)
|
||||||
|
|
||||||
|
def smtp_config(self) -> SmtpConfig | None:
|
||||||
|
if self.smtp is None and "smtp" not in self.credentials.model_fields_set:
|
||||||
|
return None
|
||||||
|
server = self.smtp or SmtpServerConfig()
|
||||||
|
return SmtpConfig.model_validate(_merge_transport_credentials(server, self.credentials.smtp))
|
||||||
|
|
||||||
|
def imap_config(self) -> ImapConfig | None:
|
||||||
|
if self.imap is None and "imap" not in self.credentials.model_fields_set:
|
||||||
|
return None
|
||||||
|
server = self.imap or ImapServerConfig()
|
||||||
|
return ImapConfig.model_validate(_merge_transport_credentials(server, self.credentials.imap))
|
||||||
|
|
||||||
|
def credentials_supplied(self) -> bool:
|
||||||
|
return any(
|
||||||
|
field in credentials.model_fields_set
|
||||||
|
for credentials in (self.credentials.smtp, self.credentials.imap)
|
||||||
|
for field in ("username", "password")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MailServerProfileResponse(BaseModel):
|
class MailServerProfileResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
@@ -100,6 +190,7 @@ class MailServerProfileResponse(BaseModel):
|
|||||||
is_active: bool
|
is_active: bool
|
||||||
smtp: dict[str, Any]
|
smtp: dict[str, Any]
|
||||||
imap: dict[str, Any] | None = None
|
imap: dict[str, Any] | None = None
|
||||||
|
credentials: dict[str, Any] = Field(default_factory=dict)
|
||||||
smtp_password_configured: bool = False
|
smtp_password_configured: bool = False
|
||||||
imap_password_configured: bool = False
|
imap_password_configured: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -123,6 +214,8 @@ class MailConnectionTestResponse(BaseModel):
|
|||||||
class MailImapFolderResponse(BaseModel):
|
class MailImapFolderResponse(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
flags: list[str] = Field(default_factory=list)
|
flags: list[str] = Field(default_factory=list)
|
||||||
|
message_count: int | None = None
|
||||||
|
unseen_count: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class MailImapFolderListResponse(BaseModel):
|
class MailImapFolderListResponse(BaseModel):
|
||||||
@@ -171,6 +264,8 @@ class MailMailboxMessageListResponse(BaseModel):
|
|||||||
port: int | None = None
|
port: int | None = None
|
||||||
security: str | None = None
|
security: str | None = None
|
||||||
total_count: int = 0
|
total_count: int = 0
|
||||||
|
offset: int = 0
|
||||||
|
limit: int = 50
|
||||||
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
|
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class ImapLoginTestResult:
|
|||||||
class ImapMailboxInfo:
|
class ImapMailboxInfo:
|
||||||
name: str
|
name: str
|
||||||
flags: list[str]
|
flags: list[str]
|
||||||
|
message_count: int | None = None
|
||||||
|
unseen_count: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -111,6 +113,8 @@ class ImapMailboxMessageListResult:
|
|||||||
folder: str
|
folder: str
|
||||||
messages: list[ImapMailboxMessageSummary]
|
messages: list[ImapMailboxMessageSummary]
|
||||||
total_count: int
|
total_count: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -133,8 +137,6 @@ class ImapAppendResult:
|
|||||||
|
|
||||||
|
|
||||||
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
|
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
|
||||||
if not config.enabled:
|
|
||||||
raise ImapConfigurationError("IMAP is disabled")
|
|
||||||
if not config.host:
|
if not config.host:
|
||||||
raise ImapConfigurationError("IMAP host is required")
|
raise ImapConfigurationError("IMAP host is required")
|
||||||
if not config.port:
|
if not config.port:
|
||||||
@@ -311,7 +313,12 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
|||||||
|
|
||||||
host, port = _require_imap_config(imap_config)
|
host, port = _require_imap_config(imap_config)
|
||||||
if is_mock_imap_host(imap_config.host):
|
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]
|
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(
|
return ImapFolderListResult(
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
@@ -334,7 +341,8 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
|||||||
continue
|
continue
|
||||||
name, flags = extracted
|
name, flags = extracted
|
||||||
parsed.append((name, flags))
|
parsed.append((name, flags))
|
||||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
|
message_count, unseen_count = (None, None) if _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name)
|
||||||
|
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
|
||||||
|
|
||||||
return ImapFolderListResult(
|
return ImapFolderListResult(
|
||||||
host=host,
|
host=host,
|
||||||
@@ -350,6 +358,31 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _parse_message(raw: bytes) -> EmailMessage:
|
||||||
return BytesParser(policy=policy.default).parsebytes(raw)
|
return BytesParser(policy=policy.default).parsebytes(raw)
|
||||||
|
|
||||||
@@ -393,6 +426,24 @@ def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]:
|
|||||||
return attachments
|
return attachments
|
||||||
|
|
||||||
|
|
||||||
|
def _message_summary_from_headers(*, uid: str, folder: str, headers: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
|
||||||
|
message = _parse_message(headers.rstrip() + b"\r\n\r\n")
|
||||||
|
return ImapMailboxMessageSummary(
|
||||||
|
uid=uid,
|
||||||
|
folder=folder,
|
||||||
|
subject=_header_text(message, "Subject"),
|
||||||
|
from_header=_header_text(message, "From"),
|
||||||
|
to_header=_header_text(message, "To"),
|
||||||
|
cc_header=_header_text(message, "Cc"),
|
||||||
|
date=_header_text(message, "Date"),
|
||||||
|
message_id=_header_text(message, "Message-ID"),
|
||||||
|
flags=flags,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
body_preview=None,
|
||||||
|
attachment_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
|
def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
|
||||||
message = _parse_message(raw)
|
message = _parse_message(raw)
|
||||||
attachments = _attachment_infos(message)
|
attachments = _attachment_infos(message)
|
||||||
@@ -434,6 +485,14 @@ def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[s
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fetch_metadata(metadata: str) -> tuple[str | None, list[str], int | None]:
|
||||||
|
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
|
||||||
|
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
|
||||||
|
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
|
||||||
|
flags = flags_match.group(1).split() if flags_match else []
|
||||||
|
return uid_match.group(1) if uid_match else None, flags, int(size_match.group(1)) if size_match else None
|
||||||
|
|
||||||
|
|
||||||
def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]:
|
def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]:
|
||||||
metadata_parts: list[str] = []
|
metadata_parts: list[str] = []
|
||||||
raw: bytes | None = None
|
raw: bytes | None = None
|
||||||
@@ -445,24 +504,38 @@ def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str
|
|||||||
else:
|
else:
|
||||||
metadata_parts.append(_decode_item(item))
|
metadata_parts.append(_decode_item(item))
|
||||||
metadata = " ".join(part for part in metadata_parts if part)
|
metadata = " ".join(part for part in metadata_parts if part)
|
||||||
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
|
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
|
||||||
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
|
|
||||||
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
|
|
||||||
flags = flags_match.group(1).split() if flags_match else []
|
|
||||||
if raw is None:
|
if raw is None:
|
||||||
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
|
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
|
||||||
return (
|
return uid, flags, size_bytes, raw
|
||||||
uid_match.group(1) if uid_match else None,
|
|
||||||
flags,
|
|
||||||
int(size_match.group(1)) if size_match else None,
|
|
||||||
raw,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _select_readonly(client: imaplib.IMAP4, folder: str) -> None:
|
def _parse_fetch_sequence(metadata: str) -> str | None:
|
||||||
|
match = re.match(r"\s*(\d+)\s+\(", metadata)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -> list[tuple[str | None, str | None, list[str], int | None, bytes]]:
|
||||||
|
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]] = []
|
||||||
|
for item in data or []:
|
||||||
|
if not isinstance(item, tuple) or not isinstance(item[1], bytes):
|
||||||
|
continue
|
||||||
|
metadata = _decode_item(item[0])
|
||||||
|
sequence = _parse_fetch_sequence(metadata)
|
||||||
|
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
|
||||||
|
parts.append((sequence, uid, flags, size_bytes, item[1]))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _select_readonly(client: imaplib.IMAP4, folder: str) -> int:
|
||||||
typ, data = client.select(folder, readonly=True)
|
typ, data = client.select(folder, readonly=True)
|
||||||
if typ != "OK":
|
if typ != "OK":
|
||||||
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
|
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
|
||||||
|
selected_count = _decode_item(data[0] if data else None).strip()
|
||||||
|
try:
|
||||||
|
return max(0, int(selected_count))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
|
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
|
||||||
@@ -473,6 +546,50 @@ def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[st
|
|||||||
return fetched_uid or str(uid), flags, size_bytes, raw
|
return fetched_uid or str(uid), flags, size_bytes, raw
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
|
||||||
|
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
|
||||||
|
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
|
||||||
|
if not fetched_uid:
|
||||||
|
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
|
||||||
|
return fetched_uid, flags, size_bytes, raw
|
||||||
|
|
||||||
|
|
||||||
|
def _sequence_set(sequences: list[str]) -> str:
|
||||||
|
sequence_numbers = sorted({int(sequence) for sequence in sequences if str(sequence).isdigit()})
|
||||||
|
if not sequence_numbers:
|
||||||
|
return ",".join(str(sequence) for sequence in sequences)
|
||||||
|
expected_range = list(range(sequence_numbers[0], sequence_numbers[-1] + 1))
|
||||||
|
if sequence_numbers == expected_range:
|
||||||
|
return f"{sequence_numbers[0]}:{sequence_numbers[-1]}"
|
||||||
|
return ",".join(str(sequence) for sequence in sequence_numbers)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
|
||||||
|
if not sequences:
|
||||||
|
return []
|
||||||
|
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
|
||||||
|
if typ != "OK":
|
||||||
|
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
|
||||||
|
|
||||||
|
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
|
||||||
|
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
|
||||||
|
if not sequence or not fetched_uid:
|
||||||
|
continue
|
||||||
|
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
|
||||||
|
|
||||||
|
summaries: list[ImapMailboxMessageSummary] = []
|
||||||
|
for sequence in sequences:
|
||||||
|
summary = by_sequence.get(str(sequence))
|
||||||
|
if summary is not None:
|
||||||
|
summaries.append(summary)
|
||||||
|
continue
|
||||||
|
fetched_uid, flags, size_bytes, raw = _fetch_message_by_sequence(client, sequence)
|
||||||
|
summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
def _mock_record_folder(record: dict[str, Any]) -> str:
|
def _mock_record_folder(record: dict[str, Any]) -> str:
|
||||||
folder = str(record.get("folder") or "").strip()
|
folder = str(record.get("folder") or "").strip()
|
||||||
if folder:
|
if folder:
|
||||||
@@ -495,14 +612,16 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
|
|||||||
return "\n".join(lines).encode("utf-8", errors="replace")
|
return "\n".join(lines).encode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> ImapMailboxMessageListResult:
|
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult:
|
||||||
"""List mailbox messages without mutating read/unread state."""
|
"""List mailbox messages without mutating read/unread state."""
|
||||||
|
|
||||||
host, port = _require_imap_config(imap_config)
|
host, port = _require_imap_config(imap_config)
|
||||||
folder = (folder or "INBOX").strip() or "INBOX"
|
folder = (folder or "INBOX").strip() or "INBOX"
|
||||||
limit = max(1, min(limit, 100))
|
limit = max(1, min(limit, 100))
|
||||||
|
offset = max(0, offset)
|
||||||
if is_mock_imap_host(imap_config.host):
|
if is_mock_imap_host(imap_config.host):
|
||||||
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
|
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
|
||||||
|
page_records = records[offset:offset + limit]
|
||||||
messages = [
|
messages = [
|
||||||
_message_summary_from_raw(
|
_message_summary_from_raw(
|
||||||
uid=str(record.get("id") or ""),
|
uid=str(record.get("id") or ""),
|
||||||
@@ -511,23 +630,16 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
|
|||||||
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
|
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
|
||||||
size_bytes=int(record.get("size_bytes") or 0) or None,
|
size_bytes=int(record.get("size_bytes") or 0) or None,
|
||||||
)
|
)
|
||||||
for record in records[:limit]
|
for record in page_records
|
||||||
]
|
]
|
||||||
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records))
|
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit)
|
||||||
|
|
||||||
client = _open_imap(imap_config)
|
client = _open_imap(imap_config)
|
||||||
try:
|
try:
|
||||||
_select_readonly(client, folder)
|
total_count = _select_readonly(client, folder)
|
||||||
typ, data = client.uid("search", None, "ALL")
|
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
|
||||||
if typ != "OK":
|
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
|
||||||
raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True)
|
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit)
|
||||||
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))
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
client.logout()
|
client.logout()
|
||||||
@@ -535,6 +647,14 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]:
|
||||||
|
if total_count <= 0 or offset >= total_count:
|
||||||
|
return []
|
||||||
|
end = total_count - offset
|
||||||
|
start = max(1, end - limit + 1)
|
||||||
|
return [str(sequence) for sequence in range(end, start - 1, -1)]
|
||||||
|
|
||||||
|
|
||||||
def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult:
|
def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult:
|
||||||
"""Fetch one message body using BODY.PEEK in a read-only selected mailbox."""
|
"""Fetch one message body using BODY.PEEK in a read-only selected mailbox."""
|
||||||
|
|
||||||
|
|||||||
68
tests/test_imap_parser.py
Normal file
68
tests/test_imap_parser.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_mail.backend.sending.imap import (
|
||||||
|
_detect_sent_folder,
|
||||||
|
_extract_mailbox_name,
|
||||||
|
_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 ()"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -14,10 +14,21 @@
|
|||||||
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.0",
|
"@govoplan/core-webui": "^0.1.6",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-policy-validation.test.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,19 @@ export type MailSmtpTestPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type MailImapTestPayload = MailSmtpTestPayload & {
|
export type MailImapTestPayload = MailSmtpTestPayload & {
|
||||||
enabled?: boolean;
|
|
||||||
sent_folder?: string | null;
|
sent_folder?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MailTransportCredentialsPayload = {
|
||||||
|
username?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailServerProfileCredentialsPayload = {
|
||||||
|
smtp?: MailTransportCredentialsPayload;
|
||||||
|
imap?: MailTransportCredentialsPayload;
|
||||||
|
};
|
||||||
|
|
||||||
export type MailConnectionTestResponse = {
|
export type MailConnectionTestResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
protocol: "smtp" | "imap";
|
protocol: "smtp" | "imap";
|
||||||
@@ -31,6 +40,8 @@ export type MailConnectionTestResponse = {
|
|||||||
export type MailImapFolderResponse = {
|
export type MailImapFolderResponse = {
|
||||||
name: string;
|
name: string;
|
||||||
flags?: string[];
|
flags?: string[];
|
||||||
|
message_count?: number | null;
|
||||||
|
unseen_count?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailImapFolderListResponse = {
|
export type MailImapFolderListResponse = {
|
||||||
@@ -59,6 +70,7 @@ export type MailMailboxMessageSummary = {
|
|||||||
from_header?: string | null;
|
from_header?: string | null;
|
||||||
to_header?: string | null;
|
to_header?: string | null;
|
||||||
cc_header?: string | null;
|
cc_header?: string | null;
|
||||||
|
bcc_header?: string | null;
|
||||||
date?: string | null;
|
date?: string | null;
|
||||||
message_id?: string | null;
|
message_id?: string | null;
|
||||||
flags?: string[];
|
flags?: string[];
|
||||||
@@ -81,6 +93,8 @@ export type MailMailboxMessageListResponse = {
|
|||||||
port?: number | null;
|
port?: number | null;
|
||||||
security?: MailSecurity | string | null;
|
security?: MailSecurity | string | null;
|
||||||
total_count?: number;
|
total_count?: number;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
messages: MailMailboxMessageSummary[];
|
messages: MailMailboxMessageSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +118,7 @@ export type MailServerProfile = {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
smtp: MailSmtpTestPayload;
|
smtp: MailSmtpTestPayload;
|
||||||
imap?: MailImapTestPayload | null;
|
imap?: MailImapTestPayload | null;
|
||||||
|
credentials?: MailServerProfileCredentialsPayload | null;
|
||||||
smtp_password_configured: boolean;
|
smtp_password_configured: boolean;
|
||||||
imap_password_configured: boolean;
|
imap_password_configured: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -123,9 +138,30 @@ export const mailProfilePatternKeys = [
|
|||||||
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
|
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
|
||||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
||||||
|
|
||||||
|
export const mailProfilePolicyLimitKeys = [
|
||||||
|
"allowed_profile_ids",
|
||||||
|
"allow_user_profiles",
|
||||||
|
"allow_group_profiles",
|
||||||
|
"allow_campaign_profiles",
|
||||||
|
"smtp_credentials.inherit",
|
||||||
|
"imap_credentials.inherit",
|
||||||
|
"whitelist.smtp_hosts",
|
||||||
|
"whitelist.imap_hosts",
|
||||||
|
"whitelist.envelope_senders",
|
||||||
|
"whitelist.from_headers",
|
||||||
|
"whitelist.recipient_domains",
|
||||||
|
"blacklist.smtp_hosts",
|
||||||
|
"blacklist.imap_hosts",
|
||||||
|
"blacklist.envelope_senders",
|
||||||
|
"blacklist.from_headers",
|
||||||
|
"blacklist.recipient_domains"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type MailProfilePolicyLimitKey = typeof mailProfilePolicyLimitKeys[number];
|
||||||
|
export type MailProfilePolicyLimitPermissions = Partial<Record<MailProfilePolicyLimitKey, boolean>>;
|
||||||
|
|
||||||
export type MailCredentialPolicy = {
|
export type MailCredentialPolicy = {
|
||||||
inherit?: boolean | null;
|
inherit?: boolean | null;
|
||||||
allow_override?: boolean | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailProfilePolicy = {
|
export type MailProfilePolicy = {
|
||||||
@@ -137,6 +173,7 @@ export type MailProfilePolicy = {
|
|||||||
imap_credentials?: MailCredentialPolicy | null;
|
imap_credentials?: MailCredentialPolicy | null;
|
||||||
whitelist?: MailProfilePatternRules | null;
|
whitelist?: MailProfilePatternRules | null;
|
||||||
blacklist?: MailProfilePatternRules | null;
|
blacklist?: MailProfilePatternRules | null;
|
||||||
|
allow_lower_level_limits?: MailProfilePolicyLimitPermissions | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PolicySourceStep = {
|
export type PolicySourceStep = {
|
||||||
@@ -144,6 +181,7 @@ export type PolicySourceStep = {
|
|||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
applied_fields?: string[];
|
applied_fields?: string[];
|
||||||
|
policy?: MailProfilePolicy | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MailProfilePolicyResponse = {
|
export type MailProfilePolicyResponse = {
|
||||||
@@ -165,6 +203,7 @@ export type MailServerProfilePayload = {
|
|||||||
scope_id?: string | null;
|
scope_id?: string | null;
|
||||||
smtp: MailSmtpTestPayload;
|
smtp: MailSmtpTestPayload;
|
||||||
imap?: MailImapTestPayload | null;
|
imap?: MailImapTestPayload | null;
|
||||||
|
credentials?: MailServerProfileCredentialsPayload | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||||
@@ -244,9 +283,10 @@ export async function listMailboxMessages(
|
|||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
folder = "INBOX",
|
folder = "INBOX",
|
||||||
limit = 50
|
limit = 50,
|
||||||
|
offset = 0
|
||||||
): Promise<MailMailboxMessageListResponse> {
|
): Promise<MailMailboxMessageListResponse> {
|
||||||
const params = new URLSearchParams({ folder, limit: String(limit) });
|
const params = new URLSearchParams({ folder, limit: String(limit), offset: String(offset) });
|
||||||
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
|
return apiFetch<MailMailboxMessageListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react";
|
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
@@ -19,14 +19,7 @@ import {
|
|||||||
type MailMailboxMessageSummary,
|
type MailMailboxMessageSummary,
|
||||||
type MailServerProfile
|
type MailServerProfile
|
||||||
} from "../../api/mail";
|
} 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 [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
||||||
@@ -37,13 +30,21 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
|
||||||
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
|
||||||
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
const [messageTotalCount, setMessageTotalCount] = useState<number | null>(null);
|
||||||
|
const [messagePage, setMessagePage] = useState(1);
|
||||||
|
const [messagePageSize, setMessagePageSize] = useState(10);
|
||||||
|
const [messageQuery, setMessageQuery] = useState("");
|
||||||
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
|
const [selectedMessage, setSelectedMessage] = useState<MailMailboxMessageDetail | null>(null);
|
||||||
|
const [selectedMessageKeyState, setSelectedMessageKeyState] = useState("");
|
||||||
const [pendingMessageKey, setPendingMessageKey] = useState("");
|
const [pendingMessageKey, setPendingMessageKey] = useState("");
|
||||||
const [loadingProfiles, setLoadingProfiles] = useState(true);
|
const [loadingProfiles, setLoadingProfiles] = useState(true);
|
||||||
const [loadingFolders, setLoadingFolders] = useState(false);
|
const [loadingFolders, setLoadingFolders] = useState(false);
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
const [loadingMessage, setLoadingMessage] = useState(false);
|
const [loadingMessage, setLoadingMessage] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [folderError, setFolderError] = useState("");
|
||||||
|
const [messageError, setMessageError] = useState("");
|
||||||
|
const [detailError, setDetailError] = useState("");
|
||||||
|
const selectedMessageKeyRef = useRef("");
|
||||||
const folderRequestRef = useRef(0);
|
const folderRequestRef = useRef(0);
|
||||||
const messageListRequestRef = useRef(0);
|
const messageListRequestRef = useRef(0);
|
||||||
const messageDetailRequestRef = useRef(0);
|
const messageDetailRequestRef = useRef(0);
|
||||||
@@ -52,19 +53,54 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
const imapProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && profile.imap), [profiles]);
|
||||||
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
|
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
|
||||||
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
|
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
|
||||||
|
const filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]);
|
||||||
|
const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize));
|
||||||
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
|
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
|
||||||
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
|
const noImapProfiles = !loadingProfiles && imapProfiles.length === 0;
|
||||||
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
|
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
|
||||||
const selectedMessageKey = selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : pendingMessageKey;
|
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
|
||||||
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
|
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
|
||||||
const folderEmptyText = noImapProfiles ? "No IMAP-enabled mail profiles." : loadingFolders ? "Loading folders..." : "No folders available.";
|
const folderEmptyText = folderError || (noImapProfiles ? "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 messageEmptyText = messageError || (!selectedProfileId ? "Select an IMAP profile." : !foldersReady || loadingMessages ? "Loading messages..." : messages.length > 0 && filteredMessages.length === 0 ? "No messages match the current filter on this page." : "No messages in this folder.");
|
||||||
const previewEmptyText = loadingMessage ? "Loading message..." : "Select a message to inspect its content.";
|
const previewEmptyText = detailError || (loadingMessage ? "Loading message..." : "Select a message to inspect its content.");
|
||||||
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
|
const loadingLabel = loadingProfiles ? "Loading mail profiles..." : loadingFolders ? "Loading folders..." : loadingMessages ? "Loading messages..." : "Loading message...";
|
||||||
|
|
||||||
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
useEffect(() => { void loadProfiles(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
useEffect(() => { selectedMessageKeyRef.current = selectedMessageKeyState; }, [selectedMessageKeyState]);
|
||||||
|
useEffect(() => { if (messagePage > messagePageCount) setMessagePage(messagePageCount); }, [messagePage, messagePageCount]);
|
||||||
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
|
useEffect(() => { if (selectedProfileId) void loadFolders(selectedProfileId); }, [selectedProfileId]);
|
||||||
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder); }, [foldersReady, selectedProfileId, selectedFolder]);
|
useEffect(() => { if (selectedProfileId && selectedFolder && foldersReady) void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize); }, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePreviewShortcut = (event: KeyboardEvent) => {
|
||||||
|
if (isEditableTarget(event.target)) return;
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (selectedMessage || selectedMessageKey || pendingMessageKey || detailError) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedMessage(null);
|
||||||
|
setSelectedMessageKeyState("");
|
||||||
|
selectedMessageKeyRef.current = "";
|
||||||
|
setPendingMessageKey("");
|
||||||
|
setDetailError("");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shellBusy || loadingMessage || filteredMessages.length === 0) return;
|
||||||
|
const selectedIndex = filteredMessages.findIndex((message) => mailboxMessageKey(message.folder || selectedFolder, message.uid) === selectedMessageKey);
|
||||||
|
let nextIndex: number | null = null;
|
||||||
|
if (event.key === "ArrowLeft") nextIndex = selectedIndex > 0 ? selectedIndex - 1 : 0;
|
||||||
|
else if (event.key === "ArrowRight") nextIndex = selectedIndex >= 0 ? Math.min(filteredMessages.length - 1, selectedIndex + 1) : 0;
|
||||||
|
else if (event.key === "Home") nextIndex = 0;
|
||||||
|
else if (event.key === "End") nextIndex = filteredMessages.length - 1;
|
||||||
|
if (nextIndex === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const nextMessage = filteredMessages[nextIndex];
|
||||||
|
if (nextMessage) void openMessage(nextMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handlePreviewShortcut);
|
||||||
|
return () => window.removeEventListener("keydown", handlePreviewShortcut);
|
||||||
|
}, [detailError, filteredMessages, loadingMessage, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
|
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
|
||||||
@@ -90,6 +126,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setSelectedMessageKeyState("");
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -107,7 +144,13 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
setLoadingFolders(true);
|
setLoadingFolders(true);
|
||||||
setFoldersLoadedForProfile("");
|
setFoldersLoadedForProfile("");
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
|
setMessagePage(1);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
setSelectedMessageKeyState("");
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
|
setFolderError("");
|
||||||
|
setMessageError("");
|
||||||
|
setDetailError("");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await listMailboxFolders(settings, profileId);
|
const response = await listMailboxFolders(settings, profileId);
|
||||||
@@ -124,40 +167,51 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
setFoldersLoadedForProfile(profileId);
|
setFoldersLoadedForProfile(profileId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId !== folderRequestRef.current) return;
|
if (requestId !== folderRequestRef.current) return;
|
||||||
|
setFolderError(errorText(err));
|
||||||
setError(errorText(err));
|
setError(errorText(err));
|
||||||
setFolders([]);
|
setFolders([]);
|
||||||
setFoldersLoadedForProfile("");
|
setFoldersLoadedForProfile("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setSelectedMessageKeyState("");
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === folderRequestRef.current) setLoadingFolders(false);
|
if (requestId === folderRequestRef.current) setLoadingFolders(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder) {
|
async function loadMessages(profileId = selectedProfileId, folder = selectedFolder, page = messagePage, pageSize = messagePageSize) {
|
||||||
if (!profileId || !folder) return;
|
if (!profileId || !folder) return;
|
||||||
const requestId = ++messageListRequestRef.current;
|
const requestId = ++messageListRequestRef.current;
|
||||||
messageDetailRequestRef.current += 1;
|
const offset = (Math.max(1, page) - 1) * pageSize;
|
||||||
setLoadingMessages(true);
|
setLoadingMessages(true);
|
||||||
setMessageTotalCount(null);
|
setMessageError("");
|
||||||
setSelectedMessage(null);
|
setDetailError("");
|
||||||
setPendingMessageKey("");
|
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await listMailboxMessages(settings, profileId, folder, 50);
|
const response = await listMailboxMessages(settings, profileId, folder, pageSize, offset);
|
||||||
if (requestId !== messageListRequestRef.current) return;
|
if (requestId !== messageListRequestRef.current) return;
|
||||||
const loaded = response.messages ?? [];
|
const loaded = response.messages ?? [];
|
||||||
|
const total = response.total_count ?? loaded.length;
|
||||||
setMessages(loaded);
|
setMessages(loaded);
|
||||||
setMessageTotalCount(response.total_count ?? loaded.length);
|
setMessageTotalCount(total);
|
||||||
setSelectedMessage(null);
|
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) {
|
} catch (err) {
|
||||||
if (requestId !== messageListRequestRef.current) return;
|
if (requestId !== messageListRequestRef.current) return;
|
||||||
setError(errorText(err));
|
const message = errorText(err);
|
||||||
|
setMessageError(message);
|
||||||
|
setError(message);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setSelectedMessageKeyState("");
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
|
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
|
||||||
@@ -168,18 +222,25 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
if (!selectedProfileId) return;
|
if (!selectedProfileId) return;
|
||||||
const folderName = message.folder || selectedFolder;
|
const folderName = message.folder || selectedFolder;
|
||||||
const requestId = ++messageDetailRequestRef.current;
|
const requestId = ++messageDetailRequestRef.current;
|
||||||
setPendingMessageKey(mailboxMessageKey(folderName, message.uid));
|
const nextKey = mailboxMessageKey(folderName, message.uid);
|
||||||
|
setSelectedMessageKeyState(nextKey);
|
||||||
|
selectedMessageKeyRef.current = nextKey;
|
||||||
|
setPendingMessageKey(nextKey);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setDetailError("");
|
||||||
setLoadingMessage(true);
|
setLoadingMessage(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
|
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid);
|
||||||
if (requestId !== messageDetailRequestRef.current) return;
|
if (requestId !== messageDetailRequestRef.current) return;
|
||||||
setSelectedMessage(response.message);
|
setSelectedMessage(response.message);
|
||||||
|
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId !== messageDetailRequestRef.current) return;
|
if (requestId !== messageDetailRequestRef.current) return;
|
||||||
setError(errorText(err));
|
const messageText = errorText(err);
|
||||||
|
setDetailError(messageText);
|
||||||
|
setError(messageText);
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
|
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
|
||||||
@@ -195,7 +256,10 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
setFoldersLoadedForProfile("");
|
setFoldersLoadedForProfile("");
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setMessageTotalCount(null);
|
setMessageTotalCount(null);
|
||||||
|
setMessagePage(1);
|
||||||
setSelectedMessage(null);
|
setSelectedMessage(null);
|
||||||
|
setSelectedMessageKeyState("");
|
||||||
|
selectedMessageKeyRef.current = "";
|
||||||
setPendingMessageKey("");
|
setPendingMessageKey("");
|
||||||
setExpandedFolders(new Set());
|
setExpandedFolders(new Set());
|
||||||
setLoadingFolders(false);
|
setLoadingFolders(false);
|
||||||
@@ -205,8 +269,16 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
|
|
||||||
function openFolderNode(node: MailFolderNode) {
|
function openFolderNode(node: MailFolderNode) {
|
||||||
if (node.folderName) {
|
if (node.folderName) {
|
||||||
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName);
|
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);
|
||||||
else setSelectedFolder(node.folderName);
|
else {
|
||||||
|
setSelectedFolder(node.folderName);
|
||||||
|
setMessagePage(1);
|
||||||
|
setSelectedMessage(null);
|
||||||
|
setSelectedMessageKeyState("");
|
||||||
|
selectedMessageKeyRef.current = "";
|
||||||
|
setPendingMessageKey("");
|
||||||
|
setDetailError("");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toggleFolderNode(node);
|
toggleFolderNode(node);
|
||||||
@@ -221,6 +293,20 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setFolderMessageCount(folderName: string, count: number) {
|
||||||
|
setFolders((current) => current.map((folder) => folder.name === folderName ? { ...folder, message_count: count } : folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMessagePage(page: number) {
|
||||||
|
const nextPage = Math.min(messagePageCount, Math.max(1, page));
|
||||||
|
if (nextPage !== messagePage) setMessagePage(nextPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMessagePageSize(pageSize: number) {
|
||||||
|
setMessagePageSize(pageSize);
|
||||||
|
setMessagePage(1);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
|
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
@@ -242,12 +328,12 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
disabled={shellBusy}
|
disabled={shellBusy}
|
||||||
renderNodeContent={(node) => {
|
renderNodeContent={(node) => {
|
||||||
const flagText = folderFlagLabel(node.flags);
|
const flagText = folderFlagLabel(node.flags);
|
||||||
const showCount = foldersReady && node.folderName === selectedFolder && messageTotalCount !== null;
|
const showCount = foldersReady && node.messageCount !== null;
|
||||||
return (
|
return (
|
||||||
<span className="mailbox-tree-node-label">
|
<span className="mailbox-tree-node-label">
|
||||||
<span className="mailbox-tree-node-main">
|
<span className="mailbox-tree-node-main">
|
||||||
<span>{node.label}</span>
|
<span>{node.label}</span>
|
||||||
{showCount && <small className="mailbox-folder-count">{messageTotalCount}</small>}
|
{showCount && <small className="mailbox-folder-count">{node.messageCount}</small>}
|
||||||
</span>
|
</span>
|
||||||
{flagText && <small>{flagText}</small>}
|
{flagText && <small>{flagText}</small>}
|
||||||
</span>
|
</span>
|
||||||
@@ -262,22 +348,22 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
|
<div className="file-manager-toolbar mailbox-toolbar" aria-label="Mail actions">
|
||||||
<label className="mailbox-profile-field">
|
<label className="mailbox-profile-field">
|
||||||
<span>IMAP profile</span>
|
<span>IMAP profile</span>
|
||||||
<select value={selectedProfileId} disabled={shellBusy || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || imapProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
|
||||||
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>}
|
{imapProfiles.length === 0 && <option value="">No IMAP profiles available</option>}
|
||||||
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
{imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
|
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "No IMAP profile selected"}</span>
|
||||||
<div className="mailbox-toolbar-actions">
|
<div className="mailbox-toolbar-actions">
|
||||||
<Button onClick={() => void loadProfiles()} disabled={shellBusy} title="Reload IMAP profiles">
|
<Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="Reload IMAP profiles">
|
||||||
<RefreshCw size={16} aria-hidden="true" />
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
Profiles
|
Profiles
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || shellBusy} title="Refresh mailbox folders">
|
<Button onClick={() => void loadFolders(selectedProfileId)} disabled={!selectedProfileId || loadingFolders} title="Refresh mailbox folders">
|
||||||
<RefreshCw size={16} aria-hidden="true" />
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
Folders
|
Folders
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || shellBusy} title="Refresh messages in the current folder">
|
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="Refresh messages in the current folder">
|
||||||
<RefreshCw size={16} aria-hidden="true" />
|
<RefreshCw size={16} aria-hidden="true" />
|
||||||
Messages
|
Messages
|
||||||
</Button>
|
</Button>
|
||||||
@@ -289,9 +375,18 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
|
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div className="mailbox-filter-row">
|
||||||
|
<label className="mailbox-search-field">
|
||||||
|
<Search size={15} aria-hidden="true" />
|
||||||
|
<input value={messageQuery} onChange={(event) => setMessageQuery(event.target.value)} placeholder="Search messages" />
|
||||||
|
{messageQuery && <button type="button" onClick={() => setMessageQuery("")} aria-label="Clear message search"><X size={14} /></button>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="file-list-meta">
|
<div className="file-list-meta">
|
||||||
<span>{messageCountLabel}</span>
|
<span>{messageCountLabel}</span>
|
||||||
<span>{selectedFolder}</span>
|
<span>{selectedFolder}</span>
|
||||||
|
{messageQuery && <span>{filteredMessages.length} match{filteredMessages.length === 1 ? "" : "es"} on page</span>}
|
||||||
{shellBusy && <span>Working...</span>}
|
{shellBusy && <span>Working...</span>}
|
||||||
{loadingMessage && <span>Loading preview...</span>}
|
{loadingMessage && <span>Loading preview...</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -305,8 +400,8 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
|
|
||||||
<div className="file-list-drop-target mailbox-message-scroll">
|
<div className="file-list-drop-target mailbox-message-scroll">
|
||||||
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
|
<div className="file-list-table mailbox-message-table" role="table" aria-label="Mailbox messages">
|
||||||
{messages.length === 0 && <div className="file-list-empty">{messageEmptyText}</div>}
|
{filteredMessages.length === 0 && <div className={`file-list-empty${messageError ? " mailbox-error-state" : ""}`}>{messageEmptyText}</div>}
|
||||||
{messages.map((message) => {
|
{filteredMessages.map((message) => {
|
||||||
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
const key = mailboxMessageKey(message.folder || selectedFolder, message.uid);
|
||||||
const selected = key === selectedMessageKey;
|
const selected = key === selectedMessageKey;
|
||||||
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
const loadingSelected = loadingMessage && key === pendingMessageKey;
|
||||||
@@ -343,6 +438,14 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MailboxPagination
|
||||||
|
page={messagePage}
|
||||||
|
pageSize={messagePageSize}
|
||||||
|
totalRows={messageTotalCount ?? 0}
|
||||||
|
disabled={loadingMessages || !foldersReady}
|
||||||
|
onPageChange={changeMessagePage}
|
||||||
|
onPageSizeChange={changeMessagePageSize}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mailbox-preview-panel" aria-label="Message preview">
|
<section className="mailbox-preview-panel" aria-label="Message preview">
|
||||||
@@ -354,6 +457,7 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
{ label: "From", value: selectedMessage.from_header || "-" },
|
{ label: "From", value: selectedMessage.from_header || "-" },
|
||||||
{ label: "To", value: selectedMessage.to_header || "-" },
|
{ label: "To", value: selectedMessage.to_header || "-" },
|
||||||
{ label: "Cc", value: selectedMessage.cc_header || null },
|
{ label: "Cc", value: selectedMessage.cc_header || null },
|
||||||
|
{ label: "Bcc", value: selectedMessage.bcc_header || null },
|
||||||
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
|
{ label: "Date", value: formatDateTime(selectedMessage.date, { fallback: "-" }) }
|
||||||
] : []}
|
] : []}
|
||||||
bodyText={selectedMessage?.body_text}
|
bodyText={selectedMessage?.body_text}
|
||||||
@@ -380,81 +484,49 @@ export default function MailboxPage({ settings }: { settings: ApiSettings }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMailboxFolderTree(folders: MailImapFolderResponse[]): MailFolderNode[] {
|
|
||||||
const roots: MailFolderNode[] = [];
|
|
||||||
const byId = new Map<string, MailFolderNode>();
|
|
||||||
|
|
||||||
function ensureNode(parts: string[], index: number): MailFolderNode | null {
|
function MailboxPagination({ page, pageSize, totalRows, disabled, onPageChange, onPageSizeChange }: { page: number; pageSize: number; totalRows: number; disabled: boolean; onPageChange: (page: number) => void; onPageSizeChange: (pageSize: number) => void }) {
|
||||||
if (index < 0 || index >= parts.length) return null;
|
const pageCount = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||||
const id = parts.slice(0, index + 1).join("/");
|
const first = totalRows === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
const existing = byId.get(id);
|
const last = Math.min(totalRows, page * pageSize);
|
||||||
if (existing) return existing;
|
const options = [10, 25, 50, 100];
|
||||||
|
return (
|
||||||
const node: MailFolderNode = { id, label: parts[index], folderName: null, flags: [], children: [] };
|
<div className="data-grid-pagination mailbox-pagination" aria-label="Mailbox message pagination">
|
||||||
byId.set(id, node);
|
<div className="data-grid-pagination-summary">{first}-{last} of {totalRows}</div>
|
||||||
|
<label className="data-grid-page-size">
|
||||||
if (index === 0) {
|
<span>Rows per page</span>
|
||||||
roots.push(node);
|
<select value={pageSize} disabled={disabled} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
|
||||||
} else {
|
{options.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||||
ensureNode(parts, index - 1)?.children.push(node);
|
</select>
|
||||||
}
|
</label>
|
||||||
return node;
|
<div className="data-grid-page-controls">
|
||||||
}
|
<button type="button" aria-label="First page" disabled={disabled || page <= 1} onClick={() => onPageChange(1)}><ChevronsLeft size={16} /></button>
|
||||||
|
<button type="button" aria-label="Previous page" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button>
|
||||||
for (const folder of folders) {
|
<span>Page {page} of {pageCount}</span>
|
||||||
const parts = folderParts(folder.name);
|
<button type="button" aria-label="Next page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(page + 1)}><ChevronRight size={16} /></button>
|
||||||
const node = ensureNode(parts, parts.length - 1);
|
<button type="button" aria-label="Last page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight size={16} /></button>
|
||||||
if (node) {
|
</div>
|
||||||
node.folderName = folder.name;
|
</div>
|
||||||
node.flags = folder.flags ?? [];
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sortFolderNodes(roots);
|
|
||||||
return roots;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function folderParts(folderName: string): string[] {
|
|
||||||
const normalized = folderName.trim();
|
|
||||||
if (!normalized) return [];
|
|
||||||
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
|
|
||||||
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
|
|
||||||
return [normalized];
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortFolderNodes(nodes: MailFolderNode[]) {
|
|
||||||
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
|
|
||||||
nodes.forEach((node) => sortFolderNodes(node.children));
|
|
||||||
}
|
|
||||||
|
|
||||||
function folderSortKey(label: string): string {
|
|
||||||
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.folderName === folderName) return node.id;
|
|
||||||
const child = findFolderNodeId(node.children, folderName);
|
|
||||||
if (child) return child;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
|
|
||||||
for (const node of nodes) {
|
|
||||||
const path = [...parents, node.id];
|
|
||||||
if (node.folderName === folderName) return path;
|
|
||||||
const child = folderAncestorIds(node.children, folderName, path);
|
|
||||||
if (child.length > 0) return child;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function mailboxMessageKey(folder: string, uid: string): string {
|
function mailboxMessageKey(folder: string, uid: string): string {
|
||||||
return `${folder || "INBOX"}::${uid}`;
|
return `${folder || "INBOX"}::${uid}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterMessages(messages: MailMailboxMessageSummary[], query: string): MailMailboxMessageSummary[] {
|
||||||
|
const normalized = query.trim().toLocaleLowerCase();
|
||||||
|
if (!normalized) return messages;
|
||||||
|
return messages.filter((message) => [
|
||||||
|
message.subject,
|
||||||
|
message.from_header,
|
||||||
|
message.to_header,
|
||||||
|
message.cc_header,
|
||||||
|
message.date,
|
||||||
|
message.body_preview
|
||||||
|
].some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
|
||||||
|
}
|
||||||
|
|
||||||
function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
|
function messageListCountLabel(visibleCount: number, totalCount: number | null, loading: boolean, ready: boolean): string {
|
||||||
if (!ready) return "No folder loaded";
|
if (!ready) return "No folder loaded";
|
||||||
if (loading) return "Loading messages";
|
if (loading) return "Loading messages";
|
||||||
@@ -508,3 +580,8 @@ function formatBytes(value?: number | null): string {
|
|||||||
function errorText(error: unknown): string {
|
function errorText(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
|
||||||
|
}
|
||||||
|
|||||||
120
webui/src/features/mail/mailPolicyValidation.ts
Normal file
120
webui/src/features/mail/mailPolicyValidation.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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: "SMTP host",
|
||||||
|
imap_hosts: "IMAP host",
|
||||||
|
envelope_senders: "Envelope sender",
|
||||||
|
from_headers: "From header",
|
||||||
|
recipient_domains: "Recipient domain"
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (normalizedPattern === "*") return true;
|
||||||
|
const escaped = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const regex = `^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`;
|
||||||
|
return new RegExp(regex, "i").test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/^@+/, "");
|
||||||
|
}
|
||||||
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()}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createElement, lazy } from "react";
|
import { createElement, lazy } from "react";
|
||||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
|
||||||
|
import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
|
||||||
|
|
||||||
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
const MailboxPage = lazy(() => import("./features/mail/MailboxPage"));
|
||||||
const mailboxRead = ["mail:mailbox:read"];
|
const mailboxRead = ["mail:mailbox:read"];
|
||||||
@@ -12,7 +14,13 @@ export const mailModule: PlatformWebModule = {
|
|||||||
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
navItems: [{ to: "/mail", label: "Mail", iconName: "mail", anyOf: mailboxRead, order: 50 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
|
{ path: "/mail", anyOf: mailboxRead, order: 50, render: ({ settings }) => createElement(MailboxPage, { settings }) }
|
||||||
]
|
],
|
||||||
|
uiCapabilities: {
|
||||||
|
"mail.profiles": { MailProfileScopeManager, MailProfilePolicyEditor, validateMailPolicy } satisfies MailProfilesUiCapability
|
||||||
|
},
|
||||||
|
runtimeUiCapabilities: {
|
||||||
|
"mail.devMailbox": { enabled: true, label: "Development mock mailbox" } satisfies MailDevMailboxUiCapability
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mailModule;
|
export default mailModule;
|
||||||
|
|||||||
@@ -123,6 +123,42 @@
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mail-policy-row {
|
||||||
|
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-table.with-effective-column .mail-policy-row {
|
||||||
|
grid-template-columns: minmax(190px, .9fr) minmax(210px, .75fr) minmax(220px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-table.with-allow-column .mail-policy-row {
|
||||||
|
grid-template-columns: minmax(170px, .8fr) minmax(200px, .75fr) minmax(150px, .55fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-table.with-effective-column.with-allow-column .mail-policy-row {
|
||||||
|
grid-template-columns: minmax(170px, .8fr) minmax(200px, .75fr) minmax(210px, .95fr) minmax(150px, .55fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-pattern-row {
|
||||||
|
grid-template-columns: minmax(190px, .8fr) minmax(220px, 1fr) minmax(220px, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-pattern-table.with-allow-column .mail-policy-pattern-row {
|
||||||
|
grid-template-columns: minmax(170px, .7fr) minmax(200px, 1fr) minmax(200px, 1fr) minmax(165px, .6fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-pattern-limits {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-policy-lower-cell .toggle-switch-row,
|
||||||
|
.mail-policy-pattern-limits .toggle-switch-row {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mail-policy-pattern-grid {
|
.mail-policy-pattern-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -179,10 +215,19 @@
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.admin-form-grid.three-columns,
|
.admin-form-grid.three-columns,
|
||||||
.mail-policy-pattern-grid,
|
.mail-policy-pattern-grid,
|
||||||
.mail-policy-effective-grid {
|
.mail-policy-effective-grid,
|
||||||
|
.mail-policy-row,
|
||||||
|
.mail-policy-table.with-effective-column .mail-policy-row,
|
||||||
|
.mail-policy-table.with-allow-column .mail-policy-row,
|
||||||
|
.mail-policy-table.with-effective-column.with-allow-column .mail-policy-row,
|
||||||
|
.mail-policy-pattern-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mail-policy-row-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.mail-credential-inheritance-row {
|
.mail-credential-inheritance-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -539,6 +584,62 @@
|
|||||||
min-width: 660px;
|
min-width: 660px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mailbox-filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-search-field {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(430px, 100%);
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--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, #8f2e2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mailbox-pagination {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mailbox-message-head,
|
.mailbox-message-head,
|
||||||
.mailbox-message-row {
|
.mailbox-message-row {
|
||||||
grid-template-columns: minmax(260px, 1fr) 190px 130px;
|
grid-template-columns: minmax(260px, 1fr) 190px 130px;
|
||||||
|
|||||||
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");
|
||||||
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");
|
||||||
25
webui/tsconfig.mail-tests.json
Normal file
25
webui/tsconfig.mail-tests.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"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-policy-validation.test.ts",
|
||||||
|
"src/features/mail/mailboxFolders.ts",
|
||||||
|
"src/features/mail/mailPolicyValidation.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user