Mail¶
HomeCloud Mail is a control-plane mail product on top of the Stalwart engine.
Architecture¶
| Layer | Role |
|---|---|
Console /console/mail |
Mailbox list + service metadata card |
Console /console/mail/{mailboxId} |
Professional 3-pane email client per mailbox |
homecloud-api /accounts/{id}/mail/* |
JWT APIs, Postgres metadata |
| Stalwart (K3s) | SMTP/IMAP + message content (source of truth) |
Bodies, attachments, and folders are not stored in Postgres — Stalwart is the single source of truth.
Console navigation¶
Same pattern as SO / Queues: list → resource detail. Opening a mailbox keeps the mail chrome visible while data loads (no full-page skeleton flash); console shell padding stays stable across the Mail section.
/console/mail— mailbox table (primary), Create mailbox, Templates, Contacts, and a collapsible Service status & DNS panel./console/mail/templates— visual Template Studio gallery (My templates preview cards + Ready-made starters)./console/mail/templates/{id}— Template Studio Pro (Design | Preview | Code)./console/mail/contacts— address book with search, multi-select, and bulk delete./console/mail/{mailboxId}— email client:- Desktop / tablet — 3-pane layout: folders sidebar, message list, reader/compose; mailbox shown as a Gmail-like identity chip (avatar + display name + bold email); New message and Full screen sit in a toolbar under that chip (fullscreen exit bar shows the same identity)
- Mobile — single-screen stack (list → reader → compose/settings) with slide-in navigation; folders open from ☰ or the folder title (drawer); FAB for new message; true edge-to-edge (console shell padding removed on the mail client); long recipient lines truncate (
name +N); list scrolls clear of the FAB and status footer - Sidebar — Inbox, Sent, Drafts, Trash, Archive, Search, Settings, and Compose (desktop)
- Message list — sender avatars, subject, preview, relative dates, unread dot, attachment indicator; multi-select checkboxes with bulk trash (and permanent delete in Trash)
- Message view — sanitized HTML rendering (DOMPurify) in a sandboxed iframe; fixed-width marketing shells (e.g. 600px Brevo tables) soft-fit to the reader width; horizontal scroll when content still overflows (zoom / rigid layouts); plain text fallback; attachment downloads; action toolbar
Contacts & bulk¶
- Contacts —
/console/mail/contactsaccount address book; multi-select + bulk delete - Compose — pick contacts into To (one message to many recipients)
- API —
GET/POST /contacts,POST /contacts/bulk-delete,POST /messages/bulk-trash,POST /messages/bulk-delete
There is no separate global Messages / Domains / Settings tab on the list page. The service status section is collapsed by default so mailboxes stay front and centre.
Email client features¶
Compose¶
- Rich text editor (Tiptap) with toolbar: bold, italic, underline, strikethrough, font size, headings, lists, blockquote, links (dialog for URL + optional label; uses selection when text is highlighted), inline images/logos (toolbar, paste, or drop; sent as
cid:multipart/related so Gmail shows them), text alignment, clear formatting, undo/redo — active marks/blocks are highlighted in the toolbar; lists and quotes use visible styles (bullets/numbers/border) - Insert template — compiles the template and keeps full email HTML (tables/styles) for free text edits — TipTap is not used for template bodies (it would strip layout).
- Text direction — compose wraps HTML with
dir="rtl"(orltr) from UI language / Hebrew-Arabic content so Gmail and other clients keep the same direction as the editor - Gmail-style email chips for To, CC, BCC — tokenize on space, comma, Enter; click a chip to edit the address; remove with backspace or X; bidi/zero-width junk stripped; send blocked if any chip is invalid
- Recipient addresses — invisible / bidirectional Unicode marks (common when pasting from RTL Gmail UI) are stripped on compose chips and on send; invalid addresses are rejected before SMTP
- HTML body (
body_html) and plain text (body_text) sent to API - Attachment support — paperclip button opens file picker, drag-and-drop anywhere on compose form; files encoded as base64 and sent with the message
- Empty 0-byte bounce/DSN placeholder parts are hidden from the attachment list
- Upload progress indicator during send with attachments
- Ctrl+Enter keyboard shortcut to send
Reply / Reply All / Forward¶
- Reply — prefills To = original sender (bare email), Subject =
Re: {subject}, original body as blockquote - Reply All — prefills all recipients minus self (sanitized bare emails)
- Forward — prefills Subject =
Fwd: {subject}, includes forwarded message header - Replies set
In-Reply-ToandReferencesSMTP headers for proper conversation threading
Conversation threading¶
- Backend fetches
Message-ID,In-Reply-To, andReferencesheaders from Stalwart via IMAP - Headers stored in Postgres metadata for thread grouping
- Replies link to original messages via standard RFC 2822 headers
Delete / Trash / Permanent delete¶
- Delete moves messages to Trash via IMAP (COPY + DELETE + EXPUNGE)
- Restore moves messages from Trash back to INBOX
- Permanent delete removes from Stalwart and Postgres
- Trash folder visible in sidebar
Sent folder¶
- Sent messages pulled from Stalwart IMAP Sent folder (not just control-plane metadata)
- Full body and headers available when viewing sent messages
- Send result — success means Stalwart accepted the message for outbound delivery (not a guarantee the remote inbox accepted it)
- Immediate SMTP rejections surface as status
failedwith the server reason in the UI - Async failures (e.g. Gmail bounce after accept) appear as Delivery Status / bounce mail in Inbox; when the bounce references a sent
Message-ID, that outbound row is markedfailed
Gmail / major providers — delivery notes¶
- Gmail rejects direct submission from residential / unauthorized IPs (
550 5.7.1 The IP you're using to send mail is not authorized…). Outbound must leave from a mail server IP with proper PTR + SPF + DKIM (Stalwart on your public mail host), not a home ISP IP. If you see this bounce, checkMAIL_PUBLIC_IP, DNS, and that Stalwart is the egress MTA — do not expect Gmail to accept DIY SMTP from a dynamic client IP. - Addresses contaminated with RTL bidi marks (e.g.
user@gmail.com+ U+202C/U+200F) causeDNS resolution error: Malformed label— fixed by address sanitization on compose/send.
Attachments¶
- Received attachment metadata extracted from IMAP MIME parts
- Attachment download via authenticated blob request (not bare links)
- Attachment list displayed in message view with filename, size, and download button
- Attachment indicator (paperclip icon) in message list
- Compose: file picker + drag-and-drop, base64 encoding, sent as part of the message
- Inline
cid:images rewritten to download URLs for proper rendering
Search¶
- Search box in sidebar triggers IMAP SEARCH via Stalwart
- Searches across Subject, From, and To fields
- Results displayed in message list
Read tracking¶
- Messages marked as read (
is_read) when opened via GET detail - Unread messages shown with bold text and a primary-color dot
- Unread count badge on Inbox in sidebar
HTML email rendering¶
- Inbound HTML emails sanitized with DOMPurify and rendered in a sandboxed iframe
- Presentation preserved — HTML profile allow-list keeps layout tags (
center, tables,font, authorstyle/align); only active/dangerous tags are stripped (script, forms, iframes, …) - Plain text fallback with
whitespace-pre-wrap - Outer reader chrome uses card padding; the iframe body itself has no inner padding so author layout controls spacing
- Dark themes (Homelab / Midnight) — transparent iframe (card shows through) with soft default text for unstyled mail; forced-white canvases / near-black text softened for readability without rewriting layout
- Outbound compose still wraps with UI
dirso Hebrew stays correct in external clients
Folders¶
- Inbox, Sent, Drafts, Trash, Archive — all backed by Stalwart IMAP folders
- Folder creation handled automatically by IMAP operations
Mailbox settings¶
- Display name — shown as the sender name in outgoing emails (From header)
- Signature — seeded into the compose editor (after
--) so you can edit or remove it before send; not appended silently by the API - Forwarding —
forward_tois saved and applied to Stalwart via Sieve (redirect :copykeeps a local copy) - Automation builder — collapsible When… Then… rule cards with a live summary; multiple conditions (all of / any of) and multiple actions; drag to reorder priority; duplicate / enable / export–import JSON; client-side test against a sample message + Sieve preview. Compiled to the same active Sieve script as forwarding. Atomic reorder:
POST …/mailboxes/{id}/rules/reorderwith{ "ids": [...] } - Settings accessible from the sidebar "Settings" tab inside each mailbox
- Settings (and Compose) use the full content pane — the empty message list is hidden; the form scrolls with a sticky Save bar
Email templates (Template Studio Pro)¶
Account-scoped reusable layouts owned as JSON blocks (document_json), compiled to email-safe HTML tables via mail_hbs (no Unlayer lock-in; HTML is not the source of truth).
| Surface | Path |
|---|---|
| Gallery | /console/mail/templates — My templates + ready-made starter cards with live thumbnails |
| Studio | /console/mail/templates/{id} — Design | Preview | Code |
| API | GET/POST /accounts/{id}/mail/templates, GET …/templates/starters, POST …/templates/lint, POST …/preview, POST …/{id}/render, POST …/{id}/duplicate, GET/POST …/mail/assets, POST …/mail/assets/upload, POST …/mail/assets/from-object |
| Send | Compose injects full HTML, or POST …/messages with optional template_id + template_variables |
Visual gallery¶
- My templates — card grid with scaled iframe thumbnails from
POST …/templates/preview - Ready-made — starter catalog from
GET …/templates/starters(includespreview_html) - Actions: open, duplicate, delete; one New template (blank); search by name
Document model¶
- Blocks:
header,text,button,image,section(children, depth ≤ 3),columns,list,code,divider,spacer,footer,preheader,hero,logo_row,product_card - Buttons support pill radius +
hoverBg - Merge tags:
{{user_name}},{{company_name}},{{cta_url}},{{portal_url}}, digest/product/notice fields, etc. - Starters: blank, welcome, notification, announcement, promo, invoice, plus inspired layouts
network_digest,promo_grid,system_notice(pattern-inspired; no brand assets)
Template Studio Pro modes¶
| Mode | Behavior |
|---|---|
| Design | Unified top bar (Back, editable name, autosave status, icon Undo/Redo, device frames, Design/Preview/Code, dark preview); preview-first canvas; left Components | Layers (+ Ready presets); right Inspector with merge-tag autocomplete and mail image assets (upload / recent / my files → managed assetId + HTTPS src for canvas); Preview variables when no block is selected (document_json.previewVariables); leave warning when dirty; icon selection toolbar (move, duplicate, delete) |
| Preview | Full-width device frames (Desktop / Tablet / Mobile) + dark preview; same compiled HTML |
| Code | Monaco split (compiled HTML + Format) | live preview; Issues panel from POST …/templates/lint |
Mail template images¶
Images in Studio are mail assets, not raw private bucket URLs:
| Action | API | Stored on block props |
|---|---|---|
| Upload from device | POST …/mail/assets/upload |
assetId (UUID) + src (public HTTPS for immediate preview) |
| Recent | GET …/mail/assets |
same |
| My files | POST …/mail/assets/from-object { bucket, key } |
copies into mail-assets, then same |
| Paste link | — | src only (merge tags / external HTTPS); clears assetId |
Header logos use logoUrl (+ optional assetId). Logo rows store assetId/src per logo entry. Renderer modes: contain | full | fixed | original (fixedWidth when fixed).
Preview variables¶
document_json.previewVariables is a string map merged over sample defaults on preview/render. Edit in the Inspector when no block is selected; Reset to defaults clears overrides back to samples.
Responsive Email Engine (renderer-owned)¶
Every compile produces email-safe table HTML that is responsive by default (ADR-030). Responsive behavior is defined at component level and enforced by mail_hbs — templates do not invent ad-hoc CSS.
| Rule | Behavior |
|---|---|
| Shell | Desktop max-width ≈600px; fluid width:100% + outer padding on mobile |
| Breakpoint | @media only screen and (max-width:600px) |
| Images | width attr + max-width:100%;height:auto |
| Buttons | Full-width tap target on mobile (opt out via responsive.mobile.fullWidth: false) |
| Columns | Stack on mobile by default (mobile: "side-by-side" or stack: false to keep row) |
| Intent | Optional responsive.mobile / shorthand mobile: "stack" on blocks; unknown keys ignored |
Compatibility validator¶
POST …/templates/lint checks compiled HTML + document for Outlook/Gmail pitfalls (scripts, flex/grid, missing alt, oversized payloads, weak button hrefs). Studio shows results in the Issues panel.
Compose template insert¶
- Insert template → compiles via preview API → full email HTML kept (tables/styles) in
MailHtmlBodyEditor— not TipTap (TipTap strips email layout) - Subject filled from the template; body is freely editable
- Autosave / send use that HTML like any other message
- Optional chip: “Inserted from template …” (dismissible)
Studio is the only full builder. Compose does not use a locked slot form.
Phase 1 scope¶
- One platform mail domain seeded from
MAIL_DOMAIN(DB row, not hardcoded) - Create / delete mailboxes on the Platform Mail Account
- Professional 3-pane email client with compose, reply, forward, trash, search
- Rich text compose with Tiptap editor
- HTML email rendering with DOMPurify sanitization
- CC/BCC support in compose
- Conversation threading via In-Reply-To/References headers
- Read/unread tracking with visual indicators and badge counts
- Attachment download and upload (base64 encoding)
- Inline cid: image rendering
- Sent folder with full message bodies from IMAP
- Search across messages
- Permanent delete from Trash
- Mailbox profile: display name, signature, forwarding
- Gmail-style email chips for recipients
- DNS hints on the list-page metadata card (MX, A, SPF, DKIM placeholder, DMARC)
System Mail Identities (ADR-035)¶
OTP codes, user invites, and account invites are sent through SystemMailService, a
single control-plane entry point for system mail — no more SMTP_*-only path:
| Layer | Role |
|---|---|
| Application services (auth, invites, …) | Call system_mail.send("noreply", ...) by identity key, never a hard-coded address |
| SystemMailService | Resolves the enabled System Identity + Mailbox, decrypts credentials, delegates to the Mail Provider |
| Mail Provider (Stalwart) | Same shared MTA as customer mail — no second system mail server |
SMTP_* (email_delivery) |
Dev escape hatch / fallback only — used when no identity or provider is configured, not the production path |
System Identity ≠ Mailbox owner. A System Identity (e.g. noreply) points at a
Mailbox that is flagged ownership=system. Platform Admin manages that mailbox in
the console Mail UI (Inbox/Sent/Drafts/Settings) but is not its owner — the System
owns it. Non-system mailboxes are unaffected and remain Account-scoped exactly as
before.
Migration preserves the mailbox. If a noreply@{MAIL_DOMAIN} mailbox already
existed (e.g. created earlier on the Platform Mail Account), HomeCloud does not
create a second mailbox for that address. On first use it flips that same row's
ownership to system and attaches a System Identity to it — same Mailbox ID, same
Stalwart principal, same stored mail, same UI. System mailboxes cannot be deleted from
the console or API (403); everything else about mailbox management is unchanged.
Not in Phase 1 (follow-ups)¶
- Moving mail DNS management under Account → Domains
- Spam/AV, tenant-owned domains
- Access Key gateway (future SES-like API)
- Event/webhook inbound (Phase 1 uses pull; events later)
- True fuzzy “similar” matching beyond Sieve
:matches/:contains
Deliverability hardening¶
Console Status → Deliverability runs live checks and can Fix safe CloudNS records when CLOUDNS_* is configured on the API.
| Item | Recommended | Notes |
|---|---|---|
| SPF | v=spf1 mx -all |
Use mx (not ip4:) so a home/dynamic IP stays valid when you update the mail A record |
| DKIM | TXT from Stalwart | Fix DKIM publishes the key + forces CloudNS zone sync |
| DMARC | p=quarantine + rua/ruf |
Create a mailbox for reports (e.g. dmarc-reports@) |
| TLS-RPT | _smtp._tls TXT |
Aggregate TLS failure reports |
| MTA-STS | _mta-sts TXT + HTTPS policy |
Policy served at https://mta-sts.<domain>/.well-known/mta-sts.txt (mode: testing until SMTP certs are solid) |
| PTR / rDNS | mail.<domain> |
ISP only — residential (e.g. Cellcom/Netvision bb.*) usually cannot set PTR |
| BIMI | later | Needs DMARC quarantine/reject + SVG (+ VMC for Gmail logos) |
| ARC | Stalwart-native | Useful mainly for forwarding chains; not required for direct SMTP |
Bootstrap / refresh forward DNS on the homelab host:
Then add Traefik route Host(mta-sts.<domain>) → API (see homecloud-infra/platform/traefik/dynamic/console-host.yml.example).
Configuration (examples)¶
MAIL_DOMAIN=example.com
MAIL_HOSTNAME=mail.example.com
MAIL_PUBLIC_IP=203.0.113.10
MAIL_TRANSPORT=DIRECT_SMTP
MAIL_PLATFORM_ACCOUNT_SHORT_ID=abc123def456
STALWART_ADMIN_URL=http://127.0.0.1:18080
STALWART_ADMIN_USER=admin
STALWART_ADMIN_PASSWORD=...
CLOUDNS_AUTH_ID=...
CLOUDNS_AUTH_PASSWORD=...
CLOUDNS_ZONE=example.com
# Optional after Traefik mta-sts route exists:
# MAIL_MTA_STS_POLICY_URL=https://mta-sts.example.com/.well-known/mta-sts.txt
Router: forward TCP 25 / 465 / 587 to the K3s node. Do not expose management port 18080 publicly.
Stalwart 0.16 listeners
Stalwart 0.16 stores listener config inside the data store, not config.json.
Plain-text IMAP (143) and submission (587) listeners are not created by default.
Create them via the JMAP management API (x:NetworkListener/set) or the Stalwart WebUI,
then restart the pod. The Helm chart exposes hostPort: 143 and hostPort: 993 so the
API container on the host can reach IMAP.
API examples¶
PowerShell — list mailboxes¶
$headers = @{ Authorization = "Bearer $TOKEN" }
Invoke-RestMethod -Headers $headers `
-Uri "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/mailboxes"
Bash — list inbox for a mailbox¶
curl -sS "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/messages?mailbox_id=$MAILBOX_ID&direction=INBOUND" \
-H "Authorization: Bearer $TOKEN"
Bash — compose send with CC and threading¶
curl -sS -X POST "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"mailbox_id":"…","to":["you@example.com"],"cc":["team@example.com"],"subject":"Re: Hello","body_html":"<p>Reply</p>","body_text":"Reply","in_reply_to":"<original-msg-id>","references":"<original-msg-id>"}'
Bash — search messages¶
curl -sS "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/messages?mailbox_id=$MAILBOX_ID&search=invoice" \
-H "Authorization: Bearer $TOKEN"
Bash — create an automation rule¶
curl -sS -X POST "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/mailboxes/$MAILBOX_ID/rules" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Invoices","match":"all","conditions":[{"field":"from","op":"is","value":["billing@acme.com"]},{"field":"has_attachment","op":"is","value":true}],"actions":[{"type":"fileinto","mailbox":"Invoices"}]}'
Bash — reorder automation rules¶
curl -sS -X POST "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/mailboxes/$MAILBOX_ID/rules/reorder" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ids":["RULE_UUID_1","RULE_UUID_2","RULE_UUID_3"]}'
Bash — download attachment¶
curl -sS -o attachment.pdf \
"https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/messages/$MESSAGE_ID/attachments/0" \
-H "Authorization: Bearer $TOKEN"
Bash — update mailbox profile (display name, signature, forwarding)¶
curl -sS -X PATCH "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/mailboxes/$MAILBOX_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"display_name":"Support Team","signature_html":"<p>Best regards,<br/>Support</p>","forward_to":"backup@example.com"}'
Bash — trash / restore / permanent delete¶
# Trash
curl -sS -X POST "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/messages/$MESSAGE_ID/trash" \
-H "Authorization: Bearer $TOKEN"
# Restore
curl -sS -X POST "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/messages/$MESSAGE_ID/restore" \
-H "Authorization: Bearer $TOKEN"
# Permanent delete
curl -sS -X DELETE "https://console.example.com/api/v1/accounts/$ACCOUNT_ID/mail/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $TOKEN"
Deploy engine¶
Chart and CI live in homecloud-data-plane/services/mail/ — see that README.