Compose, send and drafts
Purpose. Write and send mail from the webmail with the things people expect from a daily driver: reply/forward quoting, attachments, drafts that survive a closed tab, and an undo window.
Behaviour.
- Compose (
c, New message, Replyr, Reply alla, Forwardf). New message heads the folder column of the mail views, with its keycbeside it (a floating button on a phone), and is not offered on Settings, where it would open over a half-edited form. - The composer is a window, and several can be open. It docks at the bottom right, 720 × 680 and never taller than the screen, so its top is never cut off; its body scrolls and its Send row stays in view. The bar along its top holds Minimise (or Esc), Full size (over the mail, which dims behind it), Open full screen and Close. Minimised, it is a dark bar in a tray at the bottom of the screen showing its subject; a click on the bar, or Restore draft, brings it back. Only one composer is open at a time: opening or restoring one minimises whichever was open, so a second New message, Reply or Forward starts a second draft beside the first instead of replacing it, which is what it did until 2026-10-19 — losing whatever had been typed since the last autosave. Opening a saved draft that is already open brings that composer forward rather than opening it twice. Full screen is its own page, at
/compose/<draft>: the saved draft’s uid once it has one, so a reload or a bookmark reopens it from Drafts, and the URL follows the uid as autosaves replace it. Back to mail, the dock button or the browser’s Back return to the mail with the draft docked. The composer takes focus when it opens or is restored: the To field when it is empty, the body otherwise. Minimising moves focus to its bar, and closing returns it where it was. ⌘/Ctrl+Enter sends. The mailbox’s single-key shortcuts never fire while focus is anywhere inside a composer, its buttons included: until 2026-10-19cpressed on its Send button opened another composer, andearchived the message behind it. - Closing saves to Drafts. Close, saving to Drafts saves the draft when anything has changed since it was last saved, and closes it once the save has landed. If the save fails, the composer stays open with the reason, and nothing is lost. Discard draft (the bin at the far right of the composer’s footer, after the save status) is the separate way to throw a draft away. It asks first, then deletes the saved copy outright rather than moving it to Trash. A save still in flight when Discard is confirmed has its copy deleted as it lands. Closing during the undo window does not cancel the send: the composer leaves the screen while it counts down, and comes back with the error if the send fails.
- The sheet has a To field, with Cc and Bcc behind buttons beside it until asked for (or shown at once when the draft already has some). Each takes addresses separated by commas, semicolons or spaces, with autocomplete over the Sent folder (
GET /mail/contacts, ranked by how often you have written to the address; ↑/↓ to move, Enter or Tab to accept, Esc to dismiss, and addresses already in the field are not offered again), subject, the body editor with the signature appended (see User settings (webmail) and Rich text below), drag-and-drop or picker attachments (uploaded first:POST /mail/uploads, multipart, ≤ 25 MB each, kept for an hour after they were last used). - Recipients are chips. Each address becomes a chip with an avatar and the display name the sent history knows for it, and a × to remove it; Enter or a separator commits what is typed, and Backspace in the empty input removes the last chip. A chip that is not an address (no
@, or a domain with one label, such ascarol@example) is red, and Send refuses until it is fixed, naming it. The check is deliberately loose: it is there to catch a slip before Send, and the receiving server still decides. The draft holds each field as the same comma-separated text as before, so saving, sending and reopening did not change. There is no From selector: a mailbox sends from its one address until alias senders arrive after 1.0. - Attachments. Attach (the paperclip) is in the toolbar, and in the plain-text composer’s row of tools. Files dragged over the composer show an overlay saying how many will be attached and the 25 MB limit. Files upload side by side, each on its own chip with a progress bar (
XMLHttpRequest, sincefetchreports no upload progress) and its own × to cancel. A file over 25 MB is refused before a byte is sent, and its chip says so until it is removed. A refusal from the server, or a failed upload, stays on that file’s chip too. Until 2026-10-19 files went one after another with no progress, and a too-large one was only refused once it had gone up. Send waits while anything is uploading. A forward’s own attachments show their names and sizes and can be removed; they used to read forwarded attachment 2. - Insert signature (the pen) puts the signature at the cursor, or after the text in the plain-text composer. It is there for messages the signature was left off by Settings → Compose & signature (
/settings/compose, User settings (webmail)), which also holds Write messages in plain text, Quote the original message in replies and the undo-send window: Add my signature to every message I send, and under it Also on replies and forwards. Both are on by default, as the composer behaved before. - AI is a button at the right of the toolbar, shown only when AI features are available to the mailbox. It opens the Write with AI panel under the body and closes it again. The panel’s actions are those in AI inbox features. Until 2026-10-19 the panel was always open, three rows under every body.
- Rich text is the composer unless the mailbox chose Write messages in plain text. The editor is TipTap, the same as the signature’s. Its toolbar, labelled Formatting, has undo and redo, bold, italic, underline, strikethrough, colour, links, images by
httpsaddress, bulleted and numbered lists, block quotes, alignment and a divider. Every message it sends ismultipart/alternative: the HTML, and a text part rendered from it. Files dropped or pasted onto the editor become attachments; there are no inline pasted images, since the server keeps onlyhttpsimages. The message being replied to or forwarded is kept out of the editor: it sits under it as Quoted text, read-only and shown without its images (so Reply never loads a remote image the reading pane is holding back), with Remove, and is appended after the body on send — an HTML original quoted as it was, tables and all, in a cited block quote, where an editor would have flattened it into paragraphs. A saved draft marks the quote, so reopening it puts the body back in the editor and the quote back under it; a draft from another client opens in the editor whole, and one written in the plain-text composer opens from its text, line by line. The text part marks a block quote’s lines with>./mail/sendand/mail/draftsaccept a body of up to 6 MiB, so a long thread quoted in full is not refused by the API’s default 1 MiB. A request over 6 MiB answers413 message_too_large; an HTML part over 2,000,000 characters inside it answers400 validation. The AI actions work on the text and give back paragraphs, and their Undo restores the formatted body. The HTML part is cleaned by the API before it is sent or saved (sanitizeComposeHtml,apps/api/src/services/compose-html.ts): formatting, links,httpsimages and the markup quoted mail is made of — never script, handlers, forms, frames,<style>blocks or adata:/cid:image. - Reply / forward quote the original — in the plain-text composer as
>lines with an attribution line —, unless Quote the original message in replies is off, which leaves the quote out of a reply (a forward always carries the original); replies setIn-Reply-To/Referencesfrom the original and flag it\Answeredafter sending; forwards can carry the original’s attachments (forwardParts) without re-uploading. - Send (
POST /mail/send): the API composes the RFC 5322 message (nodemailerMailComposer), submits it through Postfix:587as the mailbox (master-user SASL, envelope sender = the mailbox address, so DKIM/SPF/rate limits apply exactly as for a desktop client), appends a copy to Sent, and deletes the draft it came from. The client holds the send for the undo window (undoSendSeconds, default 5 s): the composer stays open saying Sending in 5s…, counting down a second at a time (until 2026-10-19 the number never moved), and Undo cancels it — nothing has left the server yet. Closing the composer during the window does not cancel the send. A send that some recipients refused is not a failure:POST /mail/sendanswers with the refused addresses inrejected, and the composer stays open holding only those, with Sent, but not to everyone and each one’s reason, so sending again cannot reach the others twice. - Send later: the ▾ beside Send offers Send later — tomorrow morning (8:00), tomorrow afternoon (13:00), Monday morning, or any date and time, from a minute to a year ahead (the server accepts up to 366 days), in the browser’s own time. The message waits on the server, not in the browser:
POST /mail/scheduledcomposes it at once, attachments included, stores it whole inscheduled_message, and deletes the draft, so it leaves with the browser closed and an API restart loses nothing. The API sends what is due every 15 seconds through the same submission as an immediate send (so the licence’s send stop, the rate limits and DKIM apply as they do to a send), files the Sent copy and marks a reply’s original answered. A temporary failure is tried again five minutes later, for up to six attempts in all; a refusal is final, and so is an outcome nobody knows, which is recorded as may or may not have been sent. Each 15-second pass picks up at most 20 due messages. A message whose mailbox is removed goes with it; a suspension or deactivation holds it (below). When some recipients refuse it, it counts as sent and the addresses it was not delivered to go to the API’s log; when every recipient refuses it, it fails. Scheduled in the folder column (shown while anything waits) lists them at/scheduled, each with Send now, Change time, Edit (back to a draft, opened in the composer; the schedule is cancelled) and Cancel sending (a confirm first); one that could not be sent stays there marked Not sent with the server’s reason. A message caught mid-send by a restart is marked failed rather than sent again, since whether it left is unknown. The loop runs in the API, which holds the sending code and the IMAP bridge the Sent copy goes through; the API is a single instance, and each message is claimed withFOR UPDATE SKIP LOCKEDall the same. Nothing is kept once it is sent or cancelled: the row and its whole message (raw, attachments included) are deleted, and the Sent copy is the record. Ending the mailbox’s sessions holds what waits — a password change or reset, a suspension, a deactivation — and so does ending the sessions of an administrator who scheduled one from a borrowed session: a held message is Not sent with the reason, and leaves only when somebody sends it or gives it a new time. Amailctl restoreholds every waiting one the same way, since the backup cannot say which already left. Send now makes it due by the database’s clock, which the loop reads. - Drafts (
PUT /mail/drafts): autosaved every 10 seconds while there are unsaved changes, and on close, into the Drafts folder, replacing the previous draft uid; the composer shows Saved 10:44 after each save. Saves run one at a time and a send waits for the last, because each replaces the copy it read the uid of: two at once would leave a duplicate. A draft may have no recipient yet:PUT /mail/draftstakes any To list, an empty one included, wherePOST /mail/sendneeds at least one address. Until 2026-10-19 the draft took Send’s rule, so a draft with nothing in To was never autosaved. Opening a draft from the list restores it into the composer — recipients, subject, text and its attachments. The composer reads each attachment back out of the saved draft (GET /mail/messages/{uid}/parts/{part}) and uploads it, so it becomes an ordinary attachment: removable, carried by the send, and re-attached if the API loses it. Until that finishes, Send and the autosave wait — an autosave replaces the saved draft, and the attachments are still being read out of it. If any attachment cannot be read back, the composer says so and detaches from the saved draft: the send and any autosave leave it in place, so the files are not lost with it, and a later save creates a new draft beside it. Closing the composer while attachments are still being read back leaves the saved draft as it was when nothing has changed; with changes, it minimises, waits for the attachments, and then saves. Inline parts — anything with a Content-ID that the body shows — are not restored: the rich composer keeps onlyhttpsimages, so acid:reference has nothing to point at, and the plain-text composer has nowhere to put them. Send is also disabled while a file is uploading. Before 2026-09-13 a reopened draft came back with no attachments, and sending it deleted the draft that held them. A reply’s draft also keeps what it replies to: the API writesX-MailDock-Reply: <uid> <folder>into the saved draft (never into what is sent), so a reply sent after reopening its draft still carries the fullReferenceschain and flags the original\Answered. Until 2026-10-01 a reopened reply knew only itsIn-Reply-To. If the original has been moved or deleted since the reply began, the reply still threads under itsIn-Reply-Toalone, and nothing is flagged\Answered— the send goes out either way. - Signature, per
settings.signature: in the rich composer it is drafted inside the editor as itself, formatted, under a--line, below the body and so above the quoted original in a reply, and is edited like the rest of the message. With Write messages in plain text the plain-text composer drafts it after a--line instead — below the body for a new message, above the quote in a reply — and an HTML signature (signatureHtml) is drafted as its plain-text rendering and makes the messagemultipart/alternative: the text part is the draft as written, and the HTML part is the draft escaped with its line breaks kept, with the formatted signature in place of its text block while that block is unedited (draftToHtml,apps/webmail/src/lib/signature.ts). A dashed note under that composer’s body says so, with the formatted signature shown. Compose asked for beforeGET /mail/accounthas answered —cpressed straight after a page load — opens when it answers, so the draft always starts with the signature; until then it drafted none, and nothing added it afterwards. If the account cannot be loaded at all, compose opens without the signature and says so under the body.
Configuration. None beyond the bridge settings (Webmail). The compose-internal submission hop upgrades with STARTTLS whenever Postfix offers it, and nothing configures that: Postfix offers it exactly when tls.mode is not none, which is also when it renders smtpd_tls_security_level=encrypt and refuses AUTH on a plain session. With tls.mode=none nothing is offered and the hop stays plain. The certificate is not verified, because it names the public hostname rather than postfix. Until that was fixed, the API forced the hop plain from a SUBMISSION_TLS variable Compose never passed, so every install with TLS refused webmail send with 530 5.7.0 Must issue a STARTTLS command first — reproduced on maildock-mail on 2026-09-13.
Limits & edge cases.
- The plain-text composer remains, per mailbox (Write messages in plain text in Settings → Compose & signature), and a draft opens in whichever composer the mailbox uses when it is opened.
- The quoted original cannot be edited in place in the rich composer — only kept or removed — and its inline
cid:anddata:images are dropped from the quote — the parts acid:points at are not forwarded with a reply, and the server keeps onlyhttpsimages. Pasting files onto the editor attaches them only when the clipboard holds no text: Office puts a picture of the selection beside it, and the text wins. - Uploads live in API memory, per mailbox, for an hour after they were last used — so an API restart drops unsent uploads, and the send that follows is refused rather than half-delivered.
POST /mail/sendandPUT /mail/draftsanswer409 uploads_missingwhen any upload id no longer resolves; nothing is submitted, nothing is filed in Sent, and the draft is kept. The composer keeps each attached file in the page, so on that answer it uploads them again, swaps in the new ids and says so — press Send again; it never sends on the user’s behalf. An autosave that meets the same answer re-attaches silently and marks the draft changed, so the next autosave saves it; a close that meets it re-attaches, stays open and says to close again. Reading uploads does not consume them — a draft save and a send both read — and only a send that has left releases them, so the same ids cannot be sent twice. Before 2026-09-13 both halves were broken: the draft autosave consumed the uploads, so a send more than ten seconds after attaching went out without its attachments even with no restart, and a send after a restart did the same — each reported as sent, with a stripped Sent copy as the only trace. - Undo is client-side (the request is delayed), so closing the tab during the undo window cancels the send; the draft remains.
- Open composers live in the page. A reload keeps only what was saved: the full-screen one comes back from its URL, and the others are in Drafts as of their last save, at most ten seconds old. A recipient typed without an
@is not an address, so the saved copy leaves it out, though the open composer keeps it. On a phone an open composer is always the whole screen, so there is no docked or full-size mode there, and the full-screen URL is not offered. - The full-screen route’s first segment is
compose, so a folder named exactlycomposecannot open its messages by URL:/compose/<uid>is the composer’s. - Autocomplete only knows the last 500 Sent messages (cached 10 minutes, refreshed after each send) — it is a recency-and-frequency list, not an address book, so a first-time recipient is never suggested.
- A refused send is classified as the messages API’s are (
classifySubmissionError,apps/api/src/services/message-service.ts): a rate limit (Outbound rate limits), submission’s451namingRatelimit, is429 rate_limited; any other5xxreply — the licence’s send stop among them — is422 rejected; another4xx, or a failure before the message was handed over, is503 temporarily_unavailable; each carries the daemon’s text inmessage. Only an outcome nobody knows — the connection dropped after the message went — is left to the global error handler as a500. The composer keeps the message in every case, which is the part that matters to the person sending it.