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:
@@ -104,6 +104,7 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
||||
sync_error=a.sync_error,
|
||||
sync_error_since=a.sync_error_since.isoformat() if a.sync_error_since else None,
|
||||
state_detail=a.state_detail,
|
||||
kind=a.kind,
|
||||
)
|
||||
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
|
||||
# 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
|
||||
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 not body.password:
|
||||
raise_api_error(
|
||||
@@ -267,10 +270,20 @@ async def update_account(
|
||||
|
||||
@router.delete("/{account_id}", dependencies=[Depends(require_csrf)])
|
||||
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)
|
||||
if not ok:
|
||||
raise_api_error(404, "not_found", "account not found")
|
||||
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"}
|
||||
|
||||
|
||||
@@ -288,4 +301,5 @@ def _detail(account: Account) -> AccountDetailOut:
|
||||
backfill_done=account.backfill_done,
|
||||
imap_config=account.imap_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.db import get_session
|
||||
from app.hosted.admin import MailServerError
|
||||
from app.identity.deps import CurrentAdmin, require_csrf
|
||||
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
|
||||
from app.identity.schemas import (
|
||||
@@ -183,7 +184,12 @@ async def patch_user(
|
||||
if body.quota_bytes is not None:
|
||||
user.quota_bytes = body.quota_bytes
|
||||
if body.blocked is not None:
|
||||
await set_blocked(db, user, body.blocked)
|
||||
try:
|
||||
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()
|
||||
used = await db.scalar(
|
||||
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]:
|
||||
"""Delete a user and all of their data. Cannot be undone."""
|
||||
user = await _target(db, admin, user_id)
|
||||
await delete_user(db, user)
|
||||
try:
|
||||
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"}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Annotated
|
||||
@@ -17,7 +18,7 @@ from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
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.outbox.jobs import RETRY_WINDOW
|
||||
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] = []
|
||||
if att_rows:
|
||||
with contextlib.suppress(FileNotFoundError): # fetched if a hosted mailbox's
|
||||
await rawstore.ensure(msg)
|
||||
for a in att_rows:
|
||||
# Kept only inside the message: give the compose attachment a blob of its own.
|
||||
if not await asyncio.to_thread(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import html as html_lib
|
||||
import io
|
||||
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.identity.deps import CurrentUser, require_csrf
|
||||
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.sanitize import SANITIZER_VERSION, sanitize_html
|
||||
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 allow_remote or stale:
|
||||
try:
|
||||
raw = blobstore.load(user.id, message.blob_sha256)
|
||||
raw = await rawstore.ensure_bytes(message)
|
||||
except FileNotFoundError:
|
||||
raw = 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)}"
|
||||
|
||||
|
||||
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:
|
||||
"""An attachment's bytes, taken out of its message (404 if they cannot be)."""
|
||||
await _raw_ready(msg)
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
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).
|
||||
Returns them as the composer lists its attachments."""
|
||||
msg = await _owned_message(db, user.id, message_id)
|
||||
await _raw_ready(msg)
|
||||
rows = [
|
||||
ComposeAttachment(
|
||||
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
|
||||
if att is None or kind is None:
|
||||
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(
|
||||
thumbs.thumbnail,
|
||||
msg.user_id,
|
||||
@@ -350,6 +362,8 @@ async def message_attachment(
|
||||
)
|
||||
if att is None:
|
||||
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:
|
||||
# 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.
|
||||
|
||||
@@ -102,6 +102,8 @@ class AccountOut(BaseModel):
|
||||
sync_error_since: str | None = None
|
||||
# Why the state is what it is (e.g. the server's reply to a refused sign-in).
|
||||
state_detail: str = ""
|
||||
# "native": the user's own hosted mailbox — cannot be removed (App. D §D.4).
|
||||
kind: str = "external"
|
||||
|
||||
|
||||
class SearchThreadHit(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user