Compare commits

..
27 Commits
Author SHA1 Message Date
lukasandClaude Opus 5.5 f82c7eb444 Loading splash: animated logo over a dark overlay on desktop
index.html carries the overlay markup and styles inline so it paints before
any script; public/splash.js (external, for the CSP) plays the logo
animation from icons/ultimo-splash.html, holds, fades out after ~3.25 s and
removes itself. Skipped at phone sizes (same query as useIsMobile) and in
pop-out compose windows; a click skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:23:56 +01:00
lukasandClaude Opus 5.5 05db376b89 Updated logo, carried to every derived icon
New ultimo-logo and signet SVGs, re-rendered into the PWA icons (any and
maskable), apple-touch icon, favicon, the public and calendar-plugin
signet copies, the BIMI Tiny-PS logo and the system-email logo PNG, each
at its previous size, placement and background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:13:15 +01:00
lukasandClaude Opus 5.5 77e9c25353 Admin: allow changing your own quota
The self-protection guard refused every change to the admin's own account.
It now only refuses blocking and deleting yourself; a quota change is fine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:39:33 +01:00
lukasandClaude Opus 5.5 a4581ab199 Interface size setting: Default, +10%, +20%
Mailbox display and Settings → Appearance get an interface-size picker,
stored per device in localStorage and applied as CSS zoom on <html>.
Viewport-height layouts divide by --ui-zoom, and code that turns mouse
coordinates or bounding rects into CSS lengths (resizers, compose dock,
emoji picker, editor popups and image handles, swipe-to-delete) divides
by the zoom factor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:39 +01:00
lukasandClaude Opus 5.5 d8e6cca66b Spam banner: inset card, red title, plain-language reasons, translated
The banner sits inside the message body's 16px gutter with room below,
"Looks like spam!" is red, and the rspamd symbols are replaced by up to
three human-readable reasons (neutral symbols are hidden). All strings go
through ctx.t with English and Polish catalog entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:39 +01:00
lukasandClaude Opus 5.5 b122276958 Antispam: online checks no longer switch off Bayes; replies teach ham
- The "online reputation checks" rspamd profile used groups_enabled,
  which means ONLY those groups: with the switch on, a message got the
  RBL lookups alone (1 rule instead of 19, score 0.00) and Bayes never
  ran, so every spam lesson was ignored. It now disables nothing.
- A reply or reply-all teaches the answered message as ham (not
  forwards or new mail, not one's own Sent/Drafts, never over an
  explicit spam mark; once per message). message.sent now carries the
  draft's kind and reply_to_message_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:14:05 +02:00
lukasandClaude Opus 5.5 1aa0673720 Phone reading pane: icon-only top bar, roomier header; dark app icon
- Top bar: icons only (names kept for screen readers and as tooltips),
  20% larger, twice the room between them; Archive 2 px smaller
- Subject wraps to two lines; subject-bar icons twice as far apart
- Reply / Translate / Actions on their own row with more room around
  it; the ▼ for the full header can no longer be squeezed out, and the
  full header spans the card's width
- Maskable app icons on the app's dark grey, so Android's launch screen
  shows the logo without a white circle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:59:56 +02:00
lukasandClaude Opus 5.5 21cfaa07ed Translator: languages that fail to download are tried again
The first start tried the default languages once; a DNS failure at that
moment left them missing for good. Languages asked for are now kept as
pending in state.json and retried in the background (30 s, doubling to
15 min) until they are in, across restarts; an install from the admin
panel joins the same queue. A state from before this, with nothing
installed, counts as an unfinished first start. The admin panel shows
what is waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:21:38 +02:00
lukasandClaude Opus 5.5 972e10876b Translation on Firefox Translations models; new logo
- Email translation moves from LibreTranslate to our own translator
  service (translator/): Mozilla's Firefox Translations models on the
  Bergamot engine, fastText language detection (176 languages). Polish,
  English, German, French, Spanish and Italian on first start (~350 MB
  instead of ~10 GB); Admin panel > Email translation installs and
  removes languages. Built and pushed as the `translator` image.
- Every message is translated paragraph by paragraph as plain text;
  names travel with their sentence and are kept only where the model
  changed them; a sentence start the model copied ("Enclosed") is tried
  again in lower case; list markers are not sent
- The Translate button shows only for installed languages
- New logo: favicon, app/maskable/iPhone icons, notification badge,
  email logo and calendar copy regenerated; BIMI logo rebuilt in SVG
  Tiny PS with its gradients; masks render in browsers
- Launch screen background is the app's dark grey

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:04:04 +02:00
lukasandClaude Opus 5.5 8a1b9979e9 Productivity features, UI translations, local email translation
- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:00:31 +02:00
lukasandClaude Opus 5.5 463f96ad57 Invite a friend; handle field shows the domain; system mail fixes
- Invitations: each user can invite two friends by email (sign-up works
  through the link even while registration is closed); admin-editable
  template, subject and sender.
- Invitation sender defaults to the system-mail From address, which the
  SMTP account is allowed to send as (was postmaster@APP_DOMAIN, refused
  with 553 when the two differ).
- Sign-in, sign-up and forgot-password fields show the fixed
  @domain after the typed name; the registration status reports it.
- System mail gets a Date header; the invitation's plain-text part keeps
  its paragraphs and line breaks.
- SMTP EHLO uses SMTP_HELO_NAME / APP_DOMAIN, not the container name.
- DNS recommendations for the mail domains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:34:31 +02:00
lukasandClaude Opus 5.5 addb2232a1 Security hardening from the assessment; testing fixes
Security (ultimo-security-report.md):
- Security headers on every response, the app shell included: they lived at
  server level and nginx dropped them in any location with its own header.
  CSP, framing, nosniff, referrer, permissions (docker/nginx-security.conf).
- Plain HTTP forwarded by the reverse proxy is redirected to HTTPS; HSTS on
  HTTPS responses. server_tokens off.
- Cross-site state-changing /api requests refused (login CSRF), WebSocket too.
- Validation errors in the usual error shape, without the request model.
- New-password policy: max 128, common/own-address/repeated refused,
  breached-password check (HIBP k-anonymity, fails open).
- Message HTML: links keep only http(s)/mailto (sanitizer v6); the HTML
  endpoint carries its own no-script CSP.
- Outbound fetches pinned to the checked IP (DNS rebinding); external
  IMAP/SMTP servers re-checked before each connection; CGNAT and mapped
  addresses counted as non-public.

Fixes from testing:
- IDLE loop catches up before idling: mail that came while the worker was
  away no longer waits up to 10 minutes.
- Restore from Spam/Trash/Archive moves only the removed messages, not your
  reply in Sent.
- Narrow desktop list stacks sender/subject/snippet (subject was squeezed
  to nothing at the default width), with hover actions.
- Phone: wide emails scale to fit the screen.
- Singular forms ("1 message"); no zip button for a single attachment;
  app passwords no longer claim "never used" for mail apps.
- ruff format across the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:08:06 +02:00
lukasandClaude Opus 5.5 30b806e7da Phone swipe-to-delete survives a refresh or folder change
A swipe's delete waited out the Undo window in a page timer: a refresh lost
it, and changing folder un-hid the row. Pending swipes are now remembered in
the browser and sent on the next load (plus a keepalive request as the page
closes), and swiped rows stay hidden in every folder until sent or undone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:46:19 +02:00
lukasandClaude Opus 5.5 b88855931e 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>
2026-10-12 19:25:10 +02:00
lukasandClaude Opus 5.5 d45fbf2c44 M6: network and DNS plan for hosted mail on the QNAP
Hosting decided (spec App. D §D.1 option A): Stalwart on the QNAP, receiving and
sending directly; addresses @ultimo.email; mail apps allowed with app
passwords; mail host mail.ultimo.email. docs/mail-server-network.md records the
connection checks (clean IP, outbound 25 open, generic reverse DNS to change),
the router / Synology / QNAP port layout, container ports, TLS certificate,
the DNS records to set at home.pl, moving mail from home.pl, and checks after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:40:26 +02:00
lukasandClaude Opus 5.5 84d2d24c7d Calendar panel: event dots in the mini calendar, bigger arrows on phones
The side panel's mini calendar marks each day with events with a small dot under
the number (white in dark mode, the text colour in light; white on today's blue
circle), following the calendars filter. The dots come from the agenda's own
events — the panel now loads its month's six mini-calendar weeks once for both;
a mini calendar paged to another month fetches that month once.

The month arrows are drawn icons, centred with the today circle (the text
glyphs sat lower); on phones they are 50% larger with 5px more between the
three buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:40:26 +02:00
lukasandClaude Opus 5.5 53fd603e21 Phone: the side panel takes the full screen width
The Calendar / Contacts / conversation side panel on a phone filled 88% of the
width (at most 420px); it now covers the whole screen, still sliding in from
the right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:57:28 +02:00
lukasandClaude Opus 5.5 aa617a3316 Calendar reminders open the event; phones get the Calendar panel
A reminder notification links to its day, event and occurrence
(/p/calendar?date=…&event=…&start=…). The calendar opens on that day with the
event's details showing — the full calendar on a computer; on a phone any
calendar link leads to Mail with the Calendar panel slid in (the big-screen
page never opens there), scrolled to the event, details open.

The panel's agenda kept no scroll position across re-renders: opening an
event's details lower down jumped the list back to the top. It stays put now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:07:40 +02:00
lukasandClaude Opus 5.5 0e3793a4d6 App passwords; CalDAV / CardDAV server for phones, Thunderbird, macOS
App passwords (SPEC App. D §D.5): one generated password per app, shown once,
revocable on its own, revoked on a password reset; never accepted by the web
sign-in. Settings → Security → App passwords (migration 0024).

CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with
/.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks,
throttled failures). The calendar and contacts plugins serve their own tables as
collections (Plugin(dav=...)):
- calendars as iCalendar: series with overrides, time zones, all-day, attendees
  and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars;
  subscriptions read-only; writes to upstream-synced calendars flow on upstream;
- address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES;
- what a client stores is served back verbatim until the web app edits it;
- sync tokens / CTags from change logs filled by triggers (calendar0008,
  contacts0002), so every write path counts; trimmed nightly after 90 days.
REPORTs: calendar-query (time range), calendar-/addressbook-multiget,
addressbook-query, sync-collection. nginx and the Vite proxy route /dav/.
Settings shows the server and user name to enter; install guide §5.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00
lukasandClaude Opus 5.5 fa552392c8 Calendar reminders as push notifications
A job every minute finds the event reminders that fell due — repeating events
and moved occurrences expanded as the calendar view does — and pushes each
once to every device with notifications on (sdk.push, new: plugins can notify
through the new-mail push delivery). A reminder missed while the worker was down
is still sent within 10 minutes; cancelled events and declined invitations stay
quiet. Title, then "In 15 minutes, 14:00 · Room 2" in the user's zone and clock
format; it stays until dismissed and shows even with the app open; clicking it
opens that day (/p/calendar?date=…, new deep link).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00
lukasandClaude Opus 5.5 9611aedfcd Phone menu: Calendar and Contacts slide in from the right
The phone's left menu gets Calendar and Contacts (above the divider, when the
plugins are installed). Each opens the desktop's side panel on its own, as a
sheet from the right: the calendar panel (mini month, agenda, new event), or
the people panel starting on All contacts — a title instead of the tabs, and no
"In conversation" source without a conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00
lukasandClaude Opus 5.5 f44c7b9e7c Readable white-on-white mail; forwards carry the original's header
A message written in a dark-mode mail app (or pasted from a dark page) can
set all its text white with no background — invisible on the reading pane's
white page. After a message renders, text whose colour is practically the
colour behind it gets the pane's normal text colour, when a good part of the
message (30% of its text) is invisible. Text hidden on purpose — a help
desk's white ticket reference, a newsletter's hidden preview line — stays
hidden; white on dark, coloured or pictured backgrounds is left as designed
(lib/readability.ts, with tests). The check runs before the quote is folded.

A forward quoted the original under the reply's one line ("On …, Name
wrote:"), without the sender's address. It now opens with the usual block:
Forwarded message, From (name and address), Date, Subject, To, Cc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:36:47 +02:00
lukasandClaude Opus 5.5 5bf8fc81f6 Search: all results with a real count; bulk actions on results stay done
Search showed at most the first 50 matching messages ("1–50 of 50"). The
first page now carries the number of matching conversations, further pages
load as the list is paged through (a conversation spanning two appears once;
oldest-first loads them all, up to 40 pages), and the Unread/Pinned tabs filter
on the server, so their count covers every result.

Bulk Delete / Archive / Restore over selected results acts at once: the rows
leave the list (greyed instead when archived from a search, which still finds
them), the search results are refreshed, and the reading pane returns to
"Select a conversation". Each move landing on the server refreshes them again,
and a hidden row only comes back when no move has landed for 30 seconds — a
long batch no longer brings deleted results back.

Settings → Accounts → Notifications: shorter description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:15:06 +02:00
lukasandClaude Opus 5.5 9c08cbb140 Offline: newest 100 conversations; notifications no longer wait silently
Offline access keeps the newest 100 Inbox conversations (the list's first two
pages, saved as the app asks for them) plus recently opened ones, up to 130.

Turning notifications on in a browser that holds the permission request
without showing it (Opera, Chrome's quiet prompts) said nothing and never
finished. After a few seconds it now says how to allow notifications for the
site, and carries on by itself as soon as they are allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00
lukasandClaude Opus 5.5 3957dd540a Attachments stored once; nightly clean-up of unused files
Attachments are no longer stored apart from their message (they were kept
twice: base64 inside the raw message and decoded on their own). The indexer
records the MIME part (attachments.part_index, migration 0023); downloads,
zip, inline images, thumbnails and forwarding take it out of the message;
videos stream from a short-lived copy (kept a day, 1 GiB per user). A
background pass on worker start removes the copies stored before, each only
after checking it can be taken out again (spec App. D §D.6 item 1, criterion
corrected to <= 60%).

A nightly job (03:47, after the Trash/Spam purge) removes files nothing
refers to any more — deleted or purged messages, previews of attachments
gone, uploads no draft has used for a week, expired streaming copies. Files
younger than an hour are left alone; a sent message waiting for its Sent copy
is held until its job is done. Account removal keeps files a draft still uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00
lukasandClaude Opus 5.5 0514053252 Messages size at once; quoted inline images travel inside the reply
The message frame is set up as soon as its document is parsed instead of at
its load event, which waits for every image (remote ones included): it no
longer sits at its first height until the slowest image arrives.

A reply quoted an original's inline (cid:) images as this server's own URLs,
and they were sent like that — broken for the recipient, and mangled copies
("http:///api/…") made browsers look up a host named "api". Outgoing mail
(and the IMAP Drafts copy) now carries them as inline parts with new
Content-IDs; one that can no longer be had is left out. Shown messages load
such URLs from this server (sanitizer v5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00
lukasandClaude Opus 5.5 d0caf76b03 Replies: cursor on the first line, own messages answered to their recipients
A new reply opens with the caret on the empty first line above the quoted
original (on a phone the keyboard comes up with it).

Replying to one of your own messages goes to the people it was sent to
(reply all keeps its Cc), not back to you; your own addresses, under every
account, are never among reply-all recipients; a note sent only to yourself
still answers yourself. The per-message Reply / Reply all button counts
recipients the same way (lib/replyTargets.ts, with tests).

The ThreadView test suite runs again: jsdom gets a stand-in Worker (pdf.js
sets one up at import).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00
259 changed files with 23410 additions and 944 deletions
+2
View File
@@ -21,3 +21,5 @@ RSPAMD_CONTROLLER_PASSWORD=devpass
# Spelling/grammar check in the compose window (LanguageTool). Empty = off. # Spelling/grammar check in the compose window (LanguageTool). Empty = off.
LANGUAGETOOL_URL=http://127.0.0.1:8010 LANGUAGETOOL_URL=http://127.0.0.1:8010
# Email translation (the translator service, translator/). Empty = off.
TRANSLATOR_URL=http://127.0.0.1:5005
+1 -1
View File
@@ -1,5 +1,5 @@
up: ## start services up: ## start services
docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool docker compose -f docker/docker-compose.yml up -d postgres redis meilisearch languagetool translator
down: ## stop services (data kept) down: ## stop services (data kept)
docker compose -f docker/docker-compose.yml down docker compose -f docker/docker-compose.yml down
reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
+1 -3
View File
@@ -125,9 +125,7 @@ def parse_autoconfig_xml(xml_text: str) -> CandidateConfig | None:
smtp = _server("outgoingServer", "smtp") smtp = _server("outgoingServer", "smtp")
if imap is None or smtp is None: if imap is None or smtp is None:
return None return None
return CandidateConfig( return CandidateConfig(imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth))
imap, smtp, "autoconfig", auth_strategy=_strategy_from_auth(imap.auth)
)
# --- DNS SRV (RFC 6186) ------------------------------------------------------ # --- DNS SRV (RFC 6186) ------------------------------------------------------
+1
View File
@@ -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"
+8 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.schemas import CreateAccountRequest from app.accounts.schemas import CreateAccountRequest
from app.accounts.types import ServerConfig from app.accounts.types import ServerConfig
from app.indexer import blobstore from app.indexer import blobstore
from app.models import Account, Attachment, Message, Thread from app.models import Account, Attachment, ComposeAttachment, Message, Thread
from app.security.crypto import encrypt_secret from app.security.crypto import encrypt_secret
@@ -127,6 +127,13 @@ async def delete_account(
.where(Attachment.user_id == user_id, Attachment.blob_sha256 == sha) .where(Attachment.user_id == user_id, Attachment.blob_sha256 == sha)
.limit(1) .limit(1)
) )
if still_used is None:
# A forwarded file waiting in a draft shares the hash: keep it for that.
still_used = await session.scalar(
select(ComposeAttachment.id)
.where(ComposeAttachment.user_id == user_id, ComposeAttachment.blob_sha256 == sha)
.limit(1)
)
if still_used is None: if still_used is None:
blobstore.remove(user_id, sha) blobstore.remove(user_id, sha)
return True return True
+49 -28
View File
@@ -2,9 +2,10 @@
All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no All autodiscovery HTTP is HTTPS-only, 5 s timeout, response capped at 256 KB, no
off-domain redirects, and the target host must resolve exclusively to public IPs off-domain redirects, and the target host must resolve exclusively to public IPs
(reject private/loopback/link-local/reserved ranges). A residual TOCTOU gap (reject private/loopback/link-local/reserved ranges). The request then goes to
(DNS rebinding between our resolve and httpx's own resolve) is accepted for M1; the very address that was checked — the name is not looked up a second time —
a pinned-IP transport can close it later. so a DNS answer that changes in between (DNS rebinding) cannot point it inward.
TLS is still verified against the host name.
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,15 +27,12 @@ class SsrfError(Exception):
def _is_public_ip(ip: str) -> bool: def _is_public_ip(ip: str) -> bool:
addr = ipaddress.ip_address(ip) addr = ipaddress.ip_address(ip.split("%", 1)[0]) # drop an IPv6 zone index
return not ( if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr.is_private addr = addr.ipv4_mapped # ::ffff:127.0.0.1 is 127.0.0.1
or addr.is_loopback # is_global: not private, loopback, link-local, reserved, unspecified, nor
or addr.is_link_local # shared/carrier-grade NAT (100.64.0.0/10, which is_private misses).
or addr.is_multicast return addr.is_global and not addr.is_multicast
or addr.is_reserved
or addr.is_unspecified
)
async def _resolve_all(host: str) -> list[str]: async def _resolve_all(host: str) -> list[str]:
@@ -43,8 +41,10 @@ async def _resolve_all(host: str) -> list[str]:
return [info[4][0] for info in infos] return [info[4][0] for info in infos]
async def assert_host_is_public(host: str) -> None: async def assert_host_is_public(host: str) -> list[str]:
"""Resolve ``host`` and reject if any address is non-public (SPEC §10.6).""" """Resolve ``host`` and reject if any address is non-public (SPEC §10.6).
Returns the addresses checked, for the caller to connect to."""
try: try:
addresses = await _resolve_all(host) addresses = await _resolve_all(host)
except socket.gaierror as exc: except socket.gaierror as exc:
@@ -54,6 +54,31 @@ async def assert_host_is_public(host: str) -> None:
for ip in addresses: for ip in addresses:
if not _is_public_ip(ip): if not _is_public_ip(ip):
raise SsrfError(f"{host} resolves to non-public address {ip}") raise SsrfError(f"{host} resolves to non-public address {ip}")
return addresses
async def _pinned(url: str) -> tuple[str, dict[str, str], dict[str, str]]:
"""``url`` aimed at the address just checked: (url, headers, extensions).
The URL names the IP, the Host header keeps the name (virtual hosting), and
TLS gets the name for SNI and for checking the certificate."""
parts = urlsplit(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
hostname = parts.hostname
if not hostname:
raise SsrfError("missing host")
addresses = await assert_host_is_public(hostname)
# IPv4 first: containers often have no IPv6 route, though DNS returns AAAA.
ip = next((a for a in addresses if ":" not in a), addresses[0])
literal = f"[{ip}]" if ":" in ip else ip
netloc = f"{literal}:{parts.port}" if parts.port else literal
host_header = f"{hostname}:{parts.port}" if parts.port else hostname
return (
parts._replace(netloc=netloc).geturl(),
{"Host": host_header},
{"sni_hostname": hostname},
)
async def guarded_get(url: str) -> str | None: async def guarded_get(url: str) -> str | None:
@@ -63,12 +88,7 @@ async def guarded_get(url: str) -> str | None:
(any non-200 response). Raises :class:`SsrfError` on a policy violation (any non-200 response). Raises :class:`SsrfError` on a policy violation
(bad scheme, private IP, oversize); lets httpx network errors propagate. (bad scheme, private IP, oversize); lets httpx network errors propagate.
""" """
parts = urlsplit(url) target, pin_headers, extensions = await _pinned(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
if not parts.hostname:
raise SsrfError("missing host")
await assert_host_is_public(parts.hostname)
async with ( async with (
httpx.AsyncClient( httpx.AsyncClient(
@@ -76,7 +96,7 @@ async def guarded_get(url: str) -> str | None:
timeout=HTTP_TIMEOUT, timeout=HTTP_TIMEOUT,
headers={"User-Agent": "webmail-autodiscovery/1.0"}, headers={"User-Agent": "webmail-autodiscovery/1.0"},
) as client, ) as client,
client.stream("GET", url) as resp, client.stream("GET", target, headers=pin_headers, extensions=extensions) as resp,
): ):
if resp.status_code != 200: if resp.status_code != 200:
return None return None
@@ -114,16 +134,17 @@ async def guarded_request(
fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body
capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns
status + headers + body (all statuses; the caller decides what's an error).""" status + headers + body (all statuses; the caller decides what's an error)."""
parts = urlsplit(url) target, pin_headers, extensions = await _pinned(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
if not parts.hostname:
raise SsrfError("missing host")
await assert_host_is_public(parts.hostname)
async with ( async with (
httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client, httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client,
client.stream(method, url, headers=headers, content=content) as resp, client.stream(
method,
target,
headers={**(headers or {}), **pin_headers},
content=content,
extensions=extensions,
) as resp,
): ):
chunks: list[bytes] = [] chunks: list[bytes] = []
total = 0 total = 0
+3
View File
@@ -14,6 +14,7 @@ import aiosmtplib
from app.accounts.ssrf import SsrfError, assert_host_is_public from app.accounts.ssrf import SsrfError, assert_host_is_public
from app.accounts.types import ServerConfig from app.accounts.types import ServerConfig
from app.config import smtp_helo_name
from app.providers.base import ProviderAuthError, ProviderConnectError from app.providers.base import ProviderAuthError, ProviderConnectError
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
@@ -53,6 +54,7 @@ async def _test_smtp(config: ServerConfig, username: str, password: str) -> None
use_tls=config.tls == "ssl", use_tls=config.tls == "ssl",
start_tls=config.tls == "starttls", start_tls=config.tls == "starttls",
timeout=_SMTP_TIMEOUT, timeout=_SMTP_TIMEOUT,
local_hostname=smtp_helo_name(),
) )
try: try:
await smtp.connect() await smtp.connect()
@@ -89,6 +91,7 @@ async def test_account(
smtp_tls=smtp.tls, smtp_tls=smtp.tls,
username=username, username=username,
password=password, password=password,
public_only=True,
) )
) )
try: try:
+16
View File
@@ -15,7 +15,9 @@ def register_routers(app: FastAPI) -> None:
from app.api.actions import router as actions_router from app.api.actions import router as actions_router
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.avatar import router as avatar_router from app.api.avatar import router as avatar_router
from app.api.completion import router as completion_router
from app.api.compose import router as compose_router from app.api.compose import router as compose_router
from app.api.invitations import router as invitations_router
from app.api.mailboxes import router as mailboxes_router from app.api.mailboxes import router as mailboxes_router
from app.api.messages import router as messages_router from app.api.messages import router as messages_router
from app.api.plugins import router as plugins_router from app.api.plugins import router as plugins_router
@@ -24,8 +26,12 @@ def register_routers(app: FastAPI) -> None:
from app.api.saved_searches import router as saved_searches_router from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router from app.api.search import router as search_router
from app.api.settings import router as settings_router from app.api.settings import router as settings_router
from app.api.snooze import router as snooze_router
from app.api.spellcheck import router as spellcheck_router from app.api.spellcheck import router as spellcheck_router
from app.api.subscriptions import router as subscriptions_router
from app.api.templates import router as templates_router
from app.api.threads import router as threads_router from app.api.threads import router as threads_router
from app.api.translation import router as translation_router
from app.identity.routes import router as auth_router from app.identity.routes import router as auth_router
from app.ws import router as ws_router from app.ws import router as ws_router
@@ -46,9 +52,19 @@ def register_routers(app: FastAPI) -> None:
admin_router, admin_router,
spellcheck_router, spellcheck_router,
push_router, push_router,
invitations_router,
templates_router,
snooze_router,
subscriptions_router,
completion_router,
translation_router,
): ):
app.include_router(router, prefix="/api") app.include_router(router, prefix="/api")
app.include_router(ws_router, prefix="/api") app.include_router(ws_router, prefix="/api")
# CalDAV / CardDAV (SPEC Appendix E): at the root, where clients look for it.
from app.dav.routes import router as dav_router
app.include_router(dav_router)
# Mount discovered plugin routers at /api/plugins/{name} (SPEC §11). Hooks are # Mount discovered plugin routers at /api/plugins/{name} (SPEC §11). Hooks are
# subscribed in the lifespan startup so they register once per process. # subscribed in the lifespan startup so they register once per process.
+14
View File
@@ -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,
) )
+2 -6
View File
@@ -125,9 +125,7 @@ async def mark_all_read(
return {"changed": 0} return {"changed": 0}
messages = list( messages = list(
await db.scalars( await db.scalars(
select(Message).where( select(Message).where(Message.thread_id.in_(thread_ids), Message.user_id == user.id)
Message.thread_id.in_(thread_ids), Message.user_id == user.id
)
) )
) )
add, remove = sanitize_flags(["\\Seen"]), sanitize_flags([]) add, remove = sanitize_flags(["\\Seen"]), sanitize_flags([])
@@ -137,9 +135,7 @@ async def mark_all_read(
await _push_and_publish(user.id, changed, add, remove) await _push_and_publish(user.id, changed, add, remove)
bus = get_event_bus() bus = get_event_bus()
for tid in thread_ids: for tid in thread_ids:
await bus.publish( await bus.publish("thread.updated", {"user_id": str(user.id), "thread_id": str(tid)})
"thread.updated", {"user_id": str(user.id), "thread_id": str(tid)}
)
return {"changed": len(changed)} return {"changed": len(changed)}
+204 -8
View File
@@ -3,25 +3,44 @@
The backend seam for the admin panel: the registration gate — the first account The backend seam for the admin panel: the registration gate — the first account
on a fresh install becomes the administrator and closes signup behind itself, on a fresh install becomes the administrator and closes signup behind itself,
and this is how that gets reopened — the default quota, and managing users and this is how that gets reopened — the default quota, and managing users
(search, per-user quota, block, delete). An administrator cannot block or delete (search, per-user quota, block, delete). An administrator can change their own
their own account, so the instance can never lock out its last admin that way. quota but cannot block or delete their own account, so the instance can never
lock out its last admin that way.
""" """
from __future__ import annotations from __future__ import annotations
import base64
import datetime as dt import datetime as dt
import re
import uuid import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app import translation
from app.api.errors import raise_api_error from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.hosted.admin import MailServerError
from app.identity import invitations
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_CID,
LOGO_DATA_URL,
LOGO_HTML,
MailerError,
mailer_config,
send_mail,
)
from app.identity.schemas import ( from app.identity.schemas import (
AdminInvitePreviewIn,
AdminInvitePreviewOut,
AdminInviteTemplateIn,
AdminInviteTemplateOut,
AdminMailIn, AdminMailIn,
AdminMailOut, AdminMailOut,
AdminMailTestIn, AdminMailTestIn,
@@ -45,6 +64,7 @@ def _out(row: AppSettings) -> AdminSettingsOut:
return AdminSettingsOut( return AdminSettingsOut(
registration_open=row.registration_open, registration_open=row.registration_open,
default_quota_bytes=row.default_quota_bytes, default_quota_bytes=row.default_quota_bytes,
invitations_enabled=row.invitations_enabled,
) )
@@ -67,6 +87,8 @@ async def write_settings(
row.registration_open = body.registration_open row.registration_open = body.registration_open
if body.default_quota_bytes is not None: if body.default_quota_bytes is not None:
row.default_quota_bytes = body.default_quota_bytes row.default_quota_bytes = body.default_quota_bytes
if body.invitations_enabled is not None:
row.invitations_enabled = body.invitations_enabled
row.updated_at = dt.datetime.now(dt.UTC) row.updated_at = dt.datetime.now(dt.UTC)
await db.commit() await db.commit()
return _out(row) return _out(row)
@@ -140,6 +162,109 @@ async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -
return MessageOut() return MessageOut()
# --- Invitation email ------------------------------------------------------------
_EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+")
_SAMPLE = {"invitee_name": "Anna Nowak", "inviter_name": "Jan Kowalski"}
def _template_out(row: AppSettings) -> AdminInviteTemplateOut:
return AdminInviteTemplateOut(
subject=row.invite_subject,
html=row.invite_template,
from_address=row.invite_from,
default_subject=invitations.DEFAULT_SUBJECT,
default_html=invitations.DEFAULT_TEMPLATE,
default_from=row.smtp_from,
placeholders=list(invitations.PLACEHOLDERS),
)
@router.get("/invitations/template", response_model=AdminInviteTemplateOut)
async def read_invite_template(admin: CurrentAdmin, db: DbSession) -> AdminInviteTemplateOut:
return _template_out(await app_settings(db))
@router.put(
"/invitations/template",
response_model=AdminInviteTemplateOut,
dependencies=[Depends(require_csrf)],
)
async def write_invite_template(
body: AdminInviteTemplateIn, admin: CurrentAdmin, db: DbSession
) -> AdminInviteTemplateOut:
"""Upload the invitation email's HTML template, subject and sender."""
row = await app_settings(db, for_update=True)
if body.html is not None:
html = body.html.strip()
if html and (problem := invitations.template_problem(html)):
raise_api_error(400, "invalid_template", problem)
row.invite_template = html
if body.subject is not None:
row.invite_subject = " ".join(body.subject.split())
if body.from_address is not None:
sender = body.from_address.strip()
if sender and not _EMAIL_RE.fullmatch(sender):
raise_api_error(400, "invalid_email", "the sender must be an email address")
row.invite_from = sender
row.updated_at = dt.datetime.now(dt.UTC)
await db.commit()
return _template_out(row)
def _sample(row: AppSettings, subject: str, html: str) -> invitations.RenderedMail:
"""The invitation as a friend would get it, with sample names."""
if html.strip() and (problem := invitations.template_problem(html)):
raise_api_error(400, "invalid_template", problem)
draft = AppSettings(invite_subject=subject, invite_template=html.strip())
base = row.public_url.rstrip("/") or f"https://{get_settings().app_domain}"
return invitations.render(draft, link=f"{base}/register#invite=sample", **_SAMPLE)
@router.post(
"/invitations/preview",
response_model=AdminInvitePreviewOut,
dependencies=[Depends(require_csrf)],
)
async def preview_invite(
body: AdminInvitePreviewIn, admin: CurrentAdmin, db: DbSession
) -> AdminInvitePreviewOut:
"""Render a template (saved or not) with sample names, for the panel to show."""
mail = _sample(await app_settings(db), body.subject, body.html)
html = mail.html
# The preview frame has no message parts: show the images it would carry.
for part in mail.inline:
data = base64.b64encode(part.data).decode()
html = html.replace(f"cid:{part.cid}", f"data:{part.mime_type};base64,{data}")
html = html.replace(f"cid:{LOGO_CID}", LOGO_DATA_URL)
return AdminInvitePreviewOut(subject=mail.subject, html=html)
@router.post("/invitations/test", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def test_invite(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut:
"""Send the saved invitation email, with sample names and a dummy link."""
row = await app_settings(db)
cfg = mailer_config(row)
if cfg is None:
raise_api_error(
409, "mail_not_configured", "set the SMTP server, From address and public address first"
)
mail = _sample(row, row.invite_subject, row.invite_template)
try:
await send_mail(
cfg,
body.to.strip(),
mail.subject,
mail.text,
mail.html,
sender=invitations.sender_address(row),
inline=mail.inline,
)
except MailerError as exc:
raise_api_error(502, "mail_failed", f"sending failed: {exc}")
return MessageOut()
# --- Users -------------------------------------------------------------------- # --- Users --------------------------------------------------------------------
@@ -156,12 +281,15 @@ def _user_out(user: User, used_bytes: int, accounts: list[str]) -> AdminUserOut:
) )
async def _target(db: AsyncSession, admin: User, user_id: uuid.UUID) -> User: async def _target(
"""The user an admin action is aimed at; never the admin themselves.""" db: AsyncSession, admin: User, user_id: uuid.UUID, *, allow_self: bool = False
) -> User:
"""The user an admin action is aimed at. Blocking and deleting are never
allowed on the admin themselves; a quota change is (``allow_self``)."""
user = await db.scalar(select(User).where(User.id == user_id)) user = await db.scalar(select(User).where(User.id == user_id))
if user is None: if user is None:
raise_api_error(404, "not_found", "user not found") raise_api_error(404, "not_found", "user not found")
if user.id == admin.id: if user.id == admin.id and not allow_self:
raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account") raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account")
return user return user
@@ -179,11 +307,17 @@ async def patch_user(
user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession
) -> AdminUserOut: ) -> AdminUserOut:
"""Change a user's quota and/or block or unblock them.""" """Change a user's quota and/or block or unblock them."""
user = await _target(db, admin, user_id) # Your own quota is fine to change; blocking yourself is not.
user = await _target(db, admin, user_id, allow_self=body.blocked is None)
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 +334,67 @@ 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"}
# --- email translation languages (translator service) ------------------------------
class InstallProgressOut(BaseModel):
done: int
total: int
class AdminTranslationOut(BaseModel):
enabled: bool # TRANSLATOR_URL is set
reachable: bool
installed: list[str] = []
installing: dict[str, InstallProgressOut] = {}
pending: list[str] = [] # waiting to be tried again (no network last time)
available: list[str] = [] # what Mozilla has models for (empty: offline)
errors: dict[str, str] = {} # why a language's last install failed
_LANG = Path(pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$")
@router.get("/translation", response_model=AdminTranslationOut)
async def translation_status(admin: CurrentAdmin) -> AdminTranslationOut:
if not translation.enabled():
return AdminTranslationOut(enabled=False, reachable=False)
try:
data = await translation.status()
except translation.TranslationError:
return AdminTranslationOut(enabled=True, reachable=False)
return AdminTranslationOut(enabled=True, reachable=True, **data)
@router.post(
"/translation/languages/{code}",
response_model=AdminTranslationOut,
dependencies=[Depends(require_csrf)],
)
async def install_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut:
"""Download a language's models (to and from English) in the background."""
try:
await translation.install(code)
except translation.TranslationError as exc:
raise_api_error(502, "translator_failed", str(exc))
return await translation_status(admin)
@router.delete(
"/translation/languages/{code}",
response_model=AdminTranslationOut,
dependencies=[Depends(require_csrf)],
)
async def remove_language(admin: CurrentAdmin, code: str = _LANG) -> AdminTranslationOut:
try:
await translation.remove(code)
except translation.TranslationError as exc:
raise_api_error(502, "translator_failed", str(exc))
return await translation_status(admin)
+44
View File
@@ -0,0 +1,44 @@
"""Sentence completion for the compose window (app/completion.py)."""
from __future__ import annotations
import datetime as dt
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import completion
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import CompletionIndexState
from app.queue import enqueue_job
router = APIRouter(prefix="/completion", tags=["compose"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
class CompletionIn(BaseModel):
# The text before the cursor (its last line is all that matters). A POST,
# not a query string: what the user is typing stays out of access logs.
before: str = Field(max_length=2000)
class CompletionOut(BaseModel):
suggestion: str | None
@router.post("", response_model=CompletionOut)
async def suggest(body: CompletionIn, user: CurrentUser, db: DbSession) -> CompletionOut:
before = body.before[-300:]
built = await db.scalar(
select(CompletionIndexState.built_at).where(CompletionIndexState.user_id == user.id)
)
if built is None or built < dt.datetime.now(dt.UTC) - completion.REBUILD_AFTER:
# In the background; until it is built, the starter phrases still answer.
await enqueue_job("build_index_job", str(user.id), _job_id=f"complete:{user.id}")
personal = await completion.personal_phrases(db, user.id, completion.keys_for(before))
return CompletionOut(suggestion=completion.complete(personal, before))
+12 -1
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
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
@@ -15,7 +17,8 @@ from app.api.schemas import AttachmentOut
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.identity.deps import CurrentUser, require_csrf from app.identity.deps import CurrentUser, require_csrf
from app.indexer import blobstore from app.indexer import attachments as att_store
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 (
@@ -206,7 +209,15 @@ 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.
if not await asyncio.to_thread(
att_store.materialize, user.id, msg.blob_sha256, a.blob_sha256, a.part_index
):
continue
ca = ComposeAttachment( ca = ComposeAttachment(
user_id=user.id, user_id=user.id,
filename=a.filename, filename=a.filename,
+17
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from typing import NoReturn from typing import NoReturn
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
@@ -29,7 +30,23 @@ async def _handler(request: Request, exc: StarletteHTTPException) -> JSONRespons
return JSONResponse(status_code=exc.status_code, content=body, headers=headers) return JSONResponse(status_code=exc.status_code, content=body, headers=headers)
async def _validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""A malformed request, in the usual error shape. Names the first field at
fault and what is wrong with it — enough to show the user — but not the
whole request model the framework's own answer would spell out."""
errors = exc.errors()
first = errors[0] if errors else {}
loc = [str(p) for p in first.get("loc", ()) if p not in ("body", "query", "path")]
field = loc[-1] if loc else ""
reason = str(first.get("msg", "invalid value"))
message = f"{field}: {reason}" if field else reason
return JSONResponse(
status_code=422, content={"error": {"code": "validation_error", "message": message}}
)
def register_error_handlers(app: FastAPI) -> None: def register_error_handlers(app: FastAPI) -> None:
"""Install the error-shape handler for all HTTP exceptions.""" """Install the error-shape handler for all HTTP exceptions."""
app.add_exception_handler(StarletteHTTPException, _handler) # type: ignore[arg-type] app.add_exception_handler(StarletteHTTPException, _handler) # type: ignore[arg-type]
app.add_exception_handler(HTTPException, _handler) # type: ignore[arg-type] app.add_exception_handler(HTTPException, _handler) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, _validation_handler) # type: ignore[arg-type]
+123
View File
@@ -0,0 +1,123 @@
"""Invite a friend (app/identity/invitations.py): what the sidebar entry and its
dialog need — how many invitations are left, and sending one."""
from __future__ import annotations
import datetime as dt
import logging
import re
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.identity import invitations
from app.identity.deps import CurrentUser, require_csrf
from app.identity.mailer import MailerError, mailer_config, send_mail
from app.identity.service import app_settings
from app.models import Invitation
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/invitations", tags=["invitations"])
DbSession = Annotated[AsyncSession, Depends(get_session)]
_EMAIL_RE = re.compile(r"[^@\s<>\"]+@[^@\s<>\"]+\.[^@\s<>\"]+")
class SentInvitationOut(BaseModel):
invitee_name: str
invitee_email: str
created_at: str
used: bool
expired: bool
class InvitationsOut(BaseModel):
# Invitations are switched on and system mail can send them.
available: bool
remaining: int
per_user: int
sent: list[SentInvitationOut]
class InvitationIn(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: str = Field(min_length=3, max_length=320)
@router.get("", response_model=InvitationsOut)
async def read(user: CurrentUser, db: DbSession) -> InvitationsOut:
row = await app_settings(db)
now = dt.datetime.now(dt.UTC)
return InvitationsOut(
available=row.invitations_enabled and mailer_config(row) is not None,
remaining=await invitations.remaining(db, user.id),
per_user=invitations.PER_USER,
sent=[
SentInvitationOut(
invitee_name=i.invitee_name,
invitee_email=i.invitee_email,
created_at=i.created_at.isoformat(),
used=i.used_at is not None,
expired=i.used_at is None and i.expires_at <= now,
)
for i in await invitations.sent(db, user.id)
],
)
@router.post("", response_model=InvitationsOut, dependencies=[Depends(require_csrf)])
async def send(body: InvitationIn, user: CurrentUser, db: DbSession) -> InvitationsOut:
"""Email a friend a single-use sign-up link. Sent before answering, so the
user hears at once if it could not go out (and keeps the invitation)."""
name = " ".join(body.name.split())
email = body.email.strip()
if not name:
raise_api_error(400, "invalid_name", "enter your friend's name")
if not _EMAIL_RE.fullmatch(email):
raise_api_error(400, "invalid_email", "enter a valid email address")
row = await app_settings(db)
if not row.invitations_enabled:
raise_api_error(403, "invitations_disabled", "invitations are switched off")
cfg = mailer_config(row)
if cfg is None:
raise_api_error(503, "mail_not_configured", "the server cannot send email yet")
try:
invitation, token = await invitations.create(db, user, name=name, email=email)
except invitations.InvitationError as exc:
raise_api_error(409, exc.code, exc.message)
await db.commit()
# The token rides in the fragment: never sent to a server, so it stays out
# of access logs and Referer headers (as with password-reset links).
link = f"{cfg.public_url}/register#invite={token}"
mail = invitations.render(
row,
invitee_name=name,
inviter_name=await invitations.inviter_name(db, user),
link=link,
)
try:
await send_mail(
cfg,
email,
mail.subject,
mail.text,
mail.html,
sender=invitations.sender_address(row),
reply_to=user.handle, # an answer reaches the friend who invited
inline=mail.inline,
)
except MailerError as exc:
logger.warning("invitation from %s could not be sent: %s", user.handle, exc)
# Not sent: the user keeps the invitation.
await db.execute(delete(Invitation).where(Invitation.id == invitation.id))
await db.commit()
raise_api_error(502, "mail_failed", "the invitation could not be sent — try again later")
return await read(user, db)
+16 -2
View File
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session from app.db import get_session
from app.identity.deps import CurrentUser from app.identity.deps import CurrentUser
from app.models import Draft, Mailbox, Message from app.models import Draft, Mailbox, Message, Thread
from app.outbox.service import not_being_sent from app.outbox.service import not_being_sent
router = APIRouter(prefix="/mailboxes", tags=["mail"]) router = APIRouter(prefix="/mailboxes", tags=["mail"])
@@ -30,6 +30,7 @@ class MailboxOut(BaseModel):
class FolderCounts(BaseModel): class FolderCounts(BaseModel):
inbox_unread: int # unread messages sitting in Inbox mailboxes inbox_unread: int # unread messages sitting in Inbox mailboxes
drafts: int # drafts shown in the Drafts folder (local + un-adopted remote) drafts: int # drafts shown in the Drafts folder (local + un-adopted remote)
snoozed: int = 0 # conversations snoozed out of the Inbox
@router.get("", response_model=list[MailboxOut]) @router.get("", response_model=list[MailboxOut])
@@ -54,8 +55,17 @@ async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
Message.user_id == user.id, Message.user_id == user.id,
Mailbox.special_use == "inbox", Mailbox.special_use == "inbox",
~Message.flags.contains(["\\Seen"]), ~Message.flags.contains(["\\Seen"]),
# A snoozed conversation is out of the Inbox until it wakes.
~Message.thread_id.in_(
select(Thread.id).where(Thread.user_id == user.id, Thread.snoozed_until.isnot(None))
),
) )
) )
snoozed = await db.scalar(
select(func.count())
.select_from(Thread)
.where(Thread.user_id == user.id, Thread.snoozed_until.isnot(None))
)
# Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror # Drafts folder = local drafts + IMAP-Drafts messages we haven't adopted (mirror
# UIDs excluded), matching what list_drafts + list_remote_drafts return. # UIDs excluded), matching what list_drafts + list_remote_drafts return.
@@ -77,4 +87,8 @@ async def folder_counts(user: CurrentUser, db: DbSession) -> FolderCounts:
) )
remote = sum(1 for r in remote_rows if (r.account_id, r.uid) not in owned) remote = sum(1 for r in remote_rows if (r.account_id, r.uid) not in owned)
return FolderCounts(inbox_unread=int(inbox_unread or 0), drafts=int(local or 0) + remote) return FolderCounts(
inbox_unread=int(inbox_unread or 0),
drafts=int(local or 0) + remote,
snoozed=int(snoozed or 0),
)
+241 -44
View File
@@ -3,8 +3,10 @@
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 logging
import uuid import uuid
import zipfile import zipfile
from typing import Annotated from typing import Annotated
@@ -13,18 +15,37 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from fastapi.responses import FileResponse, HTMLResponse, Response from fastapi.responses import FileResponse, HTMLResponse, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app import translation
from app.api.errors import raise_api_error from app.api.errors import raise_api_error
from app.api.schemas import AttachmentOut from app.api.schemas import AttachmentOut
from app.api.settings import add_allowed_image_sender, allowed_image_senders from app.api.settings import (
add_allowed_image_sender,
allowed_image_senders,
blocks_trackers,
)
from app.completion import html_to_text
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 blobstore, thumbs from app.indexer import attachments as att_store
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
from app.models import Attachment, BodyPart, ComposeAttachment, Message from app.models import (
Account,
Attachment,
BodyPart,
ComposeAttachment,
Message,
MessageTranslation,
User,
)
from app.translation_prep import find_names_in_signature
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/messages", tags=["mail"]) router = APIRouter(prefix="/messages", tags=["mail"])
@@ -86,6 +107,31 @@ def _wrap(fragment: str) -> str:
return _DOC_TEMPLATE.format(body=fragment) return _DOC_TEMPLATE.format(body=fragment)
# The sanitized message as a document of its own. The app shows it in a
# sandboxed frame (frontend MessageBody), but this URL can also be opened
# directly in a tab, on our own origin: there, this policy is what keeps any
# script the sanitizer might ever miss from running — no scripts, no plugins,
# no forms, a sandbox of its own origin, and nothing framing it elsewhere.
_MESSAGE_CSP = (
"default-src 'none'; script-src 'none'; object-src 'none'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'self'; img-src 'self' data: https: http:; "
"style-src 'self' 'unsafe-inline'; font-src 'self' data: https:; media-src 'self'; "
"sandbox allow-popups allow-popups-to-escape-sandbox"
)
def _html(document: str, trackers: int = 0, extra: dict[str, str] | None = None) -> HTMLResponse:
# The page reads what it needs from headers: the body is the message.
return HTMLResponse(
content=document,
headers={
"Content-Security-Policy": _MESSAGE_CSP,
"X-Trackers-Blocked": str(trackers),
**(extra or {}),
},
)
async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message: async def _owned_message(db: AsyncSession, user_id: uuid.UUID, message_id: uuid.UUID) -> Message:
msg = await db.scalar( msg = await db.scalar(
select(Message).where(Message.id == message_id, Message.user_id == user_id) select(Message).where(Message.id == message_id, Message.user_id == user_id)
@@ -99,54 +145,167 @@ def _sender_email(message: Message) -> str:
return message.from_addr[0].get("email", "").lower() if message.from_addr else "" return message.from_addr[0].get("email", "").lower() if message.from_addr else ""
async def _render_fragment(
db: AsyncSession, message: Message, allow_remote: bool, block: bool
) -> tuple[str, int]:
"""The sanitized body the user sees, and how many trackers came out of it.
Re-sanitizes from the raw blob when remote images are allowed, the stored
copy predates the current sanitizer (SPEC §10.4), or trackers are to stay
in; otherwise the stored copy is served as is.
"""
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message.id)))
html_part = next((p for p in parts if p.kind == "html_sanitized"), None)
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION
# The stored copy has its trackers taken out; with blocking switched off, a
# message that had some is rendered afresh with them left in.
trackers_kept = not block and html_part is not None and html_part.trackers_blocked > 0
if html_part is not None and not allow_remote and not stale and not trackers_kept:
return html_part.content, html_part.trackers_blocked if block else 0
raw = None
if html_part is not None or allow_remote: # a plain-text message has nothing to redo
try:
raw = await rawstore.ensure_bytes(message)
except FileNotFoundError:
raw = None
parsed = parse_message(raw) if raw is not None else None
if parsed is not None and parsed.html_body is not None:
if stale and html_part is not None:
# The stored copy is always the plain one: no remote images, trackers out.
upgraded = sanitize_html(parsed.html_body, message.id)
html_part.content = upgraded.html
html_part.sanitizer_v = upgraded.sanitizer_v
html_part.trackers_blocked = upgraded.trackers_blocked
await db.commit()
if block and not allow_remote:
return upgraded.html, upgraded.trackers_blocked
result = sanitize_html(
parsed.html_body, message.id, allow_remote_images=allow_remote, block_trackers=block
)
return result.html, result.trackers_blocked
if html_part is not None:
return html_part.content, html_part.trackers_blocked
text_part = next((p for p in parts if p.kind == "text"), None)
body = html_lib.escape(text_part.content) if text_part else ""
return f'<div class="wm-plaintext">{body}</div>', 0
async def _content_language(db: AsyncSession, message: Message) -> str:
"""The message's language, found (and kept) the first time it is shown."""
if message.content_language is not None or not translation.enabled():
return message.content_language or ""
text = await db.scalar(
select(BodyPart.content).where(BodyPart.message_id == message.id, BodyPart.kind == "text")
)
try:
found = await translation.detect(text or message.snippet or "")
except translation.TranslationError as exc:
logger.info("language detection unavailable: %s", exc)
return "" # not kept: tried again once the service is back
message.content_language = found
await db.commit()
return found
@router.get("/{message_id}/html", response_class=HTMLResponse) @router.get("/{message_id}/html", response_class=HTMLResponse)
async def message_html( async def message_html(
message_id: uuid.UUID, message_id: uuid.UUID,
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
images: bool = Query(False), images: bool = Query(False),
translate: str | None = Query(None, pattern=r"^[a-z]{2,3}(-[A-Za-z]{2,4})?$"),
) -> HTMLResponse: ) -> HTMLResponse:
"""Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8). """Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8).
Re-sanitizes from the raw blob when the sender is image-approved, or when the ``images=1`` is a one-time "Load images" — render remote images for this
stored copy predates the current sanitizer version (SPEC §10.4). ``images=1`` request only, without persisting the sender as trusted. ``translate=pl``
is a one-time "Load images" — render remote images for this request only, returns it translated (app/translation.py). Headers tell the page what it
without persisting the sender as trusted. got: X-Trackers-Blocked, X-Content-Language (the message's own language,
when known) and, translated, X-Translated-From / X-Translated-To.
""" """
message = await _owned_message(db, user.id, message_id) message = await _owned_message(db, user.id, message_id)
allow_remote = images or _sender_email(message) in allowed_image_senders(user) allow_remote = images or _sender_email(message) in allowed_image_senders(user)
block = blocks_trackers(user)
fragment, trackers = await _render_fragment(db, message, allow_remote, block)
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message_id))) headers: dict[str, str] = {}
html_part = next((p for p in parts if p.kind == "html_sanitized"), None) language = await _content_language(db, message)
if language:
stale = html_part is not None and html_part.sanitizer_v < SANITIZER_VERSION headers["X-Content-Language"] = language
if html_part is not None and not allow_remote and not stale: if translate:
return HTMLResponse(content=_wrap(html_part.content)) fragment, source = await _translated(
db, user, message, fragment, language, translate, allow_remote, block
# Re-sanitize the original HTML (image-approved sender, or a stale stored copy).
# 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)
except FileNotFoundError:
raw = None
if raw is not None:
parsed = parse_message(raw)
if parsed.html_body is not None:
result = sanitize_html(
parsed.html_body, message.id, allow_remote_images=allow_remote
) )
if html_part is not None and not allow_remote: headers["X-Translated-From"] = source
html_part.content = result.html # persist the upgraded sanitization headers["X-Translated-To"] = translate
html_part.sanitizer_v = result.sanitizer_v return _html(_wrap(fragment), trackers, headers)
await db.commit()
return HTMLResponse(content=_wrap(result.html))
if html_part is not None:
return HTMLResponse(content=_wrap(html_part.content)) async def _names(db: AsyncSession, user: User, message: Message) -> list[str]:
text_part = next((p for p in parts if p.kind == "text"), None) """Names a translation leaves as they are if the model would change them:
body = html_lib.escape(text_part.content) if text_part else "" the people on the message (and the sender's single names, as signed:
return HTMLResponse(content=_wrap(f'<div class="wm-plaintext">{body}</div>')) "Milan"), the user's own names (accounts, signatures)."""
names: list[str] = []
for people in (message.from_addr, message.to_addr, message.cc_addr):
names += [p.get("name", "") for p in people if p.get("name")]
for p in message.from_addr:
names += [w for w in str(p.get("name", "")).split() if len(w) >= 3 and w[0].isupper()]
rows = await db.execute(
select(Account.display_name, Account.signature).where(Account.user_id == user.id)
)
for display_name, signature in rows:
if display_name:
names.append(display_name)
if signature:
names += find_names_in_signature(html_to_text(signature))
return names
async def _translated(
db: AsyncSession,
user: User,
message: Message,
fragment: str,
language: str,
target: str,
allow_remote: bool,
block: bool,
) -> tuple[str, str]:
"""The fragment in ``target`` — from the cache, or translated and kept."""
version = SANITIZER_VERSION * 1000 + translation.PIPELINE_VERSION
cached = await db.get(MessageTranslation, (message.id, target, allow_remote))
if cached is not None and cached.sanitizer_v == version:
return cached.html, cached.source
try:
out = await translation.translate_html(
fragment, language or "auto", target, names=await _names(db, user, message)
)
except translation.TranslationError as exc:
raise_api_error(503, "translation_failed", str(exc))
# The translation comes back as HTML: cleaned like any HTML we show.
clean = sanitize_html(
out, message.id, allow_remote_images=allow_remote, block_trackers=block
).html
source = language or "auto"
stmt = insert(MessageTranslation).values(
message_id=message.id,
target=target,
images=allow_remote,
source=source,
html=clean,
sanitizer_v=version,
)
await db.execute(
stmt.on_conflict_do_update(
index_elements=["message_id", "target", "images"],
set_={"html": clean, "source": source, "sanitizer_v": version},
)
)
await db.commit()
return clean, source
@router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)]) @router.post("/{message_id}/allow-remote-images", dependencies=[Depends(require_csrf)])
@@ -176,7 +335,7 @@ async def message_cid(
) )
if att is None: if att is None:
raise_api_error(404, "not_found", "inline part not found") raise_api_error(404, "not_found", "inline part not found")
data = blobstore.load(msg.user_id, att.blob_sha256) data = await _attachment_bytes(msg, att)
media_type = att.mime_type if att.mime_type in _INLINE_SAFE else "application/octet-stream" media_type = att.mime_type if att.mime_type in _INLINE_SAFE else "application/octet-stream"
return Response(content=data, media_type=media_type) return Response(content=data, media_type=media_type)
@@ -190,6 +349,24 @@ 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:
"""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
)
except FileNotFoundError:
raise_api_error(404, "not_found", "attachment content missing")
async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]: async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]:
"""Downloadable (non-inline) attachments for a message, in a stable order.""" """Downloadable (non-inline) attachments for a message, in a stable order."""
rows = await db.scalars( rows = await db.scalars(
@@ -221,9 +398,11 @@ async def forward_attachments(
message_id: uuid.UUID, user: CurrentUser, db: DbSession message_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> list[AttachmentOut]: ) -> list[AttachmentOut]:
"""Attach a message's files to a forward of it: one compose attachment per """Attach a message's files to a forward of it: one compose attachment per
real (non-inline) attachment, sharing the stored blob — nothing is copied. real (non-inline) attachment. Each is taken out of the message into a blob
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."""
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,
@@ -233,7 +412,9 @@ async def forward_attachments(
blob_sha256=a.blob_sha256, blob_sha256=a.blob_sha256,
) )
for a in await _real_attachments(db, message_id) for a in await _real_attachments(db, message_id)
if blobstore.exists(user.id, a.blob_sha256) if await asyncio.to_thread(
att_store.materialize, user.id, msg.blob_sha256, a.blob_sha256, a.part_index
)
] ]
db.add_all(rows) db.add_all(rows)
await db.commit() await db.commit()
@@ -276,7 +457,7 @@ async def download_attachments_zip(
name = f"{stem}-{seen[name]}{dot}{ext}" if dot else f"{name}-{seen[name]}" name = f"{stem}-{seen[name]}{dot}{ext}" if dot else f"{name}-{seen[name]}"
else: else:
seen[name] = 1 seen[name] = 1
zf.writestr(name, blobstore.load(msg.user_id, att.blob_sha256)) zf.writestr(name, await _attachment_bytes(msg, att))
return Response( return Response(
content=buffer.getvalue(), content=buffer.getvalue(),
@@ -301,7 +482,16 @@ 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")
data = await asyncio.to_thread(thumbs.thumbnail, msg.user_id, att.blob_sha256, kind) if not thumbs.made(msg.user_id, att.blob_sha256):
await _raw_ready(msg)
data = await asyncio.to_thread(
thumbs.thumbnail,
msg.user_id,
att.blob_sha256,
kind,
att.size_bytes,
lambda: att_store.load(msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index),
)
if data is None: if data is None:
raise_api_error(404, "not_found", "no thumbnail") raise_api_error(404, "not_found", "no thumbnail")
# An attachment never changes under its id: the browser may keep this. # An attachment never changes under its id: the browser may keep this.
@@ -329,8 +519,15 @@ 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")
path = blobstore.blob_path(msg.user_id, att.blob_sha256) if not att_store.has_file(msg.user_id, att.blob_sha256):
if not path.exists(): 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.
path = await asyncio.to_thread(
att_store.file, msg.user_id, msg.blob_sha256, att.blob_sha256, att.part_index
)
except FileNotFoundError:
raise_api_error(404, "not_found", "attachment content missing") raise_api_error(404, "not_found", "attachment content missing")
inline_type = None if download else _inline_type(att.mime_type, att.filename) inline_type = None if download else _inline_type(att.mime_type, att.filename)
disposition = "inline" if inline_type else "attachment" disposition = "inline" if inline_type else "attachment"
+14
View File
@@ -27,6 +27,8 @@ class ThreadSummary(BaseModel):
has_attachments: bool has_attachments: bool
pinned: bool pinned: bool
last_message_at: dt.datetime last_message_at: dt.datetime
# Set while the thread is snoozed: when it comes back to the Inbox.
snoozed_until: dt.datetime | None = None
class ThreadListOut(BaseModel): class ThreadListOut(BaseModel):
@@ -64,6 +66,13 @@ class MessageEnvelope(BaseModel):
snippet: str snippet: str
# Other accounts this same delivery also landed in (collapsed duplicates). # Other accounts this same delivery also landed in (collapsed duplicates).
also_in_account_ids: list[uuid.UUID] = [] also_in_account_ids: list[uuid.UUID] = []
# Bulk mail: the Subscriptions group it belongs to, how it can be left
# (one_click | mailto | web), and whether the user already did.
subscription_key: str | None = None
unsubscribe_via: str | None = None
unsubscribed: bool = False
# Its language, once found (the first time it is shown): "" unknown.
content_language: str = ""
class ThreadDetail(BaseModel): class ThreadDetail(BaseModel):
@@ -72,6 +81,7 @@ class ThreadDetail(BaseModel):
order: str # "asc" | "desc" order: str # "asc" | "desc"
pinned: bool pinned: bool
messages: list[MessageEnvelope] # always ASC (SPEC §9.4) messages: list[MessageEnvelope] # always ASC (SPEC §9.4)
snoozed_until: dt.datetime | None = None
class FlagChangeRequest(BaseModel): class FlagChangeRequest(BaseModel):
@@ -102,6 +112,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):
@@ -112,3 +124,5 @@ class SearchThreadHit(BaseModel):
class SearchOut(BaseModel): class SearchOut(BaseModel):
threads: list[SearchThreadHit] threads: list[SearchThreadHit]
cursor: str | None = None cursor: str | None = None
# Conversations matching in all (first page only; None where not counted).
total: int | None = None
+15 -2
View File
@@ -29,13 +29,22 @@ async def search(
q: str = Query(""), q: str = Query(""),
cursor: str | None = None, cursor: str | None = None,
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
filter: str | None = Query(None, pattern="^(unread|pinned)$"),
) -> SearchOut: ) -> SearchOut:
"""One page of matching messages, grouped into conversations. ``filter``
keeps conversations with unread mail / pinned ones (the list's Unread and
Pinned tabs). The first page also says how many conversations match in
all, where the search engine can count them."""
if not q.strip(): if not q.strip():
return SearchOut(threads=[], cursor=None) return SearchOut(threads=[], cursor=None)
backend = get_search_backend() backend = get_search_backend()
query = to_search_query(q, user.id) query = to_search_query(q, user.id)
hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor) hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor)
total: int | None = None
count = getattr(backend, "count_threads", None)
if cursor is None and count is not None:
total = await count(query, thread_filter=filter)
# Group hits by thread, preserving order and keeping the first highlight. # Group hits by thread, preserving order and keeping the first highlight.
order: list[uuid.UUID] = [] order: list[uuid.UUID] = []
@@ -48,7 +57,7 @@ async def search(
highlights[hit.thread_id] = hit.highlights highlights[hit.thread_id] = hit.highlights
if not order: if not order:
return SearchOut(threads=[], cursor=next_cursor) return SearchOut(threads=[], cursor=next_cursor, total=total)
threads = { threads = {
t.id: t t.id: t
@@ -64,5 +73,9 @@ async def search(
) )
for tid in order for tid in order
if tid in threads if tid in threads
and (
filter is None
or (threads[tid].unread_count > 0 if filter == "unread" else threads[tid].pinned)
)
] ]
return SearchOut(threads=results, cursor=next_cursor) return SearchOut(threads=results, cursor=next_cursor, total=total)
+32
View File
@@ -27,6 +27,7 @@ _ALLOWED_INVITE_FROM = {"contacts", "anyone"}
# "auto", or a LanguageTool language code such as "pl-PL", "en-GB", "de". # "auto", or a LanguageTool language code such as "pl-PL", "en-GB", "de".
_LANG_CODE = re.compile(r"auto|[a-z]{2,3}(-[A-Za-z0-9]{2,8})*") _LANG_CODE = re.compile(r"auto|[a-z]{2,3}(-[A-Za-z0-9]{2,8})*")
_UI_LANGUAGE = re.compile(r"[a-z]{2,3}(-[A-Za-z]{2,4})?")
class ReminderPref(BaseModel): class ReminderPref(BaseModel):
@@ -52,6 +53,16 @@ class SettingsOut(BaseModel):
# ("auto" detects it per message). # ("auto" detects it per message).
spellcheck_enabled: bool = True spellcheck_enabled: bool = True
spellcheck_language: str = "auto" spellcheck_language: str = "auto"
# Gmail-style keyboard shortcuts in the mail view (Settings → Keyboard shortcuts).
shortcuts_enabled: bool = True
# Remove tracking pixels from mail (Settings → Privacy).
block_trackers: bool = True
# Grey sentence endings in the compose window, taken with Tab.
completion_enabled: bool = True
# Interface language ("en", "pl", …); "" until chosen — the web app then
# picks one from the browser on first sign-in and saves it here.
ui_language: str = ""
# Names and words email translation leaves as they are.
class SettingsPatch(BaseModel): class SettingsPatch(BaseModel):
@@ -68,6 +79,10 @@ class SettingsPatch(BaseModel):
invitation_file: bool | None = None invitation_file: bool | None = None
spellcheck_enabled: bool | None = None spellcheck_enabled: bool | None = None
spellcheck_language: str | None = None spellcheck_language: str | None = None
shortcuts_enabled: bool | None = None
block_trackers: bool | None = None
completion_enabled: bool | None = None
ui_language: str | None = None
class QuotaOut(BaseModel): class QuotaOut(BaseModel):
@@ -82,6 +97,11 @@ def allowed_image_senders(user: User) -> set[str]:
return set() return set()
def blocks_trackers(user: User) -> bool:
"""Whether tracking pixels are taken out of this user's mail (on unless switched off)."""
return bool(user.settings.get("block_trackers", True))
def add_allowed_image_sender(user: User, email: str) -> None: def add_allowed_image_sender(user: User, email: str) -> None:
current = sorted(allowed_image_senders(user)) current = sorted(allowed_image_senders(user))
if email.lower() not in current: if email.lower() not in current:
@@ -127,6 +147,10 @@ def _settings_out(user: User) -> SettingsOut:
allowed_image_senders=sorted(allowed_image_senders(user)), allowed_image_senders=sorted(allowed_image_senders(user)),
spellcheck_enabled=bool(s.get("spellcheck_enabled", True)), spellcheck_enabled=bool(s.get("spellcheck_enabled", True)),
spellcheck_language=str(s.get("spellcheck_language", "auto")), spellcheck_language=str(s.get("spellcheck_language", "auto")),
shortcuts_enabled=bool(s.get("shortcuts_enabled", True)),
block_trackers=blocks_trackers(user),
completion_enabled=bool(s.get("completion_enabled", True)),
ui_language=str(s.get("ui_language", "")),
) )
@@ -175,6 +199,14 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
updates["spellcheck_enabled"] = body.spellcheck_enabled updates["spellcheck_enabled"] = body.spellcheck_enabled
if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language): if body.spellcheck_language is not None and _LANG_CODE.fullmatch(body.spellcheck_language):
updates["spellcheck_language"] = body.spellcheck_language updates["spellcheck_language"] = body.spellcheck_language
if body.shortcuts_enabled is not None:
updates["shortcuts_enabled"] = body.shortcuts_enabled
if body.block_trackers is not None:
updates["block_trackers"] = body.block_trackers
if body.completion_enabled is not None:
updates["completion_enabled"] = body.completion_enabled
if body.ui_language is not None and _UI_LANGUAGE.fullmatch(body.ui_language):
updates["ui_language"] = body.ui_language
user.settings = updates user.settings = updates
await db.commit() await db.commit()
return _settings_out(user) return _settings_out(user)
+76
View File
@@ -0,0 +1,76 @@
"""Snooze endpoints: hide a conversation from the Inbox until a set time."""
from __future__ import annotations
import datetime as dt
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import snooze
from app.api.errors import raise_api_error
from app.db import get_session
from app.events import get_event_bus
from app.identity.deps import CurrentUser, require_csrf
from app.models import Thread
router = APIRouter(prefix="/threads", tags=["mail"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
class SnoozeIn(BaseModel):
until: dt.datetime
class SnoozeOut(BaseModel):
thread_id: uuid.UUID
snoozed_until: dt.datetime | None
async def _thread(db: AsyncSession, user_id: uuid.UUID, thread_id: uuid.UUID) -> Thread:
thread = await db.scalar(
select(Thread).where(Thread.id == thread_id, Thread.user_id == user_id)
)
if thread is None:
raise_api_error(404, "not_found", "thread not found")
return thread
async def _announce(user_id: uuid.UUID, thread_id: uuid.UUID) -> None:
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)}
)
@router.post("/{thread_id}/snooze", response_model=SnoozeOut)
async def snooze_thread(
thread_id: uuid.UUID, body: SnoozeIn, user: CurrentUser, db: DbSession
) -> SnoozeOut:
until = body.until
if until.tzinfo is None:
raise_api_error(400, "invalid_time", "the time needs a time zone")
now = dt.datetime.now(dt.UTC)
if until <= now:
raise_api_error(400, "invalid_time", "pick a time in the future")
if until > now + snooze.MAX_SNOOZE:
raise_api_error(400, "invalid_time", "snooze for at most a year")
thread = await _thread(db, user.id, thread_id)
snooze.snooze(thread, until, now)
await db.commit()
await _announce(user.id, thread_id)
return SnoozeOut(thread_id=thread_id, snoozed_until=thread.snoozed_until)
@router.post("/{thread_id}/unsnooze", response_model=SnoozeOut)
async def unsnooze_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> SnoozeOut:
thread = await _thread(db, user.id, thread_id)
if thread.snoozed_until is not None:
snooze.wake(thread, dt.datetime.now(dt.UTC))
await db.commit()
await _announce(user.id, thread_id)
return SnoozeOut(thread_id=thread_id, snoozed_until=None)
+372
View File
@@ -0,0 +1,372 @@
"""Subscriptions page: bulk senders mailing the user (app/subscriptions.py)."""
from __future__ import annotations
import datetime as dt
import logging
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import case, func, literal, or_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement
from app import subscriptions as subs
from app.accounts.ssrf import guarded_request
from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import Mailbox, Message, SubscriptionState
from app.outbox.schemas import DraftIn, DraftPayload
from app.outbox.service import DraftError, create_outbox, upsert_draft
from app.queue import enqueue_job
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/subscriptions", tags=["mail"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
# RFC 8058 senders should stop within two days of a one-click request.
GRACE = dt.timedelta(days=2)
# Not counted: mail the user sent, and mail already thrown away (Trash, Spam) —
# a sender whose mail was all deleted drops off the list.
_LEFT_OUT = ("sent", "drafts", "trash", "junk")
class SubscriptionOut(BaseModel):
key: str
name: str
email: str
list_id: str | None
total: int
unread: int
last_30_days: int
per_month: float
first_at: dt.datetime
last_at: dt.datetime
last_subject: str
one_click: bool
has_mailto: bool
has_web: bool
unsubscribed_at: dt.datetime | None
method: str | None
still_sending: bool
class SubscriptionListOut(BaseModel):
subscriptions: list[SubscriptionOut] # one page
total: int # senders in all
pending: int # messages whose headers are still being read
class KeyIn(BaseModel):
key: str
class UnsubscribeIn(KeyIn):
# From the reading pane: this message's own header, wherever it is filed
# (Spam included, which the page's groups leave out).
message_id: uuid.UUID | None = None
class UnsubscribeOut(BaseModel):
status: str # done | open
method: str # one_click | mailto | web
url: str | None = None
def _sender() -> ColumnElement[Any]:
return func.lower(func.coalesce(Message.from_addr[0]["email"].astext, ""))
def _key() -> ColumnElement[Any]:
return case(
(Message.list_id.isnot(None), literal("list:") + Message.list_id),
else_=literal("from:") + _sender(),
)
def _bulk(user_id: uuid.UUID) -> list[ColumnElement[bool]]:
return [
Message.user_id == user_id,
Message.list_unsubscribe.isnot(None),
Message.list_unsubscribe != "",
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_LEFT_OUT)),
]
async def _start_backfill(user_id: uuid.UUID) -> None:
# One job per user at a time: arq refuses a second while the first exists.
await enqueue_job("backfill_list_headers_job", str(user_id), _job_id=f"listhdr:{user_id}")
@router.get("", response_model=SubscriptionListOut)
async def list_subscriptions(
user: CurrentUser,
db: DbSession,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
order: str = Query("desc"), # desc: heard from most recently first | asc: longest ago
) -> SubscriptionListOut:
"""One page of senders, by when their latest mail came."""
pending = await subs.pending_count(db, user.id)
if pending:
await _start_backfill(user.id)
now = dt.datetime.now(dt.UTC)
key = _key().label("key")
seen = Message.flags.contains(["\\Seen"])
groups = (
select(
key,
func.count().label("total"),
func.count().filter(~seen).label("unread"),
func.count()
.filter(Message.internal_date > now - dt.timedelta(days=30))
.label("recent"),
func.min(Message.internal_date).label("first_at"),
func.max(Message.internal_date).label("last_at"),
)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user.id))
.group_by(key)
)
total = await db.scalar(select(func.count()).select_from(groups.subquery())) or 0
agg_rows = (
await db.execute(
groups.order_by(
func.max(Message.internal_date).asc()
if order == "asc"
else func.max(Message.internal_date).desc(),
key,
)
.offset(offset)
.limit(limit)
)
).all()
page_keys = [r.key for r in agg_rows]
if not page_keys:
return SubscriptionListOut(subscriptions=[], total=total, pending=pending)
in_page = _key().in_(page_keys)
latest_rows = (
await db.execute(
select(
key,
Message.from_addr,
Message.subject,
Message.list_id,
Message.list_unsubscribe,
Message.list_unsubscribe_post,
)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user.id), in_page)
.distinct(key)
.order_by(key, Message.internal_date.desc())
)
).all()
latest = {r.key: r for r in latest_rows}
states = {
s.key: s
for s in await db.scalars(
select(SubscriptionState).where(
SubscriptionState.user_id == user.id, SubscriptionState.key.in_(page_keys)
)
)
}
# Senders still mailing after the grace period, for the whole page at once.
still_sending = set(
await db.scalars(
select(key)
.select_from(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.join(
SubscriptionState,
(SubscriptionState.user_id == user.id) & (SubscriptionState.key == _key()),
)
.where(
*_bulk(user.id),
in_page,
Message.internal_date > SubscriptionState.unsubscribed_at + GRACE,
)
.distinct()
)
)
out: list[SubscriptionOut] = []
for row in agg_rows:
last = latest.get(row.key)
if last is None:
continue
sender = last.from_addr[0] if last.from_addr else {}
targets = subs.unsubscribe_targets(last.list_unsubscribe or "")
state = states.get(row.key)
months = max(1.0, (row.last_at - row.first_at).days / 30.44)
out.append(
SubscriptionOut(
key=row.key,
name=sender.get("name") or sender.get("email", ""),
email=sender.get("email", ""),
list_id=last.list_id,
total=row.total,
unread=row.unread,
last_30_days=row.recent,
per_month=round(row.total / months, 1),
first_at=row.first_at,
last_at=row.last_at,
last_subject=last.subject,
one_click=bool(last.list_unsubscribe_post and targets.https),
has_mailto=targets.mailto is not None,
has_web=targets.https is not None,
unsubscribed_at=state.unsubscribed_at if state else None,
method=state.method if state else None,
still_sending=row.key in still_sending,
)
)
return SubscriptionListOut(subscriptions=out, total=total, pending=pending)
@router.get("/threads", response_model=list[uuid.UUID])
async def subscription_threads(
user: CurrentUser, db: DbSession, key: str = Query(...), limit: int = Query(50, le=200)
) -> list[uuid.UUID]:
"""The group's conversations, newest first (for the reading pane)."""
rows = await db.execute(
select(Message.thread_id, func.max(Message.internal_date).label("at"))
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user.id), _key() == key, Message.thread_id.isnot(None))
.group_by(Message.thread_id)
.order_by(func.max(Message.internal_date).desc())
.limit(limit)
)
return [r.thread_id for r in rows if r.thread_id is not None]
async def _latest_with_header(db: AsyncSession, user_id: uuid.UUID, key: str) -> Message:
message = await db.scalar(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user_id), _key() == key)
.order_by(Message.internal_date.desc())
.limit(1)
)
if message is None:
raise_api_error(404, "not_found", "no mail from this sender")
return message
async def _record(db: AsyncSession, user_id: uuid.UUID, key: str, method: str) -> None:
stmt = insert(SubscriptionState).values(user_id=user_id, key=key, method=method)
await db.execute(
stmt.on_conflict_do_update(
index_elements=["user_id", "key"],
set_={"method": method, "unsubscribed_at": func.now()},
)
)
await db.commit()
async def _one_click(url: str) -> bool:
try:
resp = await guarded_request(
"POST",
url,
headers={"Content-Type": "application/x-www-form-urlencoded"},
content="List-Unsubscribe=One-Click",
max_bytes=256 * 1024,
)
except Exception as exc: # SSRF refusal, network, TLS: any failure tries the next way
logger.info("one-click unsubscribe failed: %s", exc)
return False
return 200 <= resp.status < 400
async def _send_mailto(
db: AsyncSession, user_id: uuid.UUID, message: Message, targets: subs.UnsubscribeTargets
) -> bool:
assert targets.mailto is not None
body = DraftIn(
account_id=message.account_id,
kind="new",
payload=DraftPayload(
to=[targets.mailto], subject=targets.mailto_subject, text=targets.mailto_body
),
)
try:
draft = await upsert_draft(db, user_id=user_id, body=body, draft_id=None)
except DraftError:
return False
outbox = await create_outbox(db, user_id=user_id, draft=draft)
await db.commit()
await enqueue_job(
"send_job", str(outbox.id), _defer_by=dt.timedelta(seconds=get_settings().undo_seconds)
)
return True
@router.post("/unsubscribe", response_model=UnsubscribeOut)
async def unsubscribe(body: UnsubscribeIn, user: CurrentUser, db: DbSession) -> UnsubscribeOut:
"""Unsubscribe from a sender: one-click if offered, else by mail, else the web page."""
if body.message_id is not None:
found = await db.scalar(
select(Message).where(
Message.id == body.message_id,
Message.user_id == user.id,
Message.list_unsubscribe.isnot(None),
Message.list_unsubscribe != "",
)
)
if found is None:
raise_api_error(404, "not_found", "this message offers no way to unsubscribe")
message = found
else:
message = await _latest_with_header(db, user.id, body.key)
targets = subs.unsubscribe_targets(message.list_unsubscribe or "")
if message.list_unsubscribe_post and targets.https and await _one_click(targets.https):
await _record(db, user.id, body.key, "one_click")
return UnsubscribeOut(status="done", method="one_click")
if targets.mailto and await _send_mailto(db, user.id, message, targets):
await _record(db, user.id, body.key, "mailto")
return UnsubscribeOut(status="done", method="mailto")
if targets.https:
# The sender wants it done on their page: the user opens it.
await _record(db, user.id, body.key, "web")
return UnsubscribeOut(status="open", method="web", url=targets.https)
raise_api_error(409, "no_unsubscribe", "this sender offers no way to unsubscribe")
@router.post("/trash")
async def trash_all(body: KeyIn, user: CurrentUser, db: DbSession) -> dict[str, int]:
"""Move every conversation from this sender to Trash.
Only conversations made up entirely of this sender's mail: one where the
user replied, or someone else wrote, stays where it is.
"""
rows = (
await db.execute(
select(Message.thread_id, _key().label("key"), Mailbox.special_use)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user.id,
Message.thread_id.in_(
select(Message.thread_id)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(*_bulk(user.id), _key() == body.key)
),
)
)
).all()
own: dict[uuid.UUID, bool] = {}
for thread_id, key, special in rows:
if thread_id is None or special == "trash":
continue
own[thread_id] = own.get(thread_id, True) and key == body.key
moved = [tid for tid, only_theirs in own.items() if only_theirs]
for tid in moved:
await enqueue_job("move_thread_to_folder_job", str(tid), "trash")
return {"moved": len(moved)}
+129
View File
@@ -0,0 +1,129 @@
"""Templates: saved replies, inserted from the compose window's More menu."""
from __future__ import annotations
import datetime as dt
import uuid
from typing import Annotated
import nh3
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import MailTemplate
router = APIRouter(prefix="/templates", tags=["compose"], dependencies=[Depends(require_csrf)])
DbSession = Annotated[AsyncSession, Depends(get_session)]
MAX_TEMPLATES = 200
MAX_BODY_BYTES = 256 * 1024
# What the compose editor writes. A template goes back into the editor as HTML,
# so it is cleaned like any HTML the app renders: no scripts, no handlers.
_TAGS = {
"a", "b", "blockquote", "br", "code", "div", "em", "font", "h1", "h2", "h3",
"hr", "i", "img", "li", "ol", "p", "pre", "s", "span", "strike", "strong",
"sub", "sup", "table", "tbody", "td", "th", "thead", "tr", "u", "ul",
} # fmt: skip
_GENERIC = {"style", "class", "dir", "align"}
_SPECIFIC = {
"a": {"href", "title"},
"img": {"src", "alt", "width", "height"},
"font": {"color", "face", "size"},
"td": {"colspan", "rowspan"},
"th": {"colspan", "rowspan"},
}
# nh3 takes no wildcard: the generic attributes go onto every tag.
_ATTRS = {tag: _GENERIC | _SPECIFIC.get(tag, set()) for tag in _TAGS}
class TemplateOut(BaseModel):
id: uuid.UUID
name: str
subject: str
body_html: str
updated_at: dt.datetime
class TemplateIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
subject: str = Field(default="", max_length=500)
body_html: str = ""
def _out(t: MailTemplate) -> TemplateOut:
return TemplateOut(
id=t.id, name=t.name, subject=t.subject, body_html=t.body_html, updated_at=t.updated_at
)
def clean_body(html: str) -> str:
return nh3.clean(
html,
tags=_TAGS,
attributes=_ATTRS,
url_schemes={"http", "https", "mailto", "data"},
link_rel="noopener noreferrer",
)
@router.get("", response_model=list[TemplateOut])
async def list_templates(user: CurrentUser, db: DbSession) -> list[TemplateOut]:
rows = await db.scalars(
select(MailTemplate)
.where(MailTemplate.user_id == user.id)
.order_by(func.lower(MailTemplate.name))
)
return [_out(t) for t in rows]
@router.post("", response_model=TemplateOut)
async def save_template(body: TemplateIn, user: CurrentUser, db: DbSession) -> TemplateOut:
"""Save a template. Saving under a name that exists replaces that template."""
name = " ".join(body.name.split())
if not name:
raise_api_error(400, "invalid", "a template needs a name")
if len(body.body_html.encode()) > MAX_BODY_BYTES:
raise_api_error(400, "too_large", "the template is too large")
html = clean_body(body.body_html)
subject = " ".join(body.subject.split())
existing = await db.scalar(
select(MailTemplate).where(
MailTemplate.user_id == user.id, func.lower(MailTemplate.name) == name.lower()
)
)
if existing is not None:
existing.name, existing.subject, existing.body_html = name, subject, html
existing.updated_at = dt.datetime.now(dt.UTC)
saved = existing
else:
count = await db.scalar(
select(func.count()).select_from(MailTemplate).where(MailTemplate.user_id == user.id)
)
if (count or 0) >= MAX_TEMPLATES:
raise_api_error(409, "too_many", f"at most {MAX_TEMPLATES} templates")
saved = MailTemplate(user_id=user.id, name=name, subject=subject, body_html=html)
db.add(saved)
await db.commit()
await db.refresh(saved)
return _out(saved)
@router.delete("/{template_id}")
async def delete_template(
template_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> dict[str, str]:
row = await db.scalar(
select(MailTemplate).where(MailTemplate.id == template_id, MailTemplate.user_id == user.id)
)
if row is None:
raise_api_error(404, "not_found", "template not found")
await db.delete(row)
await db.commit()
return {"status": "deleted"}
+73 -7
View File
@@ -4,13 +4,14 @@ from __future__ import annotations
import uuid import uuid
from collections.abc import Collection from collections.abc import Collection
from typing import Annotated from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import Text, func, or_, select from sqlalchemy import Text, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from app import subscriptions as subs
from app.api.errors import raise_api_error from app.api.errors import raise_api_error
from app.api.schemas import ( from app.api.schemas import (
Address, Address,
@@ -19,10 +20,10 @@ from app.api.schemas import (
ThreadListOut, ThreadListOut,
ThreadSummary, ThreadSummary,
) )
from app.api.settings import allowed_image_senders from app.api.settings import allowed_image_senders, blocks_trackers
from app.db import get_session from app.db import get_session
from app.identity.deps import CurrentUser from app.identity.deps import CurrentUser
from app.models import Mailbox, Message, Thread, User from app.models import BodyPart, Mailbox, Message, SubscriptionState, Thread, User
router = APIRouter(prefix="/threads", tags=["mail"]) router = APIRouter(prefix="/threads", tags=["mail"])
@@ -50,6 +51,7 @@ def _summary(
has_attachments=thread.has_attachments, has_attachments=thread.has_attachments,
pinned=thread.pinned, pinned=thread.pinned,
last_message_at=thread.last_message_at, last_message_at=thread.last_message_at,
snoozed_until=thread.snoozed_until,
) )
@@ -74,6 +76,7 @@ def _envelope(msg: Message, allowed_senders: Collection[str] = ()) -> MessageEnv
images_allowed=bool(sender) and sender in allowed_senders, images_allowed=bool(sender) and sender in allowed_senders,
size_bytes=msg.size_bytes, size_bytes=msg.size_bytes,
snippet=msg.snippet, snippet=msg.snippet,
content_language=msg.content_language or "",
) )
@@ -130,6 +133,10 @@ async def list_threads(
.where(Message.user_id == user.id, Mailbox.special_use == special) .where(Message.user_id == user.id, Mailbox.special_use == special)
) )
conditions.append(Thread.id.in_(in_folder)) conditions.append(Thread.id.in_(in_folder))
if special == "inbox":
conditions.append(Thread.snoozed_until.is_(None)) # back when it wakes
elif view == "snoozed":
conditions.append(Thread.snoozed_until.isnot(None))
elif view != "unified": elif view != "unified":
raise_api_error(400, "bad_view", "unsupported view") raise_api_error(400, "bad_view", "unsupported view")
@@ -144,11 +151,20 @@ async def list_threads(
) )
) or 0 ) or 0
order_by = ( # A thread woken from snooze sorts as if it had just arrived.
(Thread.last_message_at.asc(), Thread.id.asc()) sort_at = func.greatest(
if order == "asc" Thread.last_message_at, func.coalesce(Thread.unsnoozed_at, Thread.last_message_at)
else (Thread.last_message_at.desc(), Thread.id.desc())
) )
if view == "snoozed":
# Soonest back first, whatever the list order.
order_by: tuple[ColumnElement[Any], ...] = (
Thread.snoozed_until.asc(),
Thread.id.asc(),
)
elif order == "asc":
order_by = (sort_at.asc(), Thread.id.asc())
else:
order_by = (sort_at.desc(), Thread.id.desc())
stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit) stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit)
threads = list(await db.scalars(stmt)) threads = list(await db.scalars(stmt))
@@ -177,6 +193,41 @@ async def list_threads(
) )
async def _mark_subscriptions(
db: AsyncSession,
user_id: uuid.UUID,
messages: list[Message],
envelopes: list[MessageEnvelope],
) -> None:
"""Tell the reading pane which messages can be unsubscribed from, and how."""
keys: dict[uuid.UUID, str] = {}
for m, env in zip(messages, envelopes, strict=True):
if not m.list_unsubscribe:
continue
targets = subs.unsubscribe_targets(m.list_unsubscribe)
if m.list_unsubscribe_post and targets.https:
env.unsubscribe_via = "one_click"
elif targets.mailto:
env.unsubscribe_via = "mailto"
elif targets.https:
env.unsubscribe_via = "web"
else:
continue
sender = m.from_addr[0].get("email", "") if m.from_addr else ""
env.subscription_key = keys[m.id] = subs.group_key(m.list_id, sender)
if keys:
done = set(
await db.scalars(
select(SubscriptionState.key).where(
SubscriptionState.user_id == user_id,
SubscriptionState.key.in_(set(keys.values())),
)
)
)
for env in envelopes:
env.unsubscribed = env.subscription_key in done
def _visible_in(current_special: str | None) -> ColumnElement[bool]: def _visible_in(current_special: str | None) -> ColumnElement[bool]:
"""Which of a thread's messages a view shows (see ``_REMOVAL_FOLDERS``): Trash/Spam """Which of a thread's messages a view shows (see ``_REMOVAL_FOLDERS``): Trash/Spam
show only their own messages; every other view hides them.""" show only their own messages; every other view hides them."""
@@ -281,6 +332,20 @@ async def get_thread(
env = _envelope(m, allowed_senders) env = _envelope(m, allowed_senders)
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"] env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
envelopes.append(env) envelopes.append(env)
await _mark_subscriptions(db, user.id, ordered, envelopes)
if not blocks_trackers(user) and ordered:
# Trackers left in are remote images: offer "Load images" for them too.
with_trackers = set(
await db.scalars(
select(BodyPart.message_id).where(
BodyPart.message_id.in_([m.id for m in ordered]),
BodyPart.kind == "html_sanitized",
BodyPart.trackers_blocked > 0,
)
)
)
for env in envelopes:
env.has_remote_images = env.has_remote_images or env.id in with_trackers
order = "desc" if sort == "desc" else "asc" order = "desc" if sort == "desc" else "asc"
return ThreadDetail( return ThreadDetail(
@@ -289,4 +354,5 @@ async def get_thread(
order=order, order=order,
pinned=thread.pinned, pinned=thread.pinned,
messages=envelopes, messages=envelopes,
snoozed_until=thread.snoozed_until,
) )
+30
View File
@@ -0,0 +1,30 @@
"""What email translation offers (app/translation.py), for the reading pane."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from app import translation
from app.identity.deps import CurrentUser
router = APIRouter(prefix="/translation", tags=["mail"])
class LanguageOut(BaseModel):
code: str
name: str
class TranslationInfoOut(BaseModel):
available: bool
languages: list[LanguageOut]
@router.get("/languages", response_model=TranslationInfoOut)
async def translation_languages(user: CurrentUser) -> TranslationInfoOut:
found = await translation.languages()
return TranslationInfoOut(
available=len(found) > 1, # English alone translates nothing
languages=[LanguageOut(code=x.code, name=x.name) for x in found],
)
+391
View File
@@ -0,0 +1,391 @@
"""Sentence completion in the compose window: phrases the user writes, finished.
No AI: a per-user index of the word sequences the user has written at least
twice, built from their own sent mail (quoted replies and signatures cut off),
plus a few common email phrases per language to start from. Typing "I look
forward" finds what usually follows those words — "to hearing from you." — and
the compose window shows it in grey until Tab takes it.
It works in any language the user writes in: matching is on the words just
typed, so a Polish sentence only ever meets Polish phrases.
The index lives in ``completion_phrases`` (context → continuation, with how
often it was seen) and is rebuilt in the background at most once a day, when
the compose window asks for suggestions (:func:`build_index_job`).
"""
from __future__ import annotations
import datetime as dt
import html as html_lib
import logging
import re
import uuid
from collections import Counter
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert
from app.db import session_scope
from app.models import (
Account,
BodyPart,
CompletionIndexState,
CompletionPhrase,
Mailbox,
Message,
)
logger = logging.getLogger(__name__)
START = "^" # a sentence's start, as a word: "^ pozdr" completes the first word
MAX_CONTEXT = 3 # words before the cursor matched on
MAX_CONTINUATION = 6 # words suggested at most
MIN_COUNT = 2 # seen this often to be offered; one-offs are noise (and private)
EXTEND_SHARE = 0.6 # a longer suggestion only while it is what usually follows
CORPUS_MESSAGES = 500 # most recent sent messages read
CORPUS_CHARS = 3000 # of each
REBUILD_AFTER = dt.timedelta(days=1)
# Where the user's own words end and quoted mail or a signature begins: a reply
# header ("On … wrote:", Polish Gmail's "… napisał(a):", …), a forwarded or
# original-message marker, or an Outlook-style header block.
_QUOTE_START = re.compile(
r"^\s*(?:"
r"-{2,}\s*(?:original message|forwarded message|wiadomość oryginalna"
r"|wiadomość przekazana dalej|ursprüngliche nachricht|weitergeleitete nachricht)"
r"|(?:from|od|von|de):\s.+"
r"|sent from my\s"
r")",
re.IGNORECASE,
)
_QUOTE_HEADER_END = re.compile(
# German names the writer after the verb: "Am … schrieb Anna:".
r"(?:wrote|napisał\(a\)|napisała?|pisze|schrieb(?:\s+.{0,100})?|a écrit|escribió)\s*:\s*$",
re.IGNORECASE,
)
_SIGNATURE = re.compile(r"^--\s*$")
_SENTENCE_END = re.compile(r"(?<=[.!?])\s+|\n+")
_WORD = re.compile(r"\S+")
_EDGE_PUNCT = re.compile(r"^[\"'“”„«»(\[–—-]+|[\"'“”„«»)\],.;:!?…–—-]+$")
# Words a phrase is never learned across: numbers (phones, dates, prices),
# addresses and links — useless to complete, and often private.
_NOT_A_WORD = re.compile(r"\d|@|www\.|https?:", re.IGNORECASE)
def html_to_text(html: str) -> str:
"""An HTML signature as text lines (block tags and <br> break lines)."""
text = re.sub(r"(?i)<br\s*/?>|</(?:p|div|li|tr|h[1-6])\s*>", "\n", html)
text = re.sub(r"<[^>]+>", "", text)
return html_lib.unescape(text)
def own_text(text: str, signatures: Iterable[str] = ()) -> str:
"""The part of a sent message the user wrote: no quoted mail, no signature
(the ``--`` kind, or one of ``signatures`` — the accounts' own, as text)."""
sig_lines = {norm_line(x) for sig in signatures for x in sig.splitlines() if norm_line(x)}
kept: list[str] = []
for line in text.splitlines():
if _SIGNATURE.match(line) or _QUOTE_START.match(line) or _QUOTE_HEADER_END.search(line):
break
if norm_line(line) in sig_lines:
continue
if line.lstrip().startswith(">"):
continue # an inline-quoted line
kept.append(line)
return "\n".join(kept)
def norm_line(line: str) -> str:
return " ".join(line.lower().split())
def norm(word: str) -> str:
"""A word as matched: lower case, outer punctuation off ("Hello," -> "hello")."""
return _EDGE_PUNCT.sub("", word.lower())
def sentences(text: str) -> Iterable[list[str]]:
for chunk in _SENTENCE_END.split(text):
words = _WORD.findall(chunk)
if words:
yield words
@dataclass(frozen=True)
class Phrase:
context: str # normalized words before the cursor, space-joined ("^ i look")
continuation: str # the words that follow, as written ("forward to hearing")
count: int
def build_phrases(texts: Iterable[str], weight: int = 1) -> dict[tuple[str, str], int]:
"""(context, continuation) -> count, for every sequence seen MIN_COUNT times.
Counts each sentence's word sequences (with the start marker), keeps the
frequent ones, and splits each into "what was typed" and "what follows".
"""
counts: Counter[tuple[str, ...]] = Counter()
surface: dict[tuple[str, ...], tuple[str, ...]] = {}
longest = MAX_CONTEXT + MAX_CONTINUATION
for text in texts:
for words in sentences(text):
seq = [START, *words]
keys = [START, *("" if _NOT_A_WORD.search(w) else norm(w) for w in words)]
for i in range(len(seq)):
for n in range(2, min(longest, len(seq) - i) + 1):
gram = tuple(keys[i : i + n])
if "" in gram:
break # a lone punctuation mark: no phrase across it
counts[gram] += weight
surface.setdefault(gram, tuple(seq[i : i + n]))
out: dict[tuple[str, str], int] = {}
for gram, count in counts.items():
if count < MIN_COUNT:
continue
written = surface[gram]
for k in range(1, min(MAX_CONTEXT, len(gram) - 1) + 1):
rest = len(gram) - k
if rest > MAX_CONTINUATION:
continue
key = (" ".join(gram[:k]), " ".join(written[k:]))
out[key] = max(out.get(key, 0), count)
return out
# A few common phrases per language, for users with little sent mail yet.
STARTER_PHRASES: dict[str, list[str]] = {
"en": [
"I look forward to hearing from you.",
"I'm looking forward to hearing from you.",
"Looking forward to hearing from you.",
"Looking forward to working with you.",
"Thank you for your email.",
"Thank you for your help.",
"Thank you for your quick reply.",
"Thanks in advance.",
"Please let me know if you have any questions.",
"Let me know if you have any questions.",
"Let me know what you think.",
"Please find attached the",
"I hope this email finds you well.",
"I hope you're doing well.",
"Sorry for the late reply.",
"I would be happy to help.",
"Feel free to contact me if you need anything else.",
"Could you please send me the",
"Please confirm at your earliest convenience.",
"Have a great day!",
"Have a nice weekend!",
"Best regards,",
"Kind regards,",
],
"pl": [
"Z poważaniem,",
"Pozdrawiam serdecznie,",
"Dziękuję za wiadomość.",
"Dziękuję za szybką odpowiedź.",
"Dziękuję za pomoc.",
"Z góry dziękuję.",
"W załączniku przesyłam",
"Proszę o informację zwrotną.",
"W razie pytań proszę o kontakt.",
"Czekam na odpowiedź.",
"Przepraszam za opóźnienie w odpowiedzi.",
"Będę wdzięczny za odpowiedź.",
"Będę wdzięczna za odpowiedź.",
"Zgodnie z naszą rozmową",
"Proszę o potwierdzenie.",
"Daj znać, co myślisz.",
"Miłego dnia!",
"Miłego weekendu!",
],
"de": [
"Mit freundlichen Grüßen",
"Viele Grüße",
"Beste Grüße",
"Vielen Dank für Ihre E-Mail.",
"Vielen Dank für Ihre schnelle Antwort.",
"Vielen Dank im Voraus.",
"Ich freue mich auf Ihre Antwort.",
"Bei Fragen stehe ich Ihnen gerne zur Verfügung.",
"Im Anhang finden Sie",
"Entschuldigen Sie die späte Antwort.",
"Ich hoffe, es geht Ihnen gut.",
"Schönen Tag noch!",
"Schönes Wochenende!",
],
}
# Built once: every starter phrase counts as seen MIN_COUNT times.
_STARTERS: dict[str, Counter[str]] = {}
for (_ctx, _cont), _n in build_phrases(
(p for phrases in STARTER_PHRASES.values() for p in phrases), weight=MIN_COUNT
).items():
_STARTERS.setdefault(_ctx, Counter())[_cont] = _n
_LEADING_PUNCT = re.compile(r"^[\"'“”„«»(\[]+")
def _candidates(phrases: dict[str, Counter[str]], context: str, partial: str) -> Counter[str]:
"""Continuations of ``context`` whose first word starts with ``partial``."""
out: Counter[str] = Counter()
for continuation, count in phrases.get(context, Counter()).items():
first = continuation.split(" ", 1)[0]
if partial and not first.lower().startswith(partial.lower()):
continue
if partial and len(first) <= len(partial):
continue # that word is typed in full already
out[continuation] = count
return out
def choose(candidates: Counter[str]) -> str | None:
"""The suggestion: the most frequent next word, extended word by word only
while the longer phrase is what usually follows (EXTEND_SHARE)."""
if not candidates:
return None
by_first: Counter[str] = Counter()
for continuation, count in candidates.items():
first = continuation.split(" ", 1)[0]
by_first[first] = max(by_first[first], count)
chosen, chosen_count = by_first.most_common(1)[0]
while True:
depth = chosen.count(" ") + 1
longer = [
(c, n)
for c, n in candidates.items()
if c.startswith(chosen + " ") and c.count(" ") == depth
]
if not longer:
return chosen
best, best_count = max(longer, key=lambda x: x[1])
if best_count < max(MIN_COUNT, EXTEND_SHARE * chosen_count):
return chosen
chosen, chosen_count = best, best_count
def contexts(before: str) -> tuple[list[str], str]:
"""For the text before the cursor: the contexts to try, longest first, and
the partly typed word ('' after a space). No contexts: nothing to suggest
(an empty sentence, or the cursor right after a punctuation mark)."""
if not before or not before.strip():
return [], ""
last = before[-1]
if not last.isspace() and not last.isalnum() and last not in "'’-":
return [], "" # "you," or "end." — not mid-word, and no space typed yet
tail = _SENTENCE_END.split(before)[-1]
words = _WORD.findall(tail)
partial = ""
if words and not last.isspace():
partial = _LEADING_PUNCT.sub("", words.pop())
if not words and not partial:
return [], "" # a new sentence with nothing in it yet
keys = [START, *(norm(w) for w in words)]
if "" in keys: # a lone punctuation mark: the phrase starts after it
keys = keys[len(keys) - keys[::-1].index("") :]
if not keys:
return [], ""
return [" ".join(keys[-k:]) for k in range(min(MAX_CONTEXT, len(keys)), 0, -1)], partial
def suggest(personal: dict[str, Counter[str]], before: str) -> str | None:
"""What to show after ``before`` (the text up to the cursor), or None.
The returned text starts where the cursor is: the rest of a partly typed
word, or (after a space) the next words. The user's own phrases and the
starter phrases count together.
"""
tried, partial = contexts(before)
for context in tried:
merged = _candidates(personal, context, partial)
for cont, count in _candidates(_STARTERS, context, partial).items():
merged[cont] = merged.get(cont, 0) + count
if " " not in context and not partial:
# A single word before a space is a weak hint: only a strong habit.
merged = Counter({c: n for c, n in merged.items() if n >= 2 * MIN_COUNT})
best = choose(merged)
if best:
return best[len(partial) :] if partial else best
return None
def complete(personal: dict[str, Counter[str]], before: str) -> str | None:
""":func:`suggest`, and for a word typed in full but no space yet ("I look
forward"), what follows it — with the space ("␣to hearing from you.")."""
found = suggest(personal, before)
if found is None and before[-1:].isalnum():
after_space = suggest(personal, before + " ")
if after_space:
return " " + after_space
return found
def keys_for(before: str) -> list[str]:
"""The contexts :func:`complete` may look up, to fetch them from the index."""
keys = contexts(before)[0]
if before[-1:].isalnum():
keys += contexts(before + " ")[0]
return keys
async def personal_phrases(
session: Any, user_id: uuid.UUID, keys: list[str]
) -> dict[str, Counter[str]]:
rows = await session.execute(
select(
CompletionPhrase.context, CompletionPhrase.continuation, CompletionPhrase.count
).where(CompletionPhrase.user_id == user_id, CompletionPhrase.context.in_(keys))
)
out: dict[str, Counter[str]] = {}
for context, continuation, count in rows:
out.setdefault(context, Counter())[continuation] = count
return out
async def build_index_job(ctx: dict[str, Any], user_id: str) -> int:
"""Rebuild a user's phrase index from their recent sent mail. Returns its size."""
uid = uuid.UUID(user_id)
async with session_scope() as session:
texts = list(
await session.scalars(
select(BodyPart.content)
.join(Message, Message.id == BodyPart.message_id)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == uid,
Mailbox.special_use == "sent",
BodyPart.kind == "text",
)
.order_by(Message.internal_date.desc())
.limit(CORPUS_MESSAGES)
)
)
signatures = [
html_to_text(sig)
for sig in await session.scalars(
select(Account.signature).where(Account.user_id == uid, Account.signature != "")
)
]
phrases = build_phrases(own_text(t, signatures)[:CORPUS_CHARS] for t in texts)
await session.execute(delete(CompletionPhrase).where(CompletionPhrase.user_id == uid))
rows = [
{"user_id": uid, "context": c, "continuation": cont, "count": n}
for (c, cont), n in phrases.items()
]
for start in range(0, len(rows), 1000):
await session.execute(insert(CompletionPhrase), rows[start : start + 1000])
stmt = insert(CompletionIndexState).values(user_id=uid, built_at=dt.datetime.now(dt.UTC))
await session.execute(
stmt.on_conflict_do_update(
index_elements=["user_id"], set_={"built_at": stmt.excluded.built_at}
)
)
await session.commit()
logger.info(
"completion index for %s: %d phrases from %d messages", user_id, len(rows), len(texts)
)
return len(rows)
+42
View File
@@ -71,11 +71,53 @@ class Settings(BaseSettings):
# Spelling and grammar checking in the compose window (LanguageTool server, # Spelling and grammar checking in the compose window (LanguageTool server,
# e.g. http://languagetool:8010). Empty: the check is off for everyone. # e.g. http://languagetool:8010). Empty: the check is off for everyone.
languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL") languagetool_url: str = Field(default="", alias="LANGUAGETOOL_URL")
# Email translation: the translator service (translator/, app/translation.py),
# e.g. http://translator:5000. Empty: translation is off.
translator_url: str = Field(default="", alias="TRANSLATOR_URL")
# New passwords are checked against known data breaches (Have I Been Pwned's
# k-anonymity range API: only 5 hex digits of a SHA-1 leave the server).
password_breach_check: bool = Field(default=True, alias="PASSWORD_BREACH_CHECK")
# The name the webmail gives itself when it connects to a mail server to
# send (SMTP EHLO). Empty: APP_DOMAIN — not the container's random name.
smtp_helo_name: str = Field(default="", alias="SMTP_HELO_NAME")
# 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:
"""Return the process-wide settings singleton.""" """Return the process-wide settings singleton."""
return Settings() # values are sourced from the environment return Settings() # values are sourced from the environment
def smtp_helo_name() -> str:
"""The name to introduce ourselves with to SMTP servers (EHLO)."""
settings = get_settings()
return settings.smtp_helo_name.strip() or settings.app_domain
+1
View File
@@ -0,0 +1 @@
"""CalDAV / CardDAV server (SPEC Appendix E)."""
+93
View File
@@ -0,0 +1,93 @@
"""Signing in a DAV client: HTTP Basic with an app password (SPEC App. E.2).
Only app passwords: the account password (with or without 2FA) never works
here, so a lost phone is one revocation away and 2FA cannot be bypassed. A
sync makes many requests, each with the credentials; a successful check is
remembered for a few minutes under a hash of the password, and forgotten as
soon as the user's app passwords change (``app_passwords`` bumps a counter).
"""
from __future__ import annotations
import base64
import binascii
import datetime as dt
import hashlib
import uuid
from sqlalchemy import select, update
from app.db import session_scope
from app.identity import app_passwords
from app.identity.service import resolve_handle
from app.models import User, UserAppPassword
from app.redis_client import get_redis
CACHE_S = 300
FAIL_WINDOW_S = 900
MAX_FAILS_PER_ADDRESS = 10
MAX_FAILS_PER_IP = 30
_LAST_USED_EVERY = dt.timedelta(minutes=5)
class Throttled(Exception):
"""Too many failed sign-ins recently."""
def _credentials(header: str | None) -> tuple[str, str] | None:
if not header or not header.lower().startswith("basic "):
return None
try:
decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
return None
login, sep, password = decoded.partition(":")
return (login.strip(), password) if sep and login.strip() else None
async def authenticate(header: str | None, ip: str) -> User | None:
"""The user these Basic credentials sign in, or None. Raises Throttled."""
creds = _credentials(header)
if creds is None:
return None
login, password = creds
redis = get_redis()
handle = resolve_handle(login)
fail_keys = (f"dav:fail:addr:{handle}", f"dav:fail:ip:{ip}")
fails = [int(await redis.get(k) or 0) for k in fail_keys]
if fails[0] >= MAX_FAILS_PER_ADDRESS or fails[1] >= MAX_FAILS_PER_IP:
raise Throttled
async with session_scope() as db:
user = await db.scalar(select(User).where(User.handle == handle))
if user is None or user.blocked_at is not None:
await _failed(fail_keys)
return None
gen = int(await redis.get(app_passwords.generation_key(user.id)) or 0)
digest = hashlib.sha256(f"{user.id}:{password}".encode()).hexdigest()
cache_key = f"dav:auth:{user.id}:{gen}:{digest}"
if await redis.get(cache_key):
return user
row = await app_passwords.verify(db, user.id, password)
if row is None:
await _failed(fail_keys)
return None
await redis.set(cache_key, str(row.id), ex=CACHE_S)
now = dt.datetime.now(dt.UTC)
if row.last_used_at is None or now - row.last_used_at > _LAST_USED_EVERY:
await db.execute(
update(UserAppPassword).where(UserAppPassword.id == row.id).values(last_used_at=now)
)
db.expunge(user)
return user
async def _failed(keys: tuple[str, ...]) -> None:
redis = get_redis()
for key in keys:
await redis.incr(key)
await redis.expire(key, FAIL_WINDOW_S)
def user_id_of(user: User) -> uuid.UUID:
return user.id
+118
View File
@@ -0,0 +1,118 @@
"""What a plugin implements to serve its collections over DAV (SPEC App. E.1).
The core (app/dav/routes.py) speaks the protocol; a provider only knows its
own data: collections (calendars, address books) and the resources in them
(one iCalendar object / one vCard each), named, with an ETag. Every method
gets the signed-in user's id; providers open the user's RLS session
(``sdk(name).session(user_id)``) themselves.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol
class DavError(Exception):
"""A request the provider refuses: ``status`` is the HTTP answer
(403 read-only, 404 missing, 409 conflict, 412 precondition failed, 415
unsupported data); ``condition`` an optional DAV precondition element
(e.g. ``{urn:ietf:params:xml:ns:caldav}valid-calendar-data``)."""
def __init__(self, status: int, message: str = "", condition: str | None = None) -> None:
super().__init__(message or str(status))
self.status = status
self.condition = condition
@dataclass
class Collection:
id: str
displayname: str
sync_token: int # the latest change number; also the CTag
description: str = ""
color: str | None = None # "#rrggbb"
read_only: bool = False
components: list[str] = field(default_factory=lambda: ["VEVENT"]) # calendars only
@dataclass
class Resource:
name: str # last path segment, e.g. "abc.ics"
etag: str # without quotes
data: str | None = None # the iCalendar / vCard text, when asked for
@dataclass
class Changes:
sync_token: int
changed: list[str] # resource names changed or added since the old token
deleted: list[str] # resource names gone since the old token
class DavProvider(Protocol):
kind: Literal["calendar", "addressbook"]
content_type: str # "text/calendar; charset=utf-8" / "text/vcard; charset=utf-8"
extension: str # ".ics" / ".vcf"
async def collections(self, user_id: uuid.UUID) -> list[Collection]: ...
async def resources(self, user_id: uuid.UUID, collection_id: str) -> list[Resource]: ...
async def get(
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None
) -> list[Resource]: ...
async def put(
self,
user_id: uuid.UUID,
collection_id: str,
name: str,
data: str,
*,
if_match: str | None,
if_none_match: bool,
) -> tuple[str, bool]:
"""Store ``data`` as ``name``; returns (new ETag, created)."""
...
async def delete(
self, user_id: uuid.UUID, collection_id: str, name: str, *, if_match: str | None
) -> None: ...
async def changes(self, user_id: uuid.UUID, collection_id: str, since: int) -> Changes | None:
"""What changed after change number ``since``; None when that token is
too old to answer (the client then resyncs from scratch)."""
...
async def create_collection(
self, user_id: uuid.UUID, collection_id: str, displayname: str, color: str | None
) -> None:
"""Create a collection at the id the client chose (MKCALENDAR / MKCOL);
DavError(403) when that id cannot be used."""
...
async def update_collection(
self,
user_id: uuid.UUID,
collection_id: str,
*,
displayname: str | None = None,
color: str | None = None,
description: str | None = None,
) -> None: ...
async def delete_collection(self, user_id: uuid.UUID, collection_id: str) -> None: ...
async def query(
self,
user_id: uuid.UUID,
collection_id: str,
start: datetime | None,
end: datetime | None,
) -> list[str] | None:
"""Calendars: names of resources with an occurrence overlapping the
range; None when the provider cannot filter (the core then returns all)."""
...
+595
View File
@@ -0,0 +1,595 @@
"""The CalDAV / CardDAV endpoints (SPEC Appendix E).
/.well-known/caldav, /.well-known/carddav → 301 /dav/
/dav/ root: points at the principal
/dav/principals/{user}/ the signed-in user
/dav/calendars/{user}/[{calendar}/[{name}]] calendar home, calendars, events
/dav/addressbooks/{user}/[{book}/[{name}]] address-book home, books, contacts
The data comes from the plugins' providers (app/dav/provider.py): the calendar
plugin's for ``calendars``, the contacts plugin's for ``addressbooks``. Signed
in with HTTP Basic and an app password only (app/dav/auth.py); session cookies
are never looked at, so a web page cannot drive these endpoints.
"""
from __future__ import annotations
import logging
import uuid
import xml.etree.ElementTree as ET
from typing import Any
from urllib.parse import quote, unquote, urlsplit
from fastapi import APIRouter, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.dav import auth
from app.dav.provider import Collection, DavError, DavProvider, Resource
from app.dav.xml import (
CR,
CS,
A,
BadRequest,
C,
D,
Multistatus,
PropValue,
el,
error_body,
href,
mkcol_props,
parse,
propfind_request,
proppatch_request,
q,
report_request,
)
from app.db import session_scope
from app.models import Account, User
logger = logging.getLogger(__name__)
router = APIRouter()
METHODS = [
"OPTIONS",
"PROPFIND",
"PROPPATCH",
"REPORT",
"GET",
"HEAD",
"PUT",
"DELETE",
"MKCALENDAR",
"MKCOL",
]
DAV_HEADER = "1, 3, calendar-access, addressbook, extended-mkcol"
XML_TYPE = "application/xml; charset=utf-8"
HOMES = {"calendars": "calendar", "addressbooks": "addressbook"}
SYNC_PREFIX = "http://ultimo.email/ns/sync/"
def _providers() -> dict[str, DavProvider]:
from app.plugins import loader
out: dict[str, DavProvider] = {}
for plugin in loader.get_plugins():
if plugin.dav is not None:
out[plugin.dav.kind] = plugin.dav
return out
# ---- entry points ------------------------------------------------------------------
@router.api_route("/.well-known/caldav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
@router.api_route("/.well-known/carddav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
async def well_known() -> Response:
return RedirectResponse("/dav/", status_code=301)
@router.api_route("/dav", methods=METHODS)
@router.api_route("/dav/{path:path}", methods=METHODS)
async def dav(request: Request, path: str = "") -> Response:
method = request.method.upper()
if method == "OPTIONS":
return Response(
status_code=200,
headers={"DAV": DAV_HEADER, "Allow": ", ".join(METHODS), "Content-Length": "0"},
)
ip = request.client.host if request.client else ""
try:
user = await auth.authenticate(request.headers.get("authorization"), ip)
except auth.Throttled:
return Response(status_code=429, headers={"Retry-After": "900"})
if user is None:
return Response(
status_code=401,
headers={"WWW-Authenticate": 'Basic realm="Ultimo", charset="UTF-8"'},
)
parts = [unquote(p) for p in path.split("/") if p]
ctx = _Ctx(request, user, parts)
try:
return await ctx.handle(method)
except BadRequest as err:
return Response(status_code=400, content=str(err))
except DavError as err:
body = error_body(err.condition) if err.condition else b""
return Response(
status_code=err.status,
content=body,
media_type=XML_TYPE if body else None,
)
class _Ctx:
def __init__(self, request: Request, user: User, parts: list[str]) -> None:
self.request = request
self.user = user
self.uid = str(user.id)
self.parts = parts
self.providers = _providers()
# -- paths -----------------------------------------------------------------------
def principal(self) -> str:
return f"/dav/principals/{self.uid}/"
def home(self, segment: str) -> str:
return f"/dav/{segment}/{self.uid}/"
def coll_href(self, segment: str, coll_id: str) -> str:
return f"{self.home(segment)}{quote(coll_id, safe='')}/"
def res_href(self, segment: str, coll_id: str, name: str) -> str:
return f"{self.coll_href(segment, coll_id)}{quote(name, safe='')}"
# -- dispatch --------------------------------------------------------------------
async def handle(self, method: str) -> Response:
p = self.parts
if not p:
return await self.root(method)
if p[0] == "principals":
if len(p) != 2 or p[1] != self.uid:
raise DavError(404)
return await self.principal_req(method)
if p[0] in HOMES and len(p) >= 2:
if p[1] != self.uid:
raise DavError(403)
provider = self.providers.get(HOMES[p[0]])
if provider is None:
raise DavError(404)
if len(p) == 2:
return await self.home_req(method, p[0], provider)
if len(p) == 3:
return await self.collection_req(method, p[0], provider, p[2])
if len(p) == 4:
return await self.resource_req(method, p[0], provider, p[2], p[3])
raise DavError(404)
async def body(self) -> ET.Element | None:
return parse(await self.request.body())
def depth(self) -> int:
value = (self.request.headers.get("depth") or "1").strip().lower()
return 0 if value == "0" else 1
@staticmethod
def multistatus(ms: Multistatus) -> Response:
return Response(status_code=207, content=ms.to_bytes(), media_type=XML_TYPE)
async def respond_props(
self, items: list[tuple[str, dict[str, PropValue]]], method_root: ET.Element | None
) -> Response:
req = propfind_request(method_root)
ms = Multistatus()
for path, props in items:
if req.names_only:
ms.response(path, dict.fromkeys(props))
elif req.names is None:
ms.response(path, props)
else:
found = {n: props[n] for n in req.names if n in props}
missing = [n for n in req.names if n not in props]
ms.response(path, found, missing)
return self.multistatus(ms)
# -- root and principal ----------------------------------------------------------
def common(self) -> dict[str, PropValue]:
return {
q(D, "current-user-principal"): [href(self.principal())],
q(D, "principal-URL"): [href(self.principal())],
q(D, "owner"): [href(self.principal())],
}
async def root(self, method: str) -> Response:
if method != "PROPFIND":
return Response(status_code=200 if method in ("GET", "HEAD") else 405)
props = {**self.common(), q(D, "resourcetype"): [el(q(D, "collection"))]}
return await self.respond_props([("/dav/", props)], await self.body())
async def addresses(self) -> list[str]:
async with session_scope() as db:
emails = list(
await db.scalars(select(Account.email).where(Account.user_id == self.user.id))
)
out = [self.user.handle, *emails]
return list(dict.fromkeys(e.lower() for e in out))
async def principal_props(self) -> dict[str, PropValue]:
props = {
**self.common(),
q(D, "resourcetype"): [el(q(D, "principal")), el(q(D, "collection"))],
q(D, "displayname"): self.user.handle,
q(C, "calendar-user-address-set"): [
href(f"mailto:{a}") for a in await self.addresses()
],
q(C, "calendar-user-type"): "INDIVIDUAL",
q(D, "principal-collection-set"): [href("/dav/principals/")],
q(D, "supported-report-set"): [],
}
if "calendar" in self.providers:
props[q(C, "calendar-home-set")] = [href(self.home("calendars"))]
if "addressbook" in self.providers:
props[q(CR, "addressbook-home-set")] = [href(self.home("addressbooks"))]
return props
async def principal_req(self, method: str) -> Response:
if method == "PROPFIND":
return await self.respond_props(
[(self.principal(), await self.principal_props())], await self.body()
)
if method == "REPORT": # principal-property-search, expand-property: nothing to add
return self.multistatus(Multistatus())
return Response(status_code=405)
# -- homes -------------------------------------------------------------------------
def privileges(self, read_only: bool) -> list[ET.Element]:
names = ["read", "read-current-user-privilege-set"]
if not read_only:
names += ["write", "write-content", "write-properties", "bind", "unbind"]
return [el(q(D, "privilege"), children=[el(q(D, n))]) for n in names]
def reports(self, kind: str) -> list[ET.Element]:
ns, reports = (
(C, ["calendar-query", "calendar-multiget"])
if kind == "calendar"
else (CR, ["addressbook-query", "addressbook-multiget"])
)
items = [(ns, r) for r in reports] + [(D, "sync-collection")]
return [
el(q(D, "supported-report"), children=[el(q(D, "report"), children=[el(q(n, r))])])
for n, r in items
]
async def home_req(self, method: str, segment: str, provider: DavProvider) -> Response:
if method == "PROPFIND":
home_props: dict[str, PropValue] = {
**self.common(),
q(D, "resourcetype"): [el(q(D, "collection"))],
q(D, "displayname"): "Calendars" if provider.kind == "calendar" else "Contacts",
q(D, "current-user-privilege-set"): self.privileges(False),
}
items = [(self.home(segment), home_props)]
if self.depth():
for coll in await provider.collections(self.user.id):
items.append(
(self.coll_href(segment, coll.id), self.collection_props(provider, coll))
)
return await self.respond_props(items, await self.body())
if method == "REPORT":
return self.multistatus(Multistatus())
if method in ("GET", "HEAD"):
return Response(status_code=200)
return Response(status_code=405)
# -- collections -------------------------------------------------------------------
def collection_props(self, provider: DavProvider, coll: Collection) -> dict[str, PropValue]:
token = f"{SYNC_PREFIX}{coll.sync_token}"
props: dict[str, PropValue] = {
**self.common(),
q(D, "displayname"): coll.displayname,
q(D, "current-user-privilege-set"): self.privileges(coll.read_only),
q(D, "supported-report-set"): self.reports(provider.kind),
q(D, "sync-token"): token,
q(CS, "getctag"): str(coll.sync_token),
}
if provider.kind == "calendar":
props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(C, "calendar"))]
props[q(C, "supported-calendar-component-set")] = [
el(q(C, "comp"), attrs={"name": comp}) for comp in coll.components
]
props[q(C, "supported-calendar-data")] = [
el(q(C, "calendar-data"), attrs={"content-type": "text/calendar", "version": "2.0"})
]
props[q(C, "calendar-description")] = coll.description
if coll.color:
props[q(A, "calendar-color")] = coll.color
else:
props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(CR, "addressbook"))]
props[q(CR, "supported-address-data")] = [
el(
q(CR, "address-data-type"),
attrs={"content-type": "text/vcard", "version": "3.0"},
),
el(
q(CR, "address-data-type"),
attrs={"content-type": "text/vcard", "version": "4.0"},
),
]
props[q(CR, "addressbook-description")] = coll.description
return props
async def find(self, provider: DavProvider, coll_id: str) -> Collection:
for coll in await provider.collections(self.user.id):
if coll.id == coll_id:
return coll
raise DavError(404)
def resource_props(
self, provider: DavProvider, res: Resource, with_data: bool
) -> dict[str, PropValue]:
props: dict[str, PropValue] = {
q(D, "getetag"): f'"{res.etag}"',
q(D, "getcontenttype"): provider.content_type,
q(D, "resourcetype"): [],
}
if with_data and res.data is not None:
data_prop = (
q(C, "calendar-data") if provider.kind == "calendar" else q(CR, "address-data")
)
props[data_prop] = res.data
return props
async def collection_req(
self, method: str, segment: str, provider: DavProvider, coll_id: str
) -> Response:
if method == "MKCALENDAR" or method == "MKCOL":
return await self.make_collection(segment, provider, coll_id)
coll = await self.find(provider, coll_id)
if method == "PROPFIND":
items = [(self.coll_href(segment, coll.id), self.collection_props(provider, coll))]
if self.depth():
for res in await provider.resources(self.user.id, coll.id):
items.append(
(
self.res_href(segment, coll.id, res.name),
self.resource_props(provider, res, False),
)
)
return await self.respond_props(items, await self.body())
if method == "REPORT":
return await self.report(segment, provider, coll)
if method == "PROPPATCH":
return await self.proppatch(segment, provider, coll)
if method == "DELETE":
if coll.read_only:
raise DavError(403)
await provider.delete_collection(self.user.id, coll.id)
return Response(status_code=204)
if method in ("GET", "HEAD"):
return Response(status_code=200)
return Response(status_code=405)
async def make_collection(self, segment: str, provider: DavProvider, coll_id: str) -> Response:
if any(c.id == coll_id for c in await provider.collections(self.user.id)):
raise DavError(405) # exists already
root = await self.body()
props = mkcol_props(root)
rtype = props.get(q(D, "resourcetype"))
if self.request.method.upper() == "MKCOL" and (
rtype is None
or rtype.find(q(CR, "addressbook")) is None
or provider.kind != "addressbook"
):
raise DavError(403)
if self.request.method.upper() == "MKCALENDAR" and provider.kind != "calendar":
raise DavError(403)
name_el = props.get(q(D, "displayname"))
color_el = props.get(q(A, "calendar-color"))
await provider.create_collection(
self.user.id,
coll_id,
(name_el.text or "").strip() if name_el is not None else "",
(color_el.text or "").strip() if color_el is not None else None,
)
return Response(status_code=201)
async def proppatch(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
patch = proppatch_request(await self.body())
if coll.read_only:
raise DavError(403)
values: dict[str, Any] = {}
for name, value in patch.set.items():
text = (value.text or "").strip()
if name == q(D, "displayname"):
values["displayname"] = text
elif name == q(A, "calendar-color"):
values["color"] = text
elif name in (q(C, "calendar-description"), q(CR, "addressbook-description")):
values["description"] = text
if values:
await provider.update_collection(self.user.id, coll.id, **values)
# Properties not kept (calendar order, …) are accepted and ignored:
# clients treat a refusal as a failed sync.
ms = Multistatus()
ms.response(self.coll_href(segment, coll.id), dict.fromkeys([*patch.set, *patch.remove]))
return self.multistatus(ms)
async def report(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
root = await self.body()
if root is None:
raise BadRequest("empty report")
rep = report_request(root)
wants_data = rep.props is None or any(
p in (q(C, "calendar-data"), q(CR, "address-data")) for p in rep.props
)
ms = Multistatus()
def add(res: Resource) -> None:
props = self.resource_props(provider, res, wants_data)
if rep.props is None:
ms.response(self.res_href(segment, coll.id, res.name), props)
else:
found = {n: props[n] for n in rep.props if n in props}
missing = [n for n in rep.props if n not in props]
ms.response(self.res_href(segment, coll.id, res.name), found, missing)
if rep.kind in ("calendar-multiget", "addressbook-multiget"):
names = [_last_segment(h) for h in rep.hrefs]
got = {r.name: r for r in await provider.get(self.user.id, coll.id, names)}
for h, name in zip(rep.hrefs, names, strict=True):
if name in got:
add(got[name])
else:
ms.response(h, {}, status=404)
return self.multistatus(ms)
if rep.kind in ("calendar-query", "addressbook-query"):
names_q = (
await provider.query(self.user.id, coll.id, rep.start, rep.end)
if rep.kind == "calendar-query"
else None
)
for res in (
await provider.get(self.user.id, coll.id, names_q)
if wants_data
else (
await provider.resources(self.user.id, coll.id)
if names_q is None
else [
r
for r in await provider.resources(self.user.id, coll.id)
if r.name in set(names_q)
]
)
):
add(res)
return self.multistatus(ms)
# sync-collection (RFC 6578)
since = _token_number(rep.sync_token)
if rep.sync_token and since is None:
raise DavError(403, condition=q(D, "valid-sync-token"))
if since is None:
changed = None
deleted: list[str] = []
token = coll.sync_token
else:
changes = await provider.changes(self.user.id, coll.id, since)
if changes is None:
raise DavError(403, condition=q(D, "valid-sync-token"))
changed, deleted, token = changes.changed, changes.deleted, changes.sync_token
if changed is None:
resources = (
await provider.get(self.user.id, coll.id, None)
if wants_data
else await provider.resources(self.user.id, coll.id)
)
elif changed:
resources = (
await provider.get(self.user.id, coll.id, changed)
if wants_data
else [
r
for r in await provider.resources(self.user.id, coll.id)
if r.name in set(changed)
]
)
else:
resources = []
present = {r.name for r in resources}
for res in resources:
add(res)
for name in deleted:
if name not in present:
ms.response(self.res_href(segment, coll.id, name), {}, status=404)
ms.sync_token(f"{SYNC_PREFIX}{token}")
return self.multistatus(ms)
# -- resources ---------------------------------------------------------------------
async def resource_req(
self, method: str, segment: str, provider: DavProvider, coll_id: str, name: str
) -> Response:
coll = await self.find(provider, coll_id)
if method in ("GET", "HEAD"):
got = await provider.get(self.user.id, coll.id, [name])
if not got:
raise DavError(404)
res = got[0]
content = (res.data or "").encode() if method == "GET" else b""
return Response(
status_code=200,
content=content,
media_type=provider.content_type,
headers={"ETag": f'"{res.etag}"'},
)
if method == "PROPFIND":
got = await provider.get(self.user.id, coll.id, [name])
if not got:
raise DavError(404)
return await self.respond_props(
[
(
self.res_href(segment, coll.id, name),
self.resource_props(provider, got[0], True),
)
],
await self.body(),
)
if method == "PUT":
if coll.read_only:
raise DavError(403, condition=q(D, "need-privileges"))
data = (await self.request.body()).decode("utf-8", "replace")
etag, created = await provider.put(
self.user.id,
coll.id,
name,
data,
if_match=_etag_header(self.request.headers.get("if-match")),
if_none_match=(self.request.headers.get("if-none-match") or "").strip() == "*",
)
return Response(status_code=201 if created else 204, headers={"ETag": f'"{etag}"'})
if method == "DELETE":
if coll.read_only:
raise DavError(403)
await provider.delete(
self.user.id,
coll.id,
name,
if_match=_etag_header(self.request.headers.get("if-match")),
)
return Response(status_code=204)
return Response(status_code=405)
def _last_segment(h: str) -> str:
path = urlsplit(h).path
return unquote(path.rstrip("/").rsplit("/", 1)[-1])
def _token_number(token: str) -> int | None:
if not token:
return None
tail = token.rsplit("/", 1)[-1]
return int(tail) if tail.isdigit() else None
def _etag_header(value: str | None) -> str | None:
if not value:
return None
value = value.strip()
if value == "*":
return "*"
if value.startswith("W/"):
value = value[2:]
return value.strip('"')
__all__ = ["router", "uuid"]
+236
View File
@@ -0,0 +1,236 @@
"""WebDAV XML: reading request bodies, writing multistatus answers (RFC 4918,
4791, 6352, 6578). Properties are ``{namespace}name`` strings, as ElementTree
names them."""
from __future__ import annotations
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import UTC, datetime
from defusedxml import ElementTree as SafeET
D = "DAV:"
C = "urn:ietf:params:xml:ns:caldav"
CR = "urn:ietf:params:xml:ns:carddav"
CS = "http://calendarserver.org/ns/"
A = "http://apple.com/ns/ical/"
for _prefix, _ns in (("d", D), ("cal", C), ("card", CR), ("cs", CS), ("ical", A)):
ET.register_namespace(_prefix, _ns)
def q(ns: str, name: str) -> str:
return f"{{{ns}}}{name}"
class BadRequest(Exception):
pass
def parse(body: bytes) -> ET.Element | None:
if not body.strip():
return None
try:
root = SafeET.fromstring(body)
except Exception as err: # noqa: BLE001 - any malformed XML is the client's error
raise BadRequest(str(err)) from err
return root # type: ignore[no-any-return]
@dataclass
class PropRequest:
"""PROPFIND: which properties (None = all)."""
names: list[str] | None = None
names_only: bool = False
def propfind_request(root: ET.Element | None) -> PropRequest:
if root is None or root.find(q(D, "allprop")) is not None:
return PropRequest()
if root.find(q(D, "propname")) is not None:
return PropRequest(names_only=True)
prop = root.find(q(D, "prop"))
if prop is None:
return PropRequest()
return PropRequest(names=[child.tag for child in prop])
def requested_props(root: ET.Element) -> list[str] | None:
prop = root.find(q(D, "prop"))
if prop is None:
return None
return [child.tag for child in prop]
@dataclass
class Report:
# calendar-query | calendar-multiget | addressbook-query | addressbook-multiget
# | sync-collection
kind: str
props: list[str] | None
hrefs: list[str] = field(default_factory=list)
start: datetime | None = None
end: datetime | None = None
sync_token: str = ""
limit: int | None = None
def _ical_time(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
except ValueError:
return None
def report_request(root: ET.Element) -> Report:
tag = root.tag
names = {
q(C, "calendar-query"): "calendar-query",
q(C, "calendar-multiget"): "calendar-multiget",
q(CR, "addressbook-query"): "addressbook-query",
q(CR, "addressbook-multiget"): "addressbook-multiget",
q(D, "sync-collection"): "sync-collection",
}
kind = names.get(tag)
if kind is None:
raise BadRequest(f"unsupported report {tag}")
rep = Report(kind=kind, props=requested_props(root))
rep.hrefs = [(h.text or "").strip() for h in root.findall(q(D, "href"))]
if kind == "calendar-query":
tr = root.find(f".//{q(C, 'time-range')}")
if tr is not None:
rep.start = _ical_time(tr.get("start"))
rep.end = _ical_time(tr.get("end"))
if kind == "sync-collection":
rep.sync_token = (root.findtext(q(D, "sync-token")) or "").strip()
nresults = root.find(f"{q(D, 'limit')}/{q(D, 'nresults')}")
if nresults is not None and (nresults.text or "").strip().isdigit():
rep.limit = int((nresults.text or "").strip())
return rep
@dataclass
class PropPatch:
set: dict[str, ET.Element] = field(default_factory=dict)
remove: list[str] = field(default_factory=list)
def proppatch_request(root: ET.Element | None) -> PropPatch:
out = PropPatch()
if root is None:
return out
for action in root:
prop = action.find(q(D, "prop"))
if prop is None:
continue
for child in prop:
if action.tag == q(D, "set"):
out.set[child.tag] = child
elif action.tag == q(D, "remove"):
out.remove.append(child.tag)
return out
def mkcol_props(root: ET.Element | None) -> dict[str, ET.Element]:
"""MKCALENDAR / extended MKCOL: the properties to set on the new collection."""
if root is None:
return {}
out: dict[str, ET.Element] = {}
for sett in root.findall(q(D, "set")):
prop = sett.find(q(D, "prop"))
if prop is not None:
for child in prop:
out[child.tag] = child
return out
# ---- answers --------------------------------------------------------------------------
# A property's value: text, a list of child elements, or an element builder.
PropValue = str | list[ET.Element] | None
class Multistatus:
def __init__(self) -> None:
self.root = ET.Element(q(D, "multistatus"))
def response(
self,
href: str,
found: dict[str, PropValue],
missing: list[str] | None = None,
status: int | None = None,
) -> None:
resp = ET.SubElement(self.root, q(D, "response"))
ET.SubElement(resp, q(D, "href")).text = href
if status is not None:
ET.SubElement(resp, q(D, "status")).text = _status_line(status)
return
if found:
ps = ET.SubElement(resp, q(D, "propstat"))
prop = ET.SubElement(ps, q(D, "prop"))
for name, value in found.items():
el = ET.SubElement(prop, name)
if isinstance(value, str):
el.text = value
elif isinstance(value, list):
el.extend(value)
ET.SubElement(ps, q(D, "status")).text = _status_line(200)
if missing:
ps = ET.SubElement(resp, q(D, "propstat"))
prop = ET.SubElement(ps, q(D, "prop"))
for name in missing:
ET.SubElement(prop, name)
ET.SubElement(ps, q(D, "status")).text = _status_line(404)
def sync_token(self, token: str) -> None:
ET.SubElement(self.root, q(D, "sync-token")).text = token
def to_bytes(self) -> bytes:
body: bytes = ET.tostring(self.root, encoding="utf-8")
return b'<?xml version="1.0" encoding="utf-8"?>\n' + body
def el(
tag: str,
text: str | None = None,
children: list[ET.Element] | None = None,
attrs: dict[str, str] | None = None,
) -> ET.Element:
e = ET.Element(tag, attrs or {})
if text is not None:
e.text = text
if children:
e.extend(children)
return e
def href(path: str) -> ET.Element:
return el(q(D, "href"), path)
def error_body(condition: str) -> bytes:
root = ET.Element(q(D, "error"))
ET.SubElement(root, condition)
return b'<?xml version="1.0" encoding="utf-8"?>\n' + bytes(ET.tostring(root, encoding="utf-8"))
_REASONS = {
200: "OK",
201: "Created",
204: "No Content",
207: "Multi-Status",
403: "Forbidden",
404: "Not Found",
409: "Conflict",
412: "Precondition Failed",
507: "Insufficient Storage",
}
def _status_line(code: int) -> str:
return f"HTTP/1.1 {code} {_REASONS.get(code, '')}".rstrip()
+34
View File
@@ -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}"
+177
View File
@@ -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)
+154
View File
@@ -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
+323
View File
@@ -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()
+140
View File
@@ -0,0 +1,140 @@
"""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
over IMAP/SMTP, and a calendar or contacts app to the DAV server — never with
the account password (no 2FA code can be asked for there), always with one of
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.
With hosted mailboxes on, the mail server makes the password (``app_`` and 36
more letters and digits: its format names the credential) and checks it for
IMAP/SMTP itself; its argon2id hash is kept here too, for the DAV server, 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
import secrets
import string
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app import hosted
from app.identity.passwords import hash_password, verify_password
from app.models import User, UserAppPassword
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
NAME_MAX = 60
_ALPHABET = string.ascii_lowercase
def generation_key(user_id: uuid.UUID) -> str:
"""Redis counter bumped whenever the user's app passwords change: DAV
sign-ins cached under the old value stop counting (app/dav/auth.py)."""
return f"apppw:gen:{user_id}"
async def _changed(user_id: uuid.UUID) -> None:
from app.redis_client import get_redis
await get_redis().incr(generation_key(user_id))
class TooMany(Exception):
"""The user already has MAX_PER_USER app passwords."""
def generate() -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(LENGTH))
async def list_for(db: AsyncSession, user_id: uuid.UUID) -> list[UserAppPassword]:
rows = await db.scalars(
select(UserAppPassword)
.where(UserAppPassword.user_id == user_id)
.order_by(UserAppPassword.created_at.desc())
)
return list(rows)
async def create(db: AsyncSession, user_id: uuid.UUID, name: str) -> tuple[UserAppPassword, str]:
"""A new app password named ``name``; returns the row and the password
itself, which is never available again."""
count = await db.scalar(
select(func.count()).select_from(UserAppPassword).where(UserAppPassword.user_id == user_id)
)
if (count or 0) >= MAX_PER_USER:
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()
row = UserAppPassword(
user_id=user_id, name=name, password_hash=hash_password(password), server_id=server_id
)
db.add(row)
await db.commit()
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:
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(
delete(UserAppPassword).where(
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
)
)
await db.commit()
await _changed(user_id)
return bool(result.rowcount) # type: ignore[attr-defined]
async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
"""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.commit()
await _changed(user_id)
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
sign-in and for tests; never for the webmail's own sign-in."""
if len(password) not in (LENGTH, SERVER_LENGTH):
return None
for row in await list_for(db, user_id):
if verify_password(row.password_hash, password):
return row
return None
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

+240
View File
@@ -0,0 +1,240 @@
"""Invitations: a user sends a friend a single-use sign-up link.
While the administrator has invitations switched on, every user may send
``PER_USER`` of them. A link lets exactly one account be created — even with
public registration closed — and works for ``VALID_FOR``; one that ran out
unused is handed back to its sender. Only a hash of the link's token is kept.
The email is built from the administrator's template (or the built-in one)
with these placeholders, written ``{{name}}``:
invitee_name the friend's name, as the sender typed it
inviter_name the sender's name (their mail account's display name,
else their address)
invite_link the sign-up address itself
invite_button a ready-made button linking to it
logo the Ultimo Email logo
Names are HTML-escaped where they go into the HTML: they come from users and
end up in mail to strangers.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import html as html_lib
import re
import secrets
import uuid
from dataclasses import dataclass
import nh3
from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.identity.mailer import LOGO_HTML
from app.models import Account, AppSettings, Invitation, User
from app.outbox.inline_images import InlinePart, data_image
PER_USER = 2
VALID_FOR = dt.timedelta(days=14)
MAX_TEMPLATE_BYTES = 500 * 1024
PLACEHOLDERS = ("invitee_name", "inviter_name", "invite_link", "invite_button", "logo")
_PLACEHOLDER = re.compile(r"\{\{\s*([a-z_]+)\s*\}\}")
_DATA_SRC = re.compile(r"""(\bsrc\s*=\s*)(["'])(data:image/[^"']+)\2""", re.IGNORECASE)
DEFAULT_SUBJECT = "{{inviter_name}} invites you to Ultimo Email"
_BODY = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5"
_MUTED = "font-size:13px;color:#6b7280"
_BUTTON = (
"display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;"
"padding:10px 18px;border-radius:8px;font-weight:bold"
)
DEFAULT_TEMPLATE = f"""<!doctype html>
<html><body style="{_BODY}">
{{{{logo}}}}
<p>Hi {{{{invitee_name}}}},</p>
<p><b>{{{{inviter_name}}}}</b> invites you to Ultimo Email — private email,
calendar and contacts in one place.</p>
<p>{{{{invite_button}}}}</p>
<p style="{_MUTED}">Or paste this address into your browser:<br>{{{{invite_link}}}}</p>
<p style="{_MUTED}">The invitation works once and expires in 14 days. If you
weren't expecting it, you can ignore this email.</p>
</body></html>
"""
class InvitationError(Exception):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
@dataclass(frozen=True)
class RenderedMail:
subject: str
text: str
html: str
inline: list[InlinePart]
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def sender_address(row: AppSettings) -> str:
"""The invitation's From. Empty: the system-mail From address, the one the
SMTP account is known to be allowed to send as."""
return row.invite_from.strip() or row.smtp_from
def template_problem(html: str) -> str | None:
"""Why ``html`` cannot be used as the invitation template, or None."""
if len(html.encode()) > MAX_TEMPLATE_BYTES:
return f"the template is larger than {MAX_TEMPLATE_BYTES // 1024} KB"
used = set(_PLACEHOLDER.findall(html))
unknown = sorted(used - set(PLACEHOLDERS))
if unknown:
return "unknown placeholder: " + ", ".join("{{" + u + "}}" for u in unknown)
if not used & {"invite_link", "invite_button"}:
return "the template must contain {{invite_link}} or {{invite_button}}"
return None
def render(row: AppSettings, *, invitee_name: str, inviter_name: str, link: str) -> RenderedMail:
"""The invitation email for one friend, from the configured template."""
template = row.invite_template or DEFAULT_TEMPLATE
subject_template = row.invite_subject or DEFAULT_SUBJECT
esc_link = html_lib.escape(link, quote=True)
html_values = {
"invitee_name": html_lib.escape(invitee_name),
"inviter_name": html_lib.escape(inviter_name),
"invite_link": f'<a href="{esc_link}">{esc_link}</a>',
"invite_button": f'<a href="{esc_link}" style="{_BUTTON}">Accept the invitation</a>',
"logo": LOGO_HTML,
}
text_values = {
"invitee_name": invitee_name,
"inviter_name": inviter_name,
"invite_link": link,
"invite_button": link,
"logo": "",
}
def fill(source: str, values: dict[str, str]) -> str:
return _PLACEHOLDER.sub(lambda m: values.get(m.group(1), m.group(0)), source)
# An image written into the template itself (data:) goes as an inline part:
# Gmail does not show data: images.
inline: list[InlinePart] = []
def to_cid(match: re.Match[str]) -> str:
part = data_image(match.group(3), get_settings().app_domain)
if part is None:
return match.group(0)
inline.append(part)
return f"{match.group(1)}{match.group(2)}cid:{part.cid}{match.group(2)}"
html = _DATA_SRC.sub(to_cid, fill(template, html_values))
text = fill(template, text_values)
# Keep the paragraphs and line breaks the HTML shows once its tags are gone.
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"</(?:p|div|h[1-6]|li|tr|table)\s*>", "\n\n", text, flags=re.IGNORECASE)
text = nh3.clean(text, tags=set(), attributes={}, strip_comments=True)
text = html_lib.unescape(re.sub(r"\n\s*\n\s*", "\n\n", text)).strip() + "\n"
subject = " ".join(fill(subject_template, text_values).split())
return RenderedMail(subject=subject, text=text, html=html, inline=inline)
async def inviter_name(db: AsyncSession, user: User) -> str:
"""How the friend is told who invited them."""
names = (
await db.execute(
select(Account.email, Account.display_name).where(Account.user_id == user.id)
)
).all()
own = next((n for e, n in names if e.lower() == user.handle.lower() and n), None)
return own or next((n for _, n in names if n), None) or user.handle
def _counts(now: dt.datetime): # type: ignore[no-untyped-def]
# Used, or still valid: an invitation that expired unused is given back.
return or_(Invitation.used_at.isnot(None), Invitation.expires_at > now)
async def remaining(db: AsyncSession, user_id: uuid.UUID) -> int:
now = dt.datetime.now(dt.UTC)
taken = await db.scalar(
select(func.count())
.select_from(Invitation)
.where(Invitation.inviter_id == user_id, _counts(now))
)
return max(0, PER_USER - int(taken or 0))
async def sent(db: AsyncSession, user_id: uuid.UUID) -> list[Invitation]:
return list(
await db.scalars(
select(Invitation)
.where(Invitation.inviter_id == user_id)
.order_by(Invitation.created_at.desc())
)
)
async def create(db: AsyncSession, user: User, *, name: str, email: str) -> tuple[Invitation, str]:
"""A new invitation from ``user`` (not committed): the row and the token.
Raises :class:`InvitationError` when the user has none left."""
# One at a time per user, so two quick clicks cannot both take the last one.
await db.execute(select(User.id).where(User.id == user.id).with_for_update())
if await remaining(db, user.id) <= 0:
raise InvitationError("no_invitations_left", "you have no invitations left")
token = secrets.token_urlsafe(32)
invitation = Invitation(
inviter_id=user.id,
token_hash=hash_token(token),
invitee_name=name,
invitee_email=email,
expires_at=dt.datetime.now(dt.UTC) + VALID_FOR,
)
db.add(invitation)
await db.flush()
return invitation, token
async def find_valid(db: AsyncSession, token: str) -> Invitation | None:
"""The unused, unexpired invitation behind ``token``, without using it."""
invitation: Invitation | None = await db.scalar(
select(Invitation).where(
Invitation.token_hash == hash_token(token),
Invitation.used_at.is_(None),
Invitation.expires_at > dt.datetime.now(dt.UTC),
)
)
return invitation
async def claim(db: AsyncSession, token: str) -> uuid.UUID | None:
"""Use the invitation up (in the caller's transaction): its id, or None if
it is unknown, used or expired. Atomic — two sign-ups with one link cannot
both get it."""
return await db.scalar(
update(Invitation)
.where(
Invitation.token_hash == hash_token(token),
Invitation.used_at.is_(None),
Invitation.expires_at > dt.datetime.now(dt.UTC),
)
.values(used_at=func.now())
.returning(Invitation.id)
)
async def record_user(db: AsyncSession, invitation_id: uuid.UUID, user_id: uuid.UUID) -> None:
await db.execute(
update(Invitation).where(Invitation.id == invitation_id).values(used_by=user_id)
)
+51 -8
View File
@@ -1,6 +1,7 @@
"""Outgoing system mail (SPEC §6.1): mail the server sends on its own behalf. """Outgoing system mail (SPEC §6.1): mail the server sends on its own behalf.
Today that is only the password-reset link. It goes out through the SMTP Password-reset links, and invitations users send their friends
(app/identity/invitations.py). It goes out through the SMTP
account the administrator configured in the admin panel (``app_settings``), account the administrator configured in the admin panel (``app_settings``),
never through a user's own mail account — the whole point is that the user never through a user's own mail account — the whole point is that the user
cannot sign in. The administrator's server is trusted, so unlike user mail cannot sign in. The administrator's server is trusted, so unlike user mail
@@ -10,19 +11,26 @@ on the LAN).
from __future__ import annotations from __future__ import annotations
import base64
import html as html_lib import html as html_lib
import logging import logging
from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr, make_msgid from email.utils import formataddr, formatdate, make_msgid
from functools import cache from functools import cache
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
import aiosmtplib import aiosmtplib
from app.config import smtp_helo_name
from app.models import AppSettings from app.models import AppSettings
from app.security.crypto import decrypt_secret from app.security.crypto import decrypt_secret
if TYPE_CHECKING:
from app.outbox.inline_images import InlinePart
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SENDER_NAME = "Ultimo Email" SENDER_NAME = "Ultimo Email"
@@ -33,9 +41,9 @@ _TIMEOUT = 20.0
# because mail clients (Gmail first) do not display SVG. Rendered at twice the # because mail clients (Gmail first) do not display SVG. Rendered at twice the
# size it is shown at, for sharp high-DPI screens. # size it is shown at, for sharp high-DPI screens.
_LOGO_FILE = Path(__file__).parent / "assets" / "ultimo-logo.png" _LOGO_FILE = Path(__file__).parent / "assets" / "ultimo-logo.png"
_LOGO_CID = "ultimo-logo@ultimo.email" LOGO_CID = "ultimo-logo@ultimo.email"
LOGO_HTML = ( LOGO_HTML = (
f'<p style="text-align:center;margin:0 0 24px"><img src="cid:{_LOGO_CID}" ' f'<p style="text-align:center;margin:0 0 24px"><img src="cid:{LOGO_CID}" '
'width="180" height="47" alt="Ultimo Email" style="display:inline-block;border:0"></p>' 'width="180" height="47" alt="Ultimo Email" style="display:inline-block;border:0"></p>'
) )
@@ -45,6 +53,10 @@ def _logo() -> bytes:
return _LOGO_FILE.read_bytes() return _LOGO_FILE.read_bytes()
# For previews in the admin panel, which show the HTML without message parts.
LOGO_DATA_URL = "data:image/png;base64," + base64.b64encode(_LOGO_FILE.read_bytes()).decode()
class MailerError(Exception): class MailerError(Exception):
"""System mail is not configured, or the SMTP server refused the message.""" """System mail is not configured, or the SMTP server refused the message."""
@@ -76,19 +88,49 @@ def mailer_config(row: AppSettings) -> MailerConfig | None:
) )
async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None: async def send_mail(
cfg: MailerConfig,
to: str,
subject: str,
text: str,
html: str,
*,
sender: str | None = None,
reply_to: str | None = None,
inline: Sequence[InlinePart] = (),
) -> None:
"""Send one system message. ``sender`` replaces the configured From address
(the SMTP account must be allowed to send as it); ``inline`` are images the
HTML names by ``cid:``."""
msg = EmailMessage() msg = EmailMessage()
msg["From"] = formataddr((SENDER_NAME, cfg.sender)) msg["From"] = formataddr((SENDER_NAME, sender or cfg.sender))
msg["To"] = to msg["To"] = to
if reply_to:
msg["Reply-To"] = reply_to
msg["Subject"] = subject msg["Subject"] = subject
# RFC 5322 requires it, and mail without one scores as spam (Gmail).
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[-1]) msg["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[-1])
msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this
msg.set_content(text) msg.set_content(text)
msg.add_alternative(html, subtype="html") msg.add_alternative(html, subtype="html")
if f"cid:{_LOGO_CID}" in html: if f"cid:{LOGO_CID}" in html:
html_part = msg.get_body(("html",)) html_part = msg.get_body(("html",))
if isinstance(html_part, EmailMessage): if isinstance(html_part, EmailMessage):
html_part.add_related(_logo(), maintype="image", subtype="png", cid=f"<{_LOGO_CID}>") html_part.add_related(_logo(), maintype="image", subtype="png", cid=f"<{LOGO_CID}>")
if inline:
html_part = msg.get_body(("html",))
assert isinstance(html_part, EmailMessage)
for part in inline:
maintype, _, subtype = part.mime_type.partition("/")
html_part.add_related(
part.data,
maintype=maintype,
subtype=subtype,
cid=f"<{part.cid}>",
filename=part.filename,
disposition="inline",
)
try: try:
await aiosmtplib.send( await aiosmtplib.send(
msg, msg,
@@ -99,6 +141,7 @@ async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: s
use_tls=cfg.security == "ssl", use_tls=cfg.security == "ssl",
start_tls=cfg.security == "starttls", start_tls=cfg.security == "starttls",
timeout=_TIMEOUT, timeout=_TIMEOUT,
local_hostname=smtp_helo_name(),
) )
except (aiosmtplib.SMTPException, OSError) as exc: except (aiosmtplib.SMTPException, OSError) as exc:
raise MailerError(str(exc) or type(exc).__name__) from exc raise MailerError(str(exc) or type(exc).__name__) from exc
+145
View File
@@ -0,0 +1,145 @@
"""What a new account password must not be (sign-up and reset).
Length is bounded by the request schemas (8–128). On top of that a password is
refused when it is one of the passwords every guessing attack tries first, is
made of the user's own address, is one character repeated, or has turned up in
a known data breach. The breach check asks Have I Been Pwned's range API with
the first five hex digits of the password's SHA-1 only (k-anonymity: neither the
password nor its hash leaves the server) and is skipped, not failed, when that
service cannot be reached — a sign-up never hangs on someone else's outage.
Set ``PASSWORD_BREACH_CHECK=false`` to never ask it.
"""
from __future__ import annotations
import hashlib
import logging
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
MIN_LENGTH = 8
MAX_LENGTH = 128
_RANGE_API = "https://api.pwnedpasswords.com/range/"
_TIMEOUT = 3.0
# The front of every guessing list (and of every breach), lower-cased.
_COMMON = frozenset(
{
"password",
"password1",
"password12",
"password123",
"password1234",
"passw0rd",
"p@ssw0rd",
"p@ssword",
"12345678",
"123456789",
"1234567890",
"0123456789",
"87654321",
"11111111",
"00000000",
"12341234",
"qwertyui",
"qwerty123",
"qwerty1234",
"qwertyuiop",
"1q2w3e4r",
"1q2w3e4r5t",
"1qaz2wsx",
"zaq12wsx",
"asdfghjk",
"asdfasdf",
"iloveyou",
"iloveyou1",
"sunshine",
"princess",
"football",
"baseball",
"welcome1",
"welcome123",
"letmein1",
"trustno1",
"superman",
"batman123",
"dragon123",
"monkey123",
"abc12345",
"abcd1234",
"aa123456",
"qazwsxedc",
"changeme",
"changeme1",
"administrator",
"admin123",
"adminadmin",
"computer",
"internet",
"whatever",
"starwars",
"michelle",
"jennifer",
"charlie1",
"zaq1zaq1",
"haslo123",
"polska123",
"kochamcie",
"zaq1@wsx",
"qwerty12",
"1234qwer",
"q1w2e3r4",
}
)
class PasswordRejected(Exception):
"""The password may not be used; ``code`` and ``message`` say why."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
async def _breached(password: str) -> bool:
digest = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() # noqa: S324 - HIBP's format
prefix, suffix = digest[:5], digest[5:]
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.get(_RANGE_API + prefix, headers={"Add-Padding": "true"})
resp.raise_for_status()
except httpx.HTTPError as exc:
logger.warning("breached-password check skipped: %s", exc)
return False
for line in resp.text.splitlines():
candidate, _, count = line.partition(":")
if candidate.strip() == suffix and count.strip() not in ("", "0"): # padding rows say 0
return True
return False
async def check_new_password(password: str, handle: str) -> None:
"""Raise :class:`PasswordRejected` if ``password`` may not be used by ``handle``."""
lowered = password.lower()
if len(set(password)) <= 2:
raise PasswordRejected("weak_password", "this password is too easy to guess")
if lowered in _COMMON:
raise PasswordRejected("weak_password", "this is one of the most common passwords")
localpart = handle.split("@", 1)[0].lower()
if len(localpart) >= 3 and localpart in lowered:
# The name with a year or a few characters added ("jsmith2026") is the
# first thing anyone who knows the address will try.
rest = lowered.replace(localpart, "", 1)
if len(rest) < 4 or all(not c.isalpha() for c in rest):
raise PasswordRejected("weak_password", "the password must not be your address")
if get_settings().password_breach_check and await _breached(password):
raise PasswordRejected(
"breached_password",
"this password has appeared in a data breach — please choose another",
)
+132 -3
View File
@@ -6,18 +6,20 @@ 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, invitations, trusted_devices
from app.identity import totp as totp_mod from app.identity import totp as totp_mod
from app.identity import trusted_devices
from app.identity.cookies import ( from app.identity.cookies import (
clear_auth_cookies, clear_auth_cookies,
new_csrf_token, new_csrf_token,
@@ -25,6 +27,7 @@ from app.identity.cookies import (
) )
from app.identity.deps import CurrentSession, CurrentUser, require_csrf from app.identity.deps import CurrentSession, CurrentUser, require_csrf
from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail
from app.identity.password_policy import PasswordRejected, check_new_password
from app.identity.passwords import hash_password, verify_password from app.identity.passwords import hash_password, verify_password
from app.identity.reset import ( from app.identity.reset import (
consume_token, consume_token,
@@ -34,6 +37,11 @@ from app.identity.reset import (
request_allowed, request_allowed,
) )
from app.identity.schemas import ( from app.identity.schemas import (
AppPasswordCreatedOut,
AppPasswordIn,
AppPasswordOut,
InvitationCheckIn,
InvitationCheckOut,
LoginRequest, LoginRequest,
MessageOut, MessageOut,
PasswordResetCheckIn, PasswordResetCheckIn,
@@ -102,6 +110,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,
) )
@@ -109,26 +118,68 @@ def _user_out(user: User) -> UserOut:
async def registration(db: DbSession) -> RegistrationStatusOut: async def registration(db: DbSession) -> RegistrationStatusOut:
"""Whether signup is open. Unauthenticated: the signup page needs it to render.""" """Whether signup is open. Unauthenticated: the signup page needs it to render."""
status = await registration_status(db) status = await registration_status(db)
return RegistrationStatusOut(open=status.open, bootstrap=status.bootstrap) return RegistrationStatusOut(
open=status.open, bootstrap=status.bootstrap, domain=get_settings().app_domain
)
_REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403} _REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
@router.post("/invitation/check", response_model=InvitationCheckOut)
async def invitation_check(body: InvitationCheckIn, db: DbSession) -> InvitationCheckOut:
"""Whether an invitation link still works, and who it is from — for the
sign-up page. Does not use it up."""
if not (await app_settings(db)).invitations_enabled:
return InvitationCheckOut(valid=False)
invitation = await invitations.find_valid(db, body.token)
if invitation is None:
return InvitationCheckOut(valid=False)
inviter = await db.get(User, invitation.inviter_id)
return InvitationCheckOut(
valid=True,
invitee_name=invitation.invitee_name,
inviter_name=await invitations.inviter_name(db, inviter) if inviter else "",
)
@router.post("/register", response_model=UserOut) @router.post("/register", response_model=UserOut)
async def register( async def register(
body: RegisterRequest, request: Request, response: Response, db: DbSession body: RegisterRequest, request: Request, response: Response, db: DbSession
) -> UserOut: ) -> UserOut:
try:
await check_new_password(body.password, resolve_handle(body.localpart))
except PasswordRejected as exc:
raise_api_error(400, exc.code, exc.message)
invitation_id: uuid.UUID | None = None
if body.invite:
# Used up in this same transaction: if the sign-up fails (the address
# is taken, …) the rollback hands the invitation back.
if (await app_settings(db)).invitations_enabled:
invitation_id = await invitations.claim(db, body.invite)
if invitation_id is None:
raise_api_error(
400, "invalid_invitation", "this invitation has already been used or has expired"
)
try: try:
user = await register_user( user = await register_user(
db, db,
localpart=body.localpart, localpart=body.localpart,
password=body.password, password=body.password,
recovery_email=body.recovery_email, recovery_email=body.recovery_email,
invited=invitation_id is not None,
) )
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)
if invitation_id is not None:
await invitations.record_user(db, invitation_id, user.id)
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)
@@ -300,6 +351,11 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user = await db.get(User, user_id) if user_id else None user = await db.get(User, user_id) if user_id else None
if user is None: if user is None:
raise_api_error(400, "invalid_token", "this link is invalid or has expired") raise_api_error(400, "invalid_token", "this link is invalid or has expired")
# Before the 2FA code and the link are spent: a refused password costs neither.
try:
await check_new_password(body.password, user.handle)
except PasswordRejected as exc:
raise_api_error(400, exc.code, exc.message)
if user.totp_secret is not None: if user.totp_secret is not None:
if not body.totp: if not body.totp:
raise_api_error(401, "totp_required", "a 2FA code is required") raise_api_error(401, "totp_required", "a 2FA code is required")
@@ -312,12 +368,85 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user.password_hash = hash_password(body.password) user.password_hash = hash_password(body.password)
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.
try:
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)
return MessageOut() return MessageOut()
# --- 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:
from app.models import UserAppPassword
assert isinstance(row, UserAppPassword)
return AppPasswordOut(
id=str(row.id),
name=row.name,
created_at=row.created_at.isoformat(),
last_used_at=row.last_used_at.isoformat() if row.last_used_at else None,
)
@router.get("/app-passwords", response_model=list[AppPasswordOut])
async def list_app_passwords(user: CurrentUser, db: DbSession) -> list[AppPasswordOut]:
return [_app_password_out(r) for r in await app_passwords.list_for(db, user.id)]
@router.post(
"/app-passwords", response_model=AppPasswordCreatedOut, dependencies=[Depends(require_csrf)]
)
async def create_app_password(
body: AppPasswordIn, user: CurrentUser, db: DbSession
) -> AppPasswordCreatedOut:
"""A new password for one mail app. Takes the account password: an app
password opens the mailbox without a 2FA code."""
if not verify_password(user.password_hash, body.password):
raise_api_error(403, "invalid_password", "incorrect password")
if not body.name.strip():
raise_api_error(400, "invalid_name", "give the app password a name")
try:
row, password = await app_passwords.create(db, user.id, body.name)
except app_passwords.TooMany:
raise_api_error(
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)
@router.delete(
"/app-passwords/{app_password_id}",
response_model=MessageOut,
dependencies=[Depends(require_csrf)],
)
async def revoke_app_password(
app_password_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> MessageOut:
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")
return MessageOut()
@router.put("/recovery-email", response_model=UserOut, dependencies=[Depends(require_csrf)]) @router.put("/recovery-email", response_model=UserOut, dependencies=[Depends(require_csrf)])
async def set_recovery_email(body: RecoveryEmailIn, user: CurrentUser, db: DbSession) -> UserOut: async def set_recovery_email(body: RecoveryEmailIn, user: CurrentUser, db: DbSession) -> UserOut:
"""Add, change or remove the recovery address. Takes the password: whoever """Add, change or remove the recovery address. Takes the password: whoever
+71 -2
View File
@@ -10,8 +10,11 @@ from pydantic import BaseModel, Field
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
localpart: str = Field(min_length=3, max_length=30) localpart: str = Field(min_length=3, max_length=30)
password: str = Field(min_length=8, max_length=1024) password: str = Field(min_length=8, max_length=128)
recovery_email: str | None = None recovery_email: str | None = None
# A friend's invitation (app/identity/invitations.py): signs up even while
# public registration is closed, and is used up by it.
invite: str | None = Field(default=None, max_length=200)
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
@@ -49,7 +52,7 @@ class PasswordResetRequestIn(BaseModel):
class PasswordResetIn(BaseModel): class PasswordResetIn(BaseModel):
token: str = Field(min_length=1) token: str = Field(min_length=1)
password: str = Field(min_length=8, max_length=1024) password: str = Field(min_length=8, max_length=128)
totp: str | None = None # required when the account has 2FA enabled totp: str | None = None # required when the account has 2FA enabled
@@ -63,6 +66,23 @@ class PasswordResetCheckOut(BaseModel):
totp_required: bool = False totp_required: bool = False
class AppPasswordIn(BaseModel):
name: str = Field(min_length=1, max_length=60) # "Thunderbird on laptop"
password: str = Field(min_length=1, max_length=1024) # the account password, to confirm
class AppPasswordOut(BaseModel):
id: str
name: str
created_at: str
last_used_at: str | None = None
class AppPasswordCreatedOut(AppPasswordOut):
# The generated password itself: in this answer only, never again.
app_password: str
class RecoveryEmailIn(BaseModel): class RecoveryEmailIn(BaseModel):
password: str = Field(min_length=1, max_length=1024) password: str = Field(min_length=1, max_length=1024)
# None (or blank) removes the recovery address. # None (or blank) removes the recovery address.
@@ -76,6 +96,19 @@ 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 InvitationCheckIn(BaseModel):
token: str = Field(min_length=1, max_length=200)
class InvitationCheckOut(BaseModel):
valid: bool
invitee_name: str = ""
inviter_name: str = ""
class RegistrationStatusOut(BaseModel): class RegistrationStatusOut(BaseModel):
@@ -84,17 +117,23 @@ class RegistrationStatusOut(BaseModel):
open: bool open: bool
# True when the instance has no users yet, so signup creates the administrator. # True when the instance has no users yet, so signup creates the administrator.
bootstrap: bool bootstrap: bool
# The domain every handle lives under (localpart@domain), shown beside the
# handle field.
domain: str
class AdminSettingsOut(BaseModel): class AdminSettingsOut(BaseModel):
registration_open: bool registration_open: bool
default_quota_bytes: int default_quota_bytes: int
# Users may send friends sign-up invitations (app/identity/invitations.py).
invitations_enabled: bool
class AdminSettingsIn(BaseModel): class AdminSettingsIn(BaseModel):
"""Merge semantics: omitted fields are left as they are.""" """Merge semantics: omitted fields are left as they are."""
registration_open: bool | None = None registration_open: bool | None = None
invitations_enabled: bool | None = None
# Applies to users created after the change; existing mailboxes keep theirs. # Applies to users created after the change; existing mailboxes keep theirs.
default_quota_bytes: int | None = Field(default=None, gt=0) default_quota_bytes: int | None = Field(default=None, gt=0)
@@ -125,6 +164,36 @@ class AdminMailIn(BaseModel):
public_url: str | None = Field(default=None, max_length=500) public_url: str | None = Field(default=None, max_length=500)
class AdminInviteTemplateOut(BaseModel):
# Empty strings: the built-in subject / template / sender is used.
subject: str
html: str
from_address: str
default_subject: str
default_html: str
default_from: str
placeholders: list[str]
class AdminInviteTemplateIn(BaseModel):
"""Merge semantics: omitted fields are left as they are; an empty string
goes back to the built-in one."""
subject: str | None = Field(default=None, max_length=300)
html: str | None = None
from_address: str | None = Field(default=None, max_length=320)
class AdminInvitePreviewIn(BaseModel):
subject: str = Field(default="", max_length=300)
html: str = ""
class AdminInvitePreviewOut(BaseModel):
subject: str
html: str
class AdminMailTestIn(BaseModel): class AdminMailTestIn(BaseModel):
to: str = Field(min_length=3, max_length=320) to: str = Field(min_length=3, max_length=320)
+7 -2
View File
@@ -97,7 +97,12 @@ async def registration_status(db: AsyncSession) -> RegistrationStatus:
async def register_user( async def register_user(
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None db: AsyncSession,
*,
localpart: str,
password: str,
recovery_email: str | None,
invited: bool = False,
) -> User: ) -> User:
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness. """Create a new user. Raises :class:`RegistrationError` on validation/uniqueness.
@@ -114,7 +119,7 @@ async def register_user(
settings = await app_settings(db, for_update=True) settings = await app_settings(db, for_update=True)
bootstrap = not await _has_users(db) bootstrap = not await _has_users(db)
if not bootstrap and not settings.registration_open: if not bootstrap and not settings.registration_open and not invited:
raise RegistrationError("registration_closed", "registration is closed on this server") raise RegistrationError("registration_closed", "registration is closed on this server")
user = User( user = User(
+21 -2
View File
@@ -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.
+244
View File
@@ -0,0 +1,244 @@
"""Attachments, kept once: inside their message (SPEC App. D §D.6 item 1).
An attachment is not stored apart from its message any more — it was, which
kept most of a mailbox's bytes twice (once base64 in the raw message, once
decoded on its own). It is taken out of the raw message when wanted:
* :func:`load` — its bytes (downloads, zip, inline images, forwarding, thumbnails);
* :func:`file` — a file holding it, for streaming with HTTP Range (videos):
taken out into ``{user}/cache/`` and kept a day after its last use;
* :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.
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
import contextlib
import hashlib
import logging
import time
import uuid
from collections import defaultdict
from pathlib import Path
from app.indexer import blobstore, rawstore
from app.indexer.mime import load_message, part_payload
logger = logging.getLogger(__name__)
CACHE_KEEP_S = 24 * 3600 # a taken-out file is kept this long after its last use
CACHE_MAX_BYTES = 1024**3 # per user; the least recently used go first beyond it
# part_index of a row whose content is not in its message (kept as its own file).
NOT_IN_MESSAGE = -1
def _legacy(user_id: uuid.UUID, sha: str) -> Path | None:
"""The separate copy stored before attachments were kept once, if still there."""
path = blobstore.blob_path(user_id, sha)
return path if path.exists() else None
def load(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None) -> bytes:
"""An attachment's bytes. Raises FileNotFoundError when they cannot be had."""
legacy = _legacy(user_id, sha)
if legacy is not None:
return legacy.read_bytes()
raw = rawstore.locate(user_id, message_sha)
if part_index == NOT_IN_MESSAGE or raw is None:
raise FileNotFoundError(sha)
payload = part_payload(load_message(path=raw), part_index, sha)
if payload is None:
raise FileNotFoundError(sha)
return payload
def materialize(user_id: uuid.UUID, message_sha: str, sha: str, part_index: int | None) -> bool:
"""Make sure the attachment exists as a blob of its own — for a compose
attachment (forward, a draft reopened), which is read by its hash. False
when its bytes cannot be had."""
if blobstore.exists(user_id, sha):
return True
try:
blobstore.store(user_id, load(user_id, message_sha, sha, part_index))
except FileNotFoundError:
return False
return True
def _cache_dir(user_id: uuid.UUID) -> Path:
return blobstore.blob_path(user_id, "00").parent.parent / "cache"
def prune_cache(cache: Path) -> None:
"""Drop streaming copies past their keep time, and the oldest beyond the cap."""
files = [(p, p.stat()) for p in cache.iterdir() if p.is_file()]
now = time.time()
total = 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 > CACHE_KEEP_S or total > CACHE_MAX_BYTES:
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:
"""A file holding the attachment, to stream from. Raises FileNotFoundError."""
legacy = _legacy(user_id, sha)
if legacy is not None:
return legacy
cache = _cache_dir(user_id)
path = cache / sha
if path.exists():
path.touch() # used again: kept longer
return path
data = load(user_id, message_sha, sha, part_index)
cache.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(f".{uuid.uuid4().hex}.tmp")
tmp.write_bytes(data)
tmp.replace(path)
with contextlib.suppress(OSError): # a file removed meanwhile by another request
prune_cache(cache)
return path
def drop_cached(user_id: uuid.UUID) -> None:
"""Forget every taken-out file of a user (account removed)."""
cache = _cache_dir(user_id)
if cache.exists():
for path in cache.iterdir():
path.unlink(missing_ok=True)
# ---- removing the copies stored before ------------------------------------------------
# A file this recent may belong to work in progress (a sent message waiting
# for its Sent copy is referenced only by its job): left for the next pass.
_MIN_AGE_S = 3600
def _part_indexes(raw_path: Path) -> dict[str, int]:
"""Content hash → MIME part position, for every leaf part of a message."""
out: dict[str, int] = {}
for index, part in enumerate(load_message(path=raw_path).walk()):
if part.is_multipart():
continue
payload = part.get_payload(decode=True)
if isinstance(payload, bytes):
out.setdefault(hashlib.sha256(payload).hexdigest(), index)
return out
async def dedupe_existing(batch: int = 200, *, user_id: uuid.UUID | None = None) -> dict[str, int]:
"""Record where each older attachment sits in its message, and delete its
separate copy once it is known to be there. Idempotent; resumable."""
import asyncio
from sqlalchemy import select, update
from app.db import session_scope
from app.models import Attachment, ComposeAttachment, Message
stats = {"rows": 0, "found": 0, "not_found": 0, "files_removed": 0, "bytes_freed": 0}
while True:
async with session_scope() as db:
rows = (
await db.execute(
select(
Attachment.id,
Attachment.user_id,
Attachment.blob_sha256,
Message.blob_sha256,
)
.join(Message, Message.id == Attachment.message_id)
.where(
Attachment.part_index.is_(None),
*([Attachment.user_id == user_id] if user_id is not None else []),
)
.order_by(Attachment.message_id)
.limit(batch)
)
).all()
if not rows:
return stats
by_message: dict[tuple[uuid.UUID, str], list[tuple[uuid.UUID, str]]] = defaultdict(list)
for att_id, owner, sha, msg_sha in rows:
by_message[(owner, msg_sha)].append((att_id, sha))
located: dict[uuid.UUID, int] = {}
for (owner, msg_sha), atts in by_message.items():
raw = rawstore.locate(owner, msg_sha)
try:
indexes = await asyncio.to_thread(_part_indexes, raw) if raw is not None else {}
except Exception: # noqa: BLE001 - unreadable message: keep the copies
logger.warning("attachments: cannot parse %s", raw, exc_info=True)
indexes = {}
for att_id, sha in atts:
located[att_id] = indexes.get(sha, NOT_IN_MESSAGE)
async with session_scope() as db:
for att_id, index in located.items():
await db.execute(
update(Attachment).where(Attachment.id == att_id).values(part_index=index)
)
stats["rows"] += len(located)
stats["found"] += sum(1 for i in located.values() if i != NOT_IN_MESSAGE)
stats["not_found"] += sum(1 for i in located.values() if i == NOT_IN_MESSAGE)
# A separate copy goes only when every attachment row with that content
# is found in its message and nothing else uses the file.
candidates = {
(owner, sha)
for att_id, owner, sha, _ in rows
if located.get(att_id, NOT_IN_MESSAGE) != NOT_IN_MESSAGE
}
async with session_scope() as db:
for owner, sha in candidates:
path = blobstore.blob_path(owner, sha)
if not path.exists() or time.time() - path.stat().st_mtime < _MIN_AGE_S:
continue
in_use = (
await db.scalar(
select(Message.id)
.where(Message.user_id == owner, Message.blob_sha256 == sha)
.limit(1)
)
or await db.scalar(
select(ComposeAttachment.id)
.where(
ComposeAttachment.user_id == owner,
ComposeAttachment.blob_sha256 == sha,
)
.limit(1)
)
or await db.scalar(
select(Attachment.id)
.where(
Attachment.user_id == owner,
Attachment.blob_sha256 == sha,
(Attachment.part_index.is_(None))
| (Attachment.part_index == NOT_IN_MESSAGE),
)
.limit(1)
)
)
if in_use:
continue
size = path.stat().st_size
path.unlink(missing_ok=True)
stats["files_removed"] += 1
stats["bytes_freed"] += size
async def dedupe_existing_job(ctx: dict[str, object]) -> dict[str, int]:
"""arq job (bulk queue): :func:`dedupe_existing`, run once after an update."""
stats = await dedupe_existing()
logger.info("attachments kept once: %s", stats)
return stats
+217
View File
@@ -0,0 +1,217 @@
"""The nightly clean-up of stored files nothing uses any more.
Messages leave in many ways — Delete forever, Empty Trash, the Trash/Spam
retention purge, an expunge on the server, a folder re-synced — and none of
them removes the message's file: files are stored by content, so one file can
serve several rows (the same email in two folders, a forwarded file in a
draft), and removing it is only safe once nothing refers to it. This pass does
that for every user, whatever the way the rows went:
* uploads (compose attachments) no draft has referred to for a week — left by
a sent message (its draft is gone) or a composer closed without saving;
* message/attachment files no row refers to;
* attachment previews (thumbnails) of attachments that are gone;
* 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 message being stored, a sent message waiting for its Sent copy).
"""
from __future__ import annotations
import datetime as dt
import logging
import re
import time
import uuid
from pathlib import Path
from typing import Any
from sqlalchemy import delete, or_, select
from app.config import get_settings
from app.indexer import attachments as att_store
from app.indexer import rawstore
logger = logging.getLogger(__name__)
MIN_AGE_S = 3600
UPLOAD_KEEP = dt.timedelta(days=7)
_SHA = re.compile(r"^[0-9a-f]{64}$")
# ---- files a job still needs ---------------------------------------------------------
#
# A sent message whose Sent copy could not be filed yet is referenced only by
# its retrying job (app/outbox/jobs.py), for up to a day: held here meanwhile.
# Entries older than HOLD_MAX are ignored, in case a job died without letting go.
HOLD_MAX_S = 3 * 24 * 3600
def _hold_key(user_id: uuid.UUID | str) -> str:
return f"blob:held:{user_id}"
async def hold(user_id: uuid.UUID | str, sha: str) -> None:
from app.redis_client import get_redis
await get_redis().zadd(_hold_key(user_id), {sha: time.time()})
async def release(user_id: uuid.UUID | str, sha: str) -> None:
from app.redis_client import get_redis
await get_redis().zrem(_hold_key(user_id), sha)
async def _held(user_id: uuid.UUID) -> set[str]:
from app.redis_client import get_redis
redis = get_redis()
oldest = time.time() - HOLD_MAX_S
await redis.zremrangebyscore(_hold_key(user_id), "-inf", oldest)
return {
m.decode() if isinstance(m, bytes) else str(m)
for m in await redis.zrangebyscore(_hold_key(user_id), oldest, "+inf")
}
def _draft_upload_ids(payloads: list[dict[str, Any]]) -> set[uuid.UUID]:
out: set[uuid.UUID] = set()
for payload in payloads:
for value in payload.get("attachment_ids") or []:
try:
out.add(uuid.UUID(str(value)))
except ValueError:
continue
return out
def _old(path: Path, now: float) -> bool:
return now - path.stat().st_mtime >= MIN_AGE_S
async def sweep_user(user_id: uuid.UUID) -> dict[str, int]:
"""Clean one user's stored files; returns what was removed."""
from app.db import session_scope
from app.models import Account, Attachment, ComposeAttachment, Draft, Message
stats = {"uploads": 0, "files": 0, "bytes": 0, "thumbs": 0, "threads_repaired": 0}
async with session_scope() as db:
# Uploads no draft refers to, once a week old.
used = _draft_upload_ids(
list(await db.scalars(select(Draft.payload).where(Draft.user_id == user_id)))
)
stale = select(ComposeAttachment.id).where(
ComposeAttachment.user_id == user_id,
ComposeAttachment.created_at < dt.datetime.now(dt.UTC) - UPLOAD_KEEP,
)
if used:
stale = stale.where(ComposeAttachment.id.not_in(used))
gone = list(await db.scalars(stale))
if gone:
await db.execute(delete(ComposeAttachment).where(ComposeAttachment.id.in_(gone)))
stats["uploads"] = len(gone)
async with session_scope() as db:
# 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).
# 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(
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(
await db.scalars(
select(ComposeAttachment.blob_sha256).where(ComposeAttachment.user_id == user_id)
)
)
referenced |= set(
await db.scalars(
select(Attachment.blob_sha256).where(
Attachment.user_id == user_id,
or_(
Attachment.part_index.is_(None),
Attachment.part_index == att_store.NOT_IN_MESSAGE,
),
)
)
)
referenced |= await _held(user_id)
attachment_shas = set(
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)
if not root.is_dir():
return stats
now = time.time()
for path in root.glob("??/*"):
if not path.is_file() or not _SHA.match(path.name) or path.name in referenced:
continue
if not _old(path, now):
continue
size = path.stat().st_size
path.unlink(missing_ok=True)
stats["files"] += 1
stats["bytes"] += size
thumbs = root / "thumbs"
if thumbs.is_dir():
for path in thumbs.iterdir():
sha = path.name.split("-", 1)[0]
if (
path.is_file()
and _SHA.match(sha)
and sha not in attachment_shas
and _old(path, now)
):
path.unlink(missing_ok=True)
stats["thumbs"] += 1
cache = root / "cache"
if cache.is_dir():
att_store.prune_cache(cache)
rawstore.prune(user_id)
return stats
async def sweep(user_id: uuid.UUID | None = None) -> dict[str, int]:
""":func:`sweep_user` for one user, or every user."""
from app.db import session_scope
from app.models import User
async with session_scope() as db:
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, "threads_repaired": 0}
for uid in users:
try:
stats = await sweep_user(uid)
except Exception: # noqa: BLE001 - one user's trouble must not stop the rest
logger.warning("stored-file clean-up failed for user %s", uid, exc_info=True)
continue
total["users"] += 1
for key, value in stats.items():
total[key] += value
return total
async def sweep_job(ctx: dict[str, Any]) -> dict[str, int]:
"""arq cron job: the nightly :func:`sweep`."""
stats = await sweep()
logger.info("stored-file clean-up: %s", stats)
return stats
+25 -6
View File
@@ -19,12 +19,13 @@ 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.subscriptions import read_list_headers
from app.threading_.normalize import synthesize_message_id from app.threading_.normalize import synthesize_message_id
from app.threading_.threader import thread_message from app.threading_.threader import thread_message
@@ -118,6 +119,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 +136,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,8 +163,12 @@ 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)
list_headers = read_list_headers(remote.raw) # for the Subscriptions page
message_id_hdr = await _resolve_message_id( message_id_hdr = await _resolve_message_id(
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw
@@ -196,6 +209,9 @@ async def persist_message(
has_attachments=parsed.has_attachments, has_attachments=parsed.has_attachments,
snippet=parsed.snippet, snippet=parsed.snippet,
blob_sha256=sha, blob_sha256=sha,
list_id=list_headers.list_id,
list_unsubscribe=list_headers.unsubscribe,
list_unsubscribe_post=list_headers.one_click,
) )
session.add(message) session.add(message)
await session.flush() await session.flush()
@@ -213,11 +229,13 @@ async def persist_message(
kind="html_sanitized", kind="html_sanitized",
content=result.html, content=result.html,
sanitizer_v=result.sanitizer_v, sanitizer_v=result.sanitizer_v,
trackers_blocked=result.trackers_blocked,
) )
) )
# Attachments are not stored apart: they stay inside the raw blob, and are
# taken out of it when wanted (app/indexer/attachments.py).
for att in parsed.attachments: for att in parsed.attachments:
att_sha = blobstore.store(user_id, att.payload)
session.add( session.add(
Attachment( Attachment(
message_id=message.id, message_id=message.id,
@@ -226,7 +244,8 @@ async def persist_message(
mime_type=att.mime_type, mime_type=att.mime_type,
size_bytes=len(att.payload), size_bytes=len(att.payload),
content_id=att.content_id, content_id=att.content_id,
blob_sha256=att_sha, blob_sha256=blobstore.sha256_hex(att.payload),
part_index=att.part_index,
) )
) )
+40 -2
View File
@@ -41,6 +41,9 @@ class ParsedAttachment:
mime_type: str mime_type: str
content_id: str | None content_id: str | None
payload: bytes payload: bytes
# The part's position in ``msg.walk()``: where to find it again in the raw
# message, which is the only copy kept (attachments are not stored apart).
part_index: int = -1
@dataclass @dataclass
@@ -114,7 +117,7 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
# or inline resource if it has a filename, a Content-ID, or attachment disposition; # or inline resource if it has a filename, a Content-ID, or attachment disposition;
# the body text/plain and text/html parts have none of these and are skipped. # the body text/plain and text/html parts have none of these and are skipped.
out: list[ParsedAttachment] = [] out: list[ParsedAttachment] = []
for part in msg.walk(): for index, part in enumerate(msg.walk()):
if part.is_multipart() or not isinstance(part, EmailMessage): if part.is_multipart() or not isinstance(part, EmailMessage):
continue continue
cid = part.get("Content-ID") cid = part.get("Content-ID")
@@ -137,13 +140,14 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
mime_type=part.get_content_type(), mime_type=part.get_content_type(),
content_id=cid.strip("<>") if (inline and cid is not None) else None, content_id=cid.strip("<>") if (inline and cid is not None) else None,
payload=payload, payload=payload,
part_index=index,
) )
) )
# An invitation sent only as the text/calendar alternative (Outlook, often) has # An invitation sent only as the text/calendar alternative (Outlook, often) has
# no file to show: keep it as invite.ics, so the event can be seen, answered # no file to show: keep it as invite.ics, so the event can be seen, answered
# and downloaded like a Gmail one. Not when the message attaches its own .ics. # and downloaded like a Gmail one. Not when the message attaches its own .ics.
if not any(a.filename.lower().endswith(".ics") for a in out): if not any(a.filename.lower().endswith(".ics") for a in out):
for part in msg.walk(): for index, part in enumerate(msg.walk()):
if part.get_content_type() != "text/calendar" or part.get_filename(): if part.get_content_type() != "text/calendar" or part.get_filename():
continue continue
payload = part.get_payload(decode=True) payload = part.get_payload(decode=True)
@@ -154,6 +158,7 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
mime_type="text/calendar", mime_type="text/calendar",
content_id=None, content_id=None,
payload=payload, payload=payload,
part_index=index,
) )
) )
break break
@@ -215,3 +220,36 @@ def _parse_email(msg: EmailMessage) -> ParsedEmail:
html_body=_scrub(html_body) if html_body is not None else None, html_body=_scrub(html_body) if html_body is not None else None,
attachments=_extract_attachments(msg), attachments=_extract_attachments(msg),
) )
def load_message(
raw: bytes | None = None, path: str | os.PathLike[str] | None = None
) -> EmailMessage:
"""The parsed tree of a raw message, from memory or straight from its file."""
if path is not None:
with open(path, "rb") as handle:
msg = email.message_from_binary_file(handle, policy=default_policy)
else:
msg = email.message_from_bytes(raw or b"", policy=default_policy)
assert isinstance(msg, EmailMessage)
return msg
def part_payload(msg: EmailMessage, part_index: int | None, sha256: str) -> bytes | None:
"""The decoded bytes of the attachment whose content hashes to ``sha256``:
the part at ``part_index`` when that is it, else whichever part is (rows
stored before the index was kept). None when the message has no such part."""
import hashlib
parts = list(msg.walk())
if part_index is not None and 0 <= part_index < len(parts):
payload = parts[part_index].get_payload(decode=True)
if isinstance(payload, bytes) and hashlib.sha256(payload).hexdigest() == sha256:
return payload
for part in parts:
if part.is_multipart():
continue
payload = part.get_payload(decode=True)
if isinstance(payload, bytes) and hashlib.sha256(payload).hexdigest() == sha256:
return payload
return None
+170
View File
@@ -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()
+121 -2
View File
@@ -17,7 +17,16 @@ import nh3
# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a # Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a
# render re-sanitizes stale content from the raw blob (SPEC §10.4). # render re-sanitizes stale content from the raw blob (SPEC §10.4).
SANITIZER_VERSION = 4 # v5: this server's own inline-image URLs quoted in a message load from here.
# v6: links keep only http(s) and mailto (no data: pages).
# v7: tracking pixels are removed (and counted), even when images are allowed.
# v8: CSS sizes in other units (in, em, %, pt) no longer read as pixels — v7
# took 0.31in signature logos for trackers.
SANITIZER_VERSION = 8
# This server's inline-image URL, however it ended up written: "/api/…",
# "https://host/api/…", or mangled to "http:///api/…" by an earlier copy.
INTERNAL_CID = re.compile(r"/api/messages/([0-9a-fA-F-]{36})/cid/([^\"'?#\s<>]+)")
_ALLOWED_TAGS: set[str] = { _ALLOWED_TAGS: set[str] = {
# structure # structure
@@ -148,6 +157,94 @@ class SanitizeResult:
html: str html: str
has_remote_images: bool has_remote_images: bool
sanitizer_v: int sanitizer_v: int
trackers_blocked: int = 0
# --- Tracking pixels ---------------------------------------------------------------
# A remote image that is invisible (1×1, or hidden by CSS) has no use but to tell
# the sender the mail was opened, and by whom. A few well-known open-tracking URL
# shapes are caught whatever their size. Matching runs on the raw HTML before nh3:
# a miss only leaves an image in, and nh3 still cleans whatever remains.
_IMG_TAG = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
_ATTR = re.compile(r"""([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+)""")
_CSS_HIDDEN = re.compile(
r"display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?![.\d]*[1-9])",
re.IGNORECASE,
)
_TRACKER_URL = re.compile(
r"//[^/]*mailtrack\.io/"
r"|/track/open\b"
r"|/wf/open\b"
r"|/open\.(?:php|aspx|gif)\b"
r"|[?&]tracking[_-]?pixel=",
re.IGNORECASE,
)
_PX = re.compile(r"\s*(\d+(?:\.\d*)?|\.\d+)\s*(px)?\s*$", re.IGNORECASE)
def _px(value: str | None) -> float | None:
"""A size in pixels ("1", "1px"), or None for anything else: other units
(0.31in is a 30-pixel signature logo), auto, %, or malformed (".", "1.2.3")."""
if value is None:
return None
match = _PX.match(value)
return float(match.group(1)) if match else None
def _tiny(value: str | None) -> bool:
px = _px(value)
return px is not None and px <= 2
def _css(style: str) -> dict[str, str]:
"""An inline style's declarations, lower-cased, without !important."""
out: dict[str, str] = {}
for decl in style.split(";"):
name, _, value = decl.partition(":")
value = value.lower().replace("!important", "").strip()
if name.strip() and value:
out[name.strip().lower()] = value
return out
def is_tracking_pixel(tag: str) -> bool:
"""Whether an ``<img …>`` tag is a remote open-tracking pixel."""
attrs = {name.lower(): value.strip("\"'") for name, value in _ATTR.findall(tag)}
src = attrs.get("src", "").strip()
if not src.lower().startswith(("http:", "https:", "//")):
return False # inline (cid:/data:) images can't report anything
if _TRACKER_URL.search(src):
return True
style = attrs.get("style", "")
if _CSS_HIDDEN.search(style):
return True
# The size the browser draws: a style width/height overrides the attribute
# (and "auto", % or another unit is no tiny size); a max-width/max-height
# of a pixel or two caps it; height "auto" follows the width.
css = _css(style)
wide = _tiny(css["width"]) if "width" in css else _tiny(attrs.get("width"))
tall = _tiny(css["height"]) if "height" in css else _tiny(attrs.get("height"))
wide = wide or _tiny(css.get("max-width"))
tall = tall or _tiny(css.get("max-height"))
if css.get("height") == "auto":
tall = wide
return wide and tall
def strip_tracking_pixels(html: str) -> tuple[str, int]:
"""Remove tracking pixels from raw HTML; returns the HTML and how many."""
count = 0
def repl(match: re.Match[str]) -> str:
nonlocal count
if is_tracking_pixel(match.group(0)):
count += 1
return ""
return match.group(0)
return _IMG_TAG.sub(repl, html), count
def _sanitize_style(value: str, allow_remote: bool) -> str | None: def _sanitize_style(value: str, allow_remote: bool) -> str | None:
@@ -182,26 +279,47 @@ def _sanitize_style_elements(html: str, allow_remote: bool, state: dict[str, boo
def sanitize_html( def sanitize_html(
html: str, message_id: uuid.UUID, *, allow_remote_images: bool = False html: str,
message_id: uuid.UUID,
*,
allow_remote_images: bool = False,
block_trackers: bool = True,
) -> SanitizeResult: ) -> SanitizeResult:
"""Sanitize ``html`` for the message ``message_id``. """Sanitize ``html`` for the message ``message_id``.
Returns the safe HTML, whether any remote images were blocked, and the Returns the safe HTML, whether any remote images were blocked, and the
sanitizer version. Pass ``allow_remote_images=True`` after the user approves sanitizer version. Pass ``allow_remote_images=True`` after the user approves
a sender to render remote images and CSS backgrounds (SPEC §9.4). a sender to render remote images and CSS backgrounds (SPEC §9.4).
``block_trackers=False`` (Settings → Privacy) leaves tracking pixels in; they
are remote images like any other then.
""" """
state = {"has_remote": False} state = {"has_remote": False}
trackers = 0
if block_trackers:
html, trackers = strip_tracking_pixels(html)
def attribute_filter(tag: str, attr: str, value: str) -> str | None: def attribute_filter(tag: str, attr: str, value: str) -> str | None:
if attr == "style": if attr == "style":
return _sanitize_style(value, allow_remote_images) return _sanitize_style(value, allow_remote_images)
if tag == "a" and attr == "target": if tag == "a" and attr == "target":
return "_blank" return "_blank"
if attr == "href":
# A link goes to the web or to a mail address: data: (a page made
# up by the sender) and cid: are kept for inline images only.
scheme = value.strip().lower().split(":", 1)[0] if ":" in value else ""
return value if scheme in ("", "http", "https", "mailto") else None
if tag == "img" and attr == "src": if tag == "img" and attr == "src":
lowered = value.strip().lower() lowered = value.strip().lower()
if lowered.startswith("cid:"): if lowered.startswith("cid:"):
content_id = value.strip()[4:] content_id = value.strip()[4:]
return f"/api/messages/{message_id}/cid/{content_id}" return f"/api/messages/{message_id}/cid/{content_id}"
# This server's own inline-image URL, quoted from another message
# (by an earlier version, or another of the user's own mails) —
# possibly mangled to "http:///api/…", which a browser takes for a
# host named "api": load it from here, never look that host up.
internal = INTERNAL_CID.search(value)
if internal is not None and not lowered.startswith("data:"):
return f"/api/messages/{internal.group(1)}/cid/{internal.group(2)}"
if lowered.startswith(("http:", "https:")): if lowered.startswith(("http:", "https:")):
state["has_remote"] = True state["has_remote"] = True
return value if allow_remote_images else None # block by default (FR-23) return value if allow_remote_images else None # block by default (FR-23)
@@ -229,4 +347,5 @@ def sanitize_html(
html=cleaned, html=cleaned,
has_remote_images=state["has_remote"], has_remote_images=state["has_remote"],
sanitizer_v=SANITIZER_VERSION, sanitizer_v=SANITIZER_VERSION,
trackers_blocked=trackers,
) )
+22 -5
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import io import io
import logging import logging
import uuid import uuid
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from app.indexer import blobstore from app.indexer import blobstore
@@ -86,8 +87,21 @@ def _render(data: bytes, mime_type: str) -> bytes | None:
return out.getvalue() return out.getvalue()
def thumbnail(user_id: uuid.UUID, sha: str, mime_type: str) -> bytes | None: def made(user_id: uuid.UUID, sha: str) -> bool:
"""The thumbnail of a stored attachment, or None if it cannot have one. """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(
user_id: uuid.UUID,
sha: str,
mime_type: str,
size: int,
load: Callable[[], bytes],
) -> bytes | None:
"""The thumbnail of an attachment (content hash ``sha``, ``size`` bytes,
read with ``load`` only when it is not made yet), or None if it cannot
have one.
Blocking (decoding is CPU work): call it in a thread. Blocking (decoding is CPU work): call it in a thread.
""" """
@@ -97,11 +111,14 @@ def thumbnail(user_id: uuid.UUID, sha: str, mime_type: str) -> bytes | None:
none = _path(user_id, sha, ".none") none = _path(user_id, sha, ".none")
if none.exists() or not can_thumbnail(mime_type): if none.exists() or not can_thumbnail(mime_type):
return None return None
path = blobstore.blob_path(user_id, sha) if size > _MAX_INPUT_BYTES:
if not path.exists() or path.stat().st_size > _MAX_INPUT_BYTES:
return None return None
try: try:
thumb = _render(path.read_bytes(), mime_type) data = load()
except FileNotFoundError:
return None
try:
thumb = _render(data, mime_type)
except Exception as exc: # noqa: BLE001 - any undecodable file: no thumbnail except Exception as exc: # noqa: BLE001 - any undecodable file: no thumbnail
logger.info("no thumbnail for blob %s (%s): %s", sha, mime_type, exc) logger.info("no thumbnail for blob %s (%s): %s", sha, mime_type, exc)
thumb = None thumb = None
+4
View File
@@ -37,6 +37,10 @@ def create_app() -> FastAPI:
register_error_handlers(app) register_error_handlers(app)
from app.security.cross_site import CrossSiteGuard
app.add_middleware(CrossSiteGuard)
@app.get("/api/healthz") @app.get("/api/healthz")
async def healthz() -> dict[str, str]: async def healthz() -> dict[str, str]:
"""Liveness/readiness probe: checks DB and Redis (dev-setup §6).""" """Liveness/readiness probe: checks DB and Redis (dev-setup §6)."""
+151 -5
View File
@@ -87,6 +87,13 @@ class AppSettings(Base):
# take it (base64url, uncompressed P-256 point). # take it (base64url, uncompressed P-256 point).
vapid_public: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) vapid_public: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
vapid_private_ct: Mapped[bytes | None] = mapped_column(BYTEA) vapid_private_ct: Mapped[bytes | None] = mapped_column(BYTEA)
# Users may invite friends (app/identity/invitations.py); the invitation
# email's From address (empty: postmaster@APP_DOMAIN), subject and HTML
# template (empty: the built-in ones).
invitations_enabled: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
invite_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
invite_subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
invite_template: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
updated_at: Mapped[dt.datetime] = _now() updated_at: Mapped[dt.datetime] = _now()
@@ -131,6 +138,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 +172,10 @@ 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):
@@ -187,6 +199,12 @@ class Thread(Base):
has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) has_attachments: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
last_message_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) last_message_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
# Snooze: out of the Inbox until this time (NULL when not snoozed). A message
# that arrives after ``snoozed_at`` wakes it early; ``unsnoozed_at`` (when it
# last woke) puts it back at the top of the Inbox.
snoozed_until: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
snoozed_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
unsnoozed_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
class Message(Base): class Message(Base):
@@ -214,7 +232,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")
) )
@@ -251,6 +269,17 @@ class Message(Base):
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
body_indexed: Mapped[str | None] = mapped_column(TSVECTOR) body_indexed: Mapped[str | None] = mapped_column(TSVECTOR)
# Mailing-list headers, for the Subscriptions page. ``list_unsubscribe`` is
# NULL until read (a backfill job fills older mail), '' when there is none.
list_id: Mapped[str | None] = mapped_column(Text)
list_unsubscribe: Mapped[str | None] = mapped_column(Text)
# RFC 8058: the sender takes a one-click unsubscribe POST.
list_unsubscribe_post: Mapped[bool] = mapped_column(
nullable=False, server_default=text("false")
)
# The language of its text (app/translation.py), found the first time it is
# shown: NULL not looked at yet, '' could not tell.
content_language: Mapped[str | None] = mapped_column(Text)
class BodyPart(Base): class BodyPart(Base):
@@ -264,6 +293,8 @@ class BodyPart(Base):
content: Mapped[str] = mapped_column(Text, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False)
# SPEC §10.4: re-sanitize on render if the sanitizer version changed. # SPEC §10.4: re-sanitize on render if the sanitizer version changed.
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
# Tracking pixels taken out of this HTML (shown as "N trackers blocked").
trackers_blocked: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
class Attachment(Base): class Attachment(Base):
@@ -278,7 +309,11 @@ class Attachment(Base):
mime_type: Mapped[str] = mapped_column(Text, nullable=False) mime_type: Mapped[str] = mapped_column(Text, nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
content_id: Mapped[str | None] = mapped_column(Text) content_id: Mapped[str | None] = mapped_column(Text)
# The content's hash (names its thumbnail, checks what is taken out). The
# bytes themselves live only inside the message's raw blob, at MIME part
# ``part_index`` (NULL for rows stored before that was recorded).
blob_sha256: Mapped[str] = mapped_column(Text, nullable=False) blob_sha256: Mapped[str] = mapped_column(Text, nullable=False)
part_index: Mapped[int | None] = mapped_column(Integer)
class ComposeAttachment(Base): class ComposeAttachment(Base):
@@ -317,7 +352,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()
@@ -349,6 +384,117 @@ class SavedSearch(Base):
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true")) pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true"))
class UserAppPassword(Base):
"""A password for one mail app (IMAP/SMTP), revocable on its own (§D.5)."""
__tablename__ = "user_app_passwords"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(Text, nullable=False) # "Thunderbird on laptop"
password_hash: Mapped[str] = mapped_column(Text, nullable=False) # argon2id
created_at: Mapped[dt.datetime] = _now()
# Set by the mail server's sign-in, once there is one.
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 Invitation(Base):
"""A single-use link that lets one person sign up, sent by a user."""
__tablename__ = "invitations"
id: Mapped[uuid.UUID] = _uuid_pk()
inviter_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# sha256 of the token in the link; the token itself is only in the email.
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
invitee_name: Mapped[str] = mapped_column(Text, nullable=False)
invitee_email: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[dt.datetime] = _now()
expires_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
used_by: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
)
class SubscriptionState(Base):
"""What a user did about a bulk sender on the Subscriptions page."""
__tablename__ = "subscription_states"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
# The sender group: "list:<List-Id>" or "from:<address>".
key: Mapped[str] = mapped_column(Text, primary_key=True)
unsubscribed_at: Mapped[dt.datetime] = _now()
method: Mapped[str] = mapped_column(Text, nullable=False) # one_click | mailto | web
class MailTemplate(Base):
"""A saved reply, inserted from the compose window."""
__tablename__ = "mail_templates"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(Text, nullable=False)
subject: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
body_html: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
created_at: Mapped[dt.datetime] = _now()
updated_at: Mapped[dt.datetime] = _now()
class MessageTranslation(Base):
"""A message's HTML translated into a language (app/translation.py), kept so
it is translated once. ``images``: with remote images shown or not."""
__tablename__ = "message_translations"
message_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("messages.id", ondelete="CASCADE"), primary_key=True
)
target: Mapped[str] = mapped_column(Text, primary_key=True)
images: Mapped[bool] = mapped_column(primary_key=True)
source: Mapped[str] = mapped_column(Text, nullable=False)
html: Mapped[str] = mapped_column(Text, nullable=False)
sanitizer_v: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[dt.datetime] = _now()
class CompletionPhrase(Base):
"""Sentence completion (app/completion.py): after these words, the user
usually writes this — seen ``count`` times in their sent mail."""
__tablename__ = "completion_phrases"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
context: Mapped[str] = mapped_column(Text, primary_key=True) # normalized words
continuation: Mapped[str] = mapped_column(Text, primary_key=True) # as written
count: Mapped[int] = mapped_column(Integer, nullable=False)
class CompletionIndexState(Base):
"""When a user's completion index was last built."""
__tablename__ = "completion_index_state"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
built_at: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
class PushSubscription(Base): class PushSubscription(Base):
"""A browser or installed app to tell about new mail (Web Push).""" """A browser or installed app to tell about new mail (Web Push)."""
+10 -1
View File
@@ -17,6 +17,7 @@ from sqlalchemy import select
from app.db import session_scope from app.db import session_scope
from app.models import Account, Draft, Mailbox, Message from app.models import Account, Draft, Mailbox, Message
from app.outbox import inline_images
from app.outbox.attachments import load_attachments from app.outbox.attachments import load_attachments
from app.outbox.mime_build import build_mime from app.outbox.mime_build import build_mime
from app.sync.provider_factory import build_provider from app.sync.provider_factory import build_provider
@@ -68,7 +69,15 @@ async def push_draft_to_imap_job(ctx: dict[str, Any], draft_id: str) -> None:
else None else None
) )
attachments = await load_attachments(session, user_id, draft) attachments = await load_attachments(session, user_id, draft)
_, sent_raw, _, _ = build_mime(account, draft, reply_to, attachments) html, inline = await inline_images.embed(
session,
user_id,
str(draft.payload.get("html", "") or ""),
account.email.rsplit("@", 1)[-1],
)
_, sent_raw, _, _ = build_mime(
account, draft, reply_to, attachments, html=html, inline=inline
)
drafts_mb = await session.scalar( drafts_mb = await session.scalar(
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.special_use == "drafts") select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.special_use == "drafts")
) )
+153
View File
@@ -0,0 +1,153 @@
"""Inline images in an outgoing message's HTML.
A reply quotes the original as the reading pane shows it, so an image embedded
in the original (``cid:`` — a signature logo, a pasted screenshot) appears in
the quote as this server's own URL, ``/api/messages/{id}/cid/{content-id}``.
Sent as is, that URL means nothing to the recipient (and to anyone else,
including ourselves once the original is gone). Before sending, each such image
is put back into the message as an inline part with a new Content-ID — as any
mail program does — and one that can no longer be had is left out.
An image written into the HTML itself (``data:image/…;base64,…`` — a signature
logo pasted into the editor) goes the same way: Gmail and other mail programs
do not show ``data:`` images in mail they receive, so each becomes an inline
part too (one per distinct image).
"""
from __future__ import annotations
import asyncio
import base64
import binascii
import hashlib
import re
import uuid
from dataclasses import dataclass
from email.utils import make_msgid
from urllib.parse import unquote
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.indexer import attachments as att_store
from app.indexer import rawstore
from app.indexer.sanitize import INTERNAL_CID
from app.models import Attachment, Message
_DATA_IMG = re.compile(r"^data:(image/[a-z0-9.+-]+);base64,(.*)$", re.IGNORECASE | re.DOTALL)
_EXT = {"jpeg": "jpg", "svg+xml": "svg"}
_IMG = re.compile(r"<img\b[^>]*?\bsrc\s*=\s*([\"'])(.*?)\1[^>]*>", re.IGNORECASE | re.DOTALL)
@dataclass(frozen=True)
class InlinePart:
cid: str # without angle brackets
mime_type: str
filename: str
data: bytes
def data_image(src: str, domain: str) -> InlinePart | None:
"""The inline part for a ``data:image/…;base64`` URL, or None if it is not one."""
m = _DATA_IMG.match(src.strip())
if m is None:
return None
try:
data = base64.b64decode(re.sub(r"\s+", "", m.group(2)), validate=True)
except (binascii.Error, ValueError):
return None
if not data:
return None
mime_type = m.group(1).lower()
subtype = mime_type.split("/", 1)[1]
name = f"image.{_EXT.get(subtype, subtype)}"
return InlinePart(make_msgid(domain=domain)[1:-1], mime_type, name, data)
def internal_cid(src: str) -> tuple[uuid.UUID, str] | None:
"""The (message id, content id) an image URL of this server points at."""
value = src.strip()
if value.lower().startswith(("cid:", "data:")):
return None
m = INTERNAL_CID.search(value)
if m is None:
return None
try:
return uuid.UUID(m.group(1)), unquote(m.group(2))
except ValueError:
return None
async def embed(
session: AsyncSession, user_id: uuid.UUID, html: str, domain: str
) -> tuple[str, list[InlinePart]]:
"""``html`` with this server's image URLs turned into ``cid:`` references,
and the parts to send them in."""
parts: dict[tuple[uuid.UUID, str], InlinePart | None] = {}
# Images written into the HTML, by content: the same logo twice is one part.
data_parts: dict[str, InlinePart | None] = {}
def data_key(src: str) -> str:
return hashlib.sha256(src.strip().encode()).hexdigest()
for match in _IMG.finditer(html):
src = match.group(2)
if src.strip().lower().startswith("data:"):
key = data_key(src)
if key not in data_parts:
data_parts[key] = data_image(src, domain)
continue
target = internal_cid(src)
if target is None or target in parts:
continue
message_id, content_id = target
found = (
await session.execute(
select(Attachment, Message)
.join(Message, Message.id == Attachment.message_id)
.where(
Attachment.message_id == message_id,
Attachment.content_id == content_id,
Message.user_id == user_id,
)
.limit(1)
)
).first()
part: InlinePart | None = None
if found is not None:
att, message = found
try:
await rawstore.ensure(message) # a hosted mailbox's: fetched if need be
data = await asyncio.to_thread(
att_store.load, user_id, message.blob_sha256, att.blob_sha256, att.part_index
)
cid = make_msgid(domain=domain)[1:-1]
part = InlinePart(cid, att.mime_type, att.filename, data)
except FileNotFoundError:
part = None
parts[target] = part
def replace(match: re.Match[str]) -> str:
quote = match.group(1)
src = match.group(2)
if src.strip().lower().startswith("data:"):
data_part = data_parts.get(data_key(src))
if data_part is None:
return match.group(0) # not an image we can carry: left as it was
return match.group(0).replace(
f"src={quote}{src}{quote}", f"src={quote}cid:{data_part.cid}{quote}", 1
)
target = internal_cid(match.group(2))
if target is None:
return match.group(0)
part = parts.get(target)
if part is None:
return "" # gone: no broken image for the recipient
return match.group(0).replace(
f"src={quote}{match.group(2)}{quote}", f"src={quote}cid:{part.cid}{quote}", 1
)
out = _IMG.sub(replace, html) if parts or data_parts else html
quoted = [p for p in parts.values() if p is not None]
return out, quoted + [p for p in data_parts.values() if p is not None]
+34 -3
View File
@@ -14,15 +14,17 @@ from datetime import UTC, datetime, timedelta
from email.utils import getaddresses from email.utils import getaddresses
from typing import Any from typing import Any
from arq import Retry
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
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 blobstore, gc
from app.indexer.indexer import persist_message from app.indexer.indexer import persist_message
from app.models import Account, Draft, KnownRecipient, Mailbox, Message, Outbox from app.models import Account, Draft, KnownRecipient, Mailbox, Message, Outbox
from app.outbox import inline_images
from app.outbox.attachments import load_attachments from app.outbox.attachments import load_attachments
from app.outbox.mime_build import build_mime from app.outbox.mime_build import build_mime
from app.outbox.service import delete_mirrored_draft_row from app.outbox.service import delete_mirrored_draft_row
@@ -171,7 +173,21 @@ async def _send_failed(
async def file_sent_copy_job(ctx: dict[str, Any], account_id: str, user_id: str, sha: str) -> None: async def file_sent_copy_job(ctx: dict[str, Any], account_id: str, user_id: str, sha: str) -> None:
"""File a sent message's copy in Sent, after the send itself succeeded but """File a sent message's copy in Sent, after the send itself succeeded but
the IMAP server could not be reached. Retried (arq Retry) for ~24 hours.""" the IMAP server could not be reached. Retried (arq Retry) for ~24 hours.
Until then the copy's file is referenced only by this job: it is held from
the nightly clean-up (app/indexer/gc.py) and let go once filed or given up."""
try:
await _file_sent_copy(ctx, account_id, user_id, sha)
except Retry:
raise # tried again later: the file is still needed
except BaseException:
await gc.release(user_id, sha)
raise
await gc.release(user_id, sha)
async def _file_sent_copy(ctx: dict[str, Any], account_id: str, user_id: str, sha: str) -> None:
uid = uuid.UUID(user_id) uid = uuid.UUID(user_id)
async with session_scope() as session: async with session_scope() as session:
account = await session.get(Account, uuid.UUID(account_id)) account = await session.get(Account, uuid.UUID(account_id))
@@ -236,8 +252,16 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
return return
user_id = outbox.user_id user_id = outbox.user_id
attachments = await load_attachments(session, user_id, draft) attachments = await load_attachments(session, user_id, draft)
# Images quoted from the original travel inside the message, not as
# links to this server (app/outbox/inline_images.py).
html, inline = await inline_images.embed(
session,
user_id,
str(draft.payload.get("html", "") or ""),
account.email.rsplit("@", 1)[-1],
)
transmit_raw, sent_raw, envelope_from, envelope_to = build_mime( transmit_raw, sent_raw, envelope_from, envelope_to = build_mime(
account, draft, reply_to, attachments account, draft, reply_to, attachments, html=html, inline=inline
) )
sent_path = await session.scalar( sent_path = await session.scalar(
select(Mailbox.path).where( select(Mailbox.path).where(
@@ -271,6 +295,7 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("outbox %s sent; filing it in Sent failed, will retry: %s", oid, exc) logger.warning("outbox %s sent; filing it in Sent failed, will retry: %s", oid, exc)
sha = blobstore.store(user_id, sent_raw) sha = blobstore.store(user_id, sent_raw)
await gc.hold(user_id, sha) # until filed: only the job refers to it
await enqueue_job("file_sent_copy_job", str(account_obj.id), str(user_id), sha) await enqueue_job("file_sent_copy_job", str(account_obj.id), str(user_id), sha)
# Remove the mirrored Drafts-folder copy now that it has been sent. # Remove the mirrored Drafts-folder copy now that it has been sent.
if drafts_path and draft_imap_uid: if drafts_path and draft_imap_uid:
@@ -313,6 +338,12 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
# need to read the outbox row. payload to/cc are "Name <email>" strings. # need to read the outbox row. payload to/cc are "Name <email>" strings.
"to": _addr_dicts(draft.payload.get("to")), "to": _addr_dicts(draft.payload.get("to")),
"cc": _addr_dicts(draft.payload.get("cc")), "cc": _addr_dicts(draft.payload.get("cc")),
# What it answers: "reply" / "reply_all" / "forward" / "new", and the
# message (a reply says the user wanted that mail — antispam learns it).
"kind": draft.kind,
"reply_to_message_id": (
str(draft.reply_to_message_id) if draft.reply_to_message_id else None
),
}, },
) )
await bus.publish( await bus.publish(
+22 -1
View File
@@ -14,6 +14,7 @@ from email.utils import format_datetime, make_msgid
import nh3 import nh3
from app.models import Account, Draft, Message from app.models import Account, Draft, Message
from app.outbox.inline_images import InlinePart
def _html_to_text(html: str) -> str: def _html_to_text(html: str) -> str:
@@ -49,6 +50,9 @@ def build_mime(
draft: Draft, draft: Draft,
reply_to: Message | None, reply_to: Message | None,
attachments: list[tuple[str, str, bytes]] | None = None, attachments: list[tuple[str, str, bytes]] | None = None,
*,
html: str | None = None,
inline: list[InlinePart] | None = None,
) -> tuple[bytes, bytes, str, list[str]]: ) -> tuple[bytes, bytes, str, list[str]]:
"""Return ``(transmit_raw, sent_raw, envelope_from, envelope_to)`` for ``draft``. """Return ``(transmit_raw, sent_raw, envelope_from, envelope_to)`` for ``draft``.
@@ -59,13 +63,17 @@ def build_mime(
``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the ``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the
compose Attach action; each is added as a file attachment part. compose Attach action; each is added as a file attachment part.
``html`` replaces the draft's HTML when given (images already turned into
``cid:`` references by app/outbox/inline_images.py), and ``inline`` are the
image parts those references name, sent alongside the HTML.
""" """
payload = draft.payload payload = draft.payload
to = _addr_list(payload.get("to")) to = _addr_list(payload.get("to"))
cc = _addr_list(payload.get("cc")) cc = _addr_list(payload.get("cc"))
bcc = _addr_list(payload.get("bcc")) bcc = _addr_list(payload.get("bcc"))
subject = str(payload.get("subject", "")) subject = str(payload.get("subject", ""))
html = str(payload.get("html", "") or "") html = html if html is not None else str(payload.get("html", "") or "")
text = str(payload.get("text", "") or "") text = str(payload.get("text", "") or "")
msg = EmailMessage() msg = EmailMessage()
@@ -91,6 +99,19 @@ def build_mime(
if html: if html:
msg.set_content(text or _html_to_text(html)) msg.set_content(text or _html_to_text(html))
msg.add_alternative(html, subtype="html") msg.add_alternative(html, subtype="html")
if inline:
html_part = next(p for p in msg.walk() if p.get_content_type() == "text/html")
assert isinstance(html_part, EmailMessage)
for part in inline:
maintype, _, subtype = part.mime_type.partition("/")
html_part.add_related(
part.data,
maintype=maintype or "image",
subtype=subtype or "png",
cid=f"<{part.cid}>",
filename=part.filename,
disposition="inline",
)
else: else:
msg.set_content(text) msg.set_content(text)
+3 -5
View File
@@ -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
+1 -3
View File
@@ -21,9 +21,7 @@ class PluginHttp:
content: bytes | str | None = None, content: bytes | str | None = None,
auth: tuple[str, str] | None = None, auth: tuple[str, str] | None = None,
) -> GuardedResponse: ) -> GuardedResponse:
return await guarded_request( return await guarded_request(method, url, headers=headers, content=content, auth=auth)
method, url, headers=headers, content=content, auth=auth
)
async def get(self, url: str, **kw: object) -> GuardedResponse: async def get(self, url: str, **kw: object) -> GuardedResponse:
return await self.request("GET", url, **kw) # type: ignore[arg-type] return await self.request("GET", url, **kw) # type: ignore[arg-type]
+39
View File
@@ -179,6 +179,12 @@ class PluginSdk:
CalDAV/CardDAV/ICS. Returns status + headers + body.""" CalDAV/CardDAV/ICS. Returns status + headers + body."""
return PluginHttp() return PluginHttp()
# --- notifications ------------------------------------------------------
@property
def push(self) -> PluginPush:
"""Notifications on the user's devices (Web Push) — calendar reminders."""
return PluginPush(self.name)
# --- secret vault ------------------------------------------------------- # --- secret vault -------------------------------------------------------
@property @property
def vault(self) -> PluginVault: def vault(self) -> PluginVault:
@@ -187,6 +193,39 @@ class PluginSdk:
return PluginVault() return PluginVault()
class PluginPush:
"""``sdk.push``: show a notification on every device where the user turned
notifications on (Settings → Accounts → Notifications)."""
def __init__(self, plugin: str) -> None:
self._plugin = plugin
async def notify(
self,
user_id: uuid.UUID,
*,
title: str,
body: str = "",
url: str = "/mail",
tag: str | None = None,
sticky: bool = False,
) -> int:
"""Send one notification; returns how many devices it went to. ``tag``
replaces an earlier notification with the same tag (it is prefixed with
the plugin's name); ``sticky`` keeps it on screen until dismissed."""
from app.push.service import deliver
payload: dict[str, object] = {
"type": f"plugin.{self._plugin}",
"title": title[:200],
"body": body[:500],
"url": url if url.startswith("/") else "/mail",
"tag": f"{self._plugin}-{tag}" if tag else self._plugin,
"sticky": sticky,
}
return await deliver(user_id, [payload])
def sdk(name: str) -> PluginSdk: def sdk(name: str) -> PluginSdk:
"""Return the SDK facade bound to plugin ``name`` (SPEC §11).""" """Return the SDK facade bound to plugin ``name`` (SPEC §11)."""
return PluginSdk(name) return PluginSdk(name)
+3
View File
@@ -112,6 +112,9 @@ class Plugin:
# Core plugins (calendar, contacts) ship builtin=True: always installed, never # Core plugins (calendar, contacts) ship builtin=True: always installed, never
# offered for removal in the UI, and the uninstall path refuses --drop-data. # offered for removal in the UI, and the uninstall path refuses --drop-data.
builtin: bool = False builtin: bool = False
# Serves its collections to CalDAV / CardDAV clients (app/dav/provider.py
# DavProvider; SPEC Appendix E).
dav: Any = None
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not _NAME_RE.fullmatch(self.name): if not _NAME_RE.fullmatch(self.name):
+52 -6
View File
@@ -20,7 +20,7 @@ import aiosmtplib
from aioimaplib import aioimaplib from aioimaplib import aioimaplib
from app.concurrency import loop_semaphore from app.concurrency import loop_semaphore
from app.config import get_settings from app.config import get_settings, smtp_helo_name
from app.providers.base import ( from app.providers.base import (
FlagChange, FlagChange,
MailboxDelta, MailboxDelta,
@@ -122,6 +122,16 @@ def _date_criteria(since: date | None, before: date | None) -> str:
return " ".join(parts) if parts else "ALL" return " ".join(parts) if parts else "ALL"
async def _require_public(host: str) -> None:
"""Refuse a user-named server that now resolves to a non-public address."""
from app.accounts.ssrf import SsrfError, assert_host_is_public
try:
await assert_host_is_public(host)
except SsrfError as exc:
raise ProviderConnectError(f"refusing to connect to {host}: {exc}") from exc
@dataclass(frozen=True) @dataclass(frozen=True)
class ProviderConfig: class ProviderConfig:
imap_host: str imap_host: str
@@ -132,6 +142,16 @@ 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
# A server the user named (every external account): its name is checked
# again before each connection, so DNS changed after the account was added
# cannot turn the worker on the internal network (DNS rebinding).
public_only: bool = False
class ImapSmtpProvider(MailProvider): class ImapSmtpProvider(MailProvider):
@@ -169,8 +189,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:
@@ -187,13 +210,18 @@ class ImapSmtpProvider(MailProvider):
if self._imap is not None: if self._imap is not None:
return self._imap return self._imap
cfg = self._cfg cfg = self._cfg
if cfg.public_only:
await _require_public(cfg.imap_host)
await self._acquire_conn_slots() await self._acquire_conn_slots()
try: try:
try: try:
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 +384,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 +511,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:
@@ -519,9 +556,15 @@ class ImapSmtpProvider(MailProvider):
async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None: async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None:
cfg = self._cfg cfg = self._cfg
if cfg.public_only:
await _require_public(cfg.smtp_host)
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,7 +577,10 @@ 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,
# Our own name, not the container's random host name.
local_hostname=smtp_helo_name(),
) )
except aiosmtplib.SMTPException as exc: except aiosmtplib.SMTPException as exc:
raise _smtp_error(exc) from exc raise _smtp_error(exc) from exc
+20 -4
View File
@@ -200,6 +200,26 @@ async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
pushes were delivered to push services.""" pushes were delivered to push services."""
from app.db import session_scope from app.db import session_scope
async with session_scope() as db:
if not await db.scalar(
select(PushSubscription.id).where(PushSubscription.user_id == user_id).limit(1)
):
return 0
messages = await _news(db, user_id, message_ids)
if not messages:
return 0
payloads = _payloads(messages, await _unread(db, user_id))
return await deliver(user_id, payloads)
async def deliver(user_id: uuid.UUID, payloads: list[dict[str, object]]) -> int:
"""Push ``payloads`` to every device of the user that is still signed in
(for new mail, and for plugins: calendar reminders); returns how many were
handed to push services. Devices that are gone are forgotten."""
from app.db import session_scope
if not payloads:
return 0
async with session_scope() as db: async with session_scope() as db:
# Plain values: the rows expire when the key pair is first saved below. # Plain values: the rows expire when the key pair is first saved below.
subs = [ subs = [
@@ -210,10 +230,6 @@ async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
] ]
if not subs: if not subs:
return 0 return 0
messages = await _news(db, user_id, message_ids)
if not messages:
return 0
payloads = _payloads(messages, await _unread(db, user_id))
key = await vapid(db) key = await vapid(db)
subject = _subject(await db.get(AppSettings, 1)) subject = _subject(await db.get(AppSettings, 1))
+8 -1
View File
@@ -21,7 +21,14 @@ QUEUE_INTERACTIVE = "interactive"
QUEUE_BULK = "bulk" QUEUE_BULK = "bulk"
# Jobs default to the interactive queue; only bulk backfills go to the bulk queue. # Jobs default to the interactive queue; only bulk backfills go to the bulk queue.
_BULK_JOBS = frozenset({"sync_bootstrap_account"}) _BULK_JOBS = frozenset(
{
"sync_bootstrap_account",
"dedupe_existing_job",
"backfill_list_headers_job",
"build_index_job",
}
)
def queue_for(job_name: str) -> str: def queue_for(job_name: str) -> str:
+41 -13
View File
@@ -13,6 +13,7 @@ import base64
import datetime as dt import datetime as dt
import html import html
import re import re
from typing import Any
from uuid import UUID from uuid import UUID
from sqlalchemy import ( from sqlalchemy import (
@@ -20,6 +21,7 @@ from sqlalchemy import (
any_, any_,
case, case,
cast, cast,
distinct,
exists, exists,
func, func,
literal, literal,
@@ -32,7 +34,7 @@ from sqlalchemy import (
from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import ColumnElement
from app.db import get_sessionmaker from app.db import get_sessionmaker
from app.models import Attachment, Mailbox, Message from app.models import Attachment, Mailbox, Message, Thread
from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery
_TS_CONFIG = "simple" # language-neutral; avoids wrong-language stemming (M0) _TS_CONFIG = "simple" # language-neutral; avoids wrong-language stemming (M0)
@@ -52,6 +54,8 @@ def _prefix_tsquery(value: str) -> ColumnElement[object]:
lexemes = cast(func.plainto_tsquery(_TS_CONFIG, value), Text) lexemes = cast(func.plainto_tsquery(_TS_CONFIG, value), Text)
prefixed = func.regexp_replace(lexemes, r"'([^']+)'", r"'\1':*", "g") prefixed = func.regexp_replace(lexemes, r"'([^']+)'", r"'\1':*", "g")
return func.to_tsquery(_TS_CONFIG, prefixed) return func.to_tsquery(_TS_CONFIG, prefixed)
_RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365} _RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365}
@@ -254,20 +258,9 @@ class PgSearchBackend(SearchBackend):
.join(Mailbox, Mailbox.id == Message.mailbox_id) .join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == q.user_id, Message.thread_id.is_not(None)) .where(Message.user_id == q.user_id, Message.thread_id.is_not(None))
) )
for cond in conditions: for cond in self._scope(groups, conditions):
stmt = stmt.where(cond) stmt = stmt.where(cond)
# Exclude Trash/Junk from search by default (Gmail-style), so deleting a
# message from search results removes it. An explicit `in:trash`/`in:junk`
# overrides this to search there on purpose.
if not _targets_removal_folder(groups):
stmt = stmt.where(
or_(
Mailbox.special_use.is_(None),
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
)
)
decoded = _decode_cursor(cursor) decoded = _decode_cursor(cursor)
if decoded is not None: if decoded is not None:
cur_hit, cur_date, cur_id = decoded cur_hit, cur_date, cur_id = decoded
@@ -301,6 +294,41 @@ class PgSearchBackend(SearchBackend):
) )
return hits, next_cursor return hits, next_cursor
@staticmethod
def _scope(groups: list[list[dict[str, object]]], conditions: list[Any]) -> list[Any]:
"""The query's conditions, plus leaving out Trash/Junk by default
(Gmail-style), so deleting a message from search results removes it. An
explicit `in:trash`/`in:junk` overrides this to search there on purpose."""
out = list(conditions)
if not _targets_removal_folder(groups):
out.append(
or_(
Mailbox.special_use.is_(None),
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
)
)
return out
async def count_threads(self, q: SearchQuery, *, thread_filter: str | None = None) -> int:
"""How many conversations match (``thread_filter``: "unread" / "pinned"
keeps only conversations with unread mail / pinned ones)."""
groups = q.filters.get("groups", []) if isinstance(q.filters, dict) else []
assert isinstance(groups, list)
stmt = (
select(func.count(distinct(Message.thread_id)))
.select_from(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == q.user_id, Message.thread_id.is_not(None))
)
for cond in self._scope(groups, self._build_conditions(groups)):
stmt = stmt.where(cond)
if thread_filter in ("unread", "pinned"):
stmt = stmt.join(Thread, Thread.id == Message.thread_id).where(
Thread.unread_count > 0 if thread_filter == "unread" else Thread.pinned.is_(True)
)
async with get_sessionmaker()() as session:
return int(await session.scalar(stmt) or 0)
_REMOVAL_FOLDERS = ("trash", "junk") _REMOVAL_FOLDERS = ("trash", "junk")
+91
View File
@@ -0,0 +1,91 @@
"""Refuse state-changing API requests that another web site makes a browser send.
The double-submit CSRF token (app/identity/deps.py ``require_csrf``) guards
everything a signed-in user can change. The requests made before anyone is
signed in — sign-in itself, sign-up, a password reset — have no token yet, so a
hostile page could make a visitor's browser sign in to an account of the
attacker's choosing (login CSRF): whatever the victim then writes, files or
uploads lands in the attacker's account.
Browsers say where a request comes from: ``Sec-Fetch-Site`` in every current
browser, ``Origin`` on any cross-origin POST in older ones. A request another
site started is refused here, before any route runs. Mail apps, calendar apps
and servers send neither header and are not affected (DAV lives outside /api
anyway).
"""
from __future__ import annotations
import json
from urllib.parse import urlsplit
from starlette.datastructures import Headers
from starlette.types import ASGIApp, Receive, Scope, Send
from app.config import get_settings
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
_BODY = json.dumps(
{"error": {"code": "cross_site", "message": "request refused: sent from another site"}}
).encode()
def _hostname(value: str) -> str:
"""``host[:port]`` -> ``host``, lower-cased (IPv6 brackets kept)."""
value = value.strip().lower()
if value.startswith("["):
return value.split("]", 1)[0] + "]"
return value.rsplit(":", 1)[0] if value.count(":") == 1 else value
def is_cross_site(headers: Headers) -> bool:
"""Whether a browser reports this request as started by another site."""
fetch_site = headers.get("sec-fetch-site")
if fetch_site is not None:
# "same-origin", "same-site" (our own subdomains), "none" (typed or
# bookmarked): all fine. Only another site is refused.
return fetch_site.strip().lower() == "cross-site"
origin = headers.get("origin")
if origin is None:
return False # not a browser, or a same-origin request from an old one
if origin.strip().lower() == "null":
return True # sandboxed frame, data: URL, file: page
origin_host = (urlsplit(origin.strip()).hostname or "").lower()
# Our own domain, whatever a reverse proxy did to the Host header.
domain = get_settings().app_domain.lower()
if origin_host == domain or origin_host.endswith("." + domain):
return False
# Otherwise the name the browser used: Host, or the one a reverse proxy saw.
ours = {_hostname(headers.get("host", ""))}
forwarded = headers.get("x-forwarded-host")
if forwarded:
ours.add(_hostname(forwarded.split(",")[0]))
return origin_host not in ours
class CrossSiteGuard:
"""ASGI middleware: 403 on cross-site POST/PUT/PATCH/DELETE under /api/."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] == "http"
and scope["method"] not in _SAFE_METHODS
and scope["path"].startswith("/api/")
and is_cross_site(Headers(scope=scope))
):
await send(
{
"type": "http.response.start",
"status": 403,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(_BODY)).encode()),
],
}
)
await send({"type": "http.response.body", "body": _BODY})
return
await self.app(scope, receive, send)
+92
View File
@@ -0,0 +1,92 @@
"""Snooze: a conversation leaves the Inbox until a chosen time, then comes back.
Kept in this database only (the mail server knows nothing of it): a snoozed
thread is hidden from the Inbox view and listed under Snoozed. The wake job,
run every minute, brings back threads whose time has come — or that got a new
message meanwhile, as Gmail does — at the top of the Inbox, the newest message
marked unread.
"""
from __future__ import annotations
import datetime as dt
import logging
import uuid
from typing import Any
from sqlalchemy import or_, select
from app.db import session_scope
from app.events import get_event_bus
from app.models import Mailbox, Message, Thread
from app.queue import enqueue_job
from app.sync.actions import apply_flags_to_messages
logger = logging.getLogger(__name__)
MAX_SNOOZE = dt.timedelta(days=366)
_SEEN = frozenset({"\\Seen"})
def snooze(thread: Thread, until: dt.datetime, now: dt.datetime) -> None:
thread.snoozed_until = until
thread.snoozed_at = now
def wake(thread: Thread, now: dt.datetime) -> None:
thread.snoozed_until = None
thread.snoozed_at = None
thread.unsnoozed_at = now
async def wake_due_job(ctx: dict[str, Any]) -> int:
"""Cron (every minute): wake snoozed threads that are due. Returns how many."""
now = dt.datetime.now(dt.UTC)
woken: list[tuple[uuid.UUID, uuid.UUID]] = []
unread: list[tuple[uuid.UUID, Message]] = []
async with session_scope() as session:
due = list(
await session.scalars(
select(Thread)
.where(
Thread.snoozed_until.isnot(None),
or_(Thread.snoozed_until <= now, Thread.last_message_at > Thread.snoozed_at),
)
.with_for_update(skip_locked=True)
)
)
for thread in due:
on_time = thread.snoozed_until is not None and thread.snoozed_until <= now
wake(thread, now)
woken.append((thread.user_id, thread.id))
if not on_time:
continue # a new message woke it: that message is unread already
# Back as new: the newest Inbox message becomes unread again.
newest = await session.scalar(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.thread_id == thread.id,
Message.user_id == thread.user_id,
Mailbox.special_use == "inbox",
)
.order_by(Message.internal_date.desc())
.limit(1)
)
if newest is not None and "\\Seen" in newest.flags:
await apply_flags_to_messages(session, [newest], frozenset(), _SEEN)
unread.append((thread.user_id, newest))
await session.commit()
bus = get_event_bus()
for user_id, message in unread:
await enqueue_job("push_flags_job", str(message.id), [], ["\\Seen"])
await bus.publish(
"flags.changed",
{"user_id": str(user_id), "message_id": str(message.id), "flags": list(message.flags)},
)
for user_id, thread_id in woken:
await bus.publish("thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)})
if woken:
logger.info("woke %d snoozed thread(s)", len(woken))
return len(woken)
+151
View File
@@ -0,0 +1,151 @@
"""Subscriptions: the bulk senders mailing a user, and unsubscribing from them.
No service records what a user signed up for, so the list is worked out from
received mail, as Gmail does: a message is a subscription's when it carries a
``List-Unsubscribe`` header (required of bulk senders by Gmail and Yahoo since
2024). Messages are grouped by ``List-Id`` when there is one (a company can run
several lists), otherwise by sender address.
The headers are read when a message is stored (indexer.persist_message); older
mail is read once by :func:`backfill_list_headers_job`. Unsubscribing uses the
sender's one-click POST (RFC 8058) when offered, else mails the ``mailto:``
address, else hands the web page back for the user to open.
"""
from __future__ import annotations
import logging
import re
import uuid
from dataclasses import dataclass
from email.parser import BytesHeaderParser
from email.policy import default as default_policy
from typing import Any
from urllib.parse import parse_qs, unquote, urlsplit
from sqlalchemy import select, update
from app.db import session_scope
from app.indexer import rawstore
from app.models import Message
logger = logging.getLogger(__name__)
_MAX_HEADER = 2000
_ANGLE = re.compile(r"<([^<>]+)>")
_BATCH = 200
@dataclass(frozen=True)
class ListHeaders:
list_id: str | None
unsubscribe: str # '' when the message has none
one_click: bool
def read_list_headers(raw: bytes) -> ListHeaders:
"""The mailing-list headers of a raw message (headers only are parsed)."""
end = raw.find(b"\r\n\r\n")
if end < 0:
end = raw.find(b"\n\n")
head = raw if end < 0 else raw[: end + 2]
try:
msg = BytesHeaderParser(policy=default_policy).parsebytes(head)
list_id_raw = str(msg.get("List-Id") or "")
unsubscribe = str(msg.get("List-Unsubscribe") or "")
post = str(msg.get("List-Unsubscribe-Post") or "")
except Exception: # a malformed header must not stop the message being stored
return ListHeaders(None, "", False)
list_id = None
if list_id_raw:
match = _ANGLE.search(list_id_raw)
list_id = (match.group(1) if match else list_id_raw).strip().lower()[:300] or None
unsubscribe = " ".join(unsubscribe.split())[:_MAX_HEADER]
one_click = "list-unsubscribe=one-click" in post.replace(" ", "").lower()
return ListHeaders(list_id, unsubscribe, one_click and bool(unsubscribe))
@dataclass(frozen=True)
class UnsubscribeTargets:
https: str | None
mailto: str | None # the address
mailto_subject: str
mailto_body: str
def unsubscribe_targets(header: str) -> UnsubscribeTargets:
"""The https and mailto targets listed in a ``List-Unsubscribe`` header."""
https = mailto = None
subject, body = "unsubscribe", "unsubscribe"
for target in _ANGLE.findall(header):
target = target.strip()
lowered = target.lower()
if https is None and lowered.startswith("https://"):
https = target
elif mailto is None and lowered.startswith("mailto:"):
parts = urlsplit(target)
address = unquote(parts.path).strip()
if "@" in address and "\n" not in address and "\r" not in address:
mailto = address
query = parse_qs(parts.query)
subject = (query.get("subject") or [subject])[0][:200]
body = (query.get("body") or [body])[0][:2000]
return UnsubscribeTargets(https, mailto, subject, body)
def group_key(list_id: str | None, sender: str) -> str:
return f"list:{list_id}" if list_id else f"from:{sender.lower()}"
async def pending_count(session: Any, user_id: uuid.UUID) -> int:
from sqlalchemy import func
return int(
await session.scalar(
select(func.count())
.select_from(Message)
.where(Message.user_id == user_id, Message.list_unsubscribe.is_(None))
)
or 0
)
async def backfill_list_headers_job(ctx: dict[str, Any], user_id: str) -> int:
"""Read the list headers of a user's older mail (stored before this feature).
Works in batches; a message whose raw copy can't be had is marked as having
none, so it is not tried again. Returns how many messages were read.
"""
uid = uuid.UUID(user_id)
done = 0
while True:
async with session_scope() as session:
batch = list(
await session.scalars(
select(Message)
.where(Message.user_id == uid, Message.list_unsubscribe.is_(None))
.limit(_BATCH)
)
)
if not batch:
break
for message in batch:
try:
raw = await rawstore.ensure_bytes(message)
headers = read_list_headers(raw)
except Exception: # missing blob, server unreachable: give up on it
headers = ListHeaders(None, "", False)
await session.execute(
update(Message)
.where(Message.id == message.id)
.values(
list_id=headers.list_id,
list_unsubscribe=headers.unsubscribe,
list_unsubscribe_post=headers.one_click,
)
)
await session.commit()
done += len(batch)
if done:
logger.info("read list headers of %d message(s) for user %s", done, user_id)
return done
+8 -4
View File
@@ -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
@@ -302,7 +302,8 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
Each message returns to ``restore_mailbox_id`` (the folder it was in before it Each message returns to ``restore_mailbox_id`` (the folder it was in before it
was archived/trashed) — e.g. a deleted Sent message goes back to Sent. Messages was archived/trashed) — e.g. a deleted Sent message goes back to Sent. Messages
with no recorded origin fall back to the account's Inbox. with no recorded origin fall back to the account's Inbox. Only messages in
Archive, Trash or Spam move: the thread's others (your reply in Sent) stay.
""" """
tid = uuid.UUID(thread_id) tid = uuid.UUID(thread_id)
async with session_scope() as session: async with session_scope() as session:
@@ -319,10 +320,13 @@ async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids))) await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
) )
path_by_id = {mb.id: mb.path for mb in mailboxes} path_by_id = {mb.id: mb.path for mb in mailboxes}
removal_ids = {mb.id for mb in mailboxes if mb.special_use in _REMOVAL_FOLDERS}
inbox_by_account = {mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"} inbox_by_account = {mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"}
# (message_id, account_id, source_path, target_id) — resolve the target now. # (message_id, account_id, source_path, target_id) — resolve the target now.
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = [] plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
for m in messages: for m in messages:
if m.mailbox_id not in removal_ids:
continue # never removed: nothing to restore
target_id = m.restore_mailbox_id target_id = m.restore_mailbox_id
if target_id is None or target_id not in path_by_id: if target_id is None or target_id not in path_by_id:
target_id = inbox_by_account.get(m.account_id) target_id = inbox_by_account.get(m.account_id)
@@ -504,7 +508,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 +588,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)
) )
+47 -21
View File
@@ -172,9 +172,7 @@ class _BackfillProgress:
self._mailboxes.add(mailbox_path) self._mailboxes.add(mailbox_path)
async with session_scope() as session: async with session_scope() as session:
await session.execute( await session.execute(
update(Account) update(Account).where(Account.id == self.account.id).values(backfill_done=self.done)
.where(Account.id == self.account.id)
.values(backfill_done=self.done)
) )
await get_event_bus().publish( await get_event_bus().publish(
"sync.progress", "sync.progress",
@@ -321,16 +319,28 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
dedupe = gmail and mailbox.special_use == "archive" dedupe = gmail and mailbox.special_use == "archive"
if since is not None: # 3months: single windowed pass if since is not None: # 3months: single windowed pass
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, since=since, provider,
account_obj,
mailbox,
progress,
since=since,
dedupe_account_scope=dedupe, dedupe_account_scope=dedupe,
) )
else: # all: recent window first (usable fast), then the remainder else: # all: recent window first (usable fast), then the remainder
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, since=recent_cutoff, provider,
account_obj,
mailbox,
progress,
since=recent_cutoff,
dedupe_account_scope=dedupe, dedupe_account_scope=dedupe,
) )
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, before=recent_cutoff, provider,
account_obj,
mailbox,
progress,
before=recent_cutoff,
dedupe_account_scope=dedupe, dedupe_account_scope=dedupe,
) )
await _set_state(account_id, "live") await _set_state(account_id, "live")
@@ -354,7 +364,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 +449,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}
@@ -480,9 +501,7 @@ async def apply_delta(
await _reconcile_drafts(provider, account, mailbox) await _reconcile_drafts(provider, account, mailbox)
async def _reconcile_drafts( async def _reconcile_drafts(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> None:
provider: ImapSmtpProvider, account: Account, mailbox: Mailbox
) -> None:
"""Drop DB rows for drafts that no longer exist on the server (were expunged).""" """Drop DB rows for drafts that no longer exist on the server (were expunged)."""
from app.db import session_scope from app.db import session_scope
@@ -537,13 +556,13 @@ async def live_loop(
await provider.verify() # signed in: the account is reachable await provider.verify() # signed in: the account is reachable
if health is not None: if health is not None:
await health.ok() await health.ok()
first = True
while not stop.is_set(): while not stop.is_set():
try: # Catch up before each IDLE, not only after it: IDLE reports what
await provider.idle(mailbox_path) # arrives from now on, so mail that came while this loop was not
except ProviderNotSupported: # running (a new account's first minutes, a reconnect, a worker
await asyncio.sleep(idle_fallback) # restart) would otherwise wait for the next new message or the
if stop.is_set(): # IDLE timeout (10 min) to show.
break
async with session_scope() as session: async with session_scope() as session:
mailbox = await session.scalar( mailbox = await session.scalar(
select(Mailbox).where( select(Mailbox).where(
@@ -551,14 +570,23 @@ async def live_loop(
) )
) )
mailbox_obj = mailbox mailbox_obj = mailbox
if mailbox_obj is None: if mailbox_obj is not None:
break
delta = await provider.poll( delta = await provider.poll(
mailbox_path, mailbox_path,
last_seen_uid=mailbox_obj.last_seen_uid, last_seen_uid=mailbox_obj.last_seen_uid,
highestmodseq=mailbox_obj.highestmodseq, highestmodseq=mailbox_obj.highestmodseq,
) )
await apply_delta(provider, account_obj, mailbox_obj, delta) await apply_delta(provider, account_obj, mailbox_obj, delta)
elif not first:
break # the folder is gone
# else: the account's first sync has not recorded the folder yet.
first = False
if stop.is_set():
break
try:
await provider.idle(mailbox_path)
except ProviderNotSupported:
await asyncio.sleep(idle_fallback)
finally: finally:
await provider.close() await provider.close()
@@ -619,9 +647,7 @@ async def poll_folders_once(
await provider.close() await provider.close()
async def _poll_secondary_once( async def _poll_secondary_once(account_id: uuid.UUID, health: AccountHealth | None = None) -> None:
account_id: uuid.UUID, health: AccountHealth | None = None
) -> None:
await poll_folders_once(account_id, include_inbox=False, health=health) await poll_folders_once(account_id, include_inbox=False, health=health)
+102 -38
View File
@@ -28,24 +28,52 @@ def _compact(name: str) -> str:
# Localized folder names by special_use. Written readably; compacted at load. # Localized folder names by special_use. Written readably; compacted at load.
_NAMES: dict[str, list[str]] = { _NAMES: dict[str, list[str]] = {
"sent": [ "sent": [
"sent", "sent items", "sent mail", "sent messages", # English / Outlook / Gmail "sent",
"wysłane", "elementy wysłane", "wiadomości wysłane", # Polish "sent items",
"gesendet", "gesendete objekte", "gesendete elemente", # German "sent mail",
"envoyés", "éléments envoyés", "messages envoyés", "courrier envoyé", # French "sent messages", # English / Outlook / Gmail
"enviados", "elementos enviados", "correo enviado", # Spanish "wysłane",
"inviata", "posta inviata", "messaggi inviati", # Italian "elementy wysłane",
"verzonden", "verzonden items", "verzonden berichten", # Dutch "wiadomości wysłane", # Polish
"enviadas", "itens enviados", "mensagens enviadas", # Portuguese "gesendet",
"отправленные", "исходящие", # Russian "gesendete objekte",
"skickat", "skickade", "skickade objekt", # Swedish "gesendete elemente", # German
"sendt", "sendte", "sendte elementer", # Norwegian / Danish "envoyés",
"éléments envoyés",
"messages envoyés",
"courrier envoyé", # French
"enviados",
"elementos enviados",
"correo enviado", # Spanish
"inviata",
"posta inviata",
"messaggi inviati", # Italian
"verzonden",
"verzonden items",
"verzonden berichten", # Dutch
"enviadas",
"itens enviados",
"mensagens enviadas", # Portuguese
"отправленные",
"исходящие", # Russian
"skickat",
"skickade",
"skickade objekt", # Swedish
"sendt",
"sendte",
"sendte elementer", # Norwegian / Danish
"lähetetyt", # Finnish "lähetetyt", # Finnish
"odeslané", "odeslaná pošta", # Czech "odeslané",
"odeslaná pošta", # Czech
], ],
"drafts": [ "drafts": [
"drafts", "draft", "drafts",
"robocze", "kopie robocze", "wersje robocze", "draft",
"entwürfe", "entwurf", "robocze",
"kopie robocze",
"wersje robocze",
"entwürfe",
"entwurf",
"brouillons", "brouillons",
"borradores", "borradores",
"bozze", "bozze",
@@ -58,44 +86,80 @@ _NAMES: dict[str, list[str]] = {
"koncepty", # Czech "koncepty", # Czech
], ],
"trash": [ "trash": [
"trash", "deleted", "deleted items", "deleted messages", "bin", "recycle bin", "trash",
"deleted",
"deleted items",
"deleted messages",
"bin",
"recycle bin",
"kosz", "kosz",
"papierkorb", "gelöschte objekte", "gelöschte elemente", "papierkorb",
"corbeille", "éléments supprimés", "gelöschte objekte",
"papelera", "elementos eliminados", "gelöschte elemente",
"cestino", "posta eliminata", "corbeille",
"prullenbak", "verwijderde items", "éléments supprimés",
"lixeira", "lixo", "itens excluídos", "papelera",
"корзина", "удаленные", "elementos eliminados",
"papperskorg", "borttaget", "cestino",
"papirkurv", "slettet", "posta eliminata",
"roskakori", "poistetut", "prullenbak",
"verwijderde items",
"lixeira",
"lixo",
"itens excluídos",
"корзина",
"удаленные",
"papperskorg",
"borttaget",
"papirkurv",
"slettet",
"roskakori",
"poistetut",
"koš", # Czech "koš", # Czech
], ],
"junk": [ "junk": [
"junk", "junk email", "junk e-mail", "spam", "bulk mail", "bulk", "junk",
"wiadomości-śmieci", "niechciane", "junk email",
"unerwünscht", "werbung", "junk e-mail",
"courrier indésirable", "indésirables", "pourriel", "spam",
"correo no deseado", "no deseado", "correo basura", "bulk mail",
"posta indesiderata", "indesiderata", "bulk",
"ongewenst", "ongewenste e-mail", "wiadomości-śmieci",
"niechciane",
"unerwünscht",
"werbung",
"courrier indésirable",
"indésirables",
"pourriel",
"correo no deseado",
"no deseado",
"correo basura",
"posta indesiderata",
"indesiderata",
"ongewenst",
"ongewenste e-mail",
"lixo eletrônico", "lixo eletrônico",
"спам", "нежелательная почта", "спам",
"skräp", "skräppost", "нежелательная почта",
"skräp",
"skräppost",
"uønsket", "uønsket",
"roskaposti", "roskaposti",
"nevyžádaná pošta", "nevyžádaná pošta",
], ],
"archive": [ "archive": [
"archive", "archives", "all mail", "archive",
"archives",
"all mail",
"archiwum", "archiwum",
"archiv", "archiv",
"archivio", "archivio",
"archivo", "archivo",
"archief", "archief",
"arquivo", "arquivo morto", "arquivo",
"архив", "вся почта", "arquivo morto",
"архив",
"вся почта",
"arkiv", "arkiv",
"arkisto", "arkisto",
], ],
+8
View File
@@ -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")
@@ -26,5 +33,6 @@ def build_provider(account: Account) -> ImapSmtpProvider:
smtp_tls=str(smtp.get("tls", "ssl")), smtp_tls=str(smtp.get("tls", "ssl")),
username=account.email, username=account.email,
password=password, password=password,
public_only=True, # a server the user named: never one inside our network
) )
return ImapSmtpProvider(config) return ImapSmtpProvider(config)
+61 -6
View File
@@ -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)
+423
View File
@@ -0,0 +1,423 @@
"""Email translation, on this server: the translator service (translator/:
Mozilla's Firefox Translations models and fastText language identification)
in its own container, reached only by the API. A message's text never leaves
the server.
- A message's language is detected the first time it is shown (from its own
text: quoted replies and signatures left out) and kept on the message, so the
reading pane can offer "Translate" when it differs from the interface language.
- A translation is of the message HTML the user sees (already sanitized; the
result is sanitized again), cached per message, target language and whether
remote images were shown. It is made paragraph by paragraph, as plain text
(translation_prep.split_pieces), each paragraph from its own language.
- The languages on offer are those installed on the service (admin panel).
Off when ``TRANSLATOR_URL`` is empty.
"""
from __future__ import annotations
import html as html_lib
import logging
import re
import time
from collections.abc import Iterable
from dataclasses import dataclass
import httpx
from app import translation_prep as prep
from app.completion import own_text
from app.config import get_settings
logger = logging.getLogger(__name__)
DETECT_CHARS = 1500 # of the message's own text: plenty to tell the language
MIN_CONFIDENCE = 50.0 # below this, the detector is guessing
MAX_HTML_CHARS = 150_000 # a translation request bigger than this is refused
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # long messages take a while on a CPU
_LANG_CACHE_S = 60
class TranslationError(Exception):
"""Translation is off, the service is unreachable, or it refused the text."""
@dataclass(frozen=True)
class Language:
code: str
name: str
def enabled() -> bool:
return bool(get_settings().translator_url.strip())
def _url(path: str) -> str:
return get_settings().translator_url.rstrip("/") + path
async def _call(method: str, path: str, payload: dict[str, object] | None = None) -> object:
if not enabled():
raise TranslationError("translation is not set up on this server")
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
resp = await client.request(method, _url(path), json=payload)
except httpx.HTTPError as exc:
raise TranslationError(f"the translation service is unreachable: {exc}") from exc
if resp.status_code not in (200, 202):
detail = resp.text[:200]
raise TranslationError(f"the translation service refused: {resp.status_code} {detail}")
return resp.json()
async def _post(path: str, payload: dict[str, object]) -> object:
return await _call("POST", path, payload)
_languages: tuple[float, list[Language]] = (0.0, [])
async def languages() -> list[Language]:
"""The installed languages, English among them (cached for a minute). The
browser names them in the interface language; ``name`` is the code."""
global _languages
if not enabled():
return []
stamp, cached = _languages
if cached and time.monotonic() - stamp < _LANG_CACHE_S:
return cached
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
resp = await client.get(_url("/languages"))
resp.raise_for_status()
codes = resp.json()["installed"]
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
logger.info("translation languages unavailable: %s", exc)
return []
found = [Language(code=str(c), name=str(c)) for c in codes]
_languages = (time.monotonic(), found)
return found
def forget_languages() -> None:
"""After an install or removal: ask the service again."""
global _languages
_languages = (0.0, [])
async def status() -> dict[str, object]:
"""The service's own account (admin panel): installed, installing (bytes
done of total), available from Mozilla, and why an install failed."""
async with httpx.AsyncClient(timeout=httpx.Timeout(20.0, connect=3.0)) as client:
try:
resp = await client.get(_url("/languages"))
resp.raise_for_status()
data = resp.json()
except (httpx.HTTPError, ValueError) as exc:
raise TranslationError(f"the translation service is unreachable: {exc}") from exc
return data if isinstance(data, dict) else {}
async def install(code: str) -> None:
await _call("POST", f"/languages/{code}")
forget_languages()
async def remove(code: str) -> None:
await _call("DELETE", f"/languages/{code}")
forget_languages()
_CJK = re.compile(r"[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]")
MIN_LETTERS = 25 # fewer, and the detector guesses ("Jacqueline Lian" → Esperanto)
MAX_DETECT = 60 # pieces of one message asked about
MIN_SHARE = 0.15 # of a message's letters, for its language to count
async def _detect_many(texts: list[str]) -> list[str]:
"""The language of each of ``texts`` ('' where unsure), in one request."""
if not texts:
return []
result = await _post("/detect", {"q": [t[:DETECT_CHARS] for t in texts]})
if not isinstance(result, list) or len(result) != len(texts):
return [""] * len(texts)
return [
str(r.get("language", ""))
if isinstance(r, dict) and float(r.get("confidence", 0)) >= MIN_CONFIDENCE
else ""
for r in result
]
async def _detect_one(text: str) -> str:
return (await _detect_many([text]))[0]
def _weight(text: str) -> int:
"""How much text this is: letters, a Chinese/Japanese/Korean sign as a word."""
return sum(3 if _CJK.match(c) else 1 for c in text if c.isalpha())
async def languages_of(parts: list[str]) -> list[str]:
"""The language of each of ``parts`` (paragraphs, in order). Parts too
short to tell take the language of the next part that could be told (a
greeting goes with what follows it), the last ones that of the one before;
parts without letters get ''."""
# Addresses say nothing about a language ("E-mail: lianshaojuan.lsj@…" → Estonian).
parts = [_ADDRESS.sub(" ", part) for part in parts]
told: list[str] = [""] * len(parts)
asked = [
i
for i, part in enumerate(parts)
if _CJK.search(part) or sum(c.isalpha() for c in part) >= MIN_LETTERS
][:MAX_DETECT]
for i, language in zip(asked, await _detect_many([parts[i] for i in asked]), strict=True):
told[i] = language
out = list(told)
following = ""
for i in reversed(range(len(parts))):
if told[i]:
following = told[i]
elif any(c.isalpha() for c in parts[i]):
out[i] = following
previous = ""
for i, part in enumerate(parts):
if told[i]:
previous = told[i]
elif not out[i] and any(c.isalpha() for c in part):
out[i] = previous
return out
async def detect(text: str) -> str:
"""The languages of ``text`` (a message's plain text), most of it first,
comma-separated ("zh-Hans,pl,en"); '' if unsure. A message can be in
several: each language that makes up a good part of it counts."""
own = own_text(text)
if len(" ".join(own.split())) < 20:
return "" # a line or two says too little
lines: list[str] = []
total = 0
for line in (x.strip() for x in own.splitlines()):
if line and total < DETECT_CHARS * 4:
lines.append(line)
total += len(line)
found = await languages_of(lines)
weight: dict[str, int] = {}
for line, language in zip(lines, found, strict=True):
if language:
weight[language] = weight.get(language, 0) + _weight(_ADDRESS.sub(" ", line))
if not weight:
return await _detect_one(" ".join(own.split()))
all_of_it = sum(weight.values())
ranked = sorted(weight, key=lambda k: weight[k], reverse=True)
return ",".join(k for k in ranked if weight[k] / all_of_it >= MIN_SHARE)
def _base(code: str) -> str:
return code.lower().split("-")[0]
async def _texts(texts: list[str], source: str, target: str) -> list[str]:
"""``texts`` translated, in batches."""
out: list[str] = []
for start in range(0, len(texts), 100):
batch = texts[start : start + 100]
result = await _post("/translate", {"q": batch, "source": source, "target": target})
got = result.get("translatedText") if isinstance(result, dict) else None
if not isinstance(got, list) or len(got) != len(batch):
raise TranslationError("the translation service sent no translation")
out += [str(x) for x in got]
return out
def _kept(name: str, translated: str) -> bool:
"""Whether ``name`` came through a translation: each of its words still
there, give or take an ending ("Lukas" → "Lukasie": yes; "Milan" →
"Mediolan": no)."""
return all(
word[: max(3, len(word) - 2)] in translated for word in name.split() if len(word) >= 2
)
# A capitalised word that starts a sentence ("Enclosed is a summary…").
_SENTENCE_START = re.compile(r"(?:^|(?<=[.!?…]\s))(?P<word>[^\W\d_][^\W\d_'’-]{2,})\b")
def _copied_starts(core: str, text: str, names: list[str]) -> list[str]:
"""Words that start a sentence of ``core`` and came through ``text``
untranslated, and are no known name: the models take a capitalised
sentence start for a name ("Enclosed is…" → "Enclosed jest…")."""
found: list[str] = []
for m in _SENTENCE_START.finditer(core):
word = m.group("word")
if not word[0].isupper() or word.isupper() or any(word in n for n in names):
continue
if re.search(rf"(?<!\w){re.escape(word)}(?!\w)", text):
found.append(word)
return found
def _lowered(core: str, words: list[str]) -> str:
"""``core`` with ``words`` lower-cased where they start a sentence."""
return _SENTENCE_START.sub(
lambda m: m.group(0).lower() if m.group("word") in words else m.group(0), core
)
def _capitalised(text: str) -> str:
"""Sentence starts capitalised again (after ``_lowered``): the first
letter, and one after a sentence's end — a full stop after a word of four
letters or more, so not after "r.", "np.", "tzw." (abbreviations)."""
text = re.sub(r"^(\W*)([^\W\d_])", lambda m: m.group(1) + m.group(2).upper(), text)
return re.sub(
r"((?:[^\W\d_]{4,}[.…]|[!?])\s+)([^\W\d_])",
lambda m: m.group(1) + m.group(2).upper(),
text,
)
def _as_before(text: str, first: str) -> str:
"""A word after a comma capitalised in ``text`` but not in ``first`` (the
first try) is lower-cased: lower-cased input makes the models capitalise
elsewhere ("…2026, od 13 lipca" → "…2026 r., Od 13 lipca")."""
def repl(m: re.Match[str]) -> str:
word = m.group(2)
lower = word[0].lower() + word[1:]
return m.group(1) + (lower if f", {lower}" in first else word)
return re.sub(r"(,\s+)([^\W\d_][\w'’-]*)", repl, text)
async def _with_names_kept(core: str, names: list[str], source: str, target: str) -> str:
"""``core`` translated around ``names``, which are left as they are."""
pattern = re.compile("(" + "|".join(re.escape(n) for n in names) + ")")
parts = pattern.split(core)
todo = [
(i, m.groups())
for i, part in enumerate(parts)
if i % 2 == 0 and (m := _EDGE_SPACE.match(part)) and any(c.isalnum() for c in part)
]
done = await _texts([groups[1] for _, groups in todo], source, target)
for (i, (lead, _, trail)), text in zip(todo, done, strict=True):
parts[i] = f"{lead}{text}{trail}"
return "".join(parts)
async def _translate_paragraphs(
html: str, target: str, sources: list[str], names: Iterable[str] = ()
) -> str:
"""``html`` in ``target``, as plain text paragraph by paragraph: from the
one language in ``sources``, or (several, or none known) each paragraph
from its own. Paragraphs already in ``target``, or in a language that is
not installed, are left as they are.
``names`` go along with their sentence, which then translates as a whole
(kept apart, "I came across <Imaginary Pixels>" lost its verb: "Przyszedłem
Imaginary Pixels"). Only a piece where one came out changed ("Milan" →
"Mediolan") is translated again with it left as it is."""
watch = sorted({n.strip() for n in names if len(n.strip()) >= 2}, key=len, reverse=True)
pieces, paragraphs = prep.split_pieces(html)
if len(sources) == 1:
found = [sources[0]] * len(paragraphs)
else:
found = await languages_of([p.text for p in paragraphs])
installed = {x.code for x in await languages()}
# Chinese and Japanese go without spaces: two pieces of one sentence
# ("…奎琳,</span>我从…") need one between them once translated.
followed = {i for p in paragraphs for i in p.pieces[:-1]}
by_language: dict[str, list[int]] = {}
for paragraph, language in zip(paragraphs, found, strict=True):
if language in installed and _base(language) != _base(target):
by_language.setdefault(language, []).extend(paragraph.pieces)
for language, where in by_language.items():
todo: list[tuple[int, str, str, str]] = []
for i in where:
m = _EDGE_SPACE.match(html_lib.unescape(pieces[i]))
lead, core, trail = m.groups() if m else ("", "", "")
# A list marker stays out: the models repeat it ("1. Thursday" →
# "1. 1. Czwartek").
marker = _LIST_MARKER.match(core)
if marker:
lead, core = lead + marker.group(0), core[marker.end() :]
if not trail and i in followed and _base(language) in _NO_SPACES:
trail = " "
if any(c.isalnum() for c in core):
todo.append((i, lead, core, trail))
done = await _texts([core for _, _, core, _ in todo], language, target)
# Sentence starts that came through untranslated: once more, lower-case.
again = [
(k, words)
for k, ((_, _, core, _), text) in enumerate(zip(todo, done, strict=True))
if (words := _copied_starts(core, text, watch))
]
if again:
retried = await _texts(
[_lowered(todo[k][2], words) for k, words in again], language, target
)
for (k, words), text in zip(again, retried, strict=True):
# Still there: a name after all, and the first try stands.
if len(_copied_starts(todo[k][2], text, watch)) < len(words):
done[k] = _as_before(_capitalised(text), done[k])
for (i, lead, core, trail), text in zip(todo, done, strict=True):
lost = [n for n in watch if n in core and not _kept(n, text)]
if lost:
text = await _with_names_kept(core, lost, language, target)
pieces[i] = html_lib.escape(f"{lead}{text}{trail}", quote=False)
return "".join(pieces)
_NO_SPACES = {"zh", "ja", "th"}
_LIST_MARKER = re.compile(r"(?:\d{1,3}[.)]|[a-zA-Z][.)]|[-•*–])\s+(?=\S)")
_EDGE_SPACE = re.compile(r"^(\s*)(.*?)(\s*)$", re.DOTALL)
# Web and mail addresses in the text: the models break them up ("https: / /
# vimeo.com"), so they are marked translate="no" and not sent.
_ADDRESS = re.compile(r"(?:https?://|www\.)[^\s<>\"']+|[\w.+-]+@[\w-]+(?:\.[\w-]+)+", re.IGNORECASE)
_TAG = re.compile(r"(<[^>]*>)")
def protect_addresses(html: str) -> str:
"""Mark the addresses in ``html``'s text as not to be translated (style
blocks and tag attributes are left alone)."""
out: list[str] = []
in_style = False
for piece in _TAG.split(html):
if piece.startswith("<"):
lowered = piece.lower()
if lowered.startswith("<style"):
in_style = True
elif lowered.startswith("</style"):
in_style = False
out.append(piece)
elif in_style or not piece:
out.append(piece)
else:
out.append(_ADDRESS.sub(lambda m: f'<span translate="no">{m.group(0)}</span>', piece))
return "".join(out)
# Bumped when what goes to the service, or what is done with the result,
# changes: translations kept from before are made again.
PIPELINE_VERSION = 8
async def translate_html(
html: str, source: str, target: str, keep: Iterable[str] = (), names: Iterable[str] = ()
) -> str:
"""``html`` (a sanitized message body) in ``target``; source "auto", a code,
or several ("zh-Hans,pl,en": then each paragraph from its own language).
``keep``: words always left as they are; ``names``: left as they are if
the model would change them (translation_prep)."""
if len(html) > MAX_HTML_CHARS:
raise TranslationError("this message is too long to translate")
sources = [x for x in (source or "").split(",") if x and x != "auto"]
prepared = prep.join_wrapped_lines(html)
for language in sources:
prepared = prep.translate_phrases(prepared, language, target)
candidates = {*names, *prep.find_names(prepared, sources[0] if sources else "")}
prepared = prep.keep_symbols(protect_addresses(prep.keep_terms(prepared, keep)))
return prep.tidy(await _translate_paragraphs(prepared, target, sources, candidates))
+332
View File
@@ -0,0 +1,332 @@
"""Getting a message ready for translation, and tidying what comes back.
A message is translated as plain text, piece by piece (the text between
tags), and the models stumble on things a reader would not: a sentence
wrapped onto two lines becomes two sentences ("ask you about" / "Plan for next
month"), "Best," becomes "Najlepsze," and a company name gets translated word
by word. So before a message goes out:
- lines that carry on a sentence (no end punctuation, next starts lowercase)
are joined, in HTML (<br>, one <div> per line) and in wrapped plain text;
- greetings and sign-offs that make a whole line are put in the target
language from a small phrase table (English, Polish, German);
- names are marked translate="no" and not sent — the people on the message,
the user's own names and signature names, and capitalised multi-word names
mid-sentence — but only the names the model would change ("Milan" →
"Mediolan"): a kept name splits its sentence into pieces translated alone
("Dear <kept>Lukas Remis</kept>," → "Kochanie."), so a name the model
leaves alone stays in the sentence;
- so are emoji, file names ("A_0023C017_p1CAC.mov") and text without a letter;
- the message is translated paragraph by paragraph (split_pieces), each from
its own language when there are several; those in the target stay;
- and afterwards, a kept word gets back the spaces around it.
"""
from __future__ import annotations
import html as html_lib
import re
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
KEEP_OPEN = '<span translate="no">'
_TAG = re.compile(r"(<[^>]*>)")
_LOWER_START = "a-ząćęłńóśźżäöüßàâçéèêëîïôûùüÿñ"
_UPPER = "A-ZĄĆĘŁŃÓŚŹŻÄÖÜÀÂÇÉÈÊËÎÏÔÛÙÜŸÑ"
# --- joining wrapped lines -------------------------------------------------------
# A line break between a word (or comma) and a lowercase word carries a sentence on.
_HTML_SOFT_BREAK = re.compile(
rf"([\w,;–-])[ \t]*(?:<br\s*/?>|</div>\s*<div[^>]*>|</p>\s*<p[^>]*>)\s*(?=[{_LOWER_START}])"
)
_TEXT_SOFT_BREAK = re.compile(rf"([^\s.!?:;])[ \t]*\n[ \t]*(?=[{_LOWER_START}])")
def join_wrapped_lines(html: str) -> str:
html = _HTML_SOFT_BREAK.sub(r"\1 ", html)
# Plain text (wm-plaintext): newlines inside text, outside tags.
return "".join(
p if p.startswith("<") else _TEXT_SOFT_BREAK.sub(r"\1 ", p) for p in _TAG.split(html)
)
# --- greetings and sign-offs -----------------------------------------------------
# source language → the line as written (lower case) → target language → line.
_PHRASES: dict[str, dict[str, dict[str, str]]] = {
"en": {
"best": {"pl": "Pozdrawiam", "de": "Viele Grüße"},
"best regards": {"pl": "Pozdrawiam serdecznie", "de": "Beste Grüße"},
"best wishes": {"pl": "Najlepsze życzenia", "de": "Beste Grüße"},
"kind regards": {"pl": "Z pozdrowieniami", "de": "Freundliche Grüße"},
"warm regards": {"pl": "Serdecznie pozdrawiam", "de": "Herzliche Grüße"},
"regards": {"pl": "Pozdrawiam", "de": "Grüße"},
"cheers": {"pl": "Pozdrawiam", "de": "Viele Grüße"},
"sincerely": {"pl": "Z poważaniem", "de": "Mit freundlichen Grüßen"},
"yours sincerely": {"pl": "Z poważaniem", "de": "Mit freundlichen Grüßen"},
"thanks": {"pl": "Dzięki", "de": "Danke"},
"many thanks": {"pl": "Wielkie dzięki", "de": "Vielen Dank"},
"thank you": {"pl": "Dziękuję", "de": "Vielen Dank"},
"thanks in advance": {"pl": "Z góry dziękuję", "de": "Vielen Dank im Voraus"},
"all the best": {"pl": "Wszystkiego dobrego", "de": "Alles Gute"},
"talk soon": {"pl": "Do usłyszenia", "de": "Bis bald"},
"have a great day": {"pl": "Miłego dnia", "de": "Einen schönen Tag noch"},
"have a nice day": {"pl": "Miłego dnia", "de": "Einen schönen Tag noch"},
},
"pl": {
"pozdrawiam": {"en": "Best regards", "de": "Viele Grüße"},
"pozdrawiam serdecznie": {"en": "Warm regards", "de": "Herzliche Grüße"},
"serdecznie pozdrawiam": {"en": "Warm regards", "de": "Herzliche Grüße"},
"z poważaniem": {"en": "Kind regards", "de": "Mit freundlichen Grüßen"},
"z pozdrowieniami": {"en": "Kind regards", "de": "Freundliche Grüße"},
"dziękuję": {"en": "Thank you", "de": "Vielen Dank"},
"dzięki": {"en": "Thanks", "de": "Danke"},
"z góry dziękuję": {"en": "Thanks in advance", "de": "Vielen Dank im Voraus"},
"miłego dnia": {"en": "Have a nice day", "de": "Einen schönen Tag noch"},
"do usłyszenia": {"en": "Talk soon", "de": "Bis bald"},
},
"de": {
"mit freundlichen grüßen": {"en": "Kind regards", "pl": "Z poważaniem"},
"freundliche grüße": {"en": "Kind regards", "pl": "Z pozdrowieniami"},
"viele grüße": {"en": "Best regards", "pl": "Pozdrawiam"},
"beste grüße": {"en": "Best regards", "pl": "Pozdrawiam"},
"liebe grüße": {"en": "Warm regards", "pl": "Serdecznie pozdrawiam"},
"herzliche grüße": {"en": "Warm regards", "pl": "Serdecznie pozdrawiam"},
"danke": {"en": "Thanks", "pl": "Dzięki"},
"vielen dank": {"en": "Many thanks", "pl": "Bardzo dziękuję"},
"bis bald": {"en": "Talk soon", "pl": "Do usłyszenia"},
},
}
# A whole line: after a tag or line start, before a tag or line end.
_LINE = re.compile(r"(^|>|\n)([ \t]*)([^<>\n]{2,40}?)([ \t]*[,.!]?)([ \t]*)(?=<|\n|$)")
def _base(code: str) -> str:
return code.lower().split("-")[0]
def translate_phrases(html: str, source: str, target: str) -> str:
table = _PHRASES.get(_base(source), {})
if not table:
return html
tgt = _base(target)
def repl(m: re.Match[str]) -> str:
found = table.get(" ".join(m.group(3).lower().split()), {}).get(tgt)
if found is None:
return m.group(0)
return f"{m.group(1)}{m.group(2)}{KEEP_OPEN}{found}{m.group(4).strip()}</span>{m.group(5)}"
# The pattern never crosses a tag: it stops at "<" and ">".
return _LINE.sub(repl, html)
# --- names ------------------------------------------------------------------------
# A capitalised word; dots only inside it ("U.S"), never at its end — a name
# does not run on over a sentence's end ("NukeX. My work…").
_WORD = rf"[{_UPPER}][\w'’&-]*(?:\.[\w'’&-]+)*"
# Two or more capitalised words in a row ("Imaginary Pixels", "Trusted Partner
# Network"), after a lowercase word: a name, not the start of a sentence.
_MULTI_NAME = re.compile(
rf"(?<=[{_LOWER_START},] )({_WORD}(?: (?:&|of|the|de|von|van|{_WORD}))* {_WORD})"
)
# Languages that capitalise every noun: capitals say nothing about names there.
_CAPITALISE_NOUNS = {"de", "lb"}
def _title_case(text: str) -> bool:
"""A Headline Like This: nearly every longer word capitalised (short ones
— of, and, in — often are not). A sentence full of names is not one."""
words = [w for w in re.findall(r"[^\W\d_][\w'’-]*", text) if len(w) >= 4]
return len(words) >= 3 and sum(w[0].isupper() for w in words) / len(words) > 0.8
def find_names(html: str, source: str) -> set[str]:
"""Capitalised multi-word names in the middle of sentences (not in headings
or Title Case lines, and not for languages that capitalise all nouns)."""
if _base(source) in _CAPITALISE_NOUNS:
return set()
found: set[str] = set()
for piece in _TAG.split(html):
if piece.startswith("<") or _title_case(piece):
continue
found.update(m.group(1).rstrip(".") for m in _MULTI_NAME.finditer(piece))
return found
_SIG_NAME = re.compile(rf"{_WORD}(?: (?:&|of|the|{_WORD}))* {_WORD}")
def find_names_in_signature(text: str) -> list[str]:
"""Capitalised multi-word names in a signature ("Imaginary Pixels"): the
user's company or title, which a translation should leave alone."""
return [m.group(0).rstrip(".") for m in _SIG_NAME.finditer(text)]
def keep_terms(html: str, terms: Iterable[str]) -> str:
"""Mark each of ``terms`` in ``html``'s text translate="no" (longest first;
whole words; style blocks and tags left alone)."""
words = sorted({t.strip() for t in terms if len(t.strip()) >= 2}, key=len, reverse=True)
if not words:
return html
pattern = re.compile(r"(?<![\w>])(" + "|".join(re.escape(w) for w in words) + r")(?![\w<])")
return _mark(html, pattern)
# Emoji and other pictographs (with their skin tones, joiners and variation
# selectors): on its own, an emoji comes back as a made-up word ("😊" →
# "Uzasadnienie").
_SYMBOLS = re.compile(
"[\u2190-\u21ff\u2300-\u23ff\u2460-\u27bf\u2900-\u297f\u2b00-\u2bff"
"\U0001f000-\U0001faff]"
"[\u2190-\u21ff\u2300-\u23ff\u2460-\u27bf\u2900-\u297f\u2b00-\u2bff"
"\U0001f000-\U0001faff\ufe0f\u200d\u20e3]*"
)
# File names and codes with underscores ("A_0023C017_260521_p1CAC.mov",
# "final_v2"): the models split them at every "_" and dot.
_FILE_NAME = re.compile(
r"(?<![\w./-])(?:[\w-]*_[\w-]*(?:\.[\w-]+)*|[\w-]+(?:\.[\w-]+)*\.[a-z][a-z0-9]{1,4})(?![\w/-])",
re.IGNORECASE,
)
# International phone numbers ("+48 780 549 877"): the models space out the
# "+". Only those: a kept piece splits its sentence, and plain numbers
# ("1300 - 2500 Euro") come through anyway.
_PHONE = re.compile(r"(?<![\w+])\+\d[\d ().-]{5,}\d(?!\w)")
# Only names with an extension a file would have, not every "e.g." or "U.S".
_FILE_EXT = re.compile(r"\.[a-z][a-z0-9]{1,4}$", re.IGNORECASE)
def keep_symbols(html: str) -> str:
"""Mark emoji, file names, handles ("Jacqueline_"), phone numbers and text
without a letter in ``html`` translate="no"."""
html = _mark(_mark(html, _SYMBOLS), _PHONE)
def is_file(m: re.Match[str]) -> bool:
word = m.group(0)
if "_" in word and word.strip("_"):
return True
return bool(_FILE_EXT.search(word)) and any(c.isdigit() or c in "_-" for c in word)
return _map_text(_mark(html, _FILE_NAME, keep=is_file), _keep_letterless)
_ENTITY = re.compile(r"&#?\w+;")
_EDGES = re.compile(r"^(\s*)(.*?)(\s*)$", re.DOTALL)
def _keep_letterless(text: str) -> str:
"""A piece of text with no letter or digit — an invisible byte-order mark,
a lone symbol — kept as it is: the models make up a word for it ("\ufeff"
→ "Uzasadnienie")."""
if any(c.isalnum() for c in _ENTITY.sub("", text)):
return text
lead, core, trail = _EDGES.match(text).groups() # type: ignore[union-attr]
if not _ENTITY.sub("", core).strip():
return text # only spaces and &nbsp;
return f"{lead}{KEEP_OPEN}{core}</span>{trail}"
def _mark(
html: str, pattern: re.Pattern[str], keep: Callable[[re.Match[str]], bool] | None = None
) -> str:
"""Wrap ``pattern``'s matches in ``html``'s text in translate="no" spans
(where ``keep(match)`` says so)."""
def repl(m: re.Match[str]) -> str:
if keep is not None and not keep(m):
return m.group(0)
return f"{KEEP_OPEN}{m.group(0)}</span>"
return _map_text(html, lambda text: pattern.sub(repl, text))
def _map_text(html: str, fn: Callable[[str], str]) -> str:
"""``fn`` applied to each piece of ``html``'s text; style blocks, tags and
text already kept are left alone."""
out: list[str] = []
in_style = in_keep = False
for piece in _TAG.split(html):
if piece.startswith("<"):
low = piece.lower()
if low.startswith("<style"):
in_style = True
elif low.startswith("</style"):
in_style = False
elif low.startswith(KEEP_OPEN):
in_keep = True
elif in_keep and low.startswith("</span"):
in_keep = False
out.append(piece)
elif in_style or in_keep or not piece:
out.append(piece)
else:
out.append(fn(piece))
return "".join(out)
# --- messages in more than one language ------------------------------------------
# Tags that end a paragraph (a line): the text between two of them is one
# piece of one language.
_BLOCK = re.compile(
r"</?(?:p|div|li|ul|ol|table|thead|tbody|tfoot|tr|td|th|h[1-6]|blockquote|pre|"
r"section|article|header|footer|center|br|hr)\b",
re.IGNORECASE,
)
@dataclass
class Paragraph:
"""One paragraph of a message: its text (for telling its language) and
where its translatable pieces are in ``split_pieces``' list."""
text: str = ""
pieces: list[int] = field(default_factory=list)
def split_pieces(html: str) -> tuple[list[str], list[Paragraph]]:
"""``html`` as a list of tags and text, and its paragraphs. Text in style
blocks is no paragraph's; text already kept counts for the language but
is not translated."""
pieces = _TAG.split(html)
paragraphs: list[Paragraph] = [Paragraph()]
in_style = in_keep = False
for i, piece in enumerate(pieces):
if piece.startswith("<"):
low = piece.lower()
if low.startswith("<style"):
in_style = True
elif low.startswith("</style"):
in_style = False
elif low.startswith(KEEP_OPEN):
in_keep = True
elif in_keep and low.startswith("</span"):
in_keep = False
if _BLOCK.match(piece) and (paragraphs[-1].text.strip() or paragraphs[-1].pieces):
paragraphs.append(Paragraph())
elif piece and not in_style:
paragraphs[-1].text += html_lib.unescape(piece)
if not in_keep:
paragraphs[-1].pieces.append(i)
return pieces, [p for p in paragraphs if p.text.strip()]
# --- after translation -------------------------------------------------------------
# A kept span can lose the space beside it ("Bruton</span>i twoje").
# Only kept spans: they still carry translate="no" here (the sanitizer that
# runs next takes it off); other spans are the sender's and stay as they are.
_KEPT = re.compile(r'(<span translate="no">[^<]*</span>)')
def tidy(html: str) -> str:
def repl(m: re.Match[str]) -> str:
before = html[m.start() - 1] if m.start() > 0 else ""
after = html[m.end()] if m.end() < len(html) else ""
lead = " " if re.match(r"[\w,;:)]", before) else ""
trail = " " if re.match(r"[\w(„\"“]", after) else ""
return f"{lead}{m.group(1)}{trail}"
return _KEPT.sub(repl, html)
+7
View File
@@ -18,6 +18,7 @@ from app.events import get_event_bus
from app.identity.cookies import SESSION_COOKIE_INSECURE, SESSION_COOKIE_SECURE from app.identity.cookies import SESSION_COOKIE_INSECURE, SESSION_COOKIE_SECURE
from app.identity.sessions import resolve_session from app.identity.sessions import resolve_session
from app.redis_client import get_redis from app.redis_client import get_redis
from app.security.cross_site import is_cross_site
from app.sync import leases from app.sync import leases
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,6 +28,12 @@ router = APIRouter()
@router.websocket("/ws") @router.websocket("/ws")
async def ws_endpoint(websocket: WebSocket) -> None: async def ws_endpoint(websocket: WebSocket) -> None:
# Only our own pages may listen in: a socket another site opens is refused
# even if the browser sent the session cookie with it (cross-site WebSocket
# hijacking; SameSite=Lax already stops current browsers from sending it).
if is_cross_site(websocket.headers):
await websocket.close(code=1008)
return
token = websocket.cookies.get(SESSION_COOKIE_SECURE) or websocket.cookies.get( token = websocket.cookies.get(SESSION_COOKIE_SECURE) or websocket.cookies.get(
SESSION_COOKIE_INSECURE, "" SESSION_COOKIE_INSECURE, ""
) )
@@ -0,0 +1,36 @@
"""attachments are kept once: inside their message
An attachment used to be stored twice — inside the raw message blob, and again
as a blob of its own. Now only the raw message is kept; ``part_index`` says
which MIME part of it (its position in a walk of the message) holds the
attachment, so it can be taken out when downloaded. ``blob_sha256`` stays: the
attachment's content hash, which names its thumbnail and checks what is taken
out. Rows stored before this have no index; they are found by that hash.
Existing separate copies are removed by a background pass on the worker
(app/indexer/attachments.py ``dedupe_existing``), only after checking each one
can be taken out of its message again.
Revision ID: 0023
Revises: 0022
Create Date: 2026-10-08
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0023"
down_revision: str | None = "0022"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE attachments ADD COLUMN part_index integer")
def downgrade() -> None:
op.execute("ALTER TABLE attachments DROP COLUMN IF EXISTS part_index")
@@ -0,0 +1,43 @@
"""app passwords: one password per mail app (SPEC App. D §D.5)
Mail apps (Thunderbird, a phone's Mail app) will sign in to the user's hosted
mailbox over IMAP/SMTP, where no 2FA code can be asked for. Each app gets its
own generated password instead, which can be revoked on its own; the main
password with 2FA stays for the webmail. Stored as argon2id hashes, the form
the mail server's SQL directory reads (§D.3).
Revision ID: 0024
Revises: 0023
Create Date: 2026-10-09
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0024"
down_revision: str | None = "0023"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE user_app_passwords (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz
)
"""
)
op.execute("CREATE INDEX ix_user_app_passwords_user ON user_app_passwords (user_id)")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS user_app_passwords")
@@ -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")
@@ -0,0 +1,63 @@
"""invitations: users invite friends, even while sign-up is closed
An administrator switch lets every user send a few single-use invitation links
by email; a link opens sign-up for the person it was sent to even when public
registration is off. The email is built from a template the administrator
uploads (or the built-in one) and sent from the instance's own address.
Revision ID: 0027
Revises: 0026
Create Date: 2026-10-14
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0027"
down_revision: str | None = "0026"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE app_settings
ADD COLUMN invitations_enabled boolean NOT NULL DEFAULT false,
ADD COLUMN invite_from text NOT NULL DEFAULT '',
ADD COLUMN invite_subject text NOT NULL DEFAULT '',
ADD COLUMN invite_template text NOT NULL DEFAULT ''
"""
)
op.execute(
"""
CREATE TABLE invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
inviter_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
invitee_name text NOT NULL,
invitee_email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used_at timestamptz,
used_by uuid REFERENCES users(id) ON DELETE SET NULL
)
"""
)
op.execute("CREATE INDEX ix_invitations_inviter ON invitations (inviter_id)")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS invitations")
op.execute(
"""
ALTER TABLE app_settings
DROP COLUMN IF EXISTS invitations_enabled,
DROP COLUMN IF EXISTS invite_from,
DROP COLUMN IF EXISTS invite_subject,
DROP COLUMN IF EXISTS invite_template
"""
)
@@ -0,0 +1,108 @@
"""snooze, templates, subscriptions, tracker counts
- Snooze: a thread can leave the Inbox until a set time (``snoozed_until``);
``snoozed_at`` lets a reply that arrives meanwhile wake it early, and
``unsnoozed_at`` brings a woken thread to the top of the Inbox.
- Templates: saved replies, per user, inserted from the compose window.
- Subscriptions: the mailing-list headers of each message (``List-Id``,
``List-Unsubscribe``, one-click support), read during sync. NULL
``list_unsubscribe`` means "not read yet" (filled in by a backfill job), ''
means the message has none. ``subscription_states`` records what the user did
about a sender (unsubscribed when).
- Tracker blocking: how many tracking pixels were removed from a message's HTML.
Revision ID: 0028
Revises: 0027
Create Date: 2026-10-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0028"
down_revision: str | None = "0027"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE threads
ADD COLUMN snoozed_until timestamptz,
ADD COLUMN snoozed_at timestamptz,
ADD COLUMN unsnoozed_at timestamptz
"""
)
op.execute(
"CREATE INDEX ix_threads_snoozed ON threads (snoozed_until) WHERE snoozed_until IS NOT NULL"
)
op.execute(
"""
ALTER TABLE messages
ADD COLUMN list_id text,
ADD COLUMN list_unsubscribe text,
ADD COLUMN list_unsubscribe_post boolean NOT NULL DEFAULT false
"""
)
op.execute(
"CREATE INDEX ix_messages_user_list ON messages (user_id) "
"WHERE list_unsubscribe IS NOT NULL AND list_unsubscribe <> ''"
)
op.execute(
"CREATE INDEX ix_messages_list_unread ON messages (user_id) WHERE list_unsubscribe IS NULL"
)
op.execute(
"""
CREATE TABLE subscription_states (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key text NOT NULL,
unsubscribed_at timestamptz NOT NULL DEFAULT now(),
method text NOT NULL,
PRIMARY KEY (user_id, key)
)
"""
)
op.execute(
"""
CREATE TABLE mail_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
subject text NOT NULL DEFAULT '',
body_html text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)
"""
)
op.execute("CREATE INDEX ix_mail_templates_user ON mail_templates (user_id)")
op.execute("ALTER TABLE body_parts ADD COLUMN trackers_blocked integer NOT NULL DEFAULT 0")
def downgrade() -> None:
op.execute("ALTER TABLE body_parts DROP COLUMN IF EXISTS trackers_blocked")
op.execute("DROP TABLE IF EXISTS mail_templates")
op.execute("DROP TABLE IF EXISTS subscription_states")
op.execute("DROP INDEX IF EXISTS ix_messages_list_unread")
op.execute("DROP INDEX IF EXISTS ix_messages_user_list")
op.execute(
"""
ALTER TABLE messages
DROP COLUMN IF EXISTS list_id,
DROP COLUMN IF EXISTS list_unsubscribe,
DROP COLUMN IF EXISTS list_unsubscribe_post
"""
)
op.execute("DROP INDEX IF EXISTS ix_threads_snoozed")
op.execute(
"""
ALTER TABLE threads
DROP COLUMN IF EXISTS snoozed_until,
DROP COLUMN IF EXISTS snoozed_at,
DROP COLUMN IF EXISTS unsnoozed_at
"""
)
@@ -0,0 +1,49 @@
"""sentence completion: a per-user index of phrases from their sent mail
``completion_phrases`` maps the words before the cursor (normalized) to what
the user usually writes next, with how often; rebuilt from the sent mail at
most daily (app/completion.py). The primary key leads with (user_id, context),
which is the lookup.
Revision ID: 0029
Revises: 0028
Create Date: 2026-10-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0029"
down_revision: str | None = "0028"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE completion_phrases (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
context text NOT NULL,
continuation text NOT NULL,
count integer NOT NULL,
PRIMARY KEY (user_id, context, continuation)
)
"""
)
op.execute(
"""
CREATE TABLE completion_index_state (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
built_at timestamptz NOT NULL
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS completion_index_state")
op.execute("DROP TABLE IF EXISTS completion_phrases")
@@ -0,0 +1,45 @@
"""email translation: a message's detected language, and translations kept
``messages.content_language``: NULL until the message is first shown, '' when
the language could not be told. ``message_translations``: one translated HTML
per message, target language and remote-images choice (app/translation.py);
dropped with the message.
Revision ID: 0030
Revises: 0029
Create Date: 2026-10-18
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0030"
down_revision: str | None = "0029"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE messages ADD COLUMN content_language text")
op.execute(
"""
CREATE TABLE message_translations (
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
target text NOT NULL,
images boolean NOT NULL,
source text NOT NULL,
html text NOT NULL,
sanitizer_v integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, target, images)
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS message_translations")
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS content_language")
+1 -3
View File
@@ -16,9 +16,7 @@ from app.models import Message
async def main() -> None: async def main() -> None:
async with session_scope() as session: async with session_scope() as session:
rows = list( rows = list(await session.scalars(select(Message).where(Message.delivery_id.is_(None))))
await session.scalars(select(Message).where(Message.delivery_id.is_(None)))
)
fixed = 0 fixed = 0
for m in rows: for m in rows:
try: try:
+1 -3
View File
@@ -42,9 +42,7 @@ async def main() -> None:
update(Message) update(Message)
.where(Message.id == msg.id) .where(Message.id == msg.id)
.values( .values(
body_indexed=Message.body_indexed.op("||")( body_indexed=Message.body_indexed.op("||")(func.to_tsvector(_TS_CONFIG, blob))
func.to_tsvector(_TS_CONFIG, blob)
)
) )
) )
updated += 1 updated += 1
+6 -2
View File
@@ -37,10 +37,14 @@ AUTOCONFIG_XML = """<?xml version="1.0"?>
def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def] def _skip_ssrf(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Bypass the real DNS/public-IP check so respx can intercept the fetch.""" """Bypass the real DNS/public-IP check so respx can intercept the fetch."""
async def ok(_host: str) -> None: async def ok(_host: str) -> list[str]:
return None return ["93.184.216.34"]
async def unpinned(url: str) -> tuple[str, dict[str, str], dict[str, str]]:
return url, {}, {} # respx matches on the host name, not the pinned IP
monkeypatch.setattr(ssrf, "assert_host_is_public", ok) monkeypatch.setattr(ssrf, "assert_host_is_public", ok)
monkeypatch.setattr(ssrf, "_pinned", unpinned)
# --- XML parsing ------------------------------------------------------------- # --- XML parsing -------------------------------------------------------------
+149
View File
@@ -0,0 +1,149 @@
"""A signed-in user with an API client, for endpoint tests in this package."""
from __future__ import annotations
import datetime as dt
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.main import create_app
from app.models import Account, Mailbox, Message, User
from app.providers.base import RemoteMessage
CSRF = "csrf"
HDR = {"X-CSRF": CSRF}
@dataclass
class UserCtx:
client: httpx.AsyncClient
user_id: uuid.UUID
session: AsyncSession # for arranging rows directly
@pytest_asyncio.fixture
async def user_ctx() -> AsyncIterator[UserCtx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"t{uuid.uuid4().hex[:10]}@test.local", password_hash=hash_password("pw"))
session.add(user)
await session.commit()
user_id = user.id # kept: a test may expire the session's objects
token = await create_session(user_id, "pytest")
client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
)
try:
yield UserCtx(client=client, user_id=user_id, session=session)
finally:
await client.aclose()
await session.rollback()
await session.execute(delete(User).where(User.id == user_id))
await session.commit()
await session.close()
await engine.dispose()
@dataclass
class MailCtx(UserCtx):
account_id: uuid.UUID
inbox_id: uuid.UUID
sent_id: uuid.UUID
async def deliver(
self,
raw: bytes,
uid: int,
*,
seen: bool = False,
at: dt.datetime | None = None,
folder: str = "inbox",
) -> Message:
"""Store a message the way sync does (indexer.persist_message), in the
Inbox or (``folder="sent"``) the Sent folder."""
remote = RemoteMessage(
uid=uid,
raw=raw,
flags=frozenset({"\\Seen"}) if seen else frozenset(),
internal_date=at or dt.datetime.now(dt.UTC),
)
result = await persist_message(
self.session,
user_id=self.user_id,
account_id=self.account_id,
mailbox_id=self.sent_id if folder == "sent" else self.inbox_id,
mailbox_special_use=folder,
remote=remote,
)
await self.session.commit()
assert result is not None
return result.message
def raw_mail(
subject: str,
*,
sender: str = "News <news@shop.example>",
headers: dict[str, str] | None = None,
mid: str | None = None,
html: str | None = None,
) -> bytes:
m = EmailMessage()
m["From"] = sender
m["To"] = "me@example.com"
m["Subject"] = subject
m["Message-ID"] = mid or f"<{uuid.uuid4().hex}@shop.example>"
for name, value in (headers or {}).items():
m[name] = value
m.set_content(f"{subject} body")
if html is not None:
m.add_alternative(html, subtype="html")
return m.as_bytes()
@pytest_asyncio.fixture
async def mail_ctx(user_ctx: UserCtx) -> MailCtx:
s = user_ctx.session
account = Account(
user_id=user_ctx.user_id,
email="me@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
)
s.add(account)
await s.flush()
inbox = Mailbox(
account_id=account.id, user_id=user_ctx.user_id, path="INBOX", special_use="inbox"
)
trash = Mailbox(
account_id=account.id, user_id=user_ctx.user_id, path="Trash", special_use="trash"
)
sent = Mailbox(account_id=account.id, user_id=user_ctx.user_id, path="Sent", special_use="sent")
s.add_all([inbox, trash, sent])
await s.commit()
return MailCtx(
client=user_ctx.client,
user_id=user_ctx.user_id,
session=s,
account_id=account.id,
inbox_id=inbox.id,
sent_id=sent.id,
)
@@ -0,0 +1,82 @@
"""Email translation's languages in the admin panel (app/api/admin.py), with
the translator service replaced by a stand-in."""
from __future__ import annotations
import pytest
from app import translation
from tests.api.test_admin_users import World, world # noqa: F401 — the fixture
@pytest.fixture
def service(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[str]]:
state: dict[str, list[str]] = {"installed": ["en", "pl"], "calls": []}
async def status() -> dict[str, object]:
return {
"installed": list(state["installed"]),
"installing": {"de": {"done": 10, "total": 40}} if "de" in state["calls"] else {},
"available": ["en", "pl", "de", "hu"],
"errors": {},
}
async def install(code: str) -> None:
state["calls"].append(code)
async def remove(code: str) -> None:
state["installed"].remove(code)
monkeypatch.setattr(translation, "enabled", lambda: True)
monkeypatch.setattr(translation, "status", status)
monkeypatch.setattr(translation, "install", install)
monkeypatch.setattr(translation, "remove", remove)
return state
async def test_admin_installs_and_removes_languages(
world: World, # noqa: F811
service: dict[str, list[str]],
) -> None:
async with await world.client(await world.user(admin=True)) as c:
got = (await c.get("/api/admin/translation")).json()
assert got["reachable"] and got["installed"] == ["en", "pl"]
assert got["available"] == ["en", "pl", "de", "hu"]
got = (await c.post("/api/admin/translation/languages/de")).json()
assert service["calls"] == ["de"]
assert got["installing"] == {"de": {"done": 10, "total": 40}}
got = (await c.delete("/api/admin/translation/languages/pl")).json()
assert got["installed"] == ["en"]
assert (await c.post("/api/admin/translation/languages/x;rm")).status_code == 422
async def test_only_admins(world: World, service: dict[str, list[str]]) -> None: # noqa: F811
async with await world.client(await world.user()) as c:
assert (await c.get("/api/admin/translation")).status_code == 403
assert (await c.post("/api/admin/translation/languages/de")).status_code == 403
assert service["calls"] == []
async def test_unreachable_service_is_reported(
world: World, # noqa: F811
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def down() -> dict[str, object]:
raise translation.TranslationError("down")
monkeypatch.setattr(translation, "enabled", lambda: True)
monkeypatch.setattr(translation, "status", down)
async with await world.client(await world.user(admin=True)) as c:
got = (await c.get("/api/admin/translation")).json()
assert got == {
"enabled": True,
"reachable": False,
"installed": [],
"installing": {},
"pending": [],
"available": [],
"errors": {},
}
+14
View File
@@ -168,6 +168,20 @@ async def test_admin_cannot_block_or_delete_themselves(world: World) -> None:
assert (await c.delete(f"/api/admin/users/{admin.id}")).status_code == 409 assert (await c.delete(f"/api/admin/users/{admin.id}")).status_code == 409
async def test_admin_can_change_their_own_quota(world: World) -> None:
admin = await world.user(admin=True)
async with await world.client(admin) as c:
resp = await c.patch(f"/api/admin/users/{admin.id}", json={"quota_bytes": 5 * 2**30})
assert resp.status_code == 200
assert resp.json()["quota_bytes"] == 5 * 2**30
# Quota together with a block is still refused: the block is the problem.
assert (
await c.patch(
f"/api/admin/users/{admin.id}", json={"quota_bytes": 2**30, "blocked": True}
)
).status_code == 409
async def test_delete_removes_the_user_and_their_data(world: World, bus: _Bus) -> None: async def test_delete_removes_the_user_and_their_data(world: World, bus: _Bus) -> None:
admin = await world.user(admin=True) admin = await world.user(admin=True)
target = await world.user() target = await world.user()
+28 -12
View File
@@ -310,7 +310,8 @@ async def test_attachment_download_non_ascii_filename(api: ApiCtx) -> None:
await api.session.commit() await api.session.commit()
aid = next( aid = next(
a["id"] for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json() a["id"]
for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json()
if a["filename"] == name if a["filename"] == name
) )
resp = await api.client.get(f"/api/messages/{mid}/attachments/{aid}") resp = await api.client.get(f"/api/messages/{mid}/attachments/{aid}")
@@ -390,7 +391,7 @@ async def test_folder_counts(api: ApiCtx) -> None:
# All three seeded inbox messages are unread; no drafts yet. # All three seeded inbox messages are unread; no drafts yet.
resp = await api.client.get("/api/mailboxes/counts") resp = await api.client.get("/api/mailboxes/counts")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json() == {"inbox_unread": 3, "drafts": 0} assert resp.json() == {"inbox_unread": 3, "drafts": 0, "snoozed": 0}
# Marking one message \Seen lowers the unread count. # Marking one message \Seen lowers the unread count.
msg = await api.session.get(Message, api.message_ids[0]) msg = await api.session.get(Message, api.message_ids[0])
@@ -404,8 +405,11 @@ async def test_folder_counts(api: ApiCtx) -> None:
async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None: async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None:
assert api.session is not None assert api.session is not None
# The kickoff thread's first message; give it a real delivery_id. # The kickoff thread's first message; give it a real delivery_id.
kickoff = next(t for t in (await api.client.get("/api/threads")).json()["threads"] kickoff = next(
if t["message_count"] == 2) t
for t in (await api.client.get("/api/threads")).json()["threads"]
if t["message_count"] == 2
)
detail = (await api.client.get(f"/api/threads/{kickoff['id']}")).json() detail = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
first_id = uuid.UUID(detail["messages"][0]["id"]) first_id = uuid.UUID(detail["messages"][0]["id"])
first = await api.session.get(Message, first_id) first = await api.session.get(Message, first_id)
@@ -414,20 +418,32 @@ async def test_thread_collapses_cross_account_duplicate(api: ApiCtx) -> None:
# A second account receives the SAME email (same delivery_id) into the same thread. # A second account receives the SAME email (same delivery_id) into the same thread.
acct2 = Account( acct2 = Account(
user_id=api.user_id, email="me2@example.com", imap_config={}, smtp_config={}, user_id=api.user_id,
secret_ct=b"", discovery_src="manual", email="me2@example.com",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
) )
api.session.add(acct2) api.session.add(acct2)
await api.session.flush() await api.session.flush()
inbox2 = Mailbox(account_id=acct2.id, user_id=api.user_id, path="INBOX", special_use="inbox") inbox2 = Mailbox(account_id=acct2.id, user_id=api.user_id, path="INBOX", special_use="inbox")
api.session.add(inbox2) api.session.add(inbox2)
await api.session.flush() await api.session.flush()
api.session.add(Message( api.session.add(
user_id=api.user_id, account_id=acct2.id, mailbox_id=inbox2.id, uid=1, Message(
thread_id=first.thread_id, message_id_hdr="<synthetic@synthetic.local>", user_id=api.user_id,
delivery_id="<shared@x.com>", subject=first.subject, account_id=acct2.id,
internal_date=datetime.now(UTC), blob_sha256="z" * 64, # later -> first stays the rep mailbox_id=inbox2.id,
)) uid=1,
thread_id=first.thread_id,
message_id_hdr="<synthetic@synthetic.local>",
delivery_id="<shared@x.com>",
subject=first.subject,
internal_date=datetime.now(UTC),
blob_sha256="z" * 64, # later -> first stays the rep
)
)
await api.session.commit() await api.session.commit()
body = (await api.client.get(f"/api/threads/{kickoff['id']}")).json() body = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
+41
View File
@@ -0,0 +1,41 @@
"""Sentence completion over the API, from the user's sent mail."""
from __future__ import annotations
from typing import Any
import pytest
from app.api import completion as api_completion
from app.completion import build_index_job
from tests.api.conftest import HDR, MailCtx, raw_mail
async def _suggest(ctx: MailCtx, before: str) -> str | None:
resp = await ctx.client.post("/api/completion", json={"before": before}, headers=HDR)
assert resp.status_code == 200, resp.text
return resp.json()["suggestion"]
async def test_suggests_from_sent_mail(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
queued: list[tuple[Any, ...]] = []
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
queued.append((name, *args))
monkeypatch.setattr(api_completion, "enqueue_job", fake_enqueue)
body = "Hi Anna,\nThe renders will be ready by Thursday evening.\n\nOn Mon Anna wrote:\n> ok"
for uid in (1, 2):
await mail_ctx.deliver(
raw_mail("Renders", sender="me@example.com").replace(b"Renders body", body.encode()),
uid,
folder="sent",
)
# No index yet: a build is asked for, and the starter phrases still answer.
assert await _suggest(mail_ctx, "I look forward ") == "to hearing from you."
assert queued and queued[0][0] == "build_index_job"
assert await build_index_job({}, str(mail_ctx.user_id)) > 0
assert await _suggest(mail_ctx, "The renders will be re") == "ady by Thursday evening."
assert await _suggest(mail_ctx, "> ok") is None # quoted text was not learned
+1 -3
View File
@@ -104,9 +104,7 @@ async def test_saved_search_crud_and_upsert(ctx: Ctx) -> None:
async def test_saved_search_requires_csrf(ctx: Ctx) -> None: async def test_saved_search_requires_csrf(ctx: Ctx) -> None:
# No CSRF header -> rejected (state-changing route). # No CSRF header -> rejected (state-changing route).
resp = await ctx.client.post( resp = await ctx.client.post("/api/saved-searches", json={"name": "x", "query": "y"})
"/api/saved-searches", json={"name": "x", "query": "y"}
)
assert resp.status_code == 403 assert resp.status_code == 403
+80
View File
@@ -0,0 +1,80 @@
"""Snooze: a conversation leaves the Inbox until a set time, then comes back."""
from __future__ import annotations
import datetime as dt
from sqlalchemy import select
from app.models import Message, Thread
from app.snooze import wake_due_job
from tests.api.conftest import HDR, MailCtx, raw_mail
def _in(days: float = 1) -> str:
return (dt.datetime.now(dt.UTC) + dt.timedelta(days=days)).isoformat()
async def _inbox_ids(ctx: MailCtx) -> list[str]:
resp = await ctx.client.get("/api/threads", params={"view": "folder:inbox"})
return [t["id"] for t in resp.json()["threads"]]
async def test_snoozed_thread_leaves_the_inbox(mail_ctx: MailCtx) -> None:
old = await mail_ctx.deliver(
raw_mail("Old"), 1, at=dt.datetime.now(dt.UTC) - dt.timedelta(days=2)
)
new = await mail_ctx.deliver(raw_mail("New"), 2)
c = mail_ctx.client
resp = await c.post(f"/api/threads/{new.thread_id}/snooze", json={"until": _in()}, headers=HDR)
assert resp.status_code == 200, resp.text
assert await _inbox_ids(mail_ctx) == [str(old.thread_id)]
snoozed = (await c.get("/api/threads", params={"view": "snoozed"})).json()["threads"]
assert [t["id"] for t in snoozed] == [str(new.thread_id)]
assert snoozed[0]["snoozed_until"] is not None
counts = (await c.get("/api/mailboxes/counts")).json()
assert counts["snoozed"] == 1 and counts["inbox_unread"] == 1
# Unsnooze: straight back, at the top.
await c.post(f"/api/threads/{new.thread_id}/unsnooze", headers=HDR)
assert await _inbox_ids(mail_ctx) == [str(new.thread_id), str(old.thread_id)]
async def test_snooze_needs_a_future_time(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("x"), 1)
for until in (_in(-1), _in(400), "2030-01-01T09:00:00"):
resp = await mail_ctx.client.post(
f"/api/threads/{msg.thread_id}/snooze", json={"until": until}, headers=HDR
)
assert resp.status_code == 400, until
async def test_due_thread_wakes_on_top_and_unread(mail_ctx: MailCtx) -> None:
week_ago = dt.datetime.now(dt.UTC) - dt.timedelta(days=7)
snoozed = await mail_ctx.deliver(raw_mail("Snoozed"), 1, seen=True, at=week_ago)
newer = await mail_ctx.deliver(raw_mail("Newer"), 2, seen=True)
thread = await mail_ctx.session.get(Thread, snoozed.thread_id)
assert thread is not None
thread.snoozed_until = dt.datetime.now(dt.UTC) - dt.timedelta(minutes=1)
thread.snoozed_at = week_ago
await mail_ctx.session.commit()
assert await wake_due_job({}) >= 1
assert await _inbox_ids(mail_ctx) == [str(snoozed.thread_id), str(newer.thread_id)]
snoozed_id = snoozed.id
mail_ctx.session.expire_all()
flags = await mail_ctx.session.scalar(select(Message.flags).where(Message.id == snoozed_id))
assert flags is not None and "\\Seen" not in flags
async def test_a_reply_wakes_a_snoozed_thread(mail_ctx: MailCtx) -> None:
first = await mail_ctx.deliver(raw_mail("Question", mid="<q@x>"), 1)
await mail_ctx.client.post(
f"/api/threads/{first.thread_id}/snooze", json={"until": _in(3)}, headers=HDR
)
m = raw_mail("Re: Question", headers={"In-Reply-To": "<q@x>", "References": "<q@x>"})
await mail_ctx.deliver(m, 2, at=dt.datetime.now(dt.UTC) + dt.timedelta(seconds=5))
await wake_due_job({})
assert await _inbox_ids(mail_ctx) == [str(first.thread_id)]
+239
View File
@@ -0,0 +1,239 @@
"""Subscriptions: bulk senders worked out from List-* headers, and unsubscribing."""
from __future__ import annotations
import datetime as dt
from typing import Any
import pytest
from sqlalchemy import select, update
from app.api import subscriptions as api_subs
from app.models import Draft, Mailbox, Message
from app.subscriptions import backfill_list_headers_job, read_list_headers, unsubscribe_targets
from tests.api.conftest import HDR, MailCtx, raw_mail
NEWS = {
"List-Id": '"Weekly news" <weekly.shop.example>',
"List-Unsubscribe": "<https://shop.example/u/1>, <mailto:leave@shop.example?subject=stop>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
def test_read_list_headers() -> None:
h = read_list_headers(raw_mail("x", headers=NEWS))
assert h.list_id == "weekly.shop.example"
assert h.unsubscribe.startswith("<https://shop.example/u/1>")
assert h.one_click is True
plain = read_list_headers(raw_mail("hi"))
assert (plain.list_id, plain.unsubscribe, plain.one_click) == (None, "", False)
def test_unsubscribe_targets() -> None:
t = unsubscribe_targets(NEWS["List-Unsubscribe"])
assert t.https == "https://shop.example/u/1"
assert t.mailto == "leave@shop.example" and t.mailto_subject == "stop"
assert unsubscribe_targets("<http://insecure.example/u>").https is None
assert unsubscribe_targets("<mailto:a@b.c%0d%0aBcc:x@y.z>").mailto is None
async def _list(ctx: MailCtx) -> list[dict[str, Any]]:
resp = await ctx.client.get("/api/subscriptions")
assert resp.status_code == 200, resp.text
return list(resp.json()["subscriptions"])
async def test_groups_by_list_then_sender(mail_ctx: MailCtx) -> None:
await mail_ctx.deliver(raw_mail("Issue 1", headers=NEWS), 1, seen=True)
await mail_ctx.deliver(raw_mail("Issue 2", headers=NEWS), 2)
await mail_ctx.deliver(
raw_mail(
"Sale",
sender="Deals <deals@other.example>",
headers={"List-Unsubscribe": "<mailto:u@other.example>"},
),
3,
)
await mail_ctx.deliver(raw_mail("Hi from a friend", sender="ann@friend.example"), 4)
subs = {s["key"]: s for s in await _list(mail_ctx)}
assert set(subs) == {"list:weekly.shop.example", "from:deals@other.example"}
news = subs["list:weekly.shop.example"]
assert (news["total"], news["unread"], news["name"]) == (2, 1, "News")
assert news["one_click"] and news["has_mailto"] and news["has_web"]
assert news["last_subject"] == "Issue 2"
threads = await mail_ctx.client.get(
"/api/subscriptions/threads", params={"key": "list:weekly.shop.example"}
)
assert len(threads.json()) == 2
async def test_one_click_unsubscribe(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
posted: list[str] = []
async def fake_one_click(url: str) -> bool:
posted.append(url)
return True
monkeypatch.setattr(api_subs, "_one_click", fake_one_click)
resp = await mail_ctx.client.post(
"/api/subscriptions/unsubscribe", json={"key": "list:weekly.shop.example"}, headers=HDR
)
assert resp.json() == {"status": "done", "method": "one_click", "url": None}
assert posted == ["https://shop.example/u/1"]
(news,) = await _list(mail_ctx)
assert news["unsubscribed_at"] is not None and news["still_sending"] is False
async def test_mailto_unsubscribe_and_still_sending(
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
) -> None:
enqueued: list[tuple[Any, ...]] = []
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
enqueued.append((name, *args))
monkeypatch.setattr(api_subs, "enqueue_job", fake_enqueue)
headers = {"List-Unsubscribe": "<mailto:leave@shop.example?subject=stop>"}
await mail_ctx.deliver(raw_mail("Issue", headers=headers), 1)
resp = await mail_ctx.client.post(
"/api/subscriptions/unsubscribe", json={"key": "from:news@shop.example"}, headers=HDR
)
assert resp.json()["method"] == "mailto"
assert [e[0] for e in enqueued] == ["send_job"]
draft = await mail_ctx.session.scalar(select(Draft).where(Draft.user_id == mail_ctx.user_id))
assert draft is not None
assert draft.payload["to"] == ["leave@shop.example"] and draft.payload["subject"] == "stop"
# Mail still arriving after the grace period is flagged.
later = dt.datetime.now(dt.UTC) + dt.timedelta(days=3)
await mail_ctx.deliver(raw_mail("Issue again", headers=headers), 2, at=later)
(sub,) = await _list(mail_ctx)
assert sub["still_sending"] is True
async def test_web_only_unsubscribe_hands_back_the_page(mail_ctx: MailCtx) -> None:
headers = {"List-Unsubscribe": "<https://shop.example/prefs>"}
await mail_ctx.deliver(raw_mail("Issue", headers=headers), 1)
resp = await mail_ctx.client.post(
"/api/subscriptions/unsubscribe", json={"key": "from:news@shop.example"}, headers=HDR
)
assert resp.json() == {"status": "open", "method": "web", "url": "https://shop.example/prefs"}
async def test_backfill_reads_older_mail(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("Old issue", headers=NEWS), 1)
await mail_ctx.session.execute(
update(Message).where(Message.id == msg.id).values(list_unsubscribe=None, list_id=None)
)
await mail_ctx.session.commit()
resp = await mail_ctx.client.get("/api/subscriptions")
assert resp.json()["pending"] == 1
assert await backfill_list_headers_job({}, str(mail_ctx.user_id)) == 1
resp = await mail_ctx.client.get("/api/subscriptions")
assert resp.json()["pending"] == 0
assert [s["key"] for s in resp.json()["subscriptions"]] == ["list:weekly.shop.example"]
async def test_trash_all_leaves_mixed_conversations(
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
) -> None:
moved: list[str] = []
async def fake_enqueue(name: str, *args: Any, **kwargs: Any) -> None:
moved.append(args[0])
monkeypatch.setattr(api_subs, "enqueue_job", fake_enqueue)
only = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS, mid="<i1@shop>"), 1)
await mail_ctx.deliver(raw_mail("Issue 2", headers=NEWS, mid="<i2@shop>"), 2)
reply = raw_mail(
"Re: Issue 2",
sender="me@example.com",
headers={"In-Reply-To": "<i2@shop>", "References": "<i2@shop>"},
)
await mail_ctx.deliver(reply, 3)
resp = await mail_ctx.client.post(
"/api/subscriptions/trash", json={"key": "list:weekly.shop.example"}, headers=HDR
)
assert resp.json() == {"moved": 1}
assert moved == [str(only.thread_id)]
async def test_deleted_mail_is_left_out(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
trash = await mail_ctx.session.scalar(
select(Mailbox.id).where(
Mailbox.account_id == mail_ctx.account_id, Mailbox.special_use == "trash"
)
)
await mail_ctx.session.execute(
update(Message).where(Message.id == msg.id).values(mailbox_id=trash)
)
await mail_ctx.session.commit()
assert await _list(mail_ctx) == []
async def test_reading_pane_offers_unsubscribe(
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch
) -> None:
async def fake_one_click(url: str) -> bool:
return True
monkeypatch.setattr(api_subs, "_one_click", fake_one_click)
news = await mail_ctx.deliver(raw_mail("Issue", headers=NEWS), 1)
plain = await mail_ctx.deliver(raw_mail("Hi", sender="ann@friend.example"), 2)
c = mail_ctx.client
(env,) = (await c.get(f"/api/threads/{news.thread_id}")).json()["messages"]
assert env["subscription_key"] == "list:weekly.shop.example"
assert env["unsubscribe_via"] == "one_click" and env["unsubscribed"] is False
(other,) = (await c.get(f"/api/threads/{plain.thread_id}")).json()["messages"]
assert other["subscription_key"] is None and other["unsubscribe_via"] is None
resp = await c.post(
"/api/subscriptions/unsubscribe",
json={"key": env["subscription_key"], "message_id": env["id"]},
headers=HDR,
)
assert resp.json()["status"] == "done"
(env,) = (await c.get(f"/api/threads/{news.thread_id}")).json()["messages"]
assert env["unsubscribed"] is True
# A message without the header can't be used.
resp = await c.post(
"/api/subscriptions/unsubscribe",
json={"key": "from:ann@friend.example", "message_id": str(plain.id)},
headers=HDR,
)
assert resp.status_code == 404
async def test_senders_come_in_pages_newest_first(mail_ctx: MailCtx) -> None:
now = dt.datetime.now(dt.UTC)
for i, name in enumerate(["old", "mid", "new"]):
await mail_ctx.deliver(
raw_mail(
f"From {name}",
sender=f"{name}@lists.example",
headers={"List-Unsubscribe": f"<mailto:u@{name}.example>"},
),
i + 1,
at=now - dt.timedelta(days=10 - i),
)
c = mail_ctx.client
first = (await c.get("/api/subscriptions", params={"limit": 2})).json()
assert first["total"] == 3
assert [s["email"] for s in first["subscriptions"]] == [
"new@lists.example",
"mid@lists.example",
]
rest = (await c.get("/api/subscriptions", params={"limit": 2, "offset": 2})).json()
assert [s["email"] for s in rest["subscriptions"]] == ["old@lists.example"]
assert (await c.get("/api/subscriptions", params={"offset": 9})).json()["subscriptions"] == []
oldest = (await c.get("/api/subscriptions", params={"limit": 2, "order": "asc"})).json()
assert [s["email"] for s in oldest["subscriptions"]] == [
"old@lists.example",
"mid@lists.example",
]
+54
View File
@@ -0,0 +1,54 @@
"""Templates: saved replies from the compose window's More menu."""
from __future__ import annotations
from tests.api.conftest import HDR, UserCtx
async def test_save_list_replace_delete(user_ctx: UserCtx) -> None:
c = user_ctx.client
assert (await c.get("/api/templates")).json() == []
resp = await c.post(
"/api/templates",
json={"name": " Thanks ", "subject": "Re: offer", "body_html": "<p>Thank <b>you</b></p>"},
headers=HDR,
)
assert resp.status_code == 200, resp.text
first = resp.json()
assert first["name"] == "Thanks" and first["subject"] == "Re: offer"
# Same name, any case: replaces rather than adding a second one.
resp = await c.post(
"/api/templates", json={"name": "thanks", "body_html": "<p>Cheers</p>"}, headers=HDR
)
assert resp.json()["id"] == first["id"]
await c.post("/api/templates", json={"name": "Apology", "body_html": "x"}, headers=HDR)
listed = (await c.get("/api/templates")).json()
assert [t["name"] for t in listed] == ["Apology", "thanks"]
assert listed[1]["body_html"] == "<p>Cheers</p>"
assert (await c.delete(f"/api/templates/{first['id']}", headers=HDR)).status_code == 200
assert [t["name"] for t in (await c.get("/api/templates")).json()] == ["Apology"]
async def test_template_html_is_cleaned(user_ctx: UserCtx) -> None:
resp = await user_ctx.client.post(
"/api/templates",
json={
"name": "x",
"body_html": '<p onclick="evil()">Hi<script>alert(1)</script>'
'<img src="x" onerror="evil()"><a href="javascript:evil()">l</a></p>',
},
headers=HDR,
)
html = resp.json()["body_html"]
assert "script" not in html and "onclick" not in html and "onerror" not in html
assert "javascript:" not in html and "Hi" in html
async def test_template_needs_a_name(user_ctx: UserCtx) -> None:
resp = await user_ctx.client.post(
"/api/templates", json={"name": " ", "body_html": "x"}, headers=HDR
)
assert resp.status_code == 400
+45
View File
@@ -0,0 +1,45 @@
"""Tracker blocking (Settings → Privacy): on by default, and it can be switched off."""
from __future__ import annotations
from tests.api.conftest import HDR, MailCtx, raw_mail
PIXEL = "https://t.example.com/open.gif?u=42"
HTML = f'<p>Hello</p><img src="{PIXEL}" width="1" height="1">'
async def _html(ctx: MailCtx, message_id: object, images: bool = False) -> tuple[str, str]:
resp = await ctx.client.get(
f"/api/messages/{message_id}/html", params={"images": "1"} if images else {}
)
assert resp.status_code == 200
return resp.text, resp.headers["X-Trackers-Blocked"]
async def test_trackers_blocked_by_default(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("News", html=HTML), 1)
assert (await mail_ctx.client.get("/api/settings")).json()["block_trackers"] is True
body, count = await _html(mail_ctx, msg.id, images=True)
assert count == "1" and PIXEL not in body and "Hello" in body
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
assert env["has_remote_images"] is False # nothing left worth loading
async def test_switched_off_trackers_are_ordinary_remote_images(mail_ctx: MailCtx) -> None:
msg = await mail_ctx.deliver(raw_mail("News", html=HTML), 1)
resp = await mail_ctx.client.patch("/api/settings", json={"block_trackers": False}, headers=HDR)
assert resp.json()["block_trackers"] is False
# Still a remote image: not loaded until images are allowed, but offered.
body, count = await _html(mail_ctx, msg.id)
assert count == "0" and PIXEL not in body
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
assert env["has_remote_images"] is True
body, count = await _html(mail_ctx, msg.id, images=True)
assert count == "0" and PIXEL in body.replace("&amp;", "&")
# Back on: gone again.
await mail_ctx.client.patch("/api/settings", json={"block_trackers": True}, headers=HDR)
body, count = await _html(mail_ctx, msg.id, images=True)
assert count == "1" and PIXEL not in body
+86
View File
@@ -0,0 +1,86 @@
"""Email translation over the API, with LibreTranslate replaced by a stand-in."""
from __future__ import annotations
import pytest
from app import translation
from tests.api.conftest import MailCtx, raw_mail
TEXT = "Hello Lukas, I would like to ask about the project schedule for next month."
@pytest.fixture
def fake_service(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
calls: dict[str, object] = {"detect": 0, "translate": 0}
async def detect(text: str) -> str:
calls["detect"] = int(calls["detect"]) + 1 # type: ignore[call-overload]
return "en"
async def translate_html(
html: str, source: str, target: str, keep: object = (), names: object = ()
) -> str:
calls["translate"] = int(calls["translate"]) + 1 # type: ignore[call-overload]
calls["keep"] = list(keep) # type: ignore[call-overload]
calls["names"] = list(names) # type: ignore[call-overload]
assert source == "en" and target == "pl"
return "<p>Cześć Łukasz</p><script>alert(1)</script>"
monkeypatch.setattr(translation, "enabled", lambda: True)
monkeypatch.setattr(translation, "detect", detect)
monkeypatch.setattr(translation, "translate_html", translate_html)
return calls
async def test_language_is_found_once_and_kept(
mail_ctx: MailCtx, fake_service: dict[str, object]
) -> None:
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
for _ in range(2):
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html")
assert resp.headers["X-Content-Language"] == "en"
assert fake_service["detect"] == 1
(env,) = (await mail_ctx.client.get(f"/api/threads/{msg.thread_id}")).json()["messages"]
assert env["content_language"] == "en"
async def test_translation_is_cleaned_and_kept(
mail_ctx: MailCtx, fake_service: dict[str, object]
) -> None:
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
for _ in range(2):
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html", params={"translate": "pl"})
assert resp.status_code == 200
assert "Cześć Łukasz" in resp.text and "alert(1)" not in resp.text
assert resp.headers["X-Translated-From"] == "en"
assert resp.headers["X-Translated-To"] == "pl"
assert fake_service["translate"] == 1 # the second came from the cache
# The people on the message are names to keep if the model would change them.
assert "News" in fake_service["names"] # type: ignore[operator]
async def test_service_trouble_is_a_clear_error(
mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch, fake_service: dict[str, object]
) -> None:
async def broken(
html: str, source: str, target: str, keep: object = (), names: object = ()
) -> str:
raise translation.TranslationError("the translation service is unreachable")
monkeypatch.setattr(translation, "translate_html", broken)
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html", params={"translate": "pl"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == "translation_failed"
# The message itself still shows.
assert (await mail_ctx.client.get(f"/api/messages/{msg.id}/html")).status_code == 200
async def test_off_without_a_service(mail_ctx: MailCtx, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(translation, "enabled", lambda: False)
msg = await mail_ctx.deliver(raw_mail("Project", html=f"<p>{TEXT}</p>"), 1)
resp = await mail_ctx.client.get(f"/api/messages/{msg.id}/html")
assert "X-Content-Language" not in resp.headers
info = (await mail_ctx.client.get("/api/translation/languages")).json()
assert info == {"available": False, "languages": []}
+15
View File
@@ -18,6 +18,21 @@ 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"
# No calls to the breached-password service from tests (app/identity/password_policy.py).
os.environ["PASSWORD_BREACH_CHECK"] = "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

Some files were not shown because too many files have changed in this diff Show More