M6 hosted mailboxes (off by default); dark message mode; fixes
Hosted mailboxes (SPEC App. D), behind HOSTED_MAILBOXES (off: unchanged):
- Stalwart mail server, provisioned by `python -m app.hosted.provision`;
directory read from a Postgres view, app passwords only for mail apps
- native account per user, the worker signs in as master for the user
- raw messages of hosted mailboxes only cached, fetched back on demand
- migrations 0025 (accounts.kind, directory view), 0026 (IMAP ids bigint)
Mail:
- dark message mode switch in the reading pane (Thunderbird's algorithm)
- moves and folder creation quote the folder name ("Deleted Items"):
swipe-to-delete / Trash no longer bounce back
- thread unread counters: lock before recount, sorted lock order,
UIDVALIDITY re-import recounts, nightly repair_threads
- sidebar accounts sorted alphabetically, more space above headings
Settings: Security headings renamed; antispam accounts are explicit
(none ticked = none filtered), input widths, Activity hidden.
Plugin bundles served with Cache-Control: no-cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -102,3 +102,4 @@ class AccountDetailOut(BaseModel):
|
|||||||
backfill_done: int
|
backfill_done: int
|
||||||
imap_config: dict[str, object]
|
imap_config: dict[str, object]
|
||||||
smtp_config: dict[str, object]
|
smtp_config: dict[str, object]
|
||||||
|
kind: str = "external"
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
|||||||
sync_error=a.sync_error,
|
sync_error=a.sync_error,
|
||||||
sync_error_since=a.sync_error_since.isoformat() if a.sync_error_since else None,
|
sync_error_since=a.sync_error_since.isoformat() if a.sync_error_since else None,
|
||||||
state_detail=a.state_detail,
|
state_detail=a.state_detail,
|
||||||
|
kind=a.kind,
|
||||||
)
|
)
|
||||||
for a in accounts
|
for a in accounts
|
||||||
]
|
]
|
||||||
@@ -225,6 +226,8 @@ async def update_account(
|
|||||||
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
|
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
|
||||||
# stored secret is only decrypted in the worker, never here).
|
# stored secret is only decrypted in the worker, never here).
|
||||||
conn_change = body.imap is not None or body.smtp is not None or body.password is not None
|
conn_change = body.imap is not None or body.smtp is not None or body.password is not None
|
||||||
|
if conn_change and account.kind == "native":
|
||||||
|
raise_api_error(400, "native_account", "the settings of your own mailbox cannot be changed")
|
||||||
if conn_change:
|
if conn_change:
|
||||||
if not body.password:
|
if not body.password:
|
||||||
raise_api_error(
|
raise_api_error(
|
||||||
@@ -267,10 +270,20 @@ async def update_account(
|
|||||||
|
|
||||||
@router.delete("/{account_id}", dependencies=[Depends(require_csrf)])
|
@router.delete("/{account_id}", dependencies=[Depends(require_csrf)])
|
||||||
async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
async def remove_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||||
|
kind = await db.scalar(
|
||||||
|
select(Account.kind).where(Account.id == account_id, Account.user_id == user.id)
|
||||||
|
)
|
||||||
|
if kind == "native":
|
||||||
|
raise_api_error(409, "native_account", "your own mailbox cannot be removed")
|
||||||
ok = await delete_account(db, user_id=user.id, account_id=account_id)
|
ok = await delete_account(db, user_id=user.id, account_id=account_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise_api_error(404, "not_found", "account not found")
|
raise_api_error(404, "not_found", "account not found")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
# Removing an account for the user's own address (the mail at its old
|
||||||
|
# provider) makes room for their hosted mailbox's (App. D §D.4).
|
||||||
|
from app.hosted import native
|
||||||
|
|
||||||
|
await native.ensure_and_sync(db, user)
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@@ -288,4 +301,5 @@ def _detail(account: Account) -> AccountDetailOut:
|
|||||||
backfill_done=account.backfill_done,
|
backfill_done=account.backfill_done,
|
||||||
imap_config=account.imap_config,
|
imap_config=account.imap_config,
|
||||||
smtp_config=account.smtp_config,
|
smtp_config=account.smtp_config,
|
||||||
|
kind=account.kind,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.errors import raise_api_error
|
from app.api.errors import raise_api_error
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
|
from app.hosted.admin import MailServerError
|
||||||
from app.identity.deps import CurrentAdmin, require_csrf
|
from app.identity.deps import CurrentAdmin, require_csrf
|
||||||
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
|
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
|
||||||
from app.identity.schemas import (
|
from app.identity.schemas import (
|
||||||
@@ -183,7 +184,12 @@ async def patch_user(
|
|||||||
if body.quota_bytes is not None:
|
if body.quota_bytes is not None:
|
||||||
user.quota_bytes = body.quota_bytes
|
user.quota_bytes = body.quota_bytes
|
||||||
if body.blocked is not None:
|
if body.blocked is not None:
|
||||||
|
try:
|
||||||
await set_blocked(db, user, body.blocked)
|
await set_blocked(db, user, body.blocked)
|
||||||
|
except MailServerError as exc:
|
||||||
|
raise_api_error(
|
||||||
|
502, "mail_server_unavailable", f"the mail server did not answer: {exc}"
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
used = await db.scalar(
|
used = await db.scalar(
|
||||||
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
|
select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id)
|
||||||
@@ -200,5 +206,8 @@ async def patch_user(
|
|||||||
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
|
async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]:
|
||||||
"""Delete a user and all of their data. Cannot be undone."""
|
"""Delete a user and all of their data. Cannot be undone."""
|
||||||
user = await _target(db, admin, user_id)
|
user = await _target(db, admin, user_id)
|
||||||
|
try:
|
||||||
await delete_user(db, user)
|
await delete_user(db, user)
|
||||||
|
except MailServerError as exc:
|
||||||
|
raise_api_error(502, "mail_server_unavailable", f"the mail server did not answer: {exc}")
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
@@ -17,7 +18,7 @@ from app.config import get_settings
|
|||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.identity.deps import CurrentUser, require_csrf
|
from app.identity.deps import CurrentUser, require_csrf
|
||||||
from app.indexer import attachments as att_store
|
from app.indexer import attachments as att_store
|
||||||
from app.indexer import blobstore
|
from app.indexer import blobstore, rawstore
|
||||||
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message, Outbox
|
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message, Outbox
|
||||||
from app.outbox.jobs import RETRY_WINDOW
|
from app.outbox.jobs import RETRY_WINDOW
|
||||||
from app.outbox.schemas import (
|
from app.outbox.schemas import (
|
||||||
@@ -208,6 +209,9 @@ async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSes
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
attachment_ids: list[str] = []
|
attachment_ids: list[str] = []
|
||||||
|
if att_rows:
|
||||||
|
with contextlib.suppress(FileNotFoundError): # fetched if a hosted mailbox's
|
||||||
|
await rawstore.ensure(msg)
|
||||||
for a in att_rows:
|
for a in att_rows:
|
||||||
# Kept only inside the message: give the compose attachment a blob of its own.
|
# Kept only inside the message: give the compose attachment a blob of its own.
|
||||||
if not await asyncio.to_thread(
|
if not await asyncio.to_thread(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import html as html_lib
|
import html as html_lib
|
||||||
import io
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
@@ -21,7 +22,7 @@ from app.api.settings import add_allowed_image_sender, allowed_image_senders
|
|||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.identity.deps import CurrentUser, require_csrf
|
from app.identity.deps import CurrentUser, require_csrf
|
||||||
from app.indexer import attachments as att_store
|
from app.indexer import attachments as att_store
|
||||||
from app.indexer import blobstore, thumbs
|
from app.indexer import rawstore, thumbs
|
||||||
from app.indexer.mime import parse_message
|
from app.indexer.mime import parse_message
|
||||||
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
|
from app.indexer.sanitize import SANITIZER_VERSION, sanitize_html
|
||||||
from app.indexer.thumbs import thumb_type
|
from app.indexer.thumbs import thumb_type
|
||||||
@@ -128,7 +129,7 @@ async def message_html(
|
|||||||
# If the raw blob is unavailable, fall back to whatever is already stored.
|
# If the raw blob is unavailable, fall back to whatever is already stored.
|
||||||
if allow_remote or stale:
|
if allow_remote or stale:
|
||||||
try:
|
try:
|
||||||
raw = blobstore.load(user.id, message.blob_sha256)
|
raw = await rawstore.ensure_bytes(message)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raw = None
|
raw = None
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
@@ -191,8 +192,16 @@ def _content_disposition(disposition: str, filename: str) -> str:
|
|||||||
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
|
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _raw_ready(msg: Message) -> None:
|
||||||
|
"""Have the message's raw source here (a hosted mailbox's is fetched from
|
||||||
|
the mail server when not cached); a miss is left to the reader to report."""
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
await rawstore.ensure(msg)
|
||||||
|
|
||||||
|
|
||||||
async def _attachment_bytes(msg: Message, att: Attachment) -> bytes:
|
async def _attachment_bytes(msg: Message, att: Attachment) -> bytes:
|
||||||
"""An attachment's bytes, taken out of its message (404 if they cannot be)."""
|
"""An attachment's bytes, taken out of its message (404 if they cannot be)."""
|
||||||
|
await _raw_ready(msg)
|
||||||
try:
|
try:
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(
|
||||||
att_store.load, msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index
|
att_store.load, msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index
|
||||||
@@ -236,6 +245,7 @@ async def forward_attachments(
|
|||||||
of its own (shared, by hash, with any other use of the same file).
|
of its own (shared, by hash, with any other use of the same file).
|
||||||
Returns them as the composer lists its attachments."""
|
Returns them as the composer lists its attachments."""
|
||||||
msg = await _owned_message(db, user.id, message_id)
|
msg = await _owned_message(db, user.id, message_id)
|
||||||
|
await _raw_ready(msg)
|
||||||
rows = [
|
rows = [
|
||||||
ComposeAttachment(
|
ComposeAttachment(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -315,6 +325,8 @@ async def attachment_thumbnail(
|
|||||||
kind = thumb_type(att.mime_type, att.filename) if att is not None else None
|
kind = thumb_type(att.mime_type, att.filename) if att is not None else None
|
||||||
if att is None or kind is None:
|
if att is None or kind is None:
|
||||||
raise_api_error(404, "not_found", "no thumbnail")
|
raise_api_error(404, "not_found", "no thumbnail")
|
||||||
|
if not thumbs.made(msg.user_id, att.blob_sha256):
|
||||||
|
await _raw_ready(msg)
|
||||||
data = await asyncio.to_thread(
|
data = await asyncio.to_thread(
|
||||||
thumbs.thumbnail,
|
thumbs.thumbnail,
|
||||||
msg.user_id,
|
msg.user_id,
|
||||||
@@ -350,6 +362,8 @@ async def message_attachment(
|
|||||||
)
|
)
|
||||||
if att is None:
|
if att is None:
|
||||||
raise_api_error(404, "not_found", "attachment not found")
|
raise_api_error(404, "not_found", "attachment not found")
|
||||||
|
if not att_store.has_file(msg.user_id, att.blob_sha256):
|
||||||
|
await _raw_ready(msg)
|
||||||
try:
|
try:
|
||||||
# Taken out of the message into a short-lived file (kept a day after
|
# Taken out of the message into a short-lived file (kept a day after
|
||||||
# its last use), so it can be streamed with HTTP Range like before.
|
# its last use), so it can be streamed with HTTP Range like before.
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ class AccountOut(BaseModel):
|
|||||||
sync_error_since: str | None = None
|
sync_error_since: str | None = None
|
||||||
# Why the state is what it is (e.g. the server's reply to a refused sign-in).
|
# Why the state is what it is (e.g. the server's reply to a refused sign-in).
|
||||||
state_detail: str = ""
|
state_detail: str = ""
|
||||||
|
# "native": the user's own hosted mailbox — cannot be removed (App. D §D.4).
|
||||||
|
kind: str = "external"
|
||||||
|
|
||||||
|
|
||||||
class SearchThreadHit(BaseModel):
|
class SearchThreadHit(BaseModel):
|
||||||
|
|||||||
@@ -74,6 +74,33 @@ class Settings(BaseSettings):
|
|||||||
# Auto-purge Trash/Spam messages older than this many days (0 disables).
|
# Auto-purge Trash/Spam messages older than this many days (0 disables).
|
||||||
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
|
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
|
||||||
|
|
||||||
|
# --- Hosted mailboxes (SPEC Appendix D, app/hosted/) -----------------------
|
||||||
|
# Off: no mail server, byte-identical to before (every account external).
|
||||||
|
hosted_mailboxes: bool = Field(default=False, alias="HOSTED_MAILBOXES")
|
||||||
|
# The mail server's management API (JMAP), signed in to as its recovery admin.
|
||||||
|
stalwart_url: str = Field(default="http://stalwart:8080", alias="STALWART_URL")
|
||||||
|
stalwart_admin_secret: str = Field(default="", alias="STALWART_ADMIN_SECRET")
|
||||||
|
# The webmail worker's own sign-in, which may act for any mailbox
|
||||||
|
# (``user%worker``): no user's password is ever stored (§D.3).
|
||||||
|
stalwart_master_secret: str = Field(default="", alias="STALWART_MASTER_SECRET")
|
||||||
|
# The mail server's role on Postgres (its database, and the directory view).
|
||||||
|
stalwart_db_password: str = Field(default="", alias="STALWART_DB_PASSWORD")
|
||||||
|
stalwart_db_name: str = Field(default="stalwart", alias="STALWART_DB_NAME")
|
||||||
|
# Where the mail server reaches Postgres (its own network view of it).
|
||||||
|
stalwart_pg_host: str = Field(default="postgres", alias="STALWART_PG_HOST")
|
||||||
|
# IMAP/SMTP as the webmail reaches them (TLS, certificate not checked: the
|
||||||
|
# traffic stays on the Docker network or loopback).
|
||||||
|
stalwart_imap_host: str = Field(default="stalwart", alias="STALWART_IMAP_HOST")
|
||||||
|
stalwart_imap_port: int = Field(default=993, alias="STALWART_IMAP_PORT")
|
||||||
|
stalwart_smtp_host: str = Field(default="stalwart", alias="STALWART_SMTP_HOST")
|
||||||
|
stalwart_smtp_port: int = Field(default=465, alias="STALWART_SMTP_PORT")
|
||||||
|
# The mail server's public name (SMTP greeting, reverse DNS, what mail apps
|
||||||
|
# are told to connect to). Default: mail.APP_DOMAIN.
|
||||||
|
mail_hostname: str = Field(default="", alias="MAIL_HOSTNAME")
|
||||||
|
# Raw messages of hosted mailboxes are kept only in a cache (§D.6 item 2);
|
||||||
|
# beyond this per user, the least recently used go first.
|
||||||
|
raw_cache_mb: int = Field(default=256, alias="RAW_CACHE_MB")
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Hosted mailboxes (SPEC Appendix D): every user's own mailbox on the Stalwart
|
||||||
|
mail server beside the app.
|
||||||
|
|
||||||
|
* :mod:`app.hosted.admin` — the mail server's management API (JMAP);
|
||||||
|
* :mod:`app.hosted.provision` — idempotent setup: its database and role, the
|
||||||
|
directory over the ``mail_directory`` view, the worker's sign-in;
|
||||||
|
* :mod:`app.hosted.native` — the permanent account in the webmail, and how the
|
||||||
|
worker signs in to it without any stored password;
|
||||||
|
* :mod:`app.hosted.rawcache` — raw messages kept once, on the mail server: the
|
||||||
|
webmail keeps only a bounded cache of them (§D.6 item 2).
|
||||||
|
|
||||||
|
All of it is inert while ``HOSTED_MAILBOXES`` is off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
# The webmail worker's sign-in on the mail server: ``user@domain%WORKER`` acts
|
||||||
|
# for that user (Stalwart's master-user login, given the impersonate role only).
|
||||||
|
WORKER_LOCALPART = "webmail-worker"
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return get_settings().hosted_mailboxes
|
||||||
|
|
||||||
|
|
||||||
|
def worker_address() -> str:
|
||||||
|
return f"{WORKER_LOCALPART}@{get_settings().app_domain}"
|
||||||
|
|
||||||
|
|
||||||
|
def mail_hostname() -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
return settings.mail_hostname or f"mail.{settings.app_domain}"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""The mail server's management API (Stalwart 0.16, JMAP ``x:`` methods).
|
||||||
|
|
||||||
|
Signed in to as the recovery admin (``STALWART_RECOVERY_ADMIN`` in the mail
|
||||||
|
server's environment, ``STALWART_ADMIN_SECRET`` here). Objects are Stalwart's
|
||||||
|
registry: ``x:Account``, ``x:Domain``, ``x:Directory``, ``x:Role``,
|
||||||
|
``x:AppPassword``… with the usual JMAP ``/get``, ``/query``, ``/set``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.hosted import worker_address
|
||||||
|
|
||||||
|
ADMIN_USER = "admin"
|
||||||
|
_USING = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
|
||||||
|
_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerError(Exception):
|
||||||
|
"""The mail server refused a call, or could not be reached."""
|
||||||
|
|
||||||
|
|
||||||
|
def _basic(user: str, secret: str) -> str:
|
||||||
|
return "Basic " + base64.b64encode(f"{user}:{secret}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_auth() -> str:
|
||||||
|
return _basic(ADMIN_USER, get_settings().stalwart_admin_secret)
|
||||||
|
|
||||||
|
|
||||||
|
async def call(
|
||||||
|
*method_calls: tuple[str, dict[str, Any]], auth: str | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Run JMAP method calls in one request; return each call's arguments.
|
||||||
|
|
||||||
|
Raises :class:`MailServerError` on a method error or a refused ``set``."""
|
||||||
|
body = {
|
||||||
|
"using": _USING,
|
||||||
|
"methodCalls": [[name, args, str(i)] for i, (name, args) in enumerate(method_calls)],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{get_settings().stalwart_url}/jmap",
|
||||||
|
json=body,
|
||||||
|
headers={"Authorization": auth or _admin_auth()},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise MailServerError(f"mail server unreachable: {exc}") from exc
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise MailServerError(f"mail server answered {resp.status_code}: {resp.text[:300]}")
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for name, args, _ in resp.json()["methodResponses"]:
|
||||||
|
if name == "error":
|
||||||
|
raise MailServerError(f"{args.get('type')}: {args.get('description', '')}")
|
||||||
|
for kind in ("notCreated", "notUpdated", "notDestroyed"):
|
||||||
|
if args.get(kind):
|
||||||
|
raise MailServerError(f"{name} {kind}: {args[kind]}")
|
||||||
|
out.append(args)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all(obj: str, **args: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Every object of a type (registry objects are few)."""
|
||||||
|
(res,) = await call((f"x:{obj}/get", {"ids": None, **args}))
|
||||||
|
return list(res["list"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_one(obj: str, object_id: str = "singleton") -> dict[str, Any]:
|
||||||
|
(res,) = await call((f"x:{obj}/get", {"ids": [object_id]}))
|
||||||
|
if not res["list"]:
|
||||||
|
raise MailServerError(f"{obj} {object_id} not found")
|
||||||
|
return dict(res["list"][0])
|
||||||
|
|
||||||
|
|
||||||
|
async def create(obj: str, value: dict[str, Any], **args: Any) -> str:
|
||||||
|
(res,) = await call((f"x:{obj}/set", {"create": {"n": value}, **args}))
|
||||||
|
return str(res["created"]["n"]["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def update(obj: str, object_id: str, patch: dict[str, Any], **args: Any) -> None:
|
||||||
|
await call((f"x:{obj}/set", {"update": {object_id: patch}, **args}))
|
||||||
|
|
||||||
|
|
||||||
|
async def destroy(obj: str, ids: list[str], **args: Any) -> None:
|
||||||
|
if ids:
|
||||||
|
await call((f"x:{obj}/set", {"destroy": ids, **args}))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- accounts ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def account_id(email: str) -> str | None:
|
||||||
|
"""The mail server's id of a mailbox, if it knows it yet.
|
||||||
|
|
||||||
|
Accounts from the directory reach the server's registry the first time
|
||||||
|
they are used (a sign-in, a delivery): see :func:`ensure_account`."""
|
||||||
|
local = email.split("@", 1)[0].lower()
|
||||||
|
(res,) = await call(("x:Account/query", {"filter": {"name": local}}))
|
||||||
|
ids = list(res.get("ids") or [])
|
||||||
|
if not ids:
|
||||||
|
return None
|
||||||
|
for acc in (await call(("x:Account/get", {"ids": ids, "properties": ["emailAddress"]})))[0][
|
||||||
|
"list"
|
||||||
|
]:
|
||||||
|
if str(acc.get("emailAddress", "")).lower() == email.lower():
|
||||||
|
return str(acc["id"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_account(email: str) -> str:
|
||||||
|
"""The mailbox's id, bringing it into the server's registry if new: a
|
||||||
|
sign-in as the worker acting for it does that (§D.3)."""
|
||||||
|
found = await account_id(email)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
auth = _basic(f"{email}%{worker_address()}", get_settings().stalwart_master_secret)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{get_settings().stalwart_url}/jmap/session", headers={"Authorization": auth}
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise MailServerError(f"mail server unreachable: {exc}") from exc
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise MailServerError(f"cannot open the mailbox {email}: {resp.status_code}")
|
||||||
|
found = await account_id(email)
|
||||||
|
if found is None:
|
||||||
|
raise MailServerError(f"mailbox {email} not found after sign-in")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
async def set_blocked(email: str, blocked: bool) -> None:
|
||||||
|
"""A blocked user's mailbox cannot be signed in to at all — app passwords
|
||||||
|
and the worker included. Mail to it is still accepted."""
|
||||||
|
acc = await account_id(email)
|
||||||
|
if acc is None:
|
||||||
|
return # never used: the directory alone keeps it shut
|
||||||
|
permissions: dict[str, Any] = (
|
||||||
|
{"@type": "Merge", "enabledPermissions": {}, "disabledPermissions": {"authenticate": True}}
|
||||||
|
if blocked
|
||||||
|
else {"@type": "Inherit"}
|
||||||
|
)
|
||||||
|
await update("Account", acc, {"permissions": permissions})
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_account(email: str) -> None:
|
||||||
|
"""Remove the mailbox and all its mail from the mail server."""
|
||||||
|
acc = await account_id(email)
|
||||||
|
if acc is not None:
|
||||||
|
await destroy("Account", [acc])
|
||||||
|
|
||||||
|
|
||||||
|
# ---- app passwords (§D.5) -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def create_app_password(email: str, description: str) -> tuple[str, str]:
|
||||||
|
"""A new app password for the mailbox: (server id, the password itself)."""
|
||||||
|
acc = await ensure_account(email)
|
||||||
|
(res,) = await call(
|
||||||
|
("x:AppPassword/set", {"accountId": acc, "create": {"n": {"description": description}}})
|
||||||
|
)
|
||||||
|
created = res["created"]["n"]
|
||||||
|
return str(created["id"]), str(created["secret"])
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_app_passwords(email: str, server_ids: list[str]) -> None:
|
||||||
|
acc = await account_id(email)
|
||||||
|
if acc is not None and server_ids:
|
||||||
|
(res,) = await call(("x:AppPassword/get", {"accountId": acc, "ids": server_ids}))
|
||||||
|
present = [str(p["id"]) for p in res["list"]]
|
||||||
|
await destroy("AppPassword", present, accountId=acc)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""The user's own mailbox as a webmail account (SPEC App. D §D.4).
|
||||||
|
|
||||||
|
Every user has one ``kind = 'native'`` account: their handle's mailbox on the
|
||||||
|
hosted mail server. It is created with the user (and, for users from before,
|
||||||
|
by :func:`ensure_all`, run by provisioning), cannot be removed, and its server
|
||||||
|
settings are not the user's to change.
|
||||||
|
|
||||||
|
No password of the user's is stored for it: the worker signs in as itself,
|
||||||
|
acting for the user (``user@domain%webmail-worker``, §D.3), over the Docker
|
||||||
|
network (or loopback in dev) — TLS, with the certificate left unchecked since
|
||||||
|
the traffic never leaves the host.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.hosted import enabled, worker_address
|
||||||
|
from app.models import Account, User
|
||||||
|
from app.providers.imap_smtp import ProviderConfig
|
||||||
|
from app.security.crypto import encrypt_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KIND = "native"
|
||||||
|
|
||||||
|
|
||||||
|
def is_native(account: Account) -> bool:
|
||||||
|
return account.kind == KIND
|
||||||
|
|
||||||
|
|
||||||
|
def _server(host: str, port: int) -> dict[str, object]:
|
||||||
|
return {"host": host, "port": port, "tls": "ssl", "auth": "password"}
|
||||||
|
|
||||||
|
|
||||||
|
def internal_tls() -> ssl.SSLContext:
|
||||||
|
"""TLS to the mail server on the internal network: its certificate is for
|
||||||
|
its public name (or self-signed), not the container's."""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def native_provider_config(email: str) -> ProviderConfig:
|
||||||
|
"""IMAP/SMTP to the user's mailbox, signed in as the worker acting for them.
|
||||||
|
|
||||||
|
Read from the settings, not the account row: the mail server's address is
|
||||||
|
the deployment's, and may change."""
|
||||||
|
settings = get_settings()
|
||||||
|
return ProviderConfig(
|
||||||
|
imap_host=settings.stalwart_imap_host,
|
||||||
|
imap_port=settings.stalwart_imap_port,
|
||||||
|
imap_tls="ssl",
|
||||||
|
smtp_host=settings.stalwart_smtp_host,
|
||||||
|
smtp_port=settings.stalwart_smtp_port,
|
||||||
|
smtp_tls="ssl",
|
||||||
|
username=f"{email}%{worker_address()}",
|
||||||
|
password=settings.stalwart_master_secret,
|
||||||
|
tls_context=internal_tls(),
|
||||||
|
internal=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def native_account(db: AsyncSession, user_id: uuid.UUID) -> Account | None:
|
||||||
|
account: Account | None = await db.scalar(
|
||||||
|
select(Account).where(Account.user_id == user_id, Account.kind == KIND)
|
||||||
|
)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure(db: AsyncSession, user: User) -> Account | None:
|
||||||
|
"""The user's native account, created (and its first sync queued by the
|
||||||
|
caller, see :func:`ensure_and_sync`) if missing. None when hosted
|
||||||
|
mailboxes are off. Flushes, does not commit."""
|
||||||
|
if not enabled():
|
||||||
|
return None
|
||||||
|
existing = await native_account(db, user.id)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
# An account for the same address added by hand: the same address at
|
||||||
|
# another provider (the mail before it moved here — e.g. while the domain
|
||||||
|
# still points elsewhere). It stays as it is; the mailbox account is added
|
||||||
|
# once it is removed (app/api/accounts.py).
|
||||||
|
same = await db.scalar(
|
||||||
|
select(Account.id).where(Account.user_id == user.id, Account.email == user.handle)
|
||||||
|
)
|
||||||
|
if same is not None:
|
||||||
|
logger.info("hosted: %s already has an account for its address; not added", user.handle)
|
||||||
|
return None
|
||||||
|
settings = get_settings()
|
||||||
|
imap = _server(settings.stalwart_imap_host, settings.stalwart_imap_port)
|
||||||
|
smtp = _server(settings.stalwart_smtp_host, settings.stalwart_smtp_port)
|
||||||
|
account = Account(
|
||||||
|
user_id=user.id,
|
||||||
|
email=user.handle,
|
||||||
|
kind=KIND,
|
||||||
|
imap_config=imap,
|
||||||
|
smtp_config=smtp,
|
||||||
|
secret_ct=encrypt_secret(b""), # nothing: the worker signs in as itself
|
||||||
|
discovery_src="hosted",
|
||||||
|
state="backfilling",
|
||||||
|
sync_window="all",
|
||||||
|
# The mail server's own spam filter files into Junk: show it.
|
||||||
|
sync_spam=True,
|
||||||
|
)
|
||||||
|
db.add(account)
|
||||||
|
await db.flush()
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_and_sync(db: AsyncSession, user: User) -> Account | None:
|
||||||
|
""":func:`ensure`, committed, and the account's first sync queued if new."""
|
||||||
|
before = await native_account(db, user.id)
|
||||||
|
account = await ensure(db, user)
|
||||||
|
await db.commit()
|
||||||
|
if account is not None and before is None:
|
||||||
|
from app.queue import enqueue_job
|
||||||
|
|
||||||
|
await enqueue_job("sync_bootstrap_account", str(account.id))
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_all() -> int:
|
||||||
|
"""Give every user without one their native account (users from before
|
||||||
|
hosted mailboxes). Returns how many were created."""
|
||||||
|
from app.db import session_scope
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
async with session_scope() as db:
|
||||||
|
users = list(
|
||||||
|
await db.scalars(
|
||||||
|
select(User).where(
|
||||||
|
~select(Account.id)
|
||||||
|
.where(Account.user_id == User.id, Account.kind == KIND)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for user in users:
|
||||||
|
async with session_scope() as db:
|
||||||
|
fresh = await db.get(User, user.id)
|
||||||
|
if fresh is None:
|
||||||
|
continue
|
||||||
|
await ensure_and_sync(db, fresh)
|
||||||
|
created += 1
|
||||||
|
logger.info("hosted: mailbox account for %s", fresh.handle)
|
||||||
|
return created
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""Set up the mail server for hosted mailboxes (SPEC App. D §D.2/§D.3). Idempotent.
|
||||||
|
|
||||||
|
Run after the migrations (the ``migrate`` service in production; in dev
|
||||||
|
``python -m app.hosted.provision``). Two halves:
|
||||||
|
|
||||||
|
1. **Postgres** (as the app's own, privileged database user): the mail
|
||||||
|
server's role ``stalwart`` (password ``STALWART_DB_PASSWORD``), which owns its
|
||||||
|
database and may read the ``mail_directory`` view of the app's database and
|
||||||
|
nothing else there; and the worker's sign-in in ``mail_service_accounts``.
|
||||||
|
The mail server cannot start before this has run once.
|
||||||
|
2. **The mail server** (its management API, as the recovery admin): the mail
|
||||||
|
domain and host name, mail kept on disk (not in Postgres), the SQL directory
|
||||||
|
over ``mail_directory`` as the only source of accounts, the worker's
|
||||||
|
impersonate-only role, the submission port for mail apps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import asyncpg # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.hosted import admin, mail_hostname, worker_address
|
||||||
|
from app.identity.passwords import hash_password, verify_password
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DB_ROLE = "stalwart"
|
||||||
|
DIRECTORY_NAME = "Ultimo webmail users"
|
||||||
|
WORKER_ROLE_NAME = "Ultimo webmail worker"
|
||||||
|
BLOB_PATH = "/var/lib/stalwart/blobs"
|
||||||
|
|
||||||
|
# The directory's queries ($1: the address signed in with / mail is for). A
|
||||||
|
# blocked user cannot sign in, but still receives mail.
|
||||||
|
QUERY_LOGIN = (
|
||||||
|
"SELECT name, secret, type, description FROM mail_directory WHERE name = $1 AND active"
|
||||||
|
)
|
||||||
|
QUERY_RECIPIENT = (
|
||||||
|
"SELECT name, NULL::text AS secret, type, description FROM mail_directory WHERE name = $1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pg_dsn() -> str:
|
||||||
|
return get_settings().database_url.replace("postgresql+asyncpg://", "postgresql://", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _app_db_name() -> str:
|
||||||
|
return urlparse(_pg_dsn()).path.lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_literal(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
async def provision_database() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.stalwart_db_password or not settings.stalwart_master_secret:
|
||||||
|
raise RuntimeError("STALWART_DB_PASSWORD and STALWART_MASTER_SECRET must be set")
|
||||||
|
conn = await asyncpg.connect(_pg_dsn())
|
||||||
|
try:
|
||||||
|
password = _quote_literal(settings.stalwart_db_password)
|
||||||
|
if await conn.fetchval("SELECT 1 FROM pg_roles WHERE rolname = $1", DB_ROLE):
|
||||||
|
await conn.execute(f"ALTER ROLE {DB_ROLE} WITH LOGIN PASSWORD {password}")
|
||||||
|
else:
|
||||||
|
await conn.execute(f"CREATE ROLE {DB_ROLE} WITH LOGIN PASSWORD {password}")
|
||||||
|
db = settings.stalwart_db_name
|
||||||
|
if not await conn.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", db):
|
||||||
|
await conn.execute(f'CREATE DATABASE "{db}" OWNER {DB_ROLE}')
|
||||||
|
logger.info("hosted: created database %s", db)
|
||||||
|
await conn.execute(f"GRANT SELECT ON mail_directory TO {DB_ROLE}")
|
||||||
|
|
||||||
|
# The worker's sign-in: re-hashed only when the secret changed.
|
||||||
|
name = worker_address()
|
||||||
|
current = await conn.fetchval(
|
||||||
|
"SELECT secret FROM mail_service_accounts WHERE name = $1", name
|
||||||
|
)
|
||||||
|
if current is None or not verify_password(current, settings.stalwart_master_secret):
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO mail_service_accounts (name, secret, description)
|
||||||
|
VALUES ($1, $2, 'Webmail worker')
|
||||||
|
ON CONFLICT (name) DO UPDATE SET secret = EXCLUDED.secret
|
||||||
|
""",
|
||||||
|
name,
|
||||||
|
hash_password(settings.stalwart_master_secret),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the mail server ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_server(timeout_s: float = 120.0) -> None:
|
||||||
|
deadline = asyncio.get_running_loop().time() + timeout_s
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await admin.get_one("SystemSettings")
|
||||||
|
return
|
||||||
|
except admin.MailServerError:
|
||||||
|
if asyncio.get_running_loop().time() > deadline:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _by(items: list[dict[str, Any]], key: str, value: str) -> dict[str, Any] | None:
|
||||||
|
return next((i for i in items if i.get(key) == value), None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _domain() -> str:
|
||||||
|
name = get_settings().app_domain.lower()
|
||||||
|
found = _by(await admin.get_all("Domain"), "name", name)
|
||||||
|
if found is not None:
|
||||||
|
return str(found["id"])
|
||||||
|
domain_id = await admin.create("Domain", {"name": name})
|
||||||
|
logger.info("hosted: created mail domain %s", name)
|
||||||
|
return domain_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _directory() -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
dsn = urlparse(_pg_dsn())
|
||||||
|
value: dict[str, Any] = {
|
||||||
|
"@type": "Sql",
|
||||||
|
"description": DIRECTORY_NAME,
|
||||||
|
"columnEmail": "name",
|
||||||
|
"columnSecret": "secret",
|
||||||
|
"columnClass": "type",
|
||||||
|
"columnDescription": "description",
|
||||||
|
"queryLogin": QUERY_LOGIN,
|
||||||
|
"queryRecipient": QUERY_RECIPIENT,
|
||||||
|
"queryMemberOf": None,
|
||||||
|
"queryEmailAliases": None,
|
||||||
|
"store": {
|
||||||
|
"@type": "PostgreSql",
|
||||||
|
"host": settings.stalwart_pg_host,
|
||||||
|
"port": 5432 if settings.stalwart_pg_host == "postgres" else (dsn.port or 5432),
|
||||||
|
"database": _app_db_name(),
|
||||||
|
"authUsername": DB_ROLE,
|
||||||
|
"authSecret": {"@type": "EnvironmentVariable", "variableName": "STALWART_DB_PASSWORD"},
|
||||||
|
"useTls": False,
|
||||||
|
"allowInvalidCerts": False,
|
||||||
|
"poolMaxConnections": 5,
|
||||||
|
"poolRecyclingMethod": "fast",
|
||||||
|
"readReplicas": {},
|
||||||
|
"timeout": 15000,
|
||||||
|
"options": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
found = _by(await admin.get_all("Directory"), "description", DIRECTORY_NAME)
|
||||||
|
if found is None:
|
||||||
|
directory_id = await admin.create("Directory", value)
|
||||||
|
logger.info("hosted: created the SQL directory")
|
||||||
|
else:
|
||||||
|
directory_id = str(found["id"])
|
||||||
|
await admin.update("Directory", directory_id, value)
|
||||||
|
await admin.update("Authentication", "singleton", {"directoryId": directory_id})
|
||||||
|
return directory_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _worker_role() -> str:
|
||||||
|
perms = {"impersonate": True, "authenticate": True}
|
||||||
|
found = _by(await admin.get_all("Role"), "description", WORKER_ROLE_NAME)
|
||||||
|
if found is not None:
|
||||||
|
role_id = str(found["id"])
|
||||||
|
await admin.update("Role", role_id, {"enabledPermissions": perms})
|
||||||
|
return role_id
|
||||||
|
return await admin.create(
|
||||||
|
"Role", {"description": WORKER_ROLE_NAME, "enabledPermissions": perms}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _worker_account(role_id: str) -> None:
|
||||||
|
"""The worker may act for any mailbox and do nothing as itself."""
|
||||||
|
# Signing in brings it from the directory into the server's registry. With
|
||||||
|
# its role already set that sign-in fails (it may not open a session as
|
||||||
|
# itself) — after the account exists, which is all this needs.
|
||||||
|
worker = worker_address()
|
||||||
|
acc = await admin.account_id(worker)
|
||||||
|
if acc is None:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
await client.get(
|
||||||
|
f"{get_settings().stalwart_url}/jmap/session",
|
||||||
|
auth=(worker, get_settings().stalwart_master_secret),
|
||||||
|
)
|
||||||
|
acc = await admin.account_id(worker)
|
||||||
|
if acc is None:
|
||||||
|
raise admin.MailServerError("the worker account did not appear on the mail server")
|
||||||
|
await admin.update("Account", acc, {"roles": {"@type": "Custom", "roleIds": {role_id: True}}})
|
||||||
|
|
||||||
|
|
||||||
|
async def _reload() -> None:
|
||||||
|
"""Apply the settings now. Stalwart 0.16.19 ships a few spam-list rules its
|
||||||
|
own validator rejects (``bit_and`` in SURBL tags); they never load, but
|
||||||
|
they make every reload fail — so they are removed, which changes nothing."""
|
||||||
|
for _ in range(10):
|
||||||
|
try:
|
||||||
|
await admin.create("Action", {"@type": "ReloadSettings"})
|
||||||
|
return
|
||||||
|
except admin.MailServerError as exc:
|
||||||
|
match = re.search(r"'object': '(SpamDnsblServer)', 'id': '(\w+)'", str(exc))
|
||||||
|
if match is None:
|
||||||
|
raise
|
||||||
|
logger.info("hosted: removing the invalid built-in rule %s", match.group(2))
|
||||||
|
await admin.destroy(match.group(1), [match.group(2)])
|
||||||
|
raise admin.MailServerError("settings reload kept failing")
|
||||||
|
|
||||||
|
|
||||||
|
async def _logging() -> None:
|
||||||
|
"""Log to the console (``docker logs``), not to files inside the container."""
|
||||||
|
for tracer in await admin.get_all("Tracer"):
|
||||||
|
if tracer.get("@type") != "Stdout":
|
||||||
|
await admin.update(
|
||||||
|
"Tracer",
|
||||||
|
str(tracer["id"]),
|
||||||
|
{
|
||||||
|
"@type": "Stdout",
|
||||||
|
"ansi": False,
|
||||||
|
"buffered": False,
|
||||||
|
"enable": True,
|
||||||
|
"level": "info",
|
||||||
|
"events": {},
|
||||||
|
"eventsPolicy": "exclude",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _blob_store() -> bool:
|
||||||
|
"""Mail on disk (``DATA_ROOT/stalwart``), not inside Postgres: database
|
||||||
|
backups stay small. Takes a restart; True when one is needed."""
|
||||||
|
current = await admin.get_one("BlobStore")
|
||||||
|
if current.get("@type") == "FileSystem" and current.get("path") == BLOB_PATH:
|
||||||
|
return False
|
||||||
|
await admin.update(
|
||||||
|
"BlobStore", "singleton", {"@type": "FileSystem", "path": BLOB_PATH, "depth": 2}
|
||||||
|
)
|
||||||
|
logger.info("hosted: mail is now kept on disk (takes effect after a restart)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _listeners() -> None:
|
||||||
|
"""Add submission with STARTTLS (587) beside the defaults: smtp 25, submissions
|
||||||
|
465, imaps 993 (and http 8080 for this API)."""
|
||||||
|
listeners = await admin.get_all("NetworkListener")
|
||||||
|
if _by(listeners, "name", "submission") is None:
|
||||||
|
await admin.create(
|
||||||
|
"NetworkListener",
|
||||||
|
{"name": "submission", "bind": {"[::]:587": True}, "protocol": "smtp", "useTls": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Folder names for new mailboxes: the plain ones mail apps expect, not
|
||||||
|
# Stalwart's Outlook-style defaults ("Deleted Items", "Junk Mail", "Sent
|
||||||
|
# Items"). Archive is made too: the webmail archives into it.
|
||||||
|
FOLDERS = {
|
||||||
|
"trash": "Trash",
|
||||||
|
"junk": "Junk",
|
||||||
|
"drafts": "Drafts",
|
||||||
|
"sent": "Sent",
|
||||||
|
"archive": "Archive",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _folders() -> None:
|
||||||
|
folders = {
|
||||||
|
use: {"name": name, "create": True, "subscribe": True, "aliases": {}}
|
||||||
|
for use, name in FOLDERS.items()
|
||||||
|
}
|
||||||
|
await admin.update("Email", "singleton", {"defaultFolders": folders})
|
||||||
|
|
||||||
|
|
||||||
|
async def provision_server() -> bool:
|
||||||
|
"""Set the mail server up; True when it must be restarted to finish."""
|
||||||
|
await _wait_for_server()
|
||||||
|
await _logging()
|
||||||
|
restart = await _blob_store()
|
||||||
|
domain_id = await _domain()
|
||||||
|
await admin.update(
|
||||||
|
"SystemSettings",
|
||||||
|
"singleton",
|
||||||
|
{"defaultDomainId": domain_id, "defaultHostname": mail_hostname()},
|
||||||
|
)
|
||||||
|
await _listeners()
|
||||||
|
await _folders()
|
||||||
|
await _directory()
|
||||||
|
role_id = await _worker_role()
|
||||||
|
await _reload()
|
||||||
|
await _worker_account(role_id)
|
||||||
|
return restart
|
||||||
|
|
||||||
|
|
||||||
|
async def provision() -> bool:
|
||||||
|
from app.hosted import native
|
||||||
|
|
||||||
|
await provision_database()
|
||||||
|
restart = await provision_server()
|
||||||
|
created = await native.ensure_all()
|
||||||
|
if created:
|
||||||
|
logger.info("hosted: added the mailbox account of %d existing user(s)", created)
|
||||||
|
return restart
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
if not get_settings().hosted_mailboxes:
|
||||||
|
logger.info("hosted mailboxes are off (HOSTED_MAILBOXES): nothing to set up")
|
||||||
|
return
|
||||||
|
if asyncio.run(provision()):
|
||||||
|
logger.warning("hosted mailboxes: RESTART the mail server (stalwart) to finish")
|
||||||
|
else:
|
||||||
|
logger.info("hosted mailboxes: mail server ready")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
"""App passwords: one generated password per mail app (SPEC App. D §D.5).
|
"""App passwords: one generated password per mail app (SPEC App. D §D.5).
|
||||||
|
|
||||||
A mail app (Thunderbird, a phone's Mail app) signs in to the hosted mailbox
|
A mail app (Thunderbird, a phone's Mail app) signs in to the hosted mailbox
|
||||||
over IMAP/SMTP, where no 2FA code can be asked for. It gets a password of its
|
over IMAP/SMTP, and a calendar or contacts app to the DAV server — never with
|
||||||
own — 16 random letters, shown once — that can be revoked without touching the
|
the account password (no 2FA code can be asked for there), always with one of
|
||||||
main password or the other apps. The webmail itself never accepts one.
|
these. Each gets a password of its own, shown once, that can be
|
||||||
|
revoked without touching the main password or the other apps. The webmail
|
||||||
|
itself never accepts one.
|
||||||
|
|
||||||
Stored as argon2id hashes (identity/passwords.py), the form the mail server's
|
With hosted mailboxes on, the mail server makes the password (``app_`` and 36
|
||||||
SQL directory reads (§D.3). The hash is of the bare 16 letters: the mail
|
more letters and digits: its format names the credential) and checks it for
|
||||||
server compares exactly what an app sends, so the password is copied and
|
IMAP/SMTP itself; its argon2id hash is kept here too, for the DAV server, with
|
||||||
entered without the spaces it is shown with.
|
the server's id to revoke it there. Without, it is 16 random letters, for DAV
|
||||||
|
only. Either way the hash is of the bare password: apps send exactly that, so
|
||||||
|
it is copied and entered without the spaces it is shown with.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,10 +24,12 @@ import uuid
|
|||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app import hosted
|
||||||
from app.identity.passwords import hash_password, verify_password
|
from app.identity.passwords import hash_password, verify_password
|
||||||
from app.models import UserAppPassword
|
from app.models import User, UserAppPassword
|
||||||
|
|
||||||
LENGTH = 16 # 26^16 ≈ 2^75
|
LENGTH = 16 # 26^16 ≈ 2^75
|
||||||
|
SERVER_LENGTH = 40 # the mail server's: "app_" + base32 of a 4-byte id and 18 random bytes
|
||||||
MAX_PER_USER = 25
|
MAX_PER_USER = 25
|
||||||
NAME_MAX = 60
|
NAME_MAX = 60
|
||||||
_ALPHABET = string.ascii_lowercase
|
_ALPHABET = string.ascii_lowercase
|
||||||
@@ -66,16 +72,45 @@ async def create(db: AsyncSession, user_id: uuid.UUID, name: str) -> tuple[UserA
|
|||||||
)
|
)
|
||||||
if (count or 0) >= MAX_PER_USER:
|
if (count or 0) >= MAX_PER_USER:
|
||||||
raise TooMany
|
raise TooMany
|
||||||
|
name = name.strip()[:NAME_MAX]
|
||||||
|
server_id: str | None = None
|
||||||
|
if hosted.enabled():
|
||||||
|
from app.hosted import admin as mail_admin
|
||||||
|
|
||||||
|
handle = await db.scalar(select(User.handle).where(User.id == user_id))
|
||||||
|
server_id, password = await mail_admin.create_app_password(str(handle), name)
|
||||||
|
else:
|
||||||
password = generate()
|
password = generate()
|
||||||
row = UserAppPassword(
|
row = UserAppPassword(
|
||||||
user_id=user_id, name=name.strip()[:NAME_MAX], password_hash=hash_password(password)
|
user_id=user_id, name=name, password_hash=hash_password(password), server_id=server_id
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return row, password
|
return row, password
|
||||||
|
|
||||||
|
|
||||||
|
async def _revoke_on_server(
|
||||||
|
db: AsyncSession, user_id: uuid.UUID, rows: list[UserAppPassword]
|
||||||
|
) -> None:
|
||||||
|
"""Revoke on the mail server too (first: if it cannot be reached, the
|
||||||
|
password keeps working there, so nothing is revoked here either)."""
|
||||||
|
server_ids = [r.server_id for r in rows if r.server_id]
|
||||||
|
if server_ids and hosted.enabled():
|
||||||
|
from app.hosted import admin as mail_admin
|
||||||
|
|
||||||
|
handle = await db.scalar(select(User.handle).where(User.id == user_id))
|
||||||
|
await mail_admin.delete_app_passwords(str(handle), server_ids)
|
||||||
|
|
||||||
|
|
||||||
async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUID) -> bool:
|
async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUID) -> bool:
|
||||||
|
rows = list(
|
||||||
|
await db.scalars(
|
||||||
|
select(UserAppPassword).where(
|
||||||
|
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await _revoke_on_server(db, user_id, rows)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
delete(UserAppPassword).where(
|
delete(UserAppPassword).where(
|
||||||
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
|
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
|
||||||
@@ -88,6 +123,7 @@ async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUI
|
|||||||
|
|
||||||
async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
|
async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
|
||||||
"""Every app signs in again (the main password was reset)."""
|
"""Every app signs in again (the main password was reset)."""
|
||||||
|
await _revoke_on_server(db, user_id, await list_for(db, user_id))
|
||||||
await db.execute(delete(UserAppPassword).where(UserAppPassword.user_id == user_id))
|
await db.execute(delete(UserAppPassword).where(UserAppPassword.user_id == user_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await _changed(user_id)
|
await _changed(user_id)
|
||||||
@@ -96,7 +132,7 @@ async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
|
|||||||
async def verify(db: AsyncSession, user_id: uuid.UUID, password: str) -> UserAppPassword | None:
|
async def verify(db: AsyncSession, user_id: uuid.UUID, password: str) -> UserAppPassword | None:
|
||||||
"""The app password ``password`` matches, if any — for the mail server's
|
"""The app password ``password`` matches, if any — for the mail server's
|
||||||
sign-in and for tests; never for the webmail's own sign-in."""
|
sign-in and for tests; never for the webmail's own sign-in."""
|
||||||
if len(password) != LENGTH:
|
if len(password) not in (LENGTH, SERVER_LENGTH):
|
||||||
return None
|
return None
|
||||||
for row in await list_for(db, user_id):
|
for row in await list_for(db, user_id):
|
||||||
if verify_password(row.password_hash, password):
|
if verify_password(row.password_hash, password):
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated, NoReturn
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app import hosted
|
||||||
from app.api.errors import raise_api_error
|
from app.api.errors import raise_api_error
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.events import get_event_bus
|
from app.events import get_event_bus
|
||||||
|
from app.hosted.admin import MailServerError
|
||||||
from app.identity import app_passwords, trusted_devices
|
from app.identity import app_passwords, trusted_devices
|
||||||
from app.identity import totp as totp_mod
|
from app.identity import totp as totp_mod
|
||||||
from app.identity.cookies import (
|
from app.identity.cookies import (
|
||||||
@@ -105,6 +107,7 @@ def _user_out(user: User) -> UserOut:
|
|||||||
settings=user.settings,
|
settings=user.settings,
|
||||||
totp_enabled=user.totp_secret is not None,
|
totp_enabled=user.totp_secret is not None,
|
||||||
is_admin=user.is_admin,
|
is_admin=user.is_admin,
|
||||||
|
mail_host=hosted.mail_hostname() if hosted.enabled() else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,6 +135,12 @@ async def register(
|
|||||||
except RegistrationError as exc:
|
except RegistrationError as exc:
|
||||||
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
|
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
# The user's own mailbox (App. D §D.4): it exists on the mail server as soon
|
||||||
|
# as the user does (the directory reads the users table); this adds it to
|
||||||
|
# the webmail.
|
||||||
|
from app.hosted import native
|
||||||
|
|
||||||
|
await native.ensure_and_sync(db, user)
|
||||||
await _establish_session(request, response, user)
|
await _establish_session(request, response, user)
|
||||||
return _user_out(user)
|
return _user_out(user)
|
||||||
|
|
||||||
@@ -316,7 +325,12 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await destroy_all_sessions(user.id) # force re-login everywhere after reset
|
await destroy_all_sessions(user.id) # force re-login everywhere after reset
|
||||||
# Mail apps sign in again too: the reset may be the owner taking the account back.
|
# Mail apps sign in again too: the reset may be the owner taking the account back.
|
||||||
|
try:
|
||||||
await app_passwords.revoke_all(db, user.id)
|
await app_passwords.revoke_all(db, user.id)
|
||||||
|
except MailServerError:
|
||||||
|
# The new password is set; the app passwords stay listed (and working)
|
||||||
|
# until the mail server answers, so the user can still revoke them.
|
||||||
|
logger.exception("password reset: could not revoke app passwords on the mail server")
|
||||||
# Whoever reset the password may be the owner taking the account back: no
|
# Whoever reset the password may be the owner taking the account back: no
|
||||||
# device keeps skipping the code on the old trust.
|
# device keeps skipping the code on the old trust.
|
||||||
await trusted_devices.revoke_all(user.id)
|
await trusted_devices.revoke_all(user.id)
|
||||||
@@ -326,6 +340,12 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
|
|||||||
# --- App passwords (SPEC App. D §D.5) -----------------------------------------
|
# --- App passwords (SPEC App. D §D.5) -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_server_down() -> NoReturn:
|
||||||
|
raise_api_error(
|
||||||
|
502, "mail_server_unavailable", "the mail server is not answering; try again shortly"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _app_password_out(row: object) -> AppPasswordOut:
|
def _app_password_out(row: object) -> AppPasswordOut:
|
||||||
from app.models import UserAppPassword
|
from app.models import UserAppPassword
|
||||||
|
|
||||||
@@ -361,6 +381,8 @@ async def create_app_password(
|
|||||||
raise_api_error(
|
raise_api_error(
|
||||||
409, "too_many", f"you can have up to {app_passwords.MAX_PER_USER} app passwords"
|
409, "too_many", f"you can have up to {app_passwords.MAX_PER_USER} app passwords"
|
||||||
)
|
)
|
||||||
|
except MailServerError:
|
||||||
|
_mail_server_down()
|
||||||
return AppPasswordCreatedOut(**_app_password_out(row).model_dump(), app_password=password)
|
return AppPasswordCreatedOut(**_app_password_out(row).model_dump(), app_password=password)
|
||||||
|
|
||||||
|
|
||||||
@@ -372,7 +394,11 @@ async def create_app_password(
|
|||||||
async def revoke_app_password(
|
async def revoke_app_password(
|
||||||
app_password_id: uuid.UUID, user: CurrentUser, db: DbSession
|
app_password_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||||
) -> MessageOut:
|
) -> MessageOut:
|
||||||
if not await app_passwords.revoke(db, user.id, app_password_id):
|
try:
|
||||||
|
revoked = await app_passwords.revoke(db, user.id, app_password_id)
|
||||||
|
except MailServerError:
|
||||||
|
_mail_server_down()
|
||||||
|
if not revoked:
|
||||||
raise_api_error(404, "not_found", "app password not found")
|
raise_api_error(404, "not_found", "app password not found")
|
||||||
return MessageOut()
|
return MessageOut()
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class UserOut(BaseModel):
|
|||||||
settings: dict[str, object]
|
settings: dict[str, object]
|
||||||
totp_enabled: bool = False
|
totp_enabled: bool = False
|
||||||
is_admin: bool = False
|
is_admin: bool = False
|
||||||
|
# The hosted mail server's name for mail apps (IMAP 993, SMTP 465/587), or
|
||||||
|
# None when there are no hosted mailboxes (SPEC App. D).
|
||||||
|
mail_host: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RegistrationStatusOut(BaseModel):
|
class RegistrationStatusOut(BaseModel):
|
||||||
|
|||||||
@@ -67,7 +67,16 @@ async def search_users(db: AsyncSession, q: str) -> list[tuple[User, int, list[s
|
|||||||
|
|
||||||
|
|
||||||
async def set_blocked(db: AsyncSession, user: User, blocked: bool) -> None:
|
async def set_blocked(db: AsyncSession, user: User, blocked: bool) -> None:
|
||||||
"""Block or unblock. Blocking also signs the user out everywhere."""
|
"""Block or unblock. Blocking also signs the user out everywhere, and shuts
|
||||||
|
their hosted mailbox to every sign-in (mail apps included); it still
|
||||||
|
receives mail. The mail server goes first: if it cannot be told, nothing
|
||||||
|
changes."""
|
||||||
|
from app import hosted
|
||||||
|
|
||||||
|
if hosted.enabled():
|
||||||
|
from app.hosted import admin as mail_admin
|
||||||
|
|
||||||
|
await mail_admin.set_blocked(user.handle, blocked)
|
||||||
if blocked and user.blocked_at is None:
|
if blocked and user.blocked_at is None:
|
||||||
user.blocked_at = dt.datetime.now(dt.UTC)
|
user.blocked_at = dt.datetime.now(dt.UTC)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -97,8 +106,18 @@ async def _plugin_tables(db: AsyncSession) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
async def delete_user(db: AsyncSession, user: User) -> None:
|
async def delete_user(db: AsyncSession, user: User) -> None:
|
||||||
"""Delete ``user`` and everything they own. Commits."""
|
"""Delete ``user`` and everything they own. Commits.
|
||||||
|
|
||||||
|
Their hosted mailbox goes first, with all its mail: if the mail server
|
||||||
|
cannot be reached nothing is deleted — a mailbox left behind would be
|
||||||
|
handed, mail and all, to whoever registers the address next."""
|
||||||
|
from app import hosted
|
||||||
|
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
|
if hosted.enabled():
|
||||||
|
from app.hosted import admin as mail_admin
|
||||||
|
|
||||||
|
await mail_admin.delete_account(user.handle)
|
||||||
|
|
||||||
# Mail accounts first, one by one: this is what stops their sync runners and
|
# Mail accounts first, one by one: this is what stops their sync runners and
|
||||||
# tidies threads, exactly as when the user removes an account themselves.
|
# tidies threads, exactly as when the user removes an account themselves.
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ decoded on its own). It is taken out of the raw message when wanted:
|
|||||||
* :func:`dedupe_existing` — the one-time pass removing the separate copies
|
* :func:`dedupe_existing` — the one-time pass removing the separate copies
|
||||||
stored before, each only after checking it can be taken out of its message.
|
stored before, each only after checking it can be taken out of its message.
|
||||||
|
|
||||||
Blocking (parsing, disk): call these in a thread.
|
Blocking (parsing, disk): call these in a thread — after
|
||||||
|
:func:`app.indexer.rawstore.ensure` for the message, which fetches a hosted
|
||||||
|
mailbox's message from its mail server when it is not cached.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,7 +25,7 @@ import uuid
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.indexer import blobstore
|
from app.indexer import blobstore, rawstore
|
||||||
from app.indexer.mime import load_message, part_payload
|
from app.indexer.mime import load_message, part_payload
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -45,8 +47,8 @@ def load(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None)
|
|||||||
legacy = _legacy(user_id, sha)
|
legacy = _legacy(user_id, sha)
|
||||||
if legacy is not None:
|
if legacy is not None:
|
||||||
return legacy.read_bytes()
|
return legacy.read_bytes()
|
||||||
raw = blobstore.blob_path(user_id, message_sha)
|
raw = rawstore.locate(user_id, message_sha)
|
||||||
if part_index == NOT_IN_MESSAGE or not raw.exists():
|
if part_index == NOT_IN_MESSAGE or raw is None:
|
||||||
raise FileNotFoundError(sha)
|
raise FileNotFoundError(sha)
|
||||||
payload = part_payload(load_message(path=raw), part_index, sha)
|
payload = part_payload(load_message(path=raw), part_index, sha)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
@@ -82,6 +84,11 @@ def prune_cache(cache: Path) -> None:
|
|||||||
path.unlink(missing_ok=True)
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def has_file(user_id: uuid.UUID, sha: str) -> bool:
|
||||||
|
"""Whether :func:`file` can answer without reading the message."""
|
||||||
|
return _legacy(user_id, sha) is not None or (_cache_dir(user_id) / sha).exists()
|
||||||
|
|
||||||
|
|
||||||
def file(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None) -> Path:
|
def file(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None) -> Path:
|
||||||
"""A file holding the attachment, to stream from. Raises FileNotFoundError."""
|
"""A file holding the attachment, to stream from. Raises FileNotFoundError."""
|
||||||
legacy = _legacy(user_id, sha)
|
legacy = _legacy(user_id, sha)
|
||||||
@@ -167,9 +174,9 @@ async def dedupe_existing(batch: int = 200, *, user_id: uuid.UUID | None = None)
|
|||||||
|
|
||||||
located: dict[uuid.UUID, int] = {}
|
located: dict[uuid.UUID, int] = {}
|
||||||
for (owner, msg_sha), atts in by_message.items():
|
for (owner, msg_sha), atts in by_message.items():
|
||||||
raw = blobstore.blob_path(owner, msg_sha)
|
raw = rawstore.locate(owner, msg_sha)
|
||||||
try:
|
try:
|
||||||
indexes = await asyncio.to_thread(_part_indexes, raw) if raw.exists() else {}
|
indexes = await asyncio.to_thread(_part_indexes, raw) if raw is not None else {}
|
||||||
except Exception: # noqa: BLE001 - unreadable message: keep the copies
|
except Exception: # noqa: BLE001 - unreadable message: keep the copies
|
||||||
logger.warning("attachments: cannot parse %s", raw, exc_info=True)
|
logger.warning("attachments: cannot parse %s", raw, exc_info=True)
|
||||||
indexes = {}
|
indexes = {}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ that for every user, whatever the way the rows went:
|
|||||||
a sent message (its draft is gone) or a composer closed without saving;
|
a sent message (its draft is gone) or a composer closed without saving;
|
||||||
* message/attachment files no row refers to;
|
* message/attachment files no row refers to;
|
||||||
* attachment previews (thumbnails) of attachments that are gone;
|
* attachment previews (thumbnails) of attachments that are gone;
|
||||||
* streaming copies past their keep time (app/indexer/attachments.py).
|
* streaming copies past their keep time (app/indexer/attachments.py);
|
||||||
|
* cached raw messages of a hosted mailbox beyond the cache's room
|
||||||
|
(app/indexer/rawstore.py).
|
||||||
|
|
||||||
|
It also recounts threads whose unread/message counters disagree with their
|
||||||
|
messages, and drops threads left empty (app/threading_/threader.py).
|
||||||
|
|
||||||
A file younger than an hour is left alone: it may belong to work in progress
|
A file younger than an hour is left alone: it may belong to work in progress
|
||||||
(a message being stored, a sent message waiting for its Sent copy).
|
(a message being stored, a sent message waiting for its Sent copy).
|
||||||
@@ -31,6 +36,7 @@ from sqlalchemy import delete, or_, select
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.indexer import attachments as att_store
|
from app.indexer import attachments as att_store
|
||||||
|
from app.indexer import rawstore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -94,9 +100,9 @@ def _old(path: Path, now: float) -> bool:
|
|||||||
async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
||||||
"""Clean one user's stored files; returns what was removed."""
|
"""Clean one user's stored files; returns what was removed."""
|
||||||
from app.db import session_scope
|
from app.db import session_scope
|
||||||
from app.models import Attachment, ComposeAttachment, Draft, Message
|
from app.models import Account, Attachment, ComposeAttachment, Draft, Message
|
||||||
|
|
||||||
stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0}
|
stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
# Uploads no draft refers to, once a week old.
|
# Uploads no draft refers to, once a week old.
|
||||||
used = _draft_upload_ids(
|
used = _draft_upload_ids(
|
||||||
@@ -116,8 +122,14 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
|||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
# Every file still referred to. Attachments count only where their file
|
# Every file still referred to. Attachments count only where their file
|
||||||
# is their only copy (not found in the message, or not yet looked for).
|
# is their only copy (not found in the message, or not yet looked for).
|
||||||
|
# A hosted mailbox's messages are kept by its mail server: their raw
|
||||||
|
# file here (left by a Sent copy filed late) is not needed.
|
||||||
referenced = set(
|
referenced = set(
|
||||||
await db.scalars(select(Message.blob_sha256).where(Message.user_id == user_id))
|
await db.scalars(
|
||||||
|
select(Message.blob_sha256)
|
||||||
|
.join(Account, Account.id == Message.account_id)
|
||||||
|
.where(Message.user_id == user_id, Account.kind != "native")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
referenced |= set(
|
referenced |= set(
|
||||||
await db.scalars(
|
await db.scalars(
|
||||||
@@ -140,6 +152,12 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
|||||||
await db.scalars(select(Attachment.blob_sha256).where(Attachment.user_id == user_id))
|
await db.scalars(select(Attachment.blob_sha256).where(Attachment.user_id == user_id))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Counters out of step with their messages (see repair_threads).
|
||||||
|
from app.threading_.threader import repair_threads
|
||||||
|
|
||||||
|
async with session_scope() as db:
|
||||||
|
stats["threads_repaired"] = await repair_threads(db, user_id)
|
||||||
|
|
||||||
root = Path(get_settings().blob_root) / str(user_id)
|
root = Path(get_settings().blob_root) / str(user_id)
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
return stats
|
return stats
|
||||||
@@ -168,6 +186,7 @@ async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
|
|||||||
cache = root / "cache"
|
cache = root / "cache"
|
||||||
if cache.is_dir():
|
if cache.is_dir():
|
||||||
att_store.prune_cache(cache)
|
att_store.prune_cache(cache)
|
||||||
|
rawstore.prune(user_id)
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
@@ -178,7 +197,7 @@ async def sweep(user_id: uuid.UUID | None = None) -> dict[str, int]:
|
|||||||
|
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
users = [user_id] if user_id is not None else list(await db.scalars(select(User.id)))
|
users = [user_id] if user_id is not None else list(await db.scalars(select(User.id)))
|
||||||
total = {"users": 0, "uploads": 0, "files": 0, "bytes": 0, "thumbs": 0}
|
total = {"users": 0, "uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
|
||||||
for uid in users:
|
for uid in users:
|
||||||
try:
|
try:
|
||||||
stats = await sweep_user(uid)
|
stats = await sweep_user(uid)
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.concurrency import loop_semaphore
|
from app.concurrency import loop_semaphore
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.indexer import blobstore
|
from app.indexer import blobstore, rawstore
|
||||||
from app.indexer.mime import ParsedEmail, parse_message, parse_message_file
|
from app.indexer.mime import ParsedEmail, parse_message, parse_message_file
|
||||||
from app.indexer.sanitize import sanitize_html
|
from app.indexer.sanitize import sanitize_html
|
||||||
from app.models import Attachment, BodyPart, KnownRecipient, Message
|
from app.models import Account, Attachment, BodyPart, KnownRecipient, Message
|
||||||
from app.providers.base import RemoteMessage
|
from app.providers.base import RemoteMessage
|
||||||
from app.search.base import SearchDoc
|
from app.search.base import SearchDoc
|
||||||
from app.threading_.normalize import synthesize_message_id
|
from app.threading_.normalize import synthesize_message_id
|
||||||
@@ -118,6 +118,13 @@ async def _resolve_message_id(
|
|||||||
return parsed_id
|
return parsed_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _keeps_raw(session: AsyncSession, account_id: uuid.UUID) -> bool:
|
||||||
|
"""Whether the raw message is kept for good: not for a hosted mailbox, whose
|
||||||
|
mail server holds it (it is only cached, App. D §D.6 item 2)."""
|
||||||
|
kind = await session.scalar(select(Account.kind).where(Account.id == account_id))
|
||||||
|
return kind != "native"
|
||||||
|
|
||||||
|
|
||||||
async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmail:
|
async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmail:
|
||||||
"""Parse under the PARSE_CONCURRENCY cap; stream large messages from disk (§7).
|
"""Parse under the PARSE_CONCURRENCY cap; stream large messages from disk (§7).
|
||||||
|
|
||||||
@@ -128,8 +135,9 @@ async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmai
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
threshold = settings.stream_parse_mb * 1024 * 1024
|
threshold = settings.stream_parse_mb * 1024 * 1024
|
||||||
async with loop_semaphore("mime_parse", "parse", settings.parse_concurrency):
|
async with loop_semaphore("mime_parse", "parse", settings.parse_concurrency):
|
||||||
if len(raw) > threshold:
|
path = rawstore.locate(user_id, sha) if len(raw) > threshold else None
|
||||||
return parse_message_file(blobstore.blob_path(user_id, sha))
|
if path is not None:
|
||||||
|
return parse_message_file(path)
|
||||||
return parse_message(raw)
|
return parse_message(raw)
|
||||||
|
|
||||||
|
|
||||||
@@ -154,7 +162,10 @@ async def persist_message(
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if await _keeps_raw(session, account_id):
|
||||||
sha = blobstore.store(user_id, remote.raw)
|
sha = blobstore.store(user_id, remote.raw)
|
||||||
|
else:
|
||||||
|
sha = rawstore.put_cached(user_id, remote.raw)
|
||||||
parsed = await _parse_bounded(user_id, sha, remote.raw)
|
parsed = await _parse_bounded(user_id, sha, remote.raw)
|
||||||
|
|
||||||
message_id_hdr = await _resolve_message_id(
|
message_id_hdr = await _resolve_message_id(
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Where a message's raw source is (SPEC App. D §D.6 item 2).
|
||||||
|
|
||||||
|
An external account's messages are kept whole in the blob store, as always.
|
||||||
|
A hosted mailbox's are not: the mail server beside the app holds them, so the
|
||||||
|
webmail keeps only what it reads them for (headers, the sanitized body, the
|
||||||
|
snippet, the search index) and a **cache** of raw messages — the newest ones
|
||||||
|
on arrival, the ones opened since — bounded per user (``RAW_CACHE_MB``,
|
||||||
|
least recently used out first). Anything else is fetched from the mail server
|
||||||
|
when needed: downloading an attachment, "show original", forwarding.
|
||||||
|
|
||||||
|
Layout: ``${BLOB_ROOT}/{user_id}/rawcache/{sha256}``, the same content hash
|
||||||
|
``messages.blob_sha256`` names.
|
||||||
|
|
||||||
|
* :func:`locate` — the file, if it is here (blob store or cache); cheap, sync;
|
||||||
|
* :func:`ensure` — the file, fetched from the mail server if need be (async):
|
||||||
|
call it before handing the work to a thread that reads the file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.indexer import blobstore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CACHE_DIR = "rawcache"
|
||||||
|
KEEP_S = 30 * 24 * 3600 # unused this long: dropped whatever the room
|
||||||
|
PRUNE_EVERY = 50
|
||||||
|
_writes: dict[uuid.UUID, int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def cache_dir(user_id: uuid.UUID) -> Path:
|
||||||
|
return blobstore.blob_path(user_id, "00").parent.parent / CACHE_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def cache_path(user_id: uuid.UUID, sha: str) -> Path:
|
||||||
|
return cache_dir(user_id) / sha
|
||||||
|
|
||||||
|
|
||||||
|
def locate(user_id: uuid.UUID, sha: str) -> Path | None:
|
||||||
|
"""The message's raw file, if it is kept or cached here."""
|
||||||
|
kept = blobstore.blob_path(user_id, sha)
|
||||||
|
if kept.exists():
|
||||||
|
return kept
|
||||||
|
cached = cache_path(user_id, sha)
|
||||||
|
if cached.exists():
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
cached.touch() # used again: kept longer
|
||||||
|
return cached
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load(user_id: uuid.UUID, sha: str) -> bytes:
|
||||||
|
"""The raw bytes if they are here. Raises FileNotFoundError."""
|
||||||
|
path = locate(user_id, sha)
|
||||||
|
if path is None:
|
||||||
|
raise FileNotFoundError(sha)
|
||||||
|
return path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def put_cached(user_id: uuid.UUID, data: bytes, sha: str | None = None) -> str:
|
||||||
|
"""Cache a raw message (``sha`` names it; its content hash by default)."""
|
||||||
|
sha = sha or blobstore.sha256_hex(data)
|
||||||
|
if blobstore.exists(user_id, sha):
|
||||||
|
return sha # kept for good already (another account has the same message)
|
||||||
|
path = cache_path(user_id, sha)
|
||||||
|
if not path.exists():
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(f".{uuid.uuid4().hex}.tmp")
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
tmp.replace(path)
|
||||||
|
else:
|
||||||
|
path.touch()
|
||||||
|
# Pruning lists the whole cache: done every PRUNE_EVERY writes, not each
|
||||||
|
# (a backfill writes thousands); meanwhile it overshoots by that much at most.
|
||||||
|
writes = _writes.get(user_id, 0) + 1
|
||||||
|
_writes[user_id] = writes % PRUNE_EVERY
|
||||||
|
if writes >= PRUNE_EVERY:
|
||||||
|
with contextlib.suppress(OSError): # a file removed meanwhile by another request
|
||||||
|
prune(user_id)
|
||||||
|
return sha
|
||||||
|
|
||||||
|
|
||||||
|
def prune(user_id: uuid.UUID, *, max_bytes: int | None = None) -> int:
|
||||||
|
"""Drop cached messages unused for KEEP_S, and the least recently used
|
||||||
|
beyond the cap. Returns bytes freed."""
|
||||||
|
cache = cache_dir(user_id)
|
||||||
|
if not cache.is_dir():
|
||||||
|
return 0
|
||||||
|
cap = max_bytes if max_bytes is not None else get_settings().raw_cache_mb * 1024 * 1024
|
||||||
|
files = [(p, p.stat()) for p in cache.iterdir() if p.is_file() and not p.name.endswith(".tmp")]
|
||||||
|
now = time.time()
|
||||||
|
total = freed = 0
|
||||||
|
for path, st in sorted(files, key=lambda f: f[1].st_mtime, reverse=True):
|
||||||
|
total += st.st_size
|
||||||
|
if now - st.st_mtime > KEEP_S or total > cap:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
freed += st.st_size
|
||||||
|
return freed
|
||||||
|
|
||||||
|
|
||||||
|
def drop(user_id: uuid.UUID, sha: str) -> None:
|
||||||
|
cache_path(user_id, sha).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure(message: object) -> Path:
|
||||||
|
"""The raw file of ``message`` (a :class:`~app.models.Message`), fetched
|
||||||
|
from the hosted mail server into the cache when it is not here. Raises
|
||||||
|
FileNotFoundError when it cannot be had (an external account's message
|
||||||
|
missing from the blob store; a native one gone from the server)."""
|
||||||
|
from app.models import Message
|
||||||
|
|
||||||
|
assert isinstance(message, Message)
|
||||||
|
found = locate(message.user_id, message.blob_sha256)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
raw = await _fetch_native(message)
|
||||||
|
if raw is None:
|
||||||
|
raise FileNotFoundError(message.blob_sha256)
|
||||||
|
got = blobstore.sha256_hex(raw)
|
||||||
|
if got != message.blob_sha256:
|
||||||
|
# The server's copy differs from what was indexed (it should not):
|
||||||
|
# cached under the indexed name, so the message can still be read.
|
||||||
|
logger.warning("raw of message %s changed on the mail server", message.id)
|
||||||
|
put_cached(message.user_id, raw, message.blob_sha256)
|
||||||
|
return cache_path(message.user_id, message.blob_sha256)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bytes(message: object) -> bytes:
|
||||||
|
path = await ensure(message)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
return await asyncio.to_thread(path.read_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_native(message: object) -> bytes | None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import session_scope
|
||||||
|
from app.models import Account, Mailbox, Message
|
||||||
|
|
||||||
|
assert isinstance(message, Message)
|
||||||
|
async with session_scope() as db:
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Account.kind, Account.email, Mailbox.path)
|
||||||
|
.join(Mailbox, Mailbox.account_id == Account.id)
|
||||||
|
.where(Account.id == message.account_id, Mailbox.id == message.mailbox_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None or row.kind != "native":
|
||||||
|
return None
|
||||||
|
from app.hosted.native import native_provider_config
|
||||||
|
from app.providers.base import ProviderError
|
||||||
|
from app.providers.imap_smtp import ImapSmtpProvider
|
||||||
|
|
||||||
|
provider = ImapSmtpProvider(native_provider_config(row.email))
|
||||||
|
try:
|
||||||
|
return await provider.fetch_raw(row.path, message.uid)
|
||||||
|
except ProviderError as exc:
|
||||||
|
logger.warning("cannot fetch message %s from the mail server: %s", message.id, exc)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
await provider.close()
|
||||||
@@ -87,6 +87,11 @@ def _render(data: bytes, mime_type: str) -> bytes | None:
|
|||||||
return out.getvalue()
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def made(user_id: uuid.UUID, sha: str) -> bool:
|
||||||
|
"""Whether the attachment's thumbnail (or its absence) is settled already."""
|
||||||
|
return _path(user_id, sha, ".webp").exists() or _path(user_id, sha, ".none").exists()
|
||||||
|
|
||||||
|
|
||||||
def thumbnail(
|
def thumbnail(
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
sha: str,
|
sha: str,
|
||||||
|
|||||||
+14
-5
@@ -131,6 +131,10 @@ class Account(Base):
|
|||||||
imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
||||||
smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
||||||
secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False)
|
secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False)
|
||||||
|
# external: any IMAP/SMTP server (the user's password in secret_ct);
|
||||||
|
# native: the user's own mailbox on the hosted mail server — permanent, no
|
||||||
|
# stored password (SPEC App. D §D.4; app/hosted/).
|
||||||
|
kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'external'"))
|
||||||
discovery_src: Mapped[str] = mapped_column(Text, nullable=False)
|
discovery_src: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'"))
|
state: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'backfilling'"))
|
||||||
state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
state_detail: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||||
@@ -161,9 +165,12 @@ class Mailbox(Base):
|
|||||||
)
|
)
|
||||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
special_use: Mapped[str | None] = mapped_column(Text)
|
special_use: Mapped[str | None] = mapped_column(Text)
|
||||||
uidvalidity: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
# IMAP ids are unsigned 32-bit (MODSEQ 63-bit): bigint, not integer.
|
||||||
highestmodseq: Mapped[int | None] = mapped_column(Integer)
|
uidvalidity: Mapped[int] = mapped_column(BigInteger, nullable=False, server_default=text("0"))
|
||||||
last_seen_uid: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
highestmodseq: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
last_seen_uid: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, nullable=False, server_default=text("0")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Thread(Base):
|
class Thread(Base):
|
||||||
@@ -214,7 +221,7 @@ class Message(Base):
|
|||||||
restore_mailbox_id: Mapped[uuid.UUID | None] = mapped_column(
|
restore_mailbox_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="SET NULL")
|
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="SET NULL")
|
||||||
)
|
)
|
||||||
uid: Mapped[int] = mapped_column(Integer, nullable=False)
|
uid: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
thread_id: Mapped[uuid.UUID | None] = mapped_column(
|
thread_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("threads.id")
|
UUID(as_uuid=True), ForeignKey("threads.id")
|
||||||
)
|
)
|
||||||
@@ -321,7 +328,7 @@ class Draft(Base):
|
|||||||
payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False)
|
||||||
# UID of the mirrored copy in the account's IMAP Drafts folder (so the draft
|
# UID of the mirrored copy in the account's IMAP Drafts folder (so the draft
|
||||||
# is visible/continuable on other mail clients). Replaced on each edit.
|
# is visible/continuable on other mail clients). Replaced on each edit.
|
||||||
imap_uid: Mapped[int | None] = mapped_column(Integer)
|
imap_uid: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
updated_at: Mapped[dt.datetime] = _now()
|
updated_at: Mapped[dt.datetime] = _now()
|
||||||
|
|
||||||
|
|
||||||
@@ -367,6 +374,8 @@ class UserAppPassword(Base):
|
|||||||
created_at: Mapped[dt.datetime] = _now()
|
created_at: Mapped[dt.datetime] = _now()
|
||||||
# Set by the mail server's sign-in, once there is one.
|
# Set by the mail server's sign-in, once there is one.
|
||||||
last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||||
|
# The hosted mail server's id of the same password (§D.5), when it has one.
|
||||||
|
server_id: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
class PushSubscription(Base):
|
class PushSubscription(Base):
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.indexer import attachments as att_store
|
from app.indexer import attachments as att_store
|
||||||
|
from app.indexer import rawstore
|
||||||
from app.indexer.sanitize import INTERNAL_CID
|
from app.indexer.sanitize import INTERNAL_CID
|
||||||
from app.models import Attachment, Message
|
from app.models import Attachment, Message
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ async def embed(
|
|||||||
message_id, content_id = target
|
message_id, content_id = target
|
||||||
found = (
|
found = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Attachment, Message.blob_sha256)
|
select(Attachment, Message)
|
||||||
.join(Message, Message.id == Attachment.message_id)
|
.join(Message, Message.id == Attachment.message_id)
|
||||||
.where(
|
.where(
|
||||||
Attachment.message_id == message_id,
|
Attachment.message_id == message_id,
|
||||||
@@ -76,10 +77,11 @@ async def embed(
|
|||||||
).first()
|
).first()
|
||||||
part: InlinePart | None = None
|
part: InlinePart | None = None
|
||||||
if found is not None:
|
if found is not None:
|
||||||
att, message_sha = found
|
att, message = found
|
||||||
try:
|
try:
|
||||||
|
await rawstore.ensure(message) # a hosted mailbox's: fetched if need be
|
||||||
data = await asyncio.to_thread(
|
data = await asyncio.to_thread(
|
||||||
att_store.load, user_id, message_sha, att.blob_sha256, att.part_index
|
att_store.load, user_id, message.blob_sha256, att.blob_sha256, att.part_index
|
||||||
)
|
)
|
||||||
cid = make_msgid(domain=domain)[1:-1]
|
cid = make_msgid(domain=domain)[1:-1]
|
||||||
part = InlinePart(cid, att.mime_type, att.filename, data)
|
part = InlinePart(cid, att.mime_type, att.filename, data)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from app.db import session_scope
|
from app.db import session_scope
|
||||||
from app.events import get_event_bus
|
from app.events import get_event_bus
|
||||||
from app.indexer import blobstore
|
from app.indexer import rawstore
|
||||||
from app.models import Account, Mailbox, Message, Thread, User
|
from app.models import Account, Mailbox, Message, Thread, User
|
||||||
|
|
||||||
|
|
||||||
@@ -39,9 +39,8 @@ class PluginMessages:
|
|||||||
)
|
)
|
||||||
if msg is None or not msg.blob_sha256:
|
if msg is None or not msg.blob_sha256:
|
||||||
return None
|
return None
|
||||||
sha = msg.blob_sha256
|
|
||||||
try:
|
try:
|
||||||
raw = blobstore.load(user_id, sha)
|
raw = await rawstore.ensure_bytes(msg)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
return message_from_bytes(raw).get(name)
|
return message_from_bytes(raw).get(name)
|
||||||
@@ -60,9 +59,8 @@ class PluginMessages:
|
|||||||
)
|
)
|
||||||
if msg is None or not msg.blob_sha256:
|
if msg is None or not msg.blob_sha256:
|
||||||
return None
|
return None
|
||||||
sha = msg.blob_sha256
|
|
||||||
try:
|
try:
|
||||||
return blobstore.load(user_id, sha)
|
return await rawstore.ensure_bytes(msg)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ class ProviderConfig:
|
|||||||
smtp_tls: str # "ssl" | "starttls" | "plain"
|
smtp_tls: str # "ssl" | "starttls" | "plain"
|
||||||
username: str
|
username: str
|
||||||
password: str # plaintext; caller decrypts per §10.2
|
password: str # plaintext; caller decrypts per §10.2
|
||||||
|
# TLS settings other than the default (certificate checked): the hosted
|
||||||
|
# mail server on the internal network (app/hosted/native.py).
|
||||||
|
tls_context: ssl.SSLContext | None = None
|
||||||
|
# The hosted mail server: every user's mailbox is on that one host, so the
|
||||||
|
# per-host connection cap (which spares outside providers) does not apply.
|
||||||
|
internal: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ImapSmtpProvider(MailProvider):
|
class ImapSmtpProvider(MailProvider):
|
||||||
@@ -169,8 +175,11 @@ class ImapSmtpProvider(MailProvider):
|
|||||||
account = loop_semaphore(
|
account = loop_semaphore(
|
||||||
"imap_account", f"{cfg.username}@{cfg.imap_host}", _PER_ACCOUNT_CONN_CAP
|
"imap_account", f"{cfg.username}@{cfg.imap_host}", _PER_ACCOUNT_CONN_CAP
|
||||||
)
|
)
|
||||||
host = loop_semaphore("imap_host", cfg.imap_host, get_settings().per_host_conn_cap)
|
|
||||||
await account.acquire() # consistent order (account, then host) avoids deadlock
|
await account.acquire() # consistent order (account, then host) avoids deadlock
|
||||||
|
if cfg.internal:
|
||||||
|
self._conn_slots = [account]
|
||||||
|
return
|
||||||
|
host = loop_semaphore("imap_host", cfg.imap_host, get_settings().per_host_conn_cap)
|
||||||
try:
|
try:
|
||||||
await host.acquire()
|
await host.acquire()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
@@ -193,7 +202,10 @@ class ImapSmtpProvider(MailProvider):
|
|||||||
imap: aioimaplib.IMAP4
|
imap: aioimaplib.IMAP4
|
||||||
if cfg.imap_tls == "ssl":
|
if cfg.imap_tls == "ssl":
|
||||||
imap = _IMAP4_SSL(
|
imap = _IMAP4_SSL(
|
||||||
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
|
host=cfg.imap_host,
|
||||||
|
port=cfg.imap_port,
|
||||||
|
timeout=_CONNECT_TIMEOUT,
|
||||||
|
ssl_context=cfg.tls_context,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
imap = _IMAP4(host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT)
|
imap = _IMAP4(host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT)
|
||||||
@@ -356,6 +368,15 @@ class ImapSmtpProvider(MailProvider):
|
|||||||
resp = await imap.uid_search("ALL")
|
resp = await imap.uid_search("ALL")
|
||||||
return set(parse_search_uids(resp.lines))
|
return set(parse_search_uids(resp.lines))
|
||||||
|
|
||||||
|
async def fetch_raw(self, mailbox: str, uid: int) -> bytes | None:
|
||||||
|
"""One message's raw bytes, or None if it is not (or no longer) there."""
|
||||||
|
await self._select(mailbox)
|
||||||
|
imap = await self._connect()
|
||||||
|
for message in await self._fetch_messages(imap, [uid]):
|
||||||
|
if message.uid == uid:
|
||||||
|
return message.raw
|
||||||
|
return None
|
||||||
|
|
||||||
async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]:
|
async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]:
|
||||||
if not uids:
|
if not uids:
|
||||||
return []
|
return []
|
||||||
@@ -474,12 +495,12 @@ class ImapSmtpProvider(MailProvider):
|
|||||||
"""Create a mailbox on the server (idempotent — ignores 'already exists')."""
|
"""Create a mailbox on the server (idempotent — ignores 'already exists')."""
|
||||||
imap = await self._connect()
|
imap = await self._connect()
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await imap.create(path)
|
await imap.create(self._wire(path))
|
||||||
|
|
||||||
async def move(self, uid: int, src: str, dst: str) -> int:
|
async def move(self, uid: int, src: str, dst: str) -> int:
|
||||||
await self._select(src)
|
await self._select(src)
|
||||||
imap = await self._connect()
|
imap = await self._connect()
|
||||||
resp = await imap.uid("move", str(uid), dst)
|
resp = await imap.uid("move", str(uid), self._wire(dst))
|
||||||
if resp.result != "OK":
|
if resp.result != "OK":
|
||||||
raise ProviderConnectError(f"MOVE {uid} -> {dst} failed")
|
raise ProviderConnectError(f"MOVE {uid} -> {dst} failed")
|
||||||
for line in resp.lines:
|
for line in resp.lines:
|
||||||
@@ -521,7 +542,11 @@ class ImapSmtpProvider(MailProvider):
|
|||||||
cfg = self._cfg
|
cfg = self._cfg
|
||||||
use_tls = cfg.smtp_tls == "ssl"
|
use_tls = cfg.smtp_tls == "ssl"
|
||||||
start_tls = cfg.smtp_tls == "starttls"
|
start_tls = cfg.smtp_tls == "starttls"
|
||||||
host_slot = loop_semaphore("smtp_host", cfg.smtp_host, get_settings().per_host_conn_cap)
|
host_slot = (
|
||||||
|
contextlib.nullcontext()
|
||||||
|
if cfg.internal
|
||||||
|
else loop_semaphore("smtp_host", cfg.smtp_host, get_settings().per_host_conn_cap)
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
async with host_slot: # bound simultaneous sockets to the SMTP host (§4)
|
async with host_slot: # bound simultaneous sockets to the SMTP host (§4)
|
||||||
await aiosmtplib.send(
|
await aiosmtplib.send(
|
||||||
@@ -534,6 +559,7 @@ class ImapSmtpProvider(MailProvider):
|
|||||||
password=cfg.password,
|
password=cfg.password,
|
||||||
use_tls=use_tls,
|
use_tls=use_tls,
|
||||||
start_tls=start_tls,
|
start_tls=start_tls,
|
||||||
|
tls_context=cfg.tls_context,
|
||||||
timeout=_CONNECT_TIMEOUT,
|
timeout=_CONNECT_TIMEOUT,
|
||||||
)
|
)
|
||||||
except aiosmtplib.SMTPException as exc:
|
except aiosmtplib.SMTPException as exc:
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async def apply_flags_to_messages(
|
|||||||
if message.thread_id is not None:
|
if message.thread_id is not None:
|
||||||
thread_ids.add(message.thread_id)
|
thread_ids.add(message.thread_id)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
for tid in thread_ids:
|
for tid in sorted(thread_ids): # one lock order across transactions
|
||||||
await denormalize_thread(session, tid)
|
await denormalize_thread(session, tid)
|
||||||
return thread_ids
|
return thread_ids
|
||||||
|
|
||||||
@@ -504,7 +504,7 @@ async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str)
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
# Threads with no messages left get dropped (drafts un-linked first); the
|
# Threads with no messages left get dropped (drafts un-linked first); the
|
||||||
# rest are re-denormalized so their counts/snippet reflect the removal.
|
# rest are re-denormalized so their counts/snippet reflect the removal.
|
||||||
for tid in thread_ids:
|
for tid in sorted(thread_ids):
|
||||||
remaining = await session.scalar(
|
remaining = await session.scalar(
|
||||||
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
||||||
)
|
)
|
||||||
@@ -584,7 +584,7 @@ async def autopurge_trash_spam(ctx: dict[str, Any]) -> None:
|
|||||||
)
|
)
|
||||||
await session.execute(delete(Message).where(Message.id.in_(msg_ids)))
|
await session.execute(delete(Message).where(Message.id.in_(msg_ids)))
|
||||||
await session.flush()
|
await session.flush()
|
||||||
for tid in thread_ids:
|
for tid in sorted(thread_ids):
|
||||||
remaining = await session.scalar(
|
remaining = await session.scalar(
|
||||||
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
select(func.count()).select_from(Message).where(Message.thread_id == tid)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -354,7 +354,18 @@ async def apply_delta(
|
|||||||
if delta.new_uidvalidity is not None:
|
if delta.new_uidvalidity is not None:
|
||||||
# UIDVALIDITY changed: wipe this mailbox's rows and re-backfill (SPEC §6.3).
|
# UIDVALIDITY changed: wipe this mailbox's rows and re-backfill (SPEC §6.3).
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
|
# Their threads are recounted: a message not brought back by the
|
||||||
|
# re-import (gone meanwhile) must not stay counted.
|
||||||
|
wiped_threads: set[uuid.UUID] = {
|
||||||
|
tid
|
||||||
|
for tid in await session.scalars(
|
||||||
|
select(Message.thread_id).where(Message.mailbox_id == mailbox.id)
|
||||||
|
)
|
||||||
|
if tid is not None
|
||||||
|
}
|
||||||
await session.execute(delete(Message).where(Message.mailbox_id == mailbox.id))
|
await session.execute(delete(Message).where(Message.mailbox_id == mailbox.id))
|
||||||
|
for tid in sorted(wiped_threads):
|
||||||
|
await denormalize_thread(session, tid)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(Mailbox)
|
update(Mailbox)
|
||||||
.where(Mailbox.id == mailbox.id)
|
.where(Mailbox.id == mailbox.id)
|
||||||
@@ -428,7 +439,7 @@ async def apply_delta(
|
|||||||
)
|
)
|
||||||
# After the deletes, so a thread left with no messages is removed rather
|
# After the deletes, so a thread left with no messages is removed rather
|
||||||
# than recounted (denormalize_thread handles that case).
|
# than recounted (denormalize_thread handles that case).
|
||||||
for tid in touched_threads:
|
for tid in sorted(touched_threads): # one lock order (denormalize_thread)
|
||||||
await denormalize_thread(session, tid)
|
await denormalize_thread(session, tid)
|
||||||
|
|
||||||
updates = {"last_seen_uid": max_uid}
|
updates = {"last_seen_uid": max_uid}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
This module runs only inside the worker/sync context; ``decrypt_secret`` (the
|
This module runs only inside the worker/sync context; ``decrypt_secret`` (the
|
||||||
credential vault) is invoked here to obtain the plaintext mailbox password. It
|
credential vault) is invoked here to obtain the plaintext mailbox password. It
|
||||||
must never be imported by API request code.
|
must never be imported by API request code.
|
||||||
|
|
||||||
|
A native account (the user's hosted mailbox, App. D §D.4) has no stored
|
||||||
|
password: the worker signs in as itself, acting for the user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -14,6 +17,10 @@ from app.security.crypto import decrypt_secret
|
|||||||
|
|
||||||
def build_provider(account: Account) -> ImapSmtpProvider:
|
def build_provider(account: Account) -> ImapSmtpProvider:
|
||||||
"""Construct an :class:`ImapSmtpProvider` for ``account`` (password auth, M0)."""
|
"""Construct an :class:`ImapSmtpProvider` for ``account`` (password auth, M0)."""
|
||||||
|
if account.kind == "native":
|
||||||
|
from app.hosted.native import native_provider_config
|
||||||
|
|
||||||
|
return ImapSmtpProvider(native_provider_config(account.email))
|
||||||
imap = account.imap_config
|
imap = account.imap_config
|
||||||
smtp = account.smtp_config
|
smtp = account.smtp_config
|
||||||
password = decrypt_secret(account.secret_ct).decode("utf-8")
|
password = decrypt_secret(account.secret_ct).decode("utf-8")
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from sqlalchemy import ColumnElement, delete, literal, select, update
|
from sqlalchemy import ColumnElement, delete, exists, func, literal, select, update
|
||||||
from sqlalchemy import any_ as sa_any
|
from sqlalchemy import any_ as sa_any
|
||||||
from sqlalchemy import or_ as sa_or
|
from sqlalchemy import or_ as sa_or
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models import Message, Thread
|
from app.models import Draft, Message, Thread
|
||||||
from app.threading_.normalize import is_synthetic, normalize_subject
|
from app.threading_.normalize import is_synthetic, normalize_subject
|
||||||
|
|
||||||
_SUBJECT_FALLBACK_WINDOW = timedelta(days=7)
|
_SUBJECT_FALLBACK_WINDOW = timedelta(days=7)
|
||||||
@@ -172,18 +172,38 @@ async def _create_thread(session: AsyncSession, message: Message) -> uuid.UUID:
|
|||||||
|
|
||||||
|
|
||||||
async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> None:
|
async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> None:
|
||||||
"""Recompute a thread's denormalized fields from its messages (SPEC §7.2.6)."""
|
"""Recompute a thread's denormalized fields from its messages (SPEC §7.2.6).
|
||||||
|
|
||||||
|
The thread row is locked *before* its messages are read: two transactions
|
||||||
|
recounting one thread at once (read in the webmail while a sync brings the
|
||||||
|
same flag from a phone) then take turns, and the second reads what the
|
||||||
|
first committed — instead of both reading first and the slower one writing
|
||||||
|
an old count (a message shown read, its thread still "unread"). The
|
||||||
|
messages are reloaded from the database, not taken from the session's
|
||||||
|
copies, which a bulk UPDATE may have left behind."""
|
||||||
|
thread = await session.scalar(
|
||||||
|
select(Thread)
|
||||||
|
.where(Thread.id == thread_id)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
if thread is None:
|
||||||
|
return
|
||||||
messages = list(
|
messages = list(
|
||||||
await session.scalars(
|
await session.scalars(
|
||||||
select(Message)
|
select(Message)
|
||||||
.where(Message.thread_id == thread_id)
|
.where(Message.thread_id == thread_id)
|
||||||
.order_by(Message.internal_date.desc())
|
.order_by(Message.internal_date.desc())
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
thread = await session.get(Thread, thread_id)
|
|
||||||
if thread is None:
|
|
||||||
return
|
|
||||||
if not messages:
|
if not messages:
|
||||||
|
# A reply still being written keeps its (now empty) thread: the draft
|
||||||
|
# points at it.
|
||||||
|
if await session.scalar(select(exists().where(Draft.thread_id == thread_id))):
|
||||||
|
thread.message_count = 0
|
||||||
|
thread.unread_count = 0
|
||||||
|
return
|
||||||
await session.execute(delete(Thread).where(Thread.id == thread_id))
|
await session.execute(delete(Thread).where(Thread.id == thread_id))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -241,3 +261,38 @@ async def thread_message(session: AsyncSession, message: Message) -> uuid.UUID:
|
|||||||
await denormalize_thread(session, thread_id)
|
await denormalize_thread(session, thread_id)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return thread_id
|
return thread_id
|
||||||
|
|
||||||
|
|
||||||
|
async def repair_threads(session: AsyncSession, user_id: uuid.UUID) -> int:
|
||||||
|
"""Recount the user's threads whose stored counters disagree with their
|
||||||
|
messages (and drop threads left without any). A safety net run nightly
|
||||||
|
(app/indexer/gc.py): every write path recounts its threads, but a missed
|
||||||
|
one would otherwise show a read message as unread until it changed again.
|
||||||
|
Returns how many were repaired."""
|
||||||
|
seen = Message.flags.any("\\Seen") # type: ignore[arg-type]
|
||||||
|
actual = (
|
||||||
|
select(
|
||||||
|
Message.thread_id.label("tid"),
|
||||||
|
func.count().label("n"),
|
||||||
|
func.count().filter(~seen).label("unread"),
|
||||||
|
)
|
||||||
|
.where(Message.user_id == user_id, Message.thread_id.is_not(None))
|
||||||
|
.group_by(Message.thread_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stale = list(
|
||||||
|
await session.scalars(
|
||||||
|
select(Thread.id)
|
||||||
|
.outerjoin(actual, actual.c.tid == Thread.id)
|
||||||
|
.where(
|
||||||
|
Thread.user_id == user_id,
|
||||||
|
(actual.c.tid.is_(None) & (Thread.message_count != 0))
|
||||||
|
| (actual.c.tid.is_(None) & ~exists().where(Draft.thread_id == Thread.id))
|
||||||
|
| (actual.c.n != Thread.message_count)
|
||||||
|
| (actual.c.unread != Thread.unread_count),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for tid in sorted(stale):
|
||||||
|
await denormalize_thread(session, tid)
|
||||||
|
return len(stale)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""hosted mailboxes: native accounts and the mail server's directory (SPEC App. D)
|
||||||
|
|
||||||
|
* ``accounts.kind`` — ``external`` (any IMAP/SMTP server, the user's own
|
||||||
|
password in the vault) or ``native`` (the user's mailbox on the hosted mail
|
||||||
|
server: permanent, signed in to without any stored password, §D.4).
|
||||||
|
* ``mail_directory`` — the view the mail server's SQL directory reads (§D.3):
|
||||||
|
one row per user, its address — and no password: the webmail password never
|
||||||
|
opens the mailbox in a mail app, only app passwords do (the mail server's
|
||||||
|
own, §D.5) — and ``active`` false for a blocked user. Plus the service accounts of
|
||||||
|
``mail_service_accounts``: the webmail's own worker, which may act for any
|
||||||
|
mailbox and nothing else.
|
||||||
|
* ``user_app_passwords.server_id`` — the mail server's id of the same app
|
||||||
|
password (§D.5), to revoke it there too.
|
||||||
|
|
||||||
|
The mail server reads the view as its own role, given SELECT on it alone by
|
||||||
|
``app.hosted.provision`` (the role is created there, with its password).
|
||||||
|
|
||||||
|
Revision ID: 0025
|
||||||
|
Revises: 0024
|
||||||
|
Create Date: 2026-10-11
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0025"
|
||||||
|
down_revision: str | None = "0024"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE accounts ADD COLUMN kind text NOT NULL DEFAULT 'external'")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE accounts ADD CONSTRAINT ck_accounts_kind "
|
||||||
|
"CHECK (kind IN ('external', 'native'))"
|
||||||
|
)
|
||||||
|
# One mailbox per user.
|
||||||
|
op.execute("CREATE UNIQUE INDEX uq_accounts_native ON accounts (user_id) WHERE kind = 'native'")
|
||||||
|
op.execute("ALTER TABLE user_app_passwords ADD COLUMN server_id text")
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE mail_service_accounts (
|
||||||
|
name citext PRIMARY KEY,
|
||||||
|
secret text NOT NULL,
|
||||||
|
description text NOT NULL DEFAULT ''
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE VIEW mail_directory AS
|
||||||
|
SELECT u.handle AS name,
|
||||||
|
NULL::text AS secret,
|
||||||
|
'individual'::text AS type,
|
||||||
|
u.handle::text AS description,
|
||||||
|
u.blocked_at IS NULL AS active
|
||||||
|
FROM users u
|
||||||
|
UNION ALL
|
||||||
|
SELECT s.name, s.secret, 'individual'::text, s.description, true
|
||||||
|
FROM mail_service_accounts s
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP VIEW IF EXISTS mail_directory")
|
||||||
|
op.execute("DROP TABLE IF EXISTS mail_service_accounts")
|
||||||
|
op.execute("ALTER TABLE user_app_passwords DROP COLUMN IF EXISTS server_id")
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_accounts_native")
|
||||||
|
op.execute("ALTER TABLE accounts DROP CONSTRAINT IF EXISTS ck_accounts_kind")
|
||||||
|
op.execute("ALTER TABLE accounts DROP COLUMN IF EXISTS kind")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""IMAP ids as 64-bit integers
|
||||||
|
|
||||||
|
UIDVALIDITY and UIDs are unsigned 32-bit numbers (RFC 9051), MODSEQ up to 63
|
||||||
|
bits (RFC 7162); Postgres ``integer`` is signed 32-bit. Servers that hand out
|
||||||
|
values above 2^31 — the hosted mail server's UIDVALIDITY, for one — could not
|
||||||
|
be stored ("value out of int32 range") and their folders never synced.
|
||||||
|
|
||||||
|
Revision ID: 0026
|
||||||
|
Revises: 0025
|
||||||
|
Create Date: 2026-10-11
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0026"
|
||||||
|
down_revision: str | None = "0025"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_COLUMNS = (
|
||||||
|
("mailboxes", "uidvalidity"),
|
||||||
|
("mailboxes", "highestmodseq"),
|
||||||
|
("mailboxes", "last_seen_uid"),
|
||||||
|
("messages", "uid"),
|
||||||
|
("drafts", "imap_uid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for table, column in _COLUMNS:
|
||||||
|
op.execute(f"ALTER TABLE {table} ALTER COLUMN {column} TYPE bigint")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table, column in _COLUMNS:
|
||||||
|
op.execute(f"ALTER TABLE {table} ALTER COLUMN {column} TYPE integer")
|
||||||
@@ -18,6 +18,19 @@ os.environ.setdefault(
|
|||||||
os.environ["REDIS_URL"] = "redis://127.0.0.1:6380/15"
|
os.environ["REDIS_URL"] = "redis://127.0.0.1:6380/15"
|
||||||
os.environ.setdefault("MASTER_KEY", base64.b64encode(b"\x00" * 32).decode())
|
os.environ.setdefault("MASTER_KEY", base64.b64encode(b"\x00" * 32).decode())
|
||||||
os.environ.setdefault("BLOB_ROOT", "./var/blobs-test")
|
os.environ.setdefault("BLOB_ROOT", "./var/blobs-test")
|
||||||
|
# Hosted mailboxes off: tests that need the mail server turn them on themselves
|
||||||
|
# (tests/hosted/), against the dev stack's Stalwart container.
|
||||||
|
os.environ["HOSTED_MAILBOXES"] = "false"
|
||||||
|
for _key, _value in {
|
||||||
|
"STALWART_URL": "http://127.0.0.1:8180",
|
||||||
|
"STALWART_ADMIN_SECRET": "stalwart_admin_dev",
|
||||||
|
"STALWART_MASTER_SECRET": "stalwart_master_dev",
|
||||||
|
"STALWART_IMAP_HOST": "127.0.0.1",
|
||||||
|
"STALWART_IMAP_PORT": "1993",
|
||||||
|
"STALWART_SMTP_HOST": "127.0.0.1",
|
||||||
|
"STALWART_SMTP_PORT": "1465",
|
||||||
|
}.items():
|
||||||
|
os.environ.setdefault(_key, _value)
|
||||||
|
|
||||||
from collections.abc import AsyncIterator # noqa: E402
|
from collections.abc import AsyncIterator # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
"""Hosted mailboxes (SPEC App. D) against the dev stack's Stalwart container.
|
||||||
|
|
||||||
|
Sign-in through the directory (password, 2FA, block), app passwords made by the
|
||||||
|
mail server, the permanent native account, and raw messages kept only by the
|
||||||
|
mail server (fetched back on demand). Skipped when the container is not up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import imaplib
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import session_scope
|
||||||
|
from app.hosted import admin, native
|
||||||
|
from app.identity import app_passwords
|
||||||
|
from app.identity.passwords import hash_password
|
||||||
|
from app.identity.sessions import create_session
|
||||||
|
from app.identity.user_admin import delete_user, set_blocked
|
||||||
|
from app.indexer import blobstore, rawstore
|
||||||
|
from app.main import create_app
|
||||||
|
from app.models import Account, Attachment, Mailbox, Message, User
|
||||||
|
|
||||||
|
PASSWORD = "Correct-Horse-7"
|
||||||
|
|
||||||
|
|
||||||
|
async def _server_domain() -> str | None:
|
||||||
|
try:
|
||||||
|
settings = await admin.get_one("SystemSettings")
|
||||||
|
domain = await admin.get_one("Domain", str(settings["defaultDomainId"]))
|
||||||
|
except admin.MailServerError:
|
||||||
|
return None
|
||||||
|
return str(domain["name"])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Ctx:
|
||||||
|
user: User
|
||||||
|
web: httpx.AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def ctx(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Ctx]:
|
||||||
|
domain = await _server_domain()
|
||||||
|
if domain is None:
|
||||||
|
pytest.skip("the dev mail server (docker compose service stalwart) is not running")
|
||||||
|
settings = get_settings()
|
||||||
|
monkeypatch.setattr(settings, "hosted_mailboxes", True)
|
||||||
|
monkeypatch.setattr(settings, "app_domain", domain)
|
||||||
|
handle = f"hosted{uuid.uuid4().hex[:8]}@{domain}"
|
||||||
|
async with session_scope() as db:
|
||||||
|
user = User(handle=handle, password_hash=hash_password(PASSWORD))
|
||||||
|
db.add(user)
|
||||||
|
token = await create_session(user.id, "pytest")
|
||||||
|
web = httpx.AsyncClient(
|
||||||
|
transport=ASGITransport(app=create_app()),
|
||||||
|
base_url="http://testserver",
|
||||||
|
cookies={"session": token, "csrf": "c"},
|
||||||
|
headers={"X-CSRF": "c"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield Ctx(user, web)
|
||||||
|
finally:
|
||||||
|
await web.aclose()
|
||||||
|
await admin.delete_account(handle)
|
||||||
|
async with session_scope() as db:
|
||||||
|
await db.execute(delete(User).where(User.id == user.id))
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_login(user: str, password: str) -> bool:
|
||||||
|
tls = ssl.create_default_context()
|
||||||
|
tls.check_hostname = False
|
||||||
|
tls.verify_mode = ssl.CERT_NONE
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
# A blocked account's sign-in is not answered: the server drops the
|
||||||
|
# connection after a minute. Not answering within seconds is a refusal.
|
||||||
|
conn = imaplib.IMAP4_SSL(
|
||||||
|
settings.stalwart_imap_host, settings.stalwart_imap_port, ssl_context=tls, timeout=5
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
conn.login(user, password)
|
||||||
|
conn.logout()
|
||||||
|
return True
|
||||||
|
except (imaplib.IMAP4.error, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _can_sign_in(user: str, password: str) -> bool:
|
||||||
|
return await asyncio.to_thread(_imap_login, user, password)
|
||||||
|
|
||||||
|
|
||||||
|
def _deliver(to: str, subject: str, attachment: bytes) -> None:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = "Anna Test <anna@example.org>"
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content("Hello,\n\nthe report is attached.\n")
|
||||||
|
msg.add_attachment(attachment, maintype="application", subtype="octet-stream", filename="r.bin")
|
||||||
|
with smtplib.SMTP(get_settings().stalwart_imap_host, 1025, timeout=20) as smtp:
|
||||||
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_webmail_password_never_opens_the_mailbox(ctx: Ctx) -> None:
|
||||||
|
"""Mail apps sign in with app passwords only — 2FA or not."""
|
||||||
|
assert not await _can_sign_in(ctx.user.handle, PASSWORD)
|
||||||
|
assert not await _can_sign_in(ctx.user.handle, "wrong")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_app_passwords_work_for_mail_apps_and_dav_and_are_revoked_everywhere(
|
||||||
|
ctx: Ctx,
|
||||||
|
) -> None:
|
||||||
|
handle = ctx.user.handle
|
||||||
|
async with session_scope() as db:
|
||||||
|
row, password = await app_passwords.create(db, ctx.user.id, "Thunderbird")
|
||||||
|
assert password.startswith("app_") and len(password) == app_passwords.SERVER_LENGTH
|
||||||
|
assert row.server_id
|
||||||
|
assert await _can_sign_in(handle, password)
|
||||||
|
async with session_scope() as db:
|
||||||
|
assert await app_passwords.verify(db, ctx.user.id, password) is not None # DAV
|
||||||
|
|
||||||
|
# 2FA turned on changes nothing for it.
|
||||||
|
async with session_scope() as db:
|
||||||
|
user = await db.get(User, ctx.user.id)
|
||||||
|
assert user is not None
|
||||||
|
user.totp_secret = b"x"
|
||||||
|
assert await _can_sign_in(handle, password)
|
||||||
|
|
||||||
|
# Blocked: nothing opens the mailbox; unblocked: it does again.
|
||||||
|
async with session_scope() as db:
|
||||||
|
user = await db.get(User, ctx.user.id)
|
||||||
|
assert user is not None
|
||||||
|
await set_blocked(db, user, True)
|
||||||
|
assert not await _can_sign_in(handle, password)
|
||||||
|
async with session_scope() as db:
|
||||||
|
user = await db.get(User, ctx.user.id)
|
||||||
|
assert user is not None
|
||||||
|
await set_blocked(db, user, False)
|
||||||
|
assert await _can_sign_in(handle, password)
|
||||||
|
|
||||||
|
async with session_scope() as db:
|
||||||
|
assert await app_passwords.revoke(db, ctx.user.id, row.id)
|
||||||
|
assert not await _can_sign_in(handle, password)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_native_account_keeps_mail_once_and_cannot_be_removed(ctx: Ctx) -> None:
|
||||||
|
from app.sync.engine import bootstrap_and_backfill
|
||||||
|
|
||||||
|
async with session_scope() as db:
|
||||||
|
user = await db.get(User, ctx.user.id)
|
||||||
|
assert user is not None
|
||||||
|
account = await native.ensure(db, user)
|
||||||
|
assert account is not None
|
||||||
|
account_id = account.id
|
||||||
|
payload = uuid.uuid4().bytes * 5000
|
||||||
|
await asyncio.to_thread(_deliver, ctx.user.handle, "Report", payload)
|
||||||
|
|
||||||
|
# Synced like any IMAP account (delivery is asynchronous: retry briefly).
|
||||||
|
message = None
|
||||||
|
for _ in range(20):
|
||||||
|
await bootstrap_and_backfill(account_id)
|
||||||
|
async with session_scope() as db:
|
||||||
|
message = await db.scalar(
|
||||||
|
select(Message).where(Message.account_id == account_id, Message.subject == "Report")
|
||||||
|
)
|
||||||
|
if message is not None:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
assert message is not None
|
||||||
|
|
||||||
|
# A new mailbox has the plain folder names (not "Deleted Items", …).
|
||||||
|
async with session_scope() as db:
|
||||||
|
folders = dict(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(Mailbox.special_use, Mailbox.path).where(
|
||||||
|
Mailbox.account_id == account_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
assert folders["trash"] == "Trash" and folders["sent"] == "Sent"
|
||||||
|
assert folders["junk"] == "Junk" and folders["archive"] == "Archive"
|
||||||
|
|
||||||
|
# The raw message is not kept for good — only cached.
|
||||||
|
sha = message.blob_sha256
|
||||||
|
assert not blobstore.exists(ctx.user.id, sha)
|
||||||
|
assert rawstore.cache_path(ctx.user.id, sha).exists()
|
||||||
|
|
||||||
|
# Gone from the cache, the attachment is fetched back from the mail server.
|
||||||
|
rawstore.drop(ctx.user.id, sha)
|
||||||
|
async with session_scope() as db:
|
||||||
|
att = await db.scalar(select(Attachment).where(Attachment.message_id == message.id))
|
||||||
|
assert att is not None
|
||||||
|
got = await ctx.web.get(f"/api/messages/{message.id}/attachments/{att.id}?download=1")
|
||||||
|
assert got.status_code == 200 and got.content == payload
|
||||||
|
assert rawstore.cache_path(ctx.user.id, sha).exists()
|
||||||
|
|
||||||
|
# Permanent: not removable, its server settings not changeable.
|
||||||
|
listed = (await ctx.web.get("/api/accounts")).json()
|
||||||
|
assert [a["kind"] for a in listed] == ["native"]
|
||||||
|
assert (await ctx.web.delete(f"/api/accounts/{account_id}")).status_code == 409
|
||||||
|
patched = await ctx.web.patch(f"/api/accounts/{account_id}", json={"password": "x"})
|
||||||
|
assert patched.status_code == 400 and patched.json()["error"]["code"] == "native_account"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deleting_the_user_deletes_the_mailbox(ctx: Ctx) -> None:
|
||||||
|
await admin.ensure_account(ctx.user.handle) # brings it into the server
|
||||||
|
assert await admin.account_id(ctx.user.handle) is not None
|
||||||
|
async with session_scope() as db:
|
||||||
|
user = await db.get(User, ctx.user.id)
|
||||||
|
assert user is not None
|
||||||
|
await delete_user(db, user)
|
||||||
|
assert await admin.account_id(ctx.user.handle) is None
|
||||||
|
async with session_scope() as db:
|
||||||
|
assert await db.scalar(select(Account.id).where(Account.user_id == ctx.user.id)) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_cache_keeps_the_most_recently_used(ctx: Ctx) -> None:
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
uid = ctx.user.id
|
||||||
|
old = rawstore.put_cached(uid, b"a" * 1000)
|
||||||
|
new = rawstore.put_cached(uid, b"b" * 1000)
|
||||||
|
past = time.time() - 60
|
||||||
|
os.utime(rawstore.cache_path(uid, old), (past, past))
|
||||||
|
rawstore.prune(uid, max_bytes=1500)
|
||||||
|
assert not rawstore.cache_path(uid, old).exists()
|
||||||
|
assert rawstore.cache_path(uid, new).exists()
|
||||||
|
stale = time.time() - rawstore.KEEP_S - 10
|
||||||
|
os.utime(rawstore.cache_path(uid, new), (stale, stale))
|
||||||
|
rawstore.prune(uid)
|
||||||
|
assert not rawstore.cache_path(uid, new).exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_moves_reach_folders_with_spaces_in_their_names(ctx: Ctx) -> None:
|
||||||
|
"""Older mailboxes (and many providers) say "Deleted Items": the name goes
|
||||||
|
over the wire quoted, or the move is refused and Trash never fills."""
|
||||||
|
from app.providers.imap_smtp import ImapSmtpProvider
|
||||||
|
|
||||||
|
await admin.ensure_account(ctx.user.handle)
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = "a@example.com"
|
||||||
|
msg["To"] = ctx.user.handle
|
||||||
|
msg["Subject"] = "Swipe me"
|
||||||
|
msg.set_content("x")
|
||||||
|
provider = ImapSmtpProvider(native.native_provider_config(ctx.user.handle))
|
||||||
|
try:
|
||||||
|
await provider.create_mailbox("Deleted Items")
|
||||||
|
uid = await provider.append("INBOX", msg.as_bytes(), frozenset())
|
||||||
|
moved = await provider.move(uid, "INBOX", "Deleted Items")
|
||||||
|
assert moved > 0
|
||||||
|
finally:
|
||||||
|
await provider.close()
|
||||||
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from app.indexer import indexer
|
from app.indexer import indexer
|
||||||
from app.indexer.mime import parse_message, parse_message_file
|
from app.indexer.mime import parse_message, parse_message_file
|
||||||
@@ -50,6 +51,8 @@ async def test_parse_bounded_streams_large_messages(monkeypatch) -> None: # typ
|
|||||||
|
|
||||||
monkeypatch.setattr(indexer, "parse_message_file", fake_file)
|
monkeypatch.setattr(indexer, "parse_message_file", fake_file)
|
||||||
monkeypatch.setattr(indexer, "parse_message", fake_bytes)
|
monkeypatch.setattr(indexer, "parse_message", fake_bytes)
|
||||||
|
# The message's file, written before parsing (blob store or raw cache).
|
||||||
|
monkeypatch.setattr(indexer.rawstore, "locate", lambda user_id, sha: Path("/blob"))
|
||||||
|
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
small = await indexer._parse_bounded(user_id, "sha", b"x" * 16)
|
small = await indexer._parse_bounded(user_id, "sha", b"x" * 16)
|
||||||
|
|||||||
@@ -115,7 +115,15 @@ async def test_settings_defaults_then_merge(ctx: _Ctx) -> None:
|
|||||||
s = (await ctx.client_a.get(f"{BASE}/settings")).json()
|
s = (await ctx.client_a.get(f"{BASE}/settings")).json()
|
||||||
assert (s["move_threshold"], s["banner_threshold"]) == (12.0, 6.0)
|
assert (s["move_threshold"], s["banner_threshold"]) == (12.0, 6.0)
|
||||||
assert s["network_checks"] is False # privacy default (§C.2)
|
assert s["network_checks"] is False # privacy default (§C.2)
|
||||||
assert s["enabled_accounts"] == [] # empty = every account
|
assert s["enabled_accounts"] is None # every account
|
||||||
|
|
||||||
|
# Every account off is stored as such (not read back as "all on"), and
|
||||||
|
# all_accounts goes back to every account.
|
||||||
|
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"enabled_accounts": []})).json()
|
||||||
|
assert s["enabled_accounts"] == []
|
||||||
|
assert (await ctx.client_a.get(f"{BASE}/settings")).json()["enabled_accounts"] == []
|
||||||
|
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"all_accounts": True})).json()
|
||||||
|
assert s["enabled_accounts"] is None
|
||||||
|
|
||||||
# PATCH merges: the field we leave out keeps its value.
|
# PATCH merges: the field we leave out keeps its value.
|
||||||
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 15.0})).json()
|
s = (await ctx.client_a.patch(f"{BASE}/settings", json={"move_threshold": 15.0})).json()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from webmail_plugin_antispam import policy, rspamd
|
|||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
SETTINGS = policy.Settings(
|
SETTINGS = policy.Settings(
|
||||||
enabled_accounts=[],
|
enabled_accounts=None,
|
||||||
move_threshold=12.0,
|
move_threshold=12.0,
|
||||||
banner_threshold=6.0,
|
banner_threshold=6.0,
|
||||||
network_checks=False,
|
network_checks=False,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def wired(monkeypatch: pytest.MonkeyPatch): # type: ignore[no-untyped-def]
|
|||||||
|
|
||||||
async def _settings(_u: object) -> policy.Settings:
|
async def _settings(_u: object) -> policy.Settings:
|
||||||
return policy.Settings(
|
return policy.Settings(
|
||||||
enabled_accounts=[],
|
enabled_accounts=None,
|
||||||
move_threshold=12.0,
|
move_threshold=12.0,
|
||||||
banner_threshold=6.0,
|
banner_threshold=6.0,
|
||||||
network_checks=False,
|
network_checks=False,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import pytest
|
|||||||
from webmail_plugin_antispam import hook, jobs, policy
|
from webmail_plugin_antispam import hook, jobs, policy
|
||||||
|
|
||||||
SETTINGS = policy.Settings(
|
SETTINGS = policy.Settings(
|
||||||
enabled_accounts=[],
|
enabled_accounts=None,
|
||||||
move_threshold=12.0,
|
move_threshold=12.0,
|
||||||
banner_threshold=6.0,
|
banner_threshold=6.0,
|
||||||
network_checks=False,
|
network_checks=False,
|
||||||
@@ -169,7 +169,7 @@ async def test_bootstrap_respects_the_setting(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
|
|
||||||
async def _settings(_u: object) -> policy.Settings:
|
async def _settings(_u: object) -> policy.Settings:
|
||||||
return policy.Settings(
|
return policy.Settings(
|
||||||
enabled_accounts=[],
|
enabled_accounts=None,
|
||||||
move_threshold=12.0,
|
move_threshold=12.0,
|
||||||
banner_threshold=6.0,
|
banner_threshold=6.0,
|
||||||
network_checks=False,
|
network_checks=False,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
from webmail_plugin_antispam.policy import Settings, decide, matches
|
from webmail_plugin_antispam.policy import Settings, decide, matches
|
||||||
|
|
||||||
SETTINGS = Settings(
|
SETTINGS = Settings(
|
||||||
enabled_accounts=[],
|
enabled_accounts=None,
|
||||||
move_threshold=12.0,
|
move_threshold=12.0,
|
||||||
banner_threshold=6.0,
|
banner_threshold=6.0,
|
||||||
network_checks=False,
|
network_checks=False,
|
||||||
@@ -79,7 +79,7 @@ def test_pattern_matching(patterns: set[str], email: str, hit: bool) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_per_account_enablement() -> None:
|
def test_per_account_enablement() -> None:
|
||||||
"""Empty list means every account; a populated one is an allow-list."""
|
"""None means every account; a list is an allow-list — empty, none."""
|
||||||
everywhere = SETTINGS
|
everywhere = SETTINGS
|
||||||
assert everywhere.covers("any-account-id")
|
assert everywhere.covers("any-account-id")
|
||||||
|
|
||||||
@@ -93,6 +93,15 @@ def test_per_account_enablement() -> None:
|
|||||||
assert scoped.covers("acc-1")
|
assert scoped.covers("acc-1")
|
||||||
assert not scoped.covers("acc-2")
|
assert not scoped.covers("acc-2")
|
||||||
|
|
||||||
|
nowhere = Settings(
|
||||||
|
enabled_accounts=[],
|
||||||
|
move_threshold=12.0,
|
||||||
|
banner_threshold=6.0,
|
||||||
|
network_checks=False,
|
||||||
|
learn_from_provider_junk=True,
|
||||||
|
)
|
||||||
|
assert not nowhere.covers("acc-1")
|
||||||
|
|
||||||
|
|
||||||
# --- contacts are never filed as spam ----------------------------------------
|
# --- contacts are never filed as spam ----------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Thread counters that drifted from their messages are repaired (nightly safety
|
||||||
|
net, app/threading_/threader.py repair_threads)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select, update
|
||||||
|
|
||||||
|
from app.db import session_scope
|
||||||
|
from app.identity.passwords import hash_password
|
||||||
|
from app.indexer.indexer import persist_message
|
||||||
|
from app.models import Account, Draft, Mailbox, Thread, User
|
||||||
|
from app.providers.base import RemoteMessage
|
||||||
|
from app.threading_.threader import repair_threads
|
||||||
|
|
||||||
|
|
||||||
|
def _raw(subject: str) -> bytes:
|
||||||
|
m = EmailMessage()
|
||||||
|
m["Subject"] = subject
|
||||||
|
m["From"] = "billing@example.com"
|
||||||
|
m["To"] = "me@example.com"
|
||||||
|
m["Message-ID"] = f"<{uuid.uuid4().hex}@example.com>"
|
||||||
|
m.set_content("receipt")
|
||||||
|
return m.as_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repair_recounts_stale_threads_and_drops_empty_ones() -> None:
|
||||||
|
async with session_scope() as db:
|
||||||
|
user = User(
|
||||||
|
handle=f"rep{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
account = Account(
|
||||||
|
user_id=user.id,
|
||||||
|
email="me@example.com",
|
||||||
|
imap_config={},
|
||||||
|
smtp_config={},
|
||||||
|
secret_ct=b"",
|
||||||
|
discovery_src="manual",
|
||||||
|
)
|
||||||
|
db.add(account)
|
||||||
|
await db.flush()
|
||||||
|
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||||
|
db.add(inbox)
|
||||||
|
await db.flush()
|
||||||
|
result = await persist_message(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
account_id=account.id,
|
||||||
|
mailbox_id=inbox.id,
|
||||||
|
mailbox_special_use="inbox",
|
||||||
|
remote=RemoteMessage(
|
||||||
|
uid=1,
|
||||||
|
raw=_raw("Your receipt"),
|
||||||
|
flags=frozenset({"\\Seen"}),
|
||||||
|
internal_date=datetime.now(UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
tid = result.message.thread_id
|
||||||
|
assert tid is not None
|
||||||
|
# A read message whose thread still says "unread" (the bug), and a
|
||||||
|
# thread left behind with no messages at all.
|
||||||
|
await db.execute(update(Thread).where(Thread.id == tid).values(unread_count=1))
|
||||||
|
empty = Thread(
|
||||||
|
user_id=user.id,
|
||||||
|
subject_norm="gone",
|
||||||
|
message_count=1,
|
||||||
|
unread_count=1,
|
||||||
|
last_message_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(empty)
|
||||||
|
# An emptied thread a reply draft still points at: kept, counts zeroed.
|
||||||
|
replied = Thread(
|
||||||
|
user_id=user.id,
|
||||||
|
subject_norm="replying",
|
||||||
|
message_count=1,
|
||||||
|
unread_count=0,
|
||||||
|
last_message_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(replied)
|
||||||
|
await db.flush()
|
||||||
|
db.add(Draft(user_id=user.id, account_id=account.id, thread_id=replied.id, payload={}))
|
||||||
|
await db.flush()
|
||||||
|
empty_id = empty.id
|
||||||
|
replied_id = replied.id
|
||||||
|
try:
|
||||||
|
async with session_scope() as db:
|
||||||
|
assert await repair_threads(db, user.id) == 3
|
||||||
|
async with session_scope() as db:
|
||||||
|
thread = await db.get(Thread, tid)
|
||||||
|
assert thread is not None and thread.unread_count == 0 and thread.message_count == 1
|
||||||
|
assert await db.get(Thread, empty_id) is None
|
||||||
|
kept = await db.get(Thread, replied_id)
|
||||||
|
assert kept is not None and kept.message_count == 0
|
||||||
|
assert await repair_threads(db, user.id) == 0 # nothing left to repair
|
||||||
|
finally:
|
||||||
|
async with session_scope() as db:
|
||||||
|
await db.execute(delete(User).where(User.id == user.id)) # cascades
|
||||||
|
assert await db.scalar(select(User.id).where(User.id == user.id)) is None
|
||||||
@@ -117,6 +117,29 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
# Hosted mailboxes (SPEC Appendix D). Everything but where its database is
|
||||||
|
# (config.json) lives in that database and is set up by
|
||||||
|
# `python -m app.hosted.provision`, which also creates the database and its
|
||||||
|
# role — so the first start waits (restarting) until that has run once.
|
||||||
|
# Dev publishes the ports on loopback, off the standard numbers.
|
||||||
|
stalwart:
|
||||||
|
image: stalwartlabs/stalwart:v0.16.19
|
||||||
|
environment:
|
||||||
|
STALWART_DB_PASSWORD: ${STALWART_DB_PASSWORD:-stalwart_dev}
|
||||||
|
STALWART_RECOVERY_ADMIN: admin:${STALWART_ADMIN_SECRET:-stalwart_admin_dev}
|
||||||
|
volumes:
|
||||||
|
- ./stalwart/config.json:/etc/stalwart/config.json:ro
|
||||||
|
- stalwartdata:/var/lib/stalwart
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8180:8080" # management API (JMAP)
|
||||||
|
- "127.0.0.1:1025:25" # SMTP — mail from "outside"
|
||||||
|
- "127.0.0.1:1465:465" # submissions
|
||||||
|
- "127.0.0.1:1587:587" # submission (STARTTLS)
|
||||||
|
- "127.0.0.1:1993:993" # IMAPS
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
# Test mail servers (integration tests + manual dev against a throwaway mailbox)
|
# Test mail servers (integration tests + manual dev against a throwaway mailbox)
|
||||||
# Spelling and grammar checking for the compose window (POST /api/spellcheck
|
# Spelling and grammar checking for the compose window (POST /api/spellcheck
|
||||||
# forwards to it; the browser never talks to it directly). No n-gram data:
|
# forwards to it; the browser never talks to it directly). No n-gram data:
|
||||||
@@ -152,3 +175,4 @@ volumes:
|
|||||||
blobs:
|
blobs:
|
||||||
rspamddata:
|
rspamddata:
|
||||||
rspamdredis:
|
rspamdredis:
|
||||||
|
stalwartdata:
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"@type": "PostgreSql",
|
||||||
|
"host": "postgres",
|
||||||
|
"port": 5432,
|
||||||
|
"database": "stalwart",
|
||||||
|
"authUsername": "stalwart",
|
||||||
|
"authSecret": { "@type": "EnvironmentVariable", "variableName": "STALWART_DB_PASSWORD" },
|
||||||
|
"useTls": false,
|
||||||
|
"allowInvalidCerts": false,
|
||||||
|
"poolMaxConnections": 10,
|
||||||
|
"poolRecyclingMethod": "fast",
|
||||||
|
"timeout": 15000,
|
||||||
|
"readReplicas": {},
|
||||||
|
"options": null
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ Notes for switching `ultimo.email` mail from home.pl to Ultimo's own mail server
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Hosting | **Everything on the QNAP** — receive and send directly (spec §D.1 option A). A relay (home.pl SMTP, SMTP2GO, Brevo) is optional, as a fallback only. |
|
| Hosting | **Everything on the QNAP** — receive and send directly (spec §D.1 option A). A relay (home.pl SMTP, SMTP2GO, Brevo) is optional, as a fallback only. |
|
||||||
| Domain | Addresses `name@ultimo.email`; webmail stays at `https://ultimo.email`. |
|
| Domain | Addresses `name@ultimo.email`; webmail stays at `https://ultimo.email`. |
|
||||||
| Mail apps | **Allowed**: Thunderbird and phone mail apps use the mailboxes over IMAP/SMTP, signing in with **app passwords** (required when 2FA is on). |
|
| Mail apps | **Allowed**: Thunderbird and phone mail apps use the mailboxes over IMAP/SMTP, signing in with **app passwords** only (never the webmail password). |
|
||||||
| Mail host name | **`mail.ultimo.email`** (recommended; `ultimo.email` would also work, see below). |
|
| Mail host name | **`mail.ultimo.email`** (recommended; `ultimo.email` would also work, see below). |
|
||||||
|
|
||||||
## Current setup
|
## Current setup
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
|
||||||
import { fixInvisibleText } from "../../lib/readability";
|
import { fixInvisibleText } from "../../lib/readability";
|
||||||
|
import { useTheme } from "../../lib/theme";
|
||||||
import type { MessageEnvelope } from "../../lib/types";
|
import type { MessageEnvelope } from "../../lib/types";
|
||||||
import { Attachments } from "./Attachments";
|
import { Attachments } from "./Attachments";
|
||||||
|
|
||||||
@@ -30,6 +32,12 @@ export function MessageBody({
|
|||||||
const onMailtoRef = useRef(onMailto);
|
const onMailtoRef = useRef(onMailto);
|
||||||
onMailtoRef.current = onMailto;
|
onMailtoRef.current = onMailto;
|
||||||
const [height, setHeight] = useState(80);
|
const [height, setHeight] = useState(80);
|
||||||
|
// Dark message mode (lib/darkMessage.ts): a dark page in the dark theme,
|
||||||
|
// unless switched off in the subject bar.
|
||||||
|
const darkMessages = useDarkMessages();
|
||||||
|
const dark = useTheme().resolved === "dark" && darkMessages;
|
||||||
|
const darkRef = useRef(dark);
|
||||||
|
darkRef.current = dark;
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["message-html", message.id, allowed],
|
queryKey: ["message-html", message.id, allowed],
|
||||||
@@ -89,7 +97,8 @@ export function MessageBody({
|
|||||||
btn.setAttribute(
|
btn.setAttribute(
|
||||||
"style",
|
"style",
|
||||||
"display:inline-block;margin:6px 0;padding:1px 9px;border-radius:8px;border:none;" +
|
"display:inline-block;margin:6px 0;padding:1px 9px;border-radius:8px;border:none;" +
|
||||||
"background:#e5e7eb;color:#4b5563;cursor:pointer;font:inherit;font-size:15px;line-height:1.5;",
|
(darkRef.current ? "background:#3f444a;color:#c9ccd1;" : "background:#e5e7eb;color:#4b5563;") +
|
||||||
|
"cursor:pointer;font:inherit;font-size:15px;line-height:1.5;",
|
||||||
);
|
);
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
quote.style.display = quote.style.display === "none" ? "" : "none";
|
quote.style.display = quote.style.display === "none" ? "" : "none";
|
||||||
@@ -131,8 +140,13 @@ export function MessageBody({
|
|||||||
// overflow removes any phantom scrollbar left by sub-pixel/line-box rounding
|
// overflow removes any phantom scrollbar left by sub-pixel/line-box rounding
|
||||||
// (horizontal stays scrollable for genuinely wide emails).
|
// (horizontal stays scrollable for genuinely wide emails).
|
||||||
doc.documentElement.style.overflowY = "hidden";
|
doc.documentElement.style.overflowY = "hidden";
|
||||||
|
if (darkRef.current) {
|
||||||
|
// The sender's colours that would not read on the dark page.
|
||||||
|
adaptForDarkMode(doc);
|
||||||
|
} else {
|
||||||
// Before the quote is folded away: hidden text counts as hidden on purpose.
|
// Before the quote is folded away: hidden text counts as hidden on purpose.
|
||||||
fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts)
|
fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts)
|
||||||
|
}
|
||||||
injectQuoteToggle(doc);
|
injectQuoteToggle(doc);
|
||||||
resize();
|
resize();
|
||||||
// Re-measure after the first paint settles (fonts/layout can shift the height
|
// Re-measure after the first paint settles (fonts/layout can shift the height
|
||||||
@@ -175,7 +189,7 @@ export function MessageBody({
|
|||||||
};
|
};
|
||||||
frame = requestAnimationFrame(check);
|
frame = requestAnimationFrame(check);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [data, prepare]);
|
}, [data, dark, prepare]);
|
||||||
|
|
||||||
useEffect(() => () => observerRef.current?.disconnect(), []);
|
useEffect(() => () => observerRef.current?.disconnect(), []);
|
||||||
|
|
||||||
@@ -213,13 +227,15 @@ export function MessageBody({
|
|||||||
)}
|
)}
|
||||||
{data !== undefined && !isLoading && (
|
{data !== undefined && !isLoading && (
|
||||||
<iframe
|
<iframe
|
||||||
|
// A new frame when the mode changes: the message is rendered afresh.
|
||||||
|
key={dark ? "dark" : "light"}
|
||||||
ref={frameRef}
|
ref={frameRef}
|
||||||
title={`message-${message.id}`}
|
title={`message-${message.id}`}
|
||||||
sandbox="allow-same-origin"
|
sandbox="allow-same-origin"
|
||||||
srcDoc={data}
|
srcDoc={dark ? withDarkPage(data) : data}
|
||||||
onLoad={onLoad}
|
onLoad={onLoad}
|
||||||
style={{ height }}
|
style={{ height }}
|
||||||
className="block w-full rounded-lg border-0 bg-white"
|
className={`block w-full rounded-lg border-0 ${dark ? "bg-[#212427]" : "bg-white"}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{message.has_attachments && <Attachments messageId={message.id} />}
|
{message.has_attachments && <Attachments messageId={message.id} />}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { personHue } from "../../lib/avatarColor";
|
import { personHue } from "../../lib/avatarColor";
|
||||||
|
import { setDarkMessages, useDarkMessages } from "../../lib/darkMessage";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { printMessages } from "../../lib/print";
|
import { printMessages } from "../../lib/print";
|
||||||
import { replyTargets } from "../../lib/replyTargets";
|
import { replyTargets } from "../../lib/replyTargets";
|
||||||
|
import { useTheme } from "../../lib/theme";
|
||||||
import { useIsMobile } from "../../lib/useIsMobile";
|
import { useIsMobile } from "../../lib/useIsMobile";
|
||||||
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
|
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
|
||||||
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
|
||||||
@@ -726,6 +728,8 @@ export function ThreadView({
|
|||||||
const composerRef = useRef<HTMLDivElement | null>(null);
|
const composerRef = useRef<HTMLDivElement | null>(null);
|
||||||
// On a phone, reply/reply-all/forward open full screen rather than inline.
|
// On a phone, reply/reply-all/forward open full screen rather than inline.
|
||||||
const mobile = useIsMobile();
|
const mobile = useIsMobile();
|
||||||
|
const resolvedTheme = useTheme().resolved;
|
||||||
|
const darkMessages = useDarkMessages();
|
||||||
// Bring the inline composer into view when it opens (reply/reply-all/forward).
|
// Bring the inline composer into view when it opens (reply/reply-all/forward).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (composing && !mobile) {
|
if (composing && !mobile) {
|
||||||
@@ -754,8 +758,35 @@ export function ThreadView({
|
|||||||
<LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} />
|
<LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{/* Dark message mode (lib/darkMessage.ts) — only the dark theme has it. */}
|
||||||
|
{resolvedTheme === "dark" && (
|
||||||
<button
|
<button
|
||||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
role="switch"
|
||||||
|
aria-checked={darkMessages}
|
||||||
|
className="flex items-center gap-1.5 rounded px-1.5 py-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
|
title={darkMessages ? t("mail.darkMessagesOn") : t("mail.darkMessagesOff")}
|
||||||
|
aria-label={t("mail.darkMessages")}
|
||||||
|
onClick={() => setDarkMessages(!darkMessages)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`relative inline-block h-4 w-7 rounded-full transition-colors ${
|
||||||
|
darkMessages ? "bg-app-accent" : "bg-app-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white shadow transition-[left] ${
|
||||||
|
darkMessages ? "left-[14px]" : "left-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
title={t("mail.print")}
|
title={t("mail.print")}
|
||||||
aria-label={t("mail.print")}
|
aria-label={t("mail.print")}
|
||||||
onClick={() => void printMessages(subject, visual)}
|
onClick={() => void printMessages(subject, visual)}
|
||||||
@@ -763,7 +794,7 @@ export function ThreadView({
|
|||||||
<Icon name="print" size={16} />
|
<Icon name="print" size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
className="flex items-center justify-center rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||||
title={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
|
title={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
|
||||||
aria-label={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
|
aria-label={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
|
||||||
onClick={toggleAll}
|
onClick={toggleAll}
|
||||||
|
|||||||
+13
-1
@@ -391,7 +391,14 @@ export const en = {
|
|||||||
"appPasswords.title": "App passwords",
|
"appPasswords.title": "App passwords",
|
||||||
"appPasswords.help":
|
"appPasswords.help":
|
||||||
"Calendar and contacts apps — your phone's, Thunderbird, macOS — sign in to Ultimo with an app password, never your account password. Give each app its own, so you can revoke one without affecting the others.",
|
"Calendar and contacts apps — your phone's, Thunderbird, macOS — sign in to Ultimo with an app password, never your account password. Give each app its own, so you can revoke one without affecting the others.",
|
||||||
"appPasswords.connectTitle": "Calendars and contacts in other apps",
|
"appPasswords.helpMail":
|
||||||
|
"Mail apps — Thunderbird, your phone's Mail app, Outlook — and calendar and contacts apps sign in to Ultimo with an app password. Your account password never works in them. Give each app its own, so you can revoke one without affecting the others.",
|
||||||
|
"appPasswords.mailTitle": "External mail clients configuration:",
|
||||||
|
"appPasswords.mailIncoming": "Incoming (IMAP)",
|
||||||
|
"appPasswords.mailOutgoing": "Outgoing (SMTP)",
|
||||||
|
"appPasswords.mailPorts": "{host}, port {port}, SSL/TLS",
|
||||||
|
"appPasswords.mailPortsAlt": "or port {port}, STARTTLS",
|
||||||
|
"appPasswords.connectTitle": "External Calendars and Contacts apps configuration:",
|
||||||
"appPasswords.connectServer": "Server",
|
"appPasswords.connectServer": "Server",
|
||||||
"appPasswords.connectUser": "User name",
|
"appPasswords.connectUser": "User name",
|
||||||
"appPasswords.connectPassword": "Password",
|
"appPasswords.connectPassword": "Password",
|
||||||
@@ -566,6 +573,11 @@ export const en = {
|
|||||||
"settings.serverSettingsHint": "Enter your mailbox password to apply server or password changes.",
|
"settings.serverSettingsHint": "Enter your mailbox password to apply server or password changes.",
|
||||||
"settings.signature": "Signature",
|
"settings.signature": "Signature",
|
||||||
"settings.configureAccount": "Configure account",
|
"settings.configureAccount": "Configure account",
|
||||||
|
"settings.yourMailbox": "Your mailbox",
|
||||||
|
"mail.darkMessages": "Dark message mode",
|
||||||
|
"mail.darkMessagesOn": "Dark message mode is on: messages are shown on a dark page. Click to show them as sent.",
|
||||||
|
"mail.darkMessagesOff": "Dark message mode is off: messages are shown as sent. Click to show them on a dark page.",
|
||||||
|
"settings.yourMailboxHint": "Your Ultimo address. It stays as long as your account does.",
|
||||||
"settings.editSignature": "Edit signature",
|
"settings.editSignature": "Edit signature",
|
||||||
"settings.theme": "Theme",
|
"settings.theme": "Theme",
|
||||||
"theme.light": "Light",
|
"theme.light": "Light",
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { adaptForDarkMode, contrast, luminance, parseCssColor, sanitizeStyle, withDarkPage } from "./darkMessage";
|
||||||
|
|
||||||
|
function styleOf(css: string): CSSStyleDeclaration {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.setAttribute("style", css);
|
||||||
|
return el.style;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("colours", () => {
|
||||||
|
it("parses hex, rgb and hsl, and refuses what is not a colour", () => {
|
||||||
|
expect(parseCssColor("#fff")).toEqual([255, 255, 255, 1]);
|
||||||
|
expect(parseCssColor("#00000080")?.[3]).toBeCloseTo(0.5, 2);
|
||||||
|
expect(parseCssColor("rgba(10, 20, 30, 0.4)")).toEqual([10, 20, 30, 0.4]);
|
||||||
|
expect(parseCssColor("rgb(10 20 30 / 50%)")).toEqual([10, 20, 30, 0.5]);
|
||||||
|
expect(parseCssColor("hsl(0, 100%, 50%)")?.map(Math.round)).toEqual([255, 0, 0, 1]);
|
||||||
|
expect(parseCssColor("url(x.png)")).toBeNull();
|
||||||
|
expect(parseCssColor("#fff url(x.png)")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Thunderbird's luminance (0-255) and contrast", () => {
|
||||||
|
expect(luminance("#ffffff")).toBeCloseTo(255, 0);
|
||||||
|
expect(luminance("#000")).toBe(0);
|
||||||
|
expect(luminance("not a colour")).toBe(0);
|
||||||
|
expect(contrast("#000", "#fff")).toBeGreaterThan(1000);
|
||||||
|
expect(contrast("#777", "#888")).toBeLessThan(3.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeStyle", () => {
|
||||||
|
it("drops dark text with no background, keeps light text", () => {
|
||||||
|
const dark = styleOf("color: #333");
|
||||||
|
sanitizeStyle(dark);
|
||||||
|
expect(dark.color).toBe("");
|
||||||
|
const light = styleOf("color: #f0f0f0");
|
||||||
|
sanitizeStyle(light);
|
||||||
|
expect(light.color).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a light background, and the dark text that was on it", () => {
|
||||||
|
const s = styleOf("background-color: #ffffff; color: #222222");
|
||||||
|
sanitizeStyle(s);
|
||||||
|
expect(s.backgroundColor).toBe("");
|
||||||
|
expect(s.color).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a dark box with light text as designed", () => {
|
||||||
|
const s = styleOf("background-color: #003366; color: #ffffff");
|
||||||
|
sanitizeStyle(s);
|
||||||
|
expect(s.backgroundColor).not.toBe("");
|
||||||
|
expect(s.color).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a dark background its text does not stand out from", () => {
|
||||||
|
const s = styleOf("background-color: #303030; color: #404040");
|
||||||
|
sanitizeStyle(s);
|
||||||
|
expect(s.backgroundColor).toBe("");
|
||||||
|
expect(s.color).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops gradients", () => {
|
||||||
|
const s = styleOf("background: linear-gradient(#000, #111); color: #fff");
|
||||||
|
sanitizeStyle(s);
|
||||||
|
expect(s.getPropertyValue("background") + s.getPropertyValue("background-image")).not.toContain("gradient");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adaptForDarkMode", () => {
|
||||||
|
it("clears the body's colours and colour attributes, and adapts style sheets", () => {
|
||||||
|
document.head.innerHTML = "<style>.card { background-color: #ffffff; color: #111111 } .ok { color: #eeeeee }</style>";
|
||||||
|
document.body.innerHTML =
|
||||||
|
'<table bgcolor="#ffffff"><tr><td><font color="#000080">Hi</font></td></tr></table>' +
|
||||||
|
'<p style="color: #444444">dark text</p><p style="background-color: #102030; color: #fafafa">box</p>';
|
||||||
|
document.body.setAttribute("bgcolor", "#ffffff");
|
||||||
|
document.body.style.color = "#000000";
|
||||||
|
adaptForDarkMode(document);
|
||||||
|
expect(document.body.getAttribute("bgcolor")).toBeNull();
|
||||||
|
expect(document.body.style.color).toBe("");
|
||||||
|
expect(document.querySelector("table")!.getAttribute("bgcolor")).toBeNull();
|
||||||
|
expect(document.querySelector("font")!.getAttribute("color")).toBeNull();
|
||||||
|
const [p1, p2] = Array.from(document.querySelectorAll("p"));
|
||||||
|
expect(p1.style.color).toBe("");
|
||||||
|
expect(p2.style.backgroundColor).not.toBe("");
|
||||||
|
const rules = Array.from((document.querySelector("style")!.sheet as CSSStyleSheet).cssRules) as CSSStyleRule[];
|
||||||
|
expect(rules[0].style.getPropertyValue("background-color")).toBe("");
|
||||||
|
expect(rules[0].style.getPropertyValue("color")).toBe("");
|
||||||
|
expect(rules[1].style.getPropertyValue("color")).not.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the dark page in the head", () => {
|
||||||
|
const html = withDarkPage("<html><head><style>a{}</style></head><body>x</body></html>");
|
||||||
|
expect(html.indexOf("wm-dark-message")).toBeLessThan(html.indexOf("</head>"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Dark message mode: show a message on a dark page when the app is dark.
|
||||||
|
//
|
||||||
|
// A port of Thunderbird's "dark reader" (mail/base/content/modules/DarkReader.mjs,
|
||||||
|
// Thunderbird 140 ESR, MPL 2.0). Thunderbird does not invert anything: it gives
|
||||||
|
// the message a dark page and light text, then takes away the colours a sender
|
||||||
|
// set that would not read on it — any explicit text colour that is dark
|
||||||
|
// (luminance ≤ 200 of 255) when it has no background of its own, and any
|
||||||
|
// explicit background that is light (> 200) or that its text does not stand
|
||||||
|
// out from (contrast < 3.5). What a sender set that already works on dark — a
|
||||||
|
// dark coloured box with light text, light text, a picture — is kept.
|
||||||
|
// Gradients are dropped. Same for `<font color>`, `bgcolor` and table colour
|
||||||
|
// attributes, the body's own colours, the rules of the message's style sheets,
|
||||||
|
// and SVG text fills.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export type Rgba = [number, number, number, number];
|
||||||
|
|
||||||
|
const LUMINANCE_THRESHOLD = 200;
|
||||||
|
export const CONTRAST_THRESHOLD = 3.5;
|
||||||
|
|
||||||
|
/** The dark page and its text: the reading pane's own panel colours. */
|
||||||
|
export const DARK_MESSAGE_CSS = `<style id="wm-dark-message">
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
html, body { background-color: #212427; color: #e2e4e7; }
|
||||||
|
a { color: #8ab4f8; }
|
||||||
|
blockquote { border-left-color: #3f444a; color: #b4b8be; }
|
||||||
|
</style>`;
|
||||||
|
|
||||||
|
// ---- colours -----------------------------------------------------------------
|
||||||
|
|
||||||
|
const cache = new Map<string, Rgba | null>();
|
||||||
|
|
||||||
|
function parseHex(hex: string): Rgba | null {
|
||||||
|
const h = hex.slice(1);
|
||||||
|
if (![3, 4, 6, 8].includes(h.length) || /[^0-9a-f]/i.test(h)) return null;
|
||||||
|
const full = h.length <= 4 ? [...h].map((c) => c + c).join("") : h;
|
||||||
|
const n = (i: number) => parseInt(full.slice(i, i + 2), 16);
|
||||||
|
return [n(0), n(2), n(4), full.length === 8 ? n(6) / 255 : 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFunction(value: string): Rgba | null {
|
||||||
|
const m = value.match(/^(rgba?|hsla?)\(([^)]*)\)$/i);
|
||||||
|
if (!m) return null;
|
||||||
|
const parts = m[2].split(/[\s,/]+/).filter(Boolean);
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
const num = (p: string, scale: number) => (p.endsWith("%") ? (parseFloat(p) / 100) * scale : parseFloat(p));
|
||||||
|
const alpha = parts[3] === undefined ? 1 : num(parts[3], 1);
|
||||||
|
if (m[1].toLowerCase().startsWith("rgb")) {
|
||||||
|
const [r, g, b] = parts.slice(0, 3).map((p) => num(p, 255));
|
||||||
|
return [r, g, b, alpha].some(Number.isNaN) ? null : [r, g, b, alpha];
|
||||||
|
}
|
||||||
|
const h = (((parseFloat(parts[0]) % 360) + 360) % 360) / 360;
|
||||||
|
const s = num(parts[1], 1);
|
||||||
|
const l = num(parts[2], 1);
|
||||||
|
const k = (n: number) => (n + h * 12) % 12;
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = (n: number) => 255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)));
|
||||||
|
return [h, s, l, alpha].some(Number.isNaN) ? null : [f(0), f(8), f(4), alpha];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A CSS colour as RGBA (0–255, alpha 0–1), or null when it is not a colour
|
||||||
|
* (``none``, ``url(…)``, a background shorthand with more than a colour…). Named
|
||||||
|
* colours are resolved by the browser, in ``doc``. */
|
||||||
|
export function parseCssColor(value: string, doc?: Document): Rgba | null {
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
if (!v) return null;
|
||||||
|
const known = cache.get(v);
|
||||||
|
if (known !== undefined) return known;
|
||||||
|
let out: Rgba | null = null;
|
||||||
|
if (v === "transparent") out = [0, 0, 0, 0];
|
||||||
|
else if (v.startsWith("#")) out = parseHex(v);
|
||||||
|
else if (/^(rgb|hsl)a?\(/.test(v)) out = parseFunction(v);
|
||||||
|
else if (/^[a-z]+$/.test(v) && doc?.defaultView && doc.body) {
|
||||||
|
// A named colour (or a keyword like ``currentcolor``): ask the browser.
|
||||||
|
const probe = doc.createElement("span");
|
||||||
|
probe.style.color = v;
|
||||||
|
if (probe.style.color) {
|
||||||
|
probe.style.display = "none";
|
||||||
|
doc.body.appendChild(probe);
|
||||||
|
out = parseFunction(doc.defaultView.getComputedStyle(probe).color.replace(/\s+/g, " "));
|
||||||
|
probe.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.set(v, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thunderbird's luminance: 0.2125 R + 0.7154 G + 0.0721 B, 0–255; 0 when not a colour. */
|
||||||
|
export function luminance(color: string, doc?: Document): number {
|
||||||
|
const rgba = parseCssColor(color, doc);
|
||||||
|
if (!rgba) return 0;
|
||||||
|
const [r, g, b] = rgba;
|
||||||
|
return 0.2125 * r + 0.7154 * g + 0.0721 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contrast(background: string, foreground: string, doc?: Document): number {
|
||||||
|
const bg = luminance(background, doc);
|
||||||
|
const fg = luminance(foreground, doc);
|
||||||
|
return (Math.max(bg, fg) + 0.05) / (Math.min(bg, fg) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alpha of 20% or less counts as transparent (and so does "not a colour"). */
|
||||||
|
export function isTransparent(color: string, doc?: Document): boolean {
|
||||||
|
const rgba = parseCssColor(color, doc);
|
||||||
|
return !rgba || rgba[3] <= 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- adapting a message -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** Take away the colours of one style (inline, or a style-sheet rule) that would
|
||||||
|
* not read on a dark page. */
|
||||||
|
export function sanitizeStyle(style: CSSStyleDeclaration, doc?: Document): void {
|
||||||
|
const get = (property: string) => style.getPropertyValue(property);
|
||||||
|
if (!get("color") && !get("background") && !get("background-color")) return;
|
||||||
|
|
||||||
|
// Text colour with no background of its own: keep it only if it is light.
|
||||||
|
if (
|
||||||
|
(!get("background") || get("background") === "none") &&
|
||||||
|
(!get("background-color") || isTransparent(get("background-color"), doc))
|
||||||
|
) {
|
||||||
|
if (luminance(get("color"), doc) <= LUMINANCE_THRESHOLD) style.removeProperty("color");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropBackground = (property: "background-color" | "background") => {
|
||||||
|
const value = get(property);
|
||||||
|
if (!value || !parseCssColor(value, doc)) return; // none, or a picture etc.: kept
|
||||||
|
if (
|
||||||
|
luminance(value, doc) > LUMINANCE_THRESHOLD ||
|
||||||
|
(get("color") && contrast(get("color"), value, doc) < CONTRAST_THRESHOLD)
|
||||||
|
) {
|
||||||
|
style.removeProperty(property);
|
||||||
|
// On the dark page now: its text colour must be light too.
|
||||||
|
if (get("color") && luminance(get("color"), doc) <= LUMINANCE_THRESHOLD) {
|
||||||
|
style.removeProperty("color");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dropBackground("background-color");
|
||||||
|
dropBackground("background");
|
||||||
|
|
||||||
|
// No chances with gradients behind text.
|
||||||
|
if (get("background").includes("gradient") || get("background-image").includes("gradient")) {
|
||||||
|
style.removeProperty("background");
|
||||||
|
style.removeProperty("background-image");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRules(rules: CSSRuleList, doc: Document): void {
|
||||||
|
for (const rule of Array.from(rules)) {
|
||||||
|
if ("selectorText" in rule && "style" in rule) sanitizeStyle((rule as CSSStyleRule).style, doc);
|
||||||
|
// @media, @supports…: their rules too.
|
||||||
|
else if ("cssRules" in rule) sanitizeRules((rule as CSSGroupingRule).cssRules, doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adapt a rendered message document (with DARK_MESSAGE_CSS in its head) for
|
||||||
|
* dark mode. */
|
||||||
|
export function adaptForDarkMode(doc: Document): void {
|
||||||
|
const body = doc.body;
|
||||||
|
if (!body) return;
|
||||||
|
for (const attribute of ["bgcolor", "text", "link", "vlink"]) body.removeAttribute(attribute);
|
||||||
|
body.style.removeProperty("background-color");
|
||||||
|
body.style.removeProperty("color");
|
||||||
|
|
||||||
|
for (const node of Array.from(
|
||||||
|
doc.querySelectorAll<HTMLElement>(
|
||||||
|
':not(button):is([style*="color"],[style*="background"],[bgcolor],[color])',
|
||||||
|
),
|
||||||
|
)) {
|
||||||
|
node.removeAttribute("bgcolor"); // tables, mostly
|
||||||
|
node.removeAttribute("color"); // <font color>
|
||||||
|
if (node.hasAttribute("style")) sanitizeStyle(node.style, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of Array.from(doc.getElementsByTagName("text"))) {
|
||||||
|
const fill = node.getAttribute("fill");
|
||||||
|
if (fill && luminance(fill, doc) <= LUMINANCE_THRESHOLD) node.setAttribute("fill", "currentColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of Array.from(doc.getElementsByTagName("style"))) {
|
||||||
|
if (node.id === "wm-dark-message") continue;
|
||||||
|
const sheet = (node as HTMLStyleElement).sheet;
|
||||||
|
if (!sheet) continue;
|
||||||
|
try {
|
||||||
|
sanitizeRules(sheet.cssRules, doc);
|
||||||
|
} catch {
|
||||||
|
/* an unreadable sheet: left as it is */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message's HTML with the dark page in its head (before it renders, so it
|
||||||
|
* never flashes white). */
|
||||||
|
export function withDarkPage(html: string): string {
|
||||||
|
const at = html.search(/<\/head>/i);
|
||||||
|
return at < 0 ? DARK_MESSAGE_CSS + html : html.slice(0, at) + DARK_MESSAGE_CSS + html.slice(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the switch -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// Per device, like the theme (lib/theme.tsx). On unless turned off — as in
|
||||||
|
// Thunderbird, where mail.dark-reader.enabled defaults to true.
|
||||||
|
const KEY = "darkMessages";
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function read(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) !== "0";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDarkMessages(on: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, on ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
/* kept for this page only */
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDarkMessages(): boolean {
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(cb) => {
|
||||||
|
listeners.add(cb);
|
||||||
|
const onStorage = (e: StorageEvent) => e.key === KEY && cb();
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(cb);
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
read,
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ export interface UserOut {
|
|||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
totp_enabled: boolean;
|
totp_enabled: boolean;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
|
/** The hosted mail server's name for mail apps; null without hosted mailboxes. */
|
||||||
|
mail_host?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
@@ -126,6 +128,8 @@ export interface AccountOut {
|
|||||||
sync_error?: string | null;
|
sync_error?: string | null;
|
||||||
sync_error_since?: string | null;
|
sync_error_since?: string | null;
|
||||||
state_detail?: string;
|
state_detail?: string;
|
||||||
|
/** "native": the user's own hosted mailbox — permanent, its settings fixed. */
|
||||||
|
kind?: "external" | "native";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A send the server could not take: waiting in Scheduled (retrying, or failed). */
|
/** A send the server could not take: waiting in Scheduled (retrying, or failed). */
|
||||||
|
|||||||
@@ -1054,10 +1054,13 @@ export function MailLayout() {
|
|||||||
{/* Per-account views are hidden entirely when collapsed. */}
|
{/* Per-account views are hidden entirely when collapsed. */}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<>
|
<>
|
||||||
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
|
<div className="px-3 pb-1 pt-6 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
|
||||||
{t("settings.accounts")}
|
{t("settings.accounts")}
|
||||||
</div>
|
</div>
|
||||||
{accounts.map((a) => (
|
{/* Alphabetical by the address shown. */}
|
||||||
|
{[...accounts]
|
||||||
|
.sort((x, y) => x.email.localeCompare(y.email, undefined, { sensitivity: "base" }))
|
||||||
|
.map((a) => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={a.id}
|
key={a.id}
|
||||||
active={view === `account:${a.id}`}
|
active={view === `account:${a.id}`}
|
||||||
|
|||||||
@@ -516,7 +516,9 @@ function OfflineAccessSetting() {
|
|||||||
*/
|
*/
|
||||||
function AppPasswordsSetting() {
|
function AppPasswordsSetting() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const handle = useMe().data?.handle ?? "";
|
const me = useMe().data;
|
||||||
|
const handle = me?.handle ?? "";
|
||||||
|
const mailHost = me?.mail_host ?? null;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const list = useQuery({ queryKey: ["app-passwords"], queryFn: api.appPasswords });
|
const list = useQuery({ queryKey: ["app-passwords"], queryFn: api.appPasswords });
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -558,7 +560,28 @@ function AppPasswordsSetting() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2>
|
<h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2>
|
||||||
<p className="text-sm text-app-muted">{t("appPasswords.help")}</p>
|
<p className="text-sm text-app-muted">{t(mailHost ? "appPasswords.helpMail" : "appPasswords.help")}</p>
|
||||||
|
{/* What to type into a mail app (the hosted mailbox, SPEC App. D §D.5). */}
|
||||||
|
{mailHost && (
|
||||||
|
<div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm">
|
||||||
|
<p className="font-medium">{t("appPasswords.mailTitle")}</p>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||||
|
<dt className="text-app-muted">{t("appPasswords.mailIncoming")}</dt>
|
||||||
|
<dd className="break-all font-mono text-[13px]">
|
||||||
|
{t("appPasswords.mailPorts", { host: mailHost, port: 993 })}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-app-muted">{t("appPasswords.mailOutgoing")}</dt>
|
||||||
|
<dd className="break-all font-mono text-[13px]">
|
||||||
|
{t("appPasswords.mailPorts", { host: mailHost, port: 465 })}
|
||||||
|
<span className="block text-app-muted">{t("appPasswords.mailPortsAlt", { port: 587 })}</span>
|
||||||
|
</dd>
|
||||||
|
<dt className="text-app-muted">{t("appPasswords.connectUser")}</dt>
|
||||||
|
<dd className="select-all break-all font-mono text-[13px]">{handle}</dd>
|
||||||
|
<dt className="text-app-muted">{t("appPasswords.connectPassword")}</dt>
|
||||||
|
<dd className="text-app-muted">{t("appPasswords.connectPasswordValue")}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* What to type into a calendar / contacts app (CalDAV / CardDAV, SPEC App. E). */}
|
{/* What to type into a calendar / contacts app (CalDAV / CardDAV, SPEC App. E). */}
|
||||||
<div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm">
|
<div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm">
|
||||||
<p className="font-medium">{t("appPasswords.connectTitle")}</p>
|
<p className="font-medium">{t("appPasswords.connectTitle")}</p>
|
||||||
@@ -578,10 +601,10 @@ function AppPasswordsSetting() {
|
|||||||
<p className="text-sm">{t("appPasswords.madeFor", { name: made.name })}</p>
|
<p className="text-sm">{t("appPasswords.madeFor", { name: made.name })}</p>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* Shown in groups of four; copied (and entered) without spaces. */}
|
{/* Shown in groups of four; copied (and entered) without spaces. */}
|
||||||
<code className="select-all rounded bg-app-surface px-3 py-2 font-mono text-lg tracking-wider">
|
<code className="select-all break-all rounded bg-app-surface px-3 py-2 font-mono text-lg tracking-wider">
|
||||||
{made.secret.match(/.{1,4}/g)?.map((group, i) => (
|
{made.secret.match(/.{1,4}/g)?.map((group, i) => (
|
||||||
// Spacing only, no characters: copying by hand gives the bare letters.
|
// Spacing only, no characters: copying by hand gives the bare letters.
|
||||||
<span key={i} className={i ? "ml-2.5" : ""}>
|
<span key={i} className={`inline-block ${i ? "ml-2.5" : ""}`}>
|
||||||
{group}
|
{group}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -888,6 +911,14 @@ function AccountsSection() {
|
|||||||
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
|
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
|
||||||
<span className="min-w-0 break-all">{a.email}</span>
|
<span className="min-w-0 break-all">{a.email}</span>
|
||||||
|
{a.kind === "native" && (
|
||||||
|
<span
|
||||||
|
className="rounded bg-app-accent/15 px-1.5 py-0.5 text-[11px] font-medium text-app-accent"
|
||||||
|
title={t("settings.yourMailboxHint")}
|
||||||
|
>
|
||||||
|
{t("settings.yourMailbox")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{a.state === "auth_failed" ? (
|
{a.state === "auth_failed" ? (
|
||||||
<span className="text-xs text-red-500" title={a.state_detail || undefined}>
|
<span className="text-xs text-red-500" title={a.state_detail || undefined}>
|
||||||
{t("settings.stateAuthFailed")}
|
{t("settings.stateAuthFailed")}
|
||||||
@@ -932,6 +963,8 @@ function AccountsSection() {
|
|||||||
>
|
>
|
||||||
{t("settings.configureAccount")}
|
{t("settings.configureAccount")}
|
||||||
</button>
|
</button>
|
||||||
|
{/* The user's own mailbox is permanent (SPEC App. D §D.4). */}
|
||||||
|
{a.kind !== "native" && (
|
||||||
<button
|
<button
|
||||||
className="text-xs text-red-500"
|
className="text-xs text-red-500"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -943,6 +976,7 @@ function AccountsSection() {
|
|||||||
>
|
>
|
||||||
{t("common.remove")}
|
{t("common.remove")}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1050,7 +1084,7 @@ function AccountEditor({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showServer ? (
|
{detail.data.kind === "native" ? null : !showServer ? (
|
||||||
<button className="text-xs text-app-accent" onClick={() => setShowServer(true)}>
|
<button className="text-xs text-app-accent" onClick={() => setShowServer(true)}>
|
||||||
{t("settings.serverSettings")}
|
{t("settings.serverSettings")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { ThreadView } from "../components/thread/ThreadView";
|
import { ThreadView } from "../components/thread/ThreadView";
|
||||||
import { I18nProvider } from "../i18n";
|
import { I18nProvider } from "../i18n";
|
||||||
|
import { ThemeProvider } from "../lib/theme";
|
||||||
import type { MessageEnvelope } from "../lib/types";
|
import type { MessageEnvelope } from "../lib/types";
|
||||||
|
|
||||||
function msg(id: string, minutes: number, unread = false): MessageEnvelope {
|
function msg(id: string, minutes: number, unread = false): MessageEnvelope {
|
||||||
@@ -38,7 +39,9 @@ function wrap(children: ReactNode) {
|
|||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
return (
|
return (
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,3 +12,19 @@ if (typeof globalThis.Worker === "undefined") {
|
|||||||
}
|
}
|
||||||
(globalThis as unknown as { Worker: unknown }).Worker = TestWorker;
|
(globalThis as unknown as { Worker: unknown }).Worker = TestWorker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsdom has no matchMedia; the theme (lib/theme.tsx) asks it for the system's
|
||||||
|
// light/dark preference. Answers "no match" (a light system).
|
||||||
|
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
|
||||||
|
window.matchMedia = (query: string) =>
|
||||||
|
({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}) as MediaQueryList;
|
||||||
|
}
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
"""antispam: which accounts are filtered, said explicitly
|
||||||
|
|
||||||
|
``enabled_accounts`` was "empty = every account", so turning every account off
|
||||||
|
could not be stored: it read back as all on. Now NULL is every account (the
|
||||||
|
default, including accounts added later), a list is exactly those accounts, and
|
||||||
|
an empty list is none. Existing empty lists were "every account": they become
|
||||||
|
NULL.
|
||||||
|
|
||||||
|
Revision ID: antispam0002
|
||||||
|
Revises: antispam0001
|
||||||
|
Create Date: 2026-10-11
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "antispam0002"
|
||||||
|
down_revision: str | None = "antispam0001"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts DROP NOT NULL")
|
||||||
|
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts DROP DEFAULT")
|
||||||
|
op.execute(
|
||||||
|
"UPDATE plg_antispam_settings SET enabled_accounts = NULL WHERE enabled_accounts = '{}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"UPDATE plg_antispam_settings SET enabled_accounts = '{}' WHERE enabled_accounts IS NULL"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts SET DEFAULT '{}'"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE plg_antispam_settings ALTER COLUMN enabled_accounts SET NOT NULL")
|
||||||
@@ -18,7 +18,7 @@ _SDK = sdk("antispam")
|
|||||||
|
|
||||||
# §C.3 defaults, used when a user has never opened the settings page.
|
# §C.3 defaults, used when a user has never opened the settings page.
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"enabled_accounts": [], # empty = every account
|
"enabled_accounts": None, # every account
|
||||||
"move_threshold": 12.0,
|
"move_threshold": 12.0,
|
||||||
"banner_threshold": 6.0,
|
"banner_threshold": 6.0,
|
||||||
"network_checks": False,
|
"network_checks": False,
|
||||||
@@ -28,15 +28,16 @@ DEFAULTS = {
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
enabled_accounts: list[str]
|
# None: every account (also ones added later); a list: exactly those —
|
||||||
|
# empty, none.
|
||||||
|
enabled_accounts: list[str] | None
|
||||||
move_threshold: float
|
move_threshold: float
|
||||||
banner_threshold: float
|
banner_threshold: float
|
||||||
network_checks: bool
|
network_checks: bool
|
||||||
learn_from_provider_junk: bool
|
learn_from_provider_junk: bool
|
||||||
|
|
||||||
def covers(self, account_id: str) -> bool:
|
def covers(self, account_id: str) -> bool:
|
||||||
"""Empty list means every account — the common case stays cheap."""
|
return self.enabled_accounts is None or account_id in self.enabled_accounts
|
||||||
return not self.enabled_accounts or account_id in self.enabled_accounts
|
|
||||||
|
|
||||||
|
|
||||||
async def load_settings(user_id: uuid.UUID) -> Settings:
|
async def load_settings(user_id: uuid.UUID) -> Settings:
|
||||||
@@ -54,14 +55,14 @@ async def load_settings(user_id: uuid.UUID) -> Settings:
|
|||||||
if row is None:
|
if row is None:
|
||||||
d: Any = DEFAULTS
|
d: Any = DEFAULTS
|
||||||
return Settings(
|
return Settings(
|
||||||
enabled_accounts=[],
|
enabled_accounts=None,
|
||||||
move_threshold=d["move_threshold"],
|
move_threshold=d["move_threshold"],
|
||||||
banner_threshold=d["banner_threshold"],
|
banner_threshold=d["banner_threshold"],
|
||||||
network_checks=d["network_checks"],
|
network_checks=d["network_checks"],
|
||||||
learn_from_provider_junk=d["learn_from_provider_junk"],
|
learn_from_provider_junk=d["learn_from_provider_junk"],
|
||||||
)
|
)
|
||||||
return Settings(
|
return Settings(
|
||||||
enabled_accounts=[str(a) for a in (row[0] or [])],
|
enabled_accounts=None if row[0] is None else [str(a) for a in row[0]],
|
||||||
move_threshold=float(row[1]),
|
move_threshold=float(row[1]),
|
||||||
banner_threshold=float(row[2]),
|
banner_threshold=float(row[2]),
|
||||||
network_checks=bool(row[3]),
|
network_checks=bool(row[3]),
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ async def get_settings(user_id: CurrentUserId) -> SettingsOut:
|
|||||||
)
|
)
|
||||||
async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOut:
|
async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOut:
|
||||||
current = await policy.load_settings(user_id)
|
current = await policy.load_settings(user_id)
|
||||||
merged = {
|
merged: dict[str, Any] = {
|
||||||
"enabled_accounts": (
|
"enabled_accounts": (
|
||||||
current.enabled_accounts
|
None
|
||||||
|
if body.all_accounts
|
||||||
|
else current.enabled_accounts
|
||||||
if body.enabled_accounts is None
|
if body.enabled_accounts is None
|
||||||
else body.enabled_accounts
|
else body.enabled_accounts
|
||||||
),
|
),
|
||||||
@@ -82,7 +84,9 @@ async def patch_settings(body: SettingsIn, user_id: CurrentUserId) -> SettingsOu
|
|||||||
|
|
||||||
# asyncpg binds arrays as Python lists — a "{a,b}" literal is rejected. Drop
|
# asyncpg binds arrays as Python lists — a "{a,b}" literal is rejected. Drop
|
||||||
# anything that is not a uuid rather than 500 on a malformed account id.
|
# anything that is not a uuid rather than 500 on a malformed account id.
|
||||||
account_ids: list[uuid.UUID] = []
|
account_ids: list[uuid.UUID] | None = None
|
||||||
|
if merged["enabled_accounts"] is not None:
|
||||||
|
account_ids = []
|
||||||
for raw in merged["enabled_accounts"]:
|
for raw in merged["enabled_accounts"]:
|
||||||
try:
|
try:
|
||||||
account_ids.append(uuid.UUID(str(raw)))
|
account_ids.append(uuid.UUID(str(raw)))
|
||||||
@@ -308,4 +312,8 @@ async def bundle(filename: str) -> FileResponse:
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404, detail={"code": "not_found", "message": "no bundle"}
|
status_code=404, detail={"code": "not_found", "message": "no bundle"}
|
||||||
)
|
)
|
||||||
return FileResponse(_UI / filename, media_type="text/javascript")
|
# `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
|
||||||
|
# updated bundle is used at once instead of a copy the browser kept.
|
||||||
|
return FileResponse(
|
||||||
|
_UI / filename, media_type="text/javascript", headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
class SettingsOut(BaseModel):
|
class SettingsOut(BaseModel):
|
||||||
enabled_accounts: list[str]
|
# None: every account (also ones added later); a list: exactly those.
|
||||||
|
enabled_accounts: list[str] | None
|
||||||
move_threshold: float
|
move_threshold: float
|
||||||
banner_threshold: float
|
banner_threshold: float
|
||||||
network_checks: bool
|
network_checks: bool
|
||||||
@@ -19,6 +20,8 @@ class SettingsIn(BaseModel):
|
|||||||
"""Merge semantics: omitted fields keep their current value."""
|
"""Merge semantics: omitted fields keep their current value."""
|
||||||
|
|
||||||
enabled_accounts: list[str] | None = None
|
enabled_accounts: list[str] | None = None
|
||||||
|
# True: every account again (enabled_accounts is then ignored).
|
||||||
|
all_accounts: bool | None = None
|
||||||
# Bounds are guard rails, not opinions: 0 would file everything as spam and
|
# Bounds are guard rails, not opinions: 0 would file everything as spam and
|
||||||
# the engine's own scale tops out well below 100.
|
# the engine's own scale tops out well below 100.
|
||||||
move_threshold: float | None = Field(default=None, gt=0, le=100)
|
move_threshold: float | None = Field(default=None, gt=0, le=100)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// antispam · settings.page slot (SPEC §C.6). Thresholds in plain language,
|
// antispam · settings.page slot (SPEC §C.6). Thresholds in plain language,
|
||||||
// per-account toggles, the allow/deny lists, the privacy switch, and stats.
|
// per-account toggles, the allow/deny lists, the privacy switch. (The engine's
|
||||||
|
// statistics stay available at GET /stats; the page no longer shows them.)
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||||
@@ -9,20 +10,17 @@ export default function mount(el, ctx) {
|
|||||||
let settings = null;
|
let settings = null;
|
||||||
let rules = [];
|
let rules = [];
|
||||||
let accounts = [];
|
let accounts = [];
|
||||||
let stats = null;
|
|
||||||
let bootstrapNote = ""; // outcome of the last "learn from my Spam folder now" click
|
let bootstrapNote = ""; // outcome of the last "learn from my Spam folder now" click
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [s, r, st] = await Promise.all([
|
const [s, r] = await Promise.all([
|
||||||
ctx.api.pluginFetch("/settings").catch(() => null),
|
ctx.api.pluginFetch("/settings").catch(() => null),
|
||||||
ctx.api.pluginFetch("/rules").catch(() => []),
|
ctx.api.pluginFetch("/rules").catch(() => []),
|
||||||
ctx.api.pluginFetch("/stats").catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
accounts = await ctx.api.fetch("/accounts").catch(() => []);
|
accounts = await ctx.api.fetch("/accounts").catch(() => []);
|
||||||
settings = s;
|
settings = s;
|
||||||
rules = r || [];
|
rules = r || [];
|
||||||
stats = st;
|
|
||||||
if (!disposed) render();
|
if (!disposed) render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +34,10 @@ export default function mount(el, ctx) {
|
|||||||
|
|
||||||
function accountRows() {
|
function accountRows() {
|
||||||
if (!accounts.length) return "";
|
if (!accounts.length) return "";
|
||||||
|
// null: every account (the default, so an untouched install shows all
|
||||||
|
// ticked); a list: exactly those — empty, none.
|
||||||
|
const all = settings.enabled_accounts === null || settings.enabled_accounts === undefined;
|
||||||
const on = new Set(settings.enabled_accounts || []);
|
const on = new Set(settings.enabled_accounts || []);
|
||||||
// Empty list means every account, so an untouched install shows all ticked.
|
|
||||||
const all = on.size === 0;
|
|
||||||
return accounts
|
return accounts
|
||||||
.map(
|
.map(
|
||||||
(a) =>
|
(a) =>
|
||||||
@@ -58,20 +57,6 @@ export default function mount(el, ctx) {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function statsBlock() {
|
|
||||||
if (!stats) return `<p class="wm-faint">Stats unavailable — is the engine running?</p>`;
|
|
||||||
const c = stats.counts || {};
|
|
||||||
const rowsOf = Object.entries(c)
|
|
||||||
.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v}</td></tr>`)
|
|
||||||
.join("");
|
|
||||||
const syms = (stats.top_symbols || [])
|
|
||||||
.map((s) => `<tr><td>${esc(s.symbol)}</td><td>${s.count}</td></tr>`)
|
|
||||||
.join("");
|
|
||||||
return `
|
|
||||||
<table class="wm-table"><tbody>${rowsOf || '<tr><td class="wm-faint">nothing classified yet</td><td></td></tr>'}</tbody></table>
|
|
||||||
${syms ? `<p class="wm-muted">Most common reasons</p><table class="wm-table"><tbody>${syms}</tbody></table>` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
el.innerHTML = `<p class="wm-faint">Loading…</p>`;
|
el.innerHTML = `<p class="wm-faint">Loading…</p>`;
|
||||||
@@ -83,18 +68,18 @@ export default function mount(el, ctx) {
|
|||||||
<p class="wm-muted">A second pass over mail your provider already delivered. Nothing is deleted — suspect mail is filed into Spam, and the uncertain middle only gets a warning.</p>
|
<p class="wm-muted">A second pass over mail your provider already delivered. Nothing is deleted — suspect mail is filed into Spam, and the uncertain middle only gets a warning.</p>
|
||||||
|
|
||||||
<div class="wm-card">
|
<div class="wm-card">
|
||||||
<label class="wm-row">Move to Spam at score
|
<div style="display:grid;grid-template-columns:max-content 7rem;gap:10px 12px;align-items:center;margin-bottom:10px">
|
||||||
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}">
|
<label for="as-move">Move to Spam at score</label>
|
||||||
</label>
|
<input id="as-move" class="wm-input" style="min-width:0;width:100%" type="number" step="0.5" min="0.5" data-f="move_threshold" value="${settings.move_threshold}">
|
||||||
<label class="wm-row">Warn with a banner at score
|
<label for="as-banner">Warn with a banner at score</label>
|
||||||
<input class="wm-input" type="number" step="0.5" min="0.5" data-f="banner_threshold" value="${settings.banner_threshold}">
|
<input id="as-banner" class="wm-input" style="min-width:0;width:100%" type="number" step="0.5" min="0.5" data-f="banner_threshold" value="${settings.banner_threshold}">
|
||||||
</label>
|
</div>
|
||||||
<p class="wm-faint">Higher is more permissive. A banner above the move score would never be seen, so it is capped there.</p>
|
<p class="wm-faint">Higher is more permissive. A banner above the move score would never be seen, so it is capped there.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wm-card">
|
<div class="wm-card">
|
||||||
<strong>Accounts</strong>
|
<strong>Accounts</strong>
|
||||||
<p class="wm-faint">Tick none to filter every account.</p>
|
<p class="wm-faint">Only the ticked accounts are filtered. While all are ticked, accounts you add later are filtered too.</p>
|
||||||
${accountRows()}
|
${accountRows()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -115,12 +100,10 @@ export default function mount(el, ctx) {
|
|||||||
<div class="wm-card">
|
<div class="wm-card">
|
||||||
<label class="wm-row"><input type="checkbox" data-f="network_checks"${settings.network_checks ? " checked" : ""}> Allow online reputation checks</label>
|
<label class="wm-row"><input type="checkbox" data-f="network_checks"${settings.network_checks ? " checked" : ""}> Allow online reputation checks</label>
|
||||||
<p class="wm-faint">Off by default. When on, the filter asks <strong>Spamhaus</strong> whether the sending server or a linked domain is known for spam, and <strong>DNSWL</strong> whether the sender is known-good. That reveals those addresses and domains to them — and to whichever DNS resolver you use. The message itself is never sent, and no fingerprint of it leaves your server.</p>
|
<p class="wm-faint">Off by default. When on, the filter asks <strong>Spamhaus</strong> whether the sending server or a linked domain is known for spam, and <strong>DNSWL</strong> whether the sender is known-good. That reveals those addresses and domains to them — and to whichever DNS resolver you use. The message itself is never sent, and no fingerprint of it leaves your server.</p>
|
||||||
<label class="wm-row"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as spam</label>
|
<label class="wm-row" style="margin-top:16px"><input type="checkbox" data-f="learn_from_provider_junk"${settings.learn_from_provider_junk ? " checked" : ""}> Learn from mail your provider already marked as spam</label>
|
||||||
<div class="wm-row"><button class="wm-btn" data-bootstrap${settings.learn_from_provider_junk ? "" : " disabled"}>Learn from my Spam folder now</button><span class="wm-faint" data-bootstrap-note>${esc(bootstrapNote)}</span></div>
|
<div class="wm-row"><button class="wm-btn" data-bootstrap${settings.learn_from_provider_junk ? "" : " disabled"}>Learn from my Spam folder now</button><span class="wm-faint" data-bootstrap-note>${esc(bootstrapNote)}</span></div>
|
||||||
<p class="wm-faint">Otherwise this runs on its own once an hour, for accounts with at least 10 messages in Spam. Running it again is harmless: messages the filter already learned are skipped.</p>
|
<p class="wm-faint">Otherwise this runs on its own once an hour, for accounts with at least 10 messages in Spam. Running it again is harmless: messages the filter already learned are skipped.</p>
|
||||||
</div>
|
</div>`;
|
||||||
|
|
||||||
<div class="wm-card"><strong>Activity</strong>${statsBlock()}</div>`;
|
|
||||||
|
|
||||||
el.querySelectorAll('input[type="number"][data-f]').forEach((i) =>
|
el.querySelectorAll('input[type="number"][data-f]').forEach((i) =>
|
||||||
i.addEventListener("change", () => patch({ [i.getAttribute("data-f")]: Number(i.value) })),
|
i.addEventListener("change", () => patch({ [i.getAttribute("data-f")]: Number(i.value) })),
|
||||||
@@ -131,9 +114,13 @@ export default function mount(el, ctx) {
|
|||||||
el.querySelectorAll("[data-acc]").forEach((i) =>
|
el.querySelectorAll("[data-acc]").forEach((i) =>
|
||||||
i.addEventListener("change", () => {
|
i.addEventListener("change", () => {
|
||||||
const ticked = [...el.querySelectorAll("[data-acc]")].filter((x) => x.checked);
|
const ticked = [...el.querySelectorAll("[data-acc]")].filter((x) => x.checked);
|
||||||
// All ticked is the same as "every account": store it as empty.
|
// All ticked is "every account" (accounts added later too); none ticked
|
||||||
const ids = ticked.length === accounts.length ? [] : ticked.map((x) => x.getAttribute("data-acc"));
|
// is none.
|
||||||
patch({ enabled_accounts: ids });
|
patch(
|
||||||
|
ticked.length === accounts.length
|
||||||
|
? { all_accounts: true }
|
||||||
|
: { enabled_accounts: ticked.map((x) => x.getAttribute("data-acc")) },
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const boot = el.querySelector("[data-bootstrap]");
|
const boot = el.querySelector("[data-bootstrap]");
|
||||||
|
|||||||
@@ -48,4 +48,8 @@ async def set_rules(body: RulesIn, user_id: CurrentUserId) -> dict[str, object]:
|
|||||||
async def bundle(filename: str) -> FileResponse:
|
async def bundle(filename: str) -> FileResponse:
|
||||||
if filename != "settings.js":
|
if filename != "settings.js":
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
return FileResponse(_UI / filename, media_type="text/javascript")
|
# `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
|
||||||
|
# updated bundle is used at once instead of a copy the browser kept.
|
||||||
|
return FileResponse(
|
||||||
|
_UI / filename, media_type="text/javascript", headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -52,4 +52,8 @@ async def add_note(body: NoteIn, user_id: CurrentUserId) -> dict[str, str]:
|
|||||||
@router.get("/ui/page.js")
|
@router.get("/ui/page.js")
|
||||||
async def page_bundle() -> FileResponse:
|
async def page_bundle() -> FileResponse:
|
||||||
"""Serve the page's ESM bundle for the frontend loader (SPEC §11.3)."""
|
"""Serve the page's ESM bundle for the frontend loader (SPEC §11.3)."""
|
||||||
return FileResponse(_UI / "page.js", media_type="text/javascript")
|
# `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
|
||||||
|
# updated bundle is used at once instead of a copy the browser kept.
|
||||||
|
return FileResponse(
|
||||||
|
_UI / "page.js", media_type="text/javascript", headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -179,4 +179,8 @@ async def delete_label(name: str, user_id: CurrentUserId) -> list[str]:
|
|||||||
async def bundle(filename: str) -> FileResponse:
|
async def bundle(filename: str) -> FileResponse:
|
||||||
if filename not in _BUNDLES:
|
if filename not in _BUNDLES:
|
||||||
raise HTTPException(status_code=404)
|
raise HTTPException(status_code=404)
|
||||||
return FileResponse(_UI / filename, media_type="text/javascript")
|
# `no-cache`: revalidated on every load (a cheap 304 via the ETag), so an
|
||||||
|
# updated bundle is used at once instead of a copy the browser kept.
|
||||||
|
return FileResponse(
|
||||||
|
_UI / filename, media_type="text/javascript", headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function injectStyles() {
|
|||||||
s.id = "lb-styles";
|
s.id = "lb-styles";
|
||||||
s.textContent = `
|
s.textContent = `
|
||||||
.lb-sec{position:relative;padding:4px 0 0}
|
.lb-sec{position:relative;padding:4px 0 0}
|
||||||
.lb-head{display:flex;align-items:center;gap:5px;padding:10px 8px 2px 12px}
|
.lb-head{display:flex;align-items:center;gap:5px;padding:24px 8px 2px 12px}
|
||||||
.lb-headbtn{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:rgb(var(--app-faint));font:inherit;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}
|
.lb-headbtn{display:flex;align-items:center;gap:5px;background:none;border:0;cursor:pointer;color:rgb(var(--app-faint));font:inherit;font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em}
|
||||||
.lb-add{margin-left:auto;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:2px 6px;border-radius:4px;font-size:15px;line-height:1}
|
.lb-add{margin-left:auto;background:none;border:0;color:rgb(var(--app-muted));cursor:pointer;padding:2px 6px;border-radius:4px;font-size:15px;line-height:1}
|
||||||
.lb-add:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
.lb-add:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
|
|||||||
+42
-31
@@ -832,7 +832,7 @@ At the hosted-mail phase the VPS edge (Stalwart's built-in filter or Postfix+Rsp
|
|||||||
|
|
||||||
## Appendix D — Hosted mailboxes (M6, Stalwart)
|
## Appendix D — Hosted mailboxes (M6, Stalwart)
|
||||||
|
|
||||||
Every webmail user gets a real mailbox on a **Stalwart** server running beside the app. The address is the user's handle (`localpart@APP_DOMAIN`, already mail-grade per §4), and the password is the webmail password. That mailbox appears in webmail as an account the user cannot remove. Because Stalwart holds the mail, webmail stops keeping its own full copy of it (§D.6).
|
Every webmail user gets a real mailbox on a **Stalwart** server running beside the app. The address is the user's handle (`localpart@APP_DOMAIN`, already mail-grade per §4); mail apps sign in to it with **app passwords only** (§D.5) — the webmail password never opens it. That mailbox appears in webmail as an account the user cannot remove. Because Stalwart holds the mail, webmail stops keeping its own full copy of it (§D.6).
|
||||||
|
|
||||||
### D.1 Hosting (decide first — this, not code, is the hard part)
|
### D.1 Hosting (decide first — this, not code, is the hard part)
|
||||||
Receiving needs inbound port 25, an MX record and a fixed address; many home ISPs block port 25. Sending from a home IP is commonly spam-foldered or refused, and needs reverse DNS (PTR), SPF, DKIM and DMARC.
|
Receiving needs inbound port 25, an MX record and a fixed address; many home ISPs block port 25. Sending from a home IP is commonly spam-foldered or refused, and needs reverse DNS (PTR), SPF, DKIM and DMARC.
|
||||||
@@ -846,46 +846,57 @@ Receiving needs inbound port 25, an MX record and a fixed address; many home ISP
|
|||||||
**Chosen (2026-10-10): option A** — everything on the QNAP; see `docs/mail-server-network.md` for the network, DNS and port plan and the connection checks.
|
**Chosen (2026-10-10): option A** — everything on the QNAP; see `docs/mail-server-network.md` for the network, DNS and port plan and the connection checks.
|
||||||
|
|
||||||
### D.2 Infrastructure
|
### D.2 Infrastructure
|
||||||
- `stalwart` service in both compose stacks (dev `webmail`, prod `webmail-prod` — never share the project name), image pinned to an exact version.
|
- `stalwart` service in both compose stacks (dev `webmail`, prod `webmail-prod` — never share the project name), image pinned to an exact version: **`stalwartlabs/stalwart:v0.16.19`**.
|
||||||
- **Stores:** data store = a separate `stalwart` database on the existing Postgres; blob store = filesystem under `DATA_ROOT/stalwart` (bind mount, not a named volume). `backup.sh` and `webmail.sh update` (backup → migrate → roll → health-gate) then cover it; no RocksDB to snapshot separately.
|
- **Stores:** data store = a separate `stalwart` database on the existing Postgres, owned by its own role `stalwart` (`STALWART_DB_PASSWORD`); blob store = filesystem (`/var/lib/stalwart/blobs`, bind mount under `DATA_ROOT/stalwart` in production). `backup.sh` and `webmail.sh update` (backup → migrate → roll → health-gate) then cover it; no RocksDB to snapshot separately.
|
||||||
- **TLS:** Stalwart's built-in ACME with DNS-01 (nginx owns 80/443), or the existing certificates mounted read-only.
|
- **Configuration (built, D2):** in 0.16 `config.json` holds only the data store (`docker/stalwart/config.json`, committed: its password is read from the environment, `{"@type":"EnvironmentVariable","variableName":"STALWART_DB_PASSWORD"}`); everything else lives in that database and is managed through the JMAP management API (`x:Domain`, `x:Directory`, `x:Role`, …). `app/hosted/provision.py` (`python -m app.hosted.provision`, idempotent) sets it all up: the role and database (the server restart-loops until they exist), the domain and host name, logging to the console, mail on disk (switching the blob store takes a **restart** — the script says so), submission on 587 beside smtp 25 / submissions 465 / imaps 993, plain folder names for new mailboxes (Trash, Junk, Sent, Drafts, Archive), the SQL directory, the worker's role and sign-in, and the native account of every existing user. The management API is signed in to as the recovery admin (`STALWART_RECOVERY_ADMIN=admin:$STALWART_ADMIN_SECRET`).
|
||||||
|
- **Stalwart 0.16.19 quirks:** three built-in SURBL spam-list rules fail its own validator (`bit_and`), which makes every *ReloadSettings* fail; they never load anyway, so provisioning removes them. A blocked account's IMAP sign-in is not answered — the connection is dropped after a minute.
|
||||||
|
- **TLS:** Stalwart's built-in ACME with DNS-01 (nginx owns 80/443), or the existing certificates mounted read-only. The webmail itself reaches IMAP/SMTP over the Docker network with TLS but no certificate check.
|
||||||
- **Spam:** SMTP-time filtering at the edge (Stalwart's filter or Rspamd) tags an edge-score header; the anti-spam plugin trusts it for hosted accounts (§C.7). The existing Rspamd keeps per-user learning.
|
- **Spam:** SMTP-time filtering at the edge (Stalwart's filter or Rspamd) tags an edge-score header; the anti-spam plugin trusts it for hosted accounts (§C.7). The existing Rspamd keeps per-user learning.
|
||||||
|
|
||||||
### D.3 Same username and password — no password copying
|
### D.3 Same username, no password copying
|
||||||
Stalwart uses a **SQL directory** reading a read-only view over `users` (dedicated read-only role):
|
Stalwart uses a **SQL directory** reading the view `mail_directory` (migration 0025), which the `stalwart` role may SELECT and nothing else in the app's database:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE VIEW mail_principals AS
|
CREATE VIEW mail_directory AS
|
||||||
SELECT handle AS name, handle AS email, password_hash AS secret,
|
SELECT handle AS name,
|
||||||
quota_bytes AS quota, (blocked_at IS NOT NULL) AS disabled
|
NULL::text AS secret, -- users: app passwords only (§D.5)
|
||||||
FROM users;
|
'individual' AS type, handle AS description,
|
||||||
|
blocked_at IS NULL AS active
|
||||||
|
FROM users
|
||||||
|
UNION ALL
|
||||||
|
SELECT name, secret, 'individual', description, true FROM mail_service_accounts;
|
||||||
```
|
```
|
||||||
|
|
||||||
- Stalwart verifies argon2id PHC hashes, which is what `identity/passwords.py` produces, so registration, password change, reset, block and quota apply **instantly** with no provisioning or sync step.
|
Sign-in query: `… WHERE name = $1 AND active`; recipient query: `… WHERE name = $1` (a blocked user still receives mail).
|
||||||
- User deletion additionally purges the mailbox through Stalwart's management API.
|
|
||||||
- Webmail never stores the user's password: the worker signs in as a **master user** (`handle%master`, secret in `.env` as `STALWART_MASTER_SECRET`), not via `accounts.secret_ct`. *Verify the master-user feature and syntax in the pinned version during D2.*
|
|
||||||
- Reserved handles (`postmaster`, `abuse`, …) stay unregistrable; route them to the administrator.
|
|
||||||
|
|
||||||
### D.4 The permanent account in webmail
|
- **Decided (2026-10-11): the webmail password never opens the mailbox** — not even with 2FA off. A mail app's password is stored on a phone or laptop and sent on every connection; an app password limits a leak to the mailbox (not the webmail account) and can be revoked on its own. The view still lists every user (for delivery and for the worker's impersonation), with no secret.
|
||||||
- Migration: `accounts.kind text NOT NULL DEFAULT 'external'` (`external` | `native`). The native account is created in `register_user`; a one-off command creates it for users that already exist.
|
- **Built (D2):** the SQL directory returns **one secret per account** (Stalwart reads the first row only), so app passwords cannot be extra rows of this view — see §D.5.
|
||||||
- `DELETE /api/accounts/{id}` on a native account → `409 {error:{code:"native_account"}}`. UI hides Remove and the server-settings fields; signature, colour and display name stay editable.
|
- **Block:** `user_admin.set_blocked` also takes away the mailbox account's *authenticate* permission on the mail server (app passwords and the worker included); unblocking restores it. The mail server goes first: if it cannot be told, nothing changes (502).
|
||||||
- `provider_factory`: native accounts connect to `stalwart:993` / `stalwart:465` on the Docker network with the master credentials.
|
- **User deletion** first deletes the mailbox and its mail through the management API — if that fails nothing is deleted, since a mailbox left behind would be handed to whoever registers the address next.
|
||||||
- **Sync:** first reuse the IMAP provider unchanged (IDLE + QRESYNC already work). Optional later: a JMAP provider with push (Stalwart's native protocol).
|
- **The worker's sign-in (built):** Stalwart's master-user login `user@domain%webmail-worker@domain`. The worker account is a row of `mail_service_accounts` (argon2id of `STALWART_MASTER_SECRET`); on the mail server its only role is *impersonate* + *authenticate*, so it cannot open a session as itself. Webmail stores no user's password (`secret_ct` holds nothing for native accounts). Accounts never signed in to are brought into Stalwart's registry by that first impersonated sign-in.
|
||||||
- Sending goes through Stalwart; Sent copy, retries and the Scheduled folder behave as for any account.
|
- Reserved handles (`postmaster`, `abuse`, …) stay unregistrable; route them to the administrator. `webmail-worker` is taken by the service account.
|
||||||
|
|
||||||
|
### D.4 The permanent account in webmail — built (D3)
|
||||||
|
- Migration 0025: `accounts.kind text NOT NULL DEFAULT 'external'` (`external` | `native`), at most one native per user. `app/hosted/native.py`: the native account is created at registration, by provisioning for users that already exist, and when the user removes an account for their own address (such an account — the same address at its old provider, e.g. home.pl during the move — is left alone: no native account is added while it exists).
|
||||||
|
- `DELETE /api/accounts/{id}` on a native account → `409 native_account`; `PATCH` with server settings or a password → `400 native_account`. Lists carry `kind`; the UI shows a "Your mailbox" badge and hides Remove and the server settings; signature, colour and display name stay editable.
|
||||||
|
- `provider_factory`: native accounts connect to `STALWART_IMAP_HOST:993` / `STALWART_SMTP_HOST:465` (from the settings, not the row) as the worker acting for the user, outside the per-host connection cap. The account syncs Junk (the mail server's own filter files there).
|
||||||
|
- **Sync:** the IMAP provider unchanged (IDLE, CONDSTORE, QRESYNC, BINARY, MOVE, UIDPLUS all advertised). Optional later: a JMAP provider with push. Found on the way: UIDVALIDITY values above 2^31 did not fit the `integer` columns — migration 0026 makes UIDVALIDITY, MODSEQ and UIDs `bigint`.
|
||||||
|
- Sending goes through Stalwart, which refuses a From address the user does not own; Sent copy, retries and the Scheduled folder behave as for any account.
|
||||||
|
|
||||||
### D.5 External mail clients and 2FA
|
### D.5 External mail clients and 2FA
|
||||||
If phones/desktop clients may use the mailbox over IMAP/SMTP, a password alone would bypass TOTP. Rule: users with 2FA enabled must use **app passwords** for IMAP/SMTP — a `user_app_passwords` table (argon2id hashes) added to the directory view, plus a Settings screen to create/revoke them. Stalwart's login rate limits / auto-ban on. If external clients are not wanted, IMAP/SMTP submission is exposed only on the Docker network and this section shrinks to nothing.
|
Mail apps (IMAP/SMTP) sign in with **app passwords only**, whether 2FA is on or not: the webmail password would bypass TOTP, and is better never typed into a mail app. The directory view returns no password hash for users (verified: the password fails, app passwords and the worker work). Stalwart's login rate limits / auto-ban on.
|
||||||
|
|
||||||
**App passwords — built (v1.3.37, ahead of D5):** table `user_app_passwords` (migration 0024: `id, user_id, name, password_hash, created_at, last_used_at`), `app/identity/app_passwords.py`, routes `GET/POST /api/auth/app-passwords`, `DELETE /api/auth/app-passwords/{id}`, Settings → Security → App passwords.
|
**App passwords — built (v1.3.37, ahead of D5):** table `user_app_passwords` (migration 0024: `id, user_id, name, password_hash, created_at, last_used_at`), `app/identity/app_passwords.py`, routes `GET/POST /api/auth/app-passwords`, `DELETE /api/auth/app-passwords/{id}`, Settings → Security → App passwords.
|
||||||
- 16 random lowercase letters (~75 bits), shown once in groups of four, copied and entered **without spaces**: the hash is of the bare letters, because the mail server compares exactly what an app sends.
|
- Without hosted mailboxes: 16 random lowercase letters (~75 bits), for the DAV server only.
|
||||||
- Creating one takes the account password; at most 25 per user; each is revoked on its own; a password reset revokes them all (as Google does). The webmail's own sign-in never accepts one.
|
- **With hosted mailboxes (built, D2):** the mail server makes the password — Stalwart's own app-password credential, `app_` + 36 base32 characters naming the credential and 144 random bits — and checks it for IMAP/SMTP itself; its argon2id hash is kept in `user_app_passwords` too (for DAV), with the server's id in `server_id` (migration 0025) to revoke it there. One password works for mail and DAV. Revoking goes to the mail server first (502 `mail_server_unavailable` if it does not answer).
|
||||||
- Still for D2/D5: add the hashes to the directory's secrets query (a user's secrets = `users.password_hash` unless 2FA is on, plus every `user_app_passwords.password_hash`), and set `last_used_at` from the mail server's successful sign-ins.
|
- Shown once in groups of four, copied and entered **without spaces**. Creating one takes the account password; at most 25 per user; each is revoked on its own; a password reset revokes them all (as Google does). The webmail's own sign-in never accepts one. Settings → Security shows the mail-app settings (`mail_host` on `/api/auth/me`: IMAP 993 SSL, SMTP 465 SSL or 587 STARTTLS, user name = the address).
|
||||||
|
- Still for D5: `last_used_at` (Stalwart's app-password credential records no last use).
|
||||||
|
|
||||||
### D.6 Storing mail once
|
### D.6 Storing mail once
|
||||||
Today the indexer keeps every message in full (raw `.eml` blob + sanitized body in `body_parts` + search index), so external accounts are already a second copy, and with Stalwart both copies would sit on the same disk. Ideas, best first:
|
Today the indexer keeps every message in full (raw `.eml` blob + sanitized body in `body_parts` + search index), so external accounts are already a second copy, and with Stalwart both copies would sit on the same disk. Ideas, best first:
|
||||||
|
|
||||||
1. **Attachments are stored twice inside webmail today.** `indexer.py` stores each attachment as its own blob *and* it is inside the raw blob (base64), ≈2.3× its size; attachments are most of mail's bytes. Fix: keep only the raw message, extract an attachment when downloaded, keep thumbnails cached. Cuts attachment storage by about 40% (≈1.37× instead of ≈2.37× the file size) for **all** accounts, independent of Stalwart. A background pass frees existing duplicates. **Done (v1.3.34):** `app/indexer/attachments.py`, migration 0023.
|
1. **Attachments are stored twice inside webmail today.** `indexer.py` stores each attachment as its own blob *and* it is inside the raw blob (base64), ≈2.3× its size; attachments are most of mail's bytes. Fix: keep only the raw message, extract an attachment when downloaded, keep thumbnails cached. Cuts attachment storage by about 40% (≈1.37× instead of ≈2.37× the file size) for **all** accounts, independent of Stalwart. A background pass frees existing duplicates. **Done (v1.3.34):** `app/indexer/attachments.py`, migration 0023.
|
||||||
2. **Native accounts: no raw copy in webmail.** Stalwart is the master copy; webmail keeps metadata, sanitized body, snippet and search index (~5–10% of the size). Full message, attachments and "show original" are fetched on demand over the local network (IMAP `BODY[part]` or JMAP blob download), with a small cache for repeat opens and thumbnails.
|
2. **Native accounts: no raw copy in webmail.** Stalwart is the master copy; webmail keeps metadata, sanitized body, snippet and search index (~5–10% of the size). Full message, attachments and "show original" are fetched on demand over the local network (IMAP `BODY[part]` or JMAP blob download), with a small cache for repeat opens and thumbnails. **Built (D4):** `app/indexer/rawstore.py` — a native message's raw source goes into `{BLOB_ROOT}/{user}/rawcache/{sha256}` instead of the blob store, bounded per user (`RAW_CACHE_MB`, default 256; least recently used out first, anything unused for 30 days; pruned every 50 writes and by the nightly clean-up). Readers find it with `rawstore.locate` (blob store, then cache); request handlers call `rawstore.ensure(message)` first, which fetches the whole message (`UID FETCH BODY.PEEK[]`, ~0.3 s on the same host) into the cache when missing. Covered: attachments (download, zip, thumbnails, inline images, forward, reopening a draft), re-sanitizing the HTML, the plugins' header/raw access. The nightly clean-up no longer keeps a blob-store file only native messages refer to. Thumbnails and taken-out attachments keep their own caches, as for every account.
|
||||||
3. **External accounts (optional setting): age out old raw copies.** Keep raw blobs for N days or the newest X GB; refetch older ones over IMAP when needed. Readable bodies stay local; old attachments then need the external server reachable.
|
3. **External accounts (optional setting): age out old raw copies.** Keep raw blobs for N days or the newest X GB; refetch older ones over IMAP when needed. Readable bodies stay local; old attachments then need the external server reachable.
|
||||||
4. **Filesystem:** at least lz4 compression on the ZFS data share (QuTS hero). ZFS dedup would merge identical copies but costs a lot of RAM and only hits uncompressed byte-identical data — last resort.
|
4. **Filesystem:** at least lz4 compression on the ZFS data share (QuTS hero). ZFS dedup would merge identical copies but costs a lot of RAM and only hits uncompressed byte-identical data — last resort.
|
||||||
5. **Rejected — MTA-only Stalwart** delivering straight into webmail with no IMAP mailbox: zero duplication, but phones/Thunderbird lose access unless webmail implements IMAP. Items 1 + 2 give nearly the same saving.
|
5. **Rejected — MTA-only Stalwart** delivering straight into webmail with no IMAP mailbox: zero duplication, but phones/Thunderbird lose access unless webmail implements IMAP. Items 1 + 2 give nearly the same saving.
|
||||||
@@ -895,18 +906,18 @@ Today the indexer keeps every message in full (raw `.eml` blob + sanitized body
|
|||||||
### D.7 Phases
|
### D.7 Phases
|
||||||
- **D0 — Decisions:** hosting option (§D.1), domain, whether external IMAP clients are allowed (§D.5).
|
- **D0 — Decisions:** hosting option (§D.1), domain, whether external IMAP clients are allowed (§D.5).
|
||||||
- **D1 — Attachment de-duplication** (§D.6 item 1): independent, can ship any time.
|
- **D1 — Attachment de-duplication** (§D.6 item 1): independent, can ship any time.
|
||||||
- **D2 — Spike (dev):** Stalwart container, Postgres stores, SQL-directory view, master-user login, IMAP sync of a test user.
|
- **D2 — Spike (dev):** Stalwart container, Postgres stores, SQL-directory view, master-user login, IMAP sync of a test user. **Done (2026-10-11)** — dev compose service `stalwart`, `app/hosted/`.
|
||||||
- **D3 — Native account:** `accounts.kind`, creation at registration + for existing users, undeletable in API and UI, provider wiring, delete/block hooks; tests against a Stalwart container (may replace greenmail).
|
- **D3 — Native account:** `accounts.kind`, creation at registration + for existing users, undeletable in API and UI, provider wiring, delete/block hooks; tests against a Stalwart container (`tests/hosted/`, skipped when it is not running). **Done.**
|
||||||
- **D4 — On-demand bodies for native accounts** (§D.6 item 2).
|
- **D4 — On-demand bodies for native accounts** (§D.6 item 2). **Done.**
|
||||||
- **D5 — Security:** app passwords + 2FA rule, rate limits.
|
- **D5 — Security:** app passwords only for mail apps (**done**), rate limits, `last_used_at`.
|
||||||
- **D6 — Production:** compose, DNS (MX/SPF/DKIM/DMARC/PTR), relay or VPS per §D.1, backup, `docs/nas-install.md` runbook, release.
|
- **D6 — Production:** compose, DNS (MX/SPF/DKIM/DMARC/PTR), relay or VPS per §D.1, backup, `docs/nas-install.md` runbook, release.
|
||||||
|
|
||||||
### D.8 Configuration additions (§14)
|
### D.8 Configuration additions (§14)
|
||||||
`STALWART_URL` (management API), `STALWART_IMAP_HOST` / `STALWART_SMTP_HOST`, `STALWART_MASTER_SECRET`, `STALWART_ADMIN_SECRET`, `HOSTED_MAILBOXES` (on/off; off = M6 inert, byte-identical to pre-M6 behaviour).
|
`HOSTED_MAILBOXES` (on/off; off = M6 inert, byte-identical to pre-M6 behaviour), `STALWART_URL` (management API, default `http://stalwart:8080`), `STALWART_ADMIN_SECRET` (its recovery admin), `STALWART_MASTER_SECRET` (the worker's sign-in), `STALWART_DB_PASSWORD` (its Postgres role), `STALWART_DB_NAME` (default `stalwart`), `STALWART_PG_HOST` (Postgres as the mail server sees it, default `postgres`), `STALWART_IMAP_HOST` / `STALWART_IMAP_PORT` (993), `STALWART_SMTP_HOST` / `STALWART_SMTP_PORT` (465), `MAIL_HOSTNAME` (public name, default `mail.APP_DOMAIN`), `RAW_CACHE_MB` (256). Dev values are in `.env.dev`; the dev Stalwart publishes 8180 (API), 1025 (SMTP in), 1465, 1587, 1993 on loopback.
|
||||||
|
|
||||||
### D.9 Acceptance criteria
|
### D.9 Acceptance criteria
|
||||||
1. A newly registered user can receive mail from an outside address at their handle and sees it in webmail within one sync cycle; mail they send arrives at Gmail not in spam (SPF/DKIM/DMARC pass).
|
1. A newly registered user can receive mail from an outside address at their handle and sees it in webmail within one sync cycle; mail they send arrives at Gmail not in spam (SPF/DKIM/DMARC pass).
|
||||||
2. The same handle + password signs in to IMAP (when §D.5 allows it); after a webmail password change or reset, the old password fails and the new one works with no other step; a blocked user cannot sign in to IMAP.
|
2. The handle + an app password signs in to IMAP/SMTP; the webmail password never does; revoking the app password (or a password reset) stops it at once; a blocked user cannot sign in to IMAP.
|
||||||
3. The native account cannot be removed via API (409) or UI; deleting the user removes the Stalwart mailbox.
|
3. The native account cannot be removed via API (409) or UI; deleting the user removes the Stalwart mailbox.
|
||||||
4. Webmail stores no copy of the account's password (`secret_ct` unused for native accounts).
|
4. Webmail stores no copy of the account's password (`secret_ct` unused for native accounts).
|
||||||
5. After D1, an attachment-heavy fixture mailbox uses ≤ 60% of the pre-D1 blob storage, and every attachment still downloads byte-identical.
|
5. After D1, an attachment-heavy fixture mailbox uses ≤ 60% of the pre-D1 blob storage, and every attachment still downloads byte-identical.
|
||||||
|
|||||||
Reference in New Issue
Block a user