6 Commits

Author SHA1 Message Date
b4b0c76455 Release v0.1.6 2026-07-07 15:55:37 +02:00
5f800a24db Release v0.1.5 2026-07-07 15:49:06 +02:00
3743b23613 Release v0.1.4 2026-07-02 14:59:52 +02:00
e6e243c14d Release v0.1.3 2026-06-26 01:39:19 +02:00
3d82d7b32d Release v0.1.2 2026-06-25 19:58:20 +02:00
3cd8c45c42 Fix WebUI release peer metadata 2026-06-24 20:17:42 +02:00
50 changed files with 2423 additions and 620 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,44 @@
# GovOPlaN Mail Codex Guide
## Scope
This repository owns the `mail` module: SMTP/IMAP profiles, mail profile policy, encrypted mail credentials, SMTP sending, IMAP append and mailbox access, mock mail infrastructure, backend module manifest, and `@govoplan/mail-webui`.
Core owns auth, tenants, RBAC, database/session primitives, CSRF/API helpers, shared components, and shell layout. Campaign/files integrations must remain optional and go through core module metadata or capabilities.
## Local Commands
Install and run through core:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -r requirements-dev.txt
```
Focused backend tests:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
```
Focused WebUI tests:
```bash
cd /mnt/DATA/git/govoplan-mail/webui
PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:mail-ui
```
For combined checks, run:
```bash
cd /mnt/DATA/git/govoplan-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.

View File

@@ -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/`.

View File

@@ -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
}
} }
} }

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-mail" name = "govoplan-mail"
version = "0.1.0" version = "0.1.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",

View File

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

View File

@@ -1,35 +1,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)

View File

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

View File

@@ -5,7 +5,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope from govoplan_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

View File

@@ -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

View File

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

View File

@@ -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,14 +284,13 @@ 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
@@ -310,10 +320,12 @@ 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,
scope_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
@@ -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,

View File

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

View File

@@ -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
View 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()

View File

@@ -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"
} }
} }

View File

@@ -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()}`);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Home, Mail, Paperclip, RefreshCw } from "lucide-react"; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { import {
Button, Button,
DismissibleAlert, DismissibleAlert,
@@ -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);
setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
}
} catch (err) { } catch (err) {
if (requestId !== messageListRequestRef.current) return; if (requestId !== messageListRequestRef.current) return;
setError(errorText(err)); const message = errorText(err);
setMessageError(message);
setError(message);
setMessages([]); setMessages([]);
setMessageTotalCount(null); setMessageTotalCount(null);
setSelectedMessage(null); setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey(""); setPendingMessageKey("");
} finally { } finally {
if (requestId === messageListRequestRef.current) setLoadingMessages(false); if (requestId === messageListRequestRef.current) setLoadingMessages(false);
@@ -168,18 +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>
<div className="data-grid-page-controls">
<button type="button" aria-label="First page" disabled={disabled || page <= 1} onClick={() => onPageChange(1)}><ChevronsLeft size={16} /></button>
<button type="button" aria-label="Previous page" disabled={disabled || page <= 1} onClick={() => onPageChange(page - 1)}><ChevronLeft size={16} /></button>
<span>Page {page} of {pageCount}</span>
<button type="button" aria-label="Next page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(page + 1)}><ChevronRight size={16} /></button>
<button type="button" aria-label="Last page" disabled={disabled || page >= pageCount} onClick={() => onPageChange(pageCount)}><ChevronsRight size={16} /></button>
</div>
</div>
);
} }
return node;
}
for (const folder of folders) {
const parts = folderParts(folder.name);
const node = ensureNode(parts, parts.length - 1);
if (node) {
node.folderName = folder.name;
node.flags = folder.flags ?? [];
}
}
sortFolderNodes(roots);
return roots;
}
function folderParts(folderName: string): string[] {
const normalized = folderName.trim();
if (!normalized) return [];
if (normalized.includes("/")) return normalized.split("/").filter(Boolean);
if (/^INBOX[.]/i.test(normalized)) return ["INBOX", ...normalized.slice(6).split(".").filter(Boolean)];
return [normalized];
}
function sortFolderNodes(nodes: MailFolderNode[]) {
nodes.sort((left, right) => folderSortKey(left.label).localeCompare(folderSortKey(right.label)));
nodes.forEach((node) => sortFolderNodes(node.children));
}
function folderSortKey(label: string): string {
return label.toUpperCase() === "INBOX" ? "0" : `1:${label.toLocaleLowerCase()}`;
}
function findFolderNodeId(nodes: MailFolderNode[], folderName: string): string | null {
for (const node of nodes) {
if (node.folderName === folderName) return node.id;
const child = findFolderNodeId(node.children, folderName);
if (child) return child;
}
return null;
}
function folderAncestorIds(nodes: MailFolderNode[], folderName: string, parents: string[] = []): string[] {
for (const node of nodes) {
const path = [...parents, node.id];
if (node.folderName === folderName) return path;
const child = folderAncestorIds(node.children, folderName, path);
if (child.length > 0) return child;
}
return [];
}
function mailboxMessageKey(folder: string, uid: string): string { function mailboxMessageKey(folder: string, uid: string): string {
return `${folder || "INBOX"}::${uid}`; return `${folder || "INBOX"}::${uid}`;
} }
function 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);
}

View 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(/^@+/, "");
}

View File

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,46 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
}
import { mailPolicyValueAllowed, validateMailPolicy, wildcardPatternMatches } from "../src/features/mail/mailPolicyValidation";
assertEqual(wildcardPatternMatches("*.example.org", "smtp.example.org"), true);
assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp1.example.org"), true);
assertEqual(wildcardPatternMatches("smtp?.example.org", "smtp12.example.org"), false);
const policy = {
whitelist: {
smtp_hosts: ["smtp.allowed.test"],
from_headers: ["*@allowed.test"],
recipient_domains: ["allowed.test"]
},
blacklist: {
smtp_hosts: ["smtp.blocked.test"],
envelope_senders: ["blocked@*"]
}
};
assertDeepEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.allowed.test"), { allowed: true, value: "smtp.allowed.test" });
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.blocked.test").allowed, false, "blacklist wins for SMTP host");
assertEqual(mailPolicyValueAllowed(policy, "smtp_hosts", "smtp.other.test").allowed, false, "whitelist blocks unknown SMTP host");
const messages = validateMailPolicy(policy, {
smtpHost: "smtp.other.test",
envelopeSender: "blocked@allowed.test",
fromHeader: "sender@other.test",
recipientDomains: ["allowed.test", "denied.test", "user@denied.test"]
});
assert(messages.some((item) => item.key === "smtp_hosts" && item.value === "smtp.other.test"));
assert(messages.some((item) => item.key === "envelope_senders" && item.value === "blocked@allowed.test"));
assert(messages.some((item) => item.key === "from_headers" && item.value === "sender@other.test"));
assertEqual(messages.filter((item) => item.key === "recipient_domains" && item.value === "denied.test").length, 1, "recipient domains are normalized and de-duplicated");

View File

@@ -0,0 +1,30 @@
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function assertEqual<T>(actual: T, expected: T, message = "values should be equal"): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
function assertDeepEqual(actual: unknown, expected: unknown, message = "values should be deeply equal"): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) throw new Error(`${message}: expected ${expectedJson}, got ${actualJson}`);
}
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, folderParts } from "../src/features/mail/mailboxFolders";
assertDeepEqual(folderParts("INBOX.Sent.Archive"), ["INBOX", "Sent", "Archive"]);
assertDeepEqual(folderParts("Projects/2026/Inbox"), ["Projects", "2026", "Inbox"]);
const tree = buildMailboxFolderTree([
{ name: "Archive/2026", flags: [] },
{ name: "INBOX", flags: [] },
{ name: "INBOX.Sent", flags: ["\\Sent"] },
{ name: "Projects/2026/Inbox", flags: [] }
]);
assertEqual(tree[0].id, "INBOX", "INBOX sorts first");
assertEqual(findFolderNodeId(tree, "INBOX.Sent"), "INBOX/Sent");
assertDeepEqual(folderAncestorIds(tree, "Projects/2026/Inbox"), ["Projects", "Projects/2026", "Projects/2026/Inbox"]);
assertEqual(tree.find((node) => node.id === "INBOX")?.children[0]?.folderName, "INBOX.Sent");

View File

@@ -0,0 +1,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"
]
}