Initial commit: webmail client (M0/M1)

Three-pane webmail client.
- Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP
  sync, threading, search, drafts/outbox with undo, attachments, 2FA.
- Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane
  UI with rich-text composer, light/dark themes, resizable panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
"""Assemble an RFC822 message from a draft (SPEC §6.5).
Builds a multipart/alternative (auto-generated text from HTML), sets threading
headers from the message being replied to, and returns the raw bytes plus the
SMTP envelope. Attachment assembly is deferred to M2 (attachment upload).
"""
from __future__ import annotations
from datetime import UTC, datetime
from email.message import EmailMessage
from email.utils import format_datetime, make_msgid
import nh3
from app.models import Account, Draft, Message
def _html_to_text(html: str) -> str:
return nh3.clean(html, tags=set(), attributes={}, strip_comments=True)
def _addr_list(values: object) -> list[str]:
if not isinstance(values, list):
return []
out: list[str] = []
for v in values:
if isinstance(v, str) and v.strip():
out.append(v.strip())
elif isinstance(v, dict) and v.get("email"):
name, email = v.get("name", ""), v["email"]
out.append(f"{name} <{email}>" if name else str(email))
return out
def _email_only(formatted: str) -> str:
if "<" in formatted and ">" in formatted:
return formatted.split("<", 1)[1].split(">", 1)[0].strip()
return formatted.strip()
def _bracket(mid: str) -> str:
mid = mid.strip()
return mid if mid.startswith("<") else f"<{mid}>"
def build_mime(
account: Account,
draft: Draft,
reply_to: Message | None,
attachments: list[tuple[str, str, bytes]] | None = None,
) -> tuple[bytes, str, list[str]]:
"""Return ``(raw_rfc822, envelope_from, envelope_to)`` for ``draft``.
``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the
compose Attach action; each is added as a file attachment part.
"""
payload = draft.payload
to = _addr_list(payload.get("to"))
cc = _addr_list(payload.get("cc"))
bcc = _addr_list(payload.get("bcc"))
subject = str(payload.get("subject", ""))
html = str(payload.get("html", "") or "")
text = str(payload.get("text", "") or "")
msg = EmailMessage()
from_name = account.display_name or ""
msg["From"] = f"{from_name} <{account.email}>" if from_name else account.email
if to:
msg["To"] = ", ".join(to)
if cc:
msg["Cc"] = ", ".join(cc)
msg["Subject"] = subject
msg["Date"] = format_datetime(datetime.now(UTC))
domain = account.email.rsplit("@", 1)[-1]
msg["Message-ID"] = make_msgid(domain=domain)
# Threading headers so the recipient's client threads the reply (SPEC M1).
if reply_to is not None and reply_to.message_id_hdr:
parent = _bracket(reply_to.message_id_hdr)
msg["In-Reply-To"] = parent
refs = [_bracket(r) for r in reply_to.references_hdr] + [parent]
msg["References"] = " ".join(refs)
# Body: text + HTML alternative (auto text when only HTML given).
if html:
msg.set_content(text or _html_to_text(html))
msg.add_alternative(html, subtype="html")
else:
msg.set_content(text)
for filename, mime_type, data in attachments or []:
maintype, _, subtype = mime_type.partition("/")
msg.add_attachment(
data,
maintype=maintype or "application",
subtype=subtype or "octet-stream",
filename=filename,
)
envelope_to = [_email_only(a) for a in (to + cc + bcc)]
return msg.as_bytes(), account.email, envelope_to