#24: deliver what the composer wrote, keep a copy, and store drafts

Four faults, all in the path between the compose screen and the mailbox.

**Every message sent from the webmail had an empty body.** The HTTP handler
read a field named "body"; the composer has always sent "html". The two never
matched, so the payload handed to the queue contained headers and nothing
else. The handler now reads html and text, and still accepts body so an older
client is not broken. Sending was otherwise working, which is why this went
unnoticed: the mail left, it just said nothing.

**Bcc was discarded.** The field was absent from the request struct and from
ComposeInput, so a blind copy typed into the webmail reached nobody. It now
joins the envelope recipients and is deliberately kept out of the headers,
which is what makes it blind.

**HTML was labelled text/plain.** Even once the body was carried, the composer
produces markup and the message declared text/plain, so a recipient would see
raw tags. Messages with HTML now go out as multipart/alternative with a
plain-text part derived from the markup and ordered first per RFC 2046 5.1.4.
An empty or missing text alternative is itself a spam signal.

**Sent mail left no trace.** Nothing wrote to the Sent folder, so a delivered
message vanished from the sender's view entirely. A copy is now filed after
the message is durably queued - not before, or a send refused by the rate
limit would still leave a copy - reusing the inbound persistence path so it
gets a real IMAP UID and appears over IMAP too. A failure to file is logged
and swallowed: the mail is already on its way, and reporting failure would
invite the user to send it twice.

**Drafts were never saved.** There was no endpoint at all. The composer showed
"draft saved automatically" from a setTimeout with no request behind it, so
closing the tab lost the message the screen had just promised was safe. Adds
POST /api/v1/mail/draft, storing the draft as a whole RFC 5322 message in the
Drafts folder, and debounces the composer onto it.

The composer also navigated to Sent whether or not the send succeeded, which
is how a rejected message disappears silently. It now reports the error and
stays put.

**Delay notifications were being filed as spam.** A DSN this server writes is
injected through the same inbound path as mail from the internet, but without
a client IP - so SPF can only be "none" - and its bounce-shaped content scores
highly. The warning telling you your mail was delayed landed in your own Junk
folder. Messages the server generates itself now skip content scanning; the
exemption is narrow, and a test covers that ordinary mail is still scanned so
this cannot become a way past the filter. The DSN also declared
Auto-Submitted: auto-replied, which belongs to vacation responders; a delivery
report is auto-generated (RFC 3834 section 5), and filters read that header.

Not fixed here, because it is not a code fault: outbound port 25 is blocked by
the provider, which is what the delay notification was reporting
("i/o timeout" to Google's MX - a timeout, not a refusal, is the signature of
a block). Receiving is unaffected and queued mail keeps retrying. Documented
with the two ways out, including the relay path, which is verified working
code rather than another stub.

Verified: 22 Go packages pass, 5 new compose tests and 3 for the DSN handling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 19:56:59 -03:00
1 parent 8dab254729
commit d8e2de2e11
15 files changed
+629 -36

No files matched your search

+2
View File
@@ -138,7 +138,9 @@
"bulletList": "Bulleted list",
"compose": "Compose",
"discard": "Discard",
"draftSaveFailed": "Draft could not be saved",
"draftSaved": "Draft saved automatically",
"draftSaving": "Saving draft...",
"drafts": "Drafts",
"forward": "Forward",
"hideCcBcc": "- Hide Cc/Bcc",
+2
View File
@@ -138,7 +138,9 @@
"bulletList": "Lista con viñetas",
"compose": "Redactar",
"discard": "Descartar",
"draftSaveFailed": "No se pudo guardar el borrador",
"draftSaved": "Borrador guardado automáticamente",
"draftSaving": "Guardando borrador...",
"drafts": "Borradores",
"forward": "Reenviar",
"hideCcBcc": "- Ocultar Cc/Cco",
+2
View File
@@ -138,7 +138,9 @@
"bulletList": "Lista com marcadores",
"compose": "Escrever",
"discard": "Descartar",
"draftSaveFailed": "Não foi possível salvar o rascunho",
"draftSaved": "Rascunho salvo automaticamente",
"draftSaving": "Salvando rascunho...",
"drafts": "Rascunhos",
"forward": "Encaminhar",
"hideCcBcc": "- Ocultar Cc/Cco",
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useRef, useState, use } from "react";
import React, { useCallback, useEffect, useRef, useState, use } from "react";
import Link from "next/link";
import {
Send,
@@ -21,6 +21,14 @@ import { useTranslations } from "../../../../i18n/provider";
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
import { Button } from "../../../../components/ui/Button";
/** Splits a comma or semicolon separated field, dropping empty entries. */
function splitAddresses(value: string): string[] {
return value
.split(/[,;]/)
.map((entry) => entry.trim())
.filter(Boolean);
}
interface AttachedFile {
id: string;
name: string;
@@ -45,6 +53,7 @@ export default function ComposePage({
const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
const [draftStatus, setDraftStatus] = useState<string>("");
const [sendError, setSendError] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
@@ -55,13 +64,39 @@ export default function ComposePage({
}
}, []);
// Saves the draft for real. This used to be a setTimeout that set the words
// "draft saved automatically" and nothing else - no request, no storage - so
// closing the tab lost the message while the screen had just said otherwise.
const saveDraft = useCallback(async () => {
const html = editorRef.current?.innerHTML ?? "";
if (!to && !subject && !html.trim()) return;
setDraftStatus(t("mail.draftSaving"));
try {
const res = await fetch("/api/v1/mail/draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: splitAddresses(to),
cc: splitAddresses(cc),
bcc: splitAddresses(bcc),
subject,
html,
}),
});
setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed"));
} catch {
setDraftStatus(t("mail.draftSaveFailed"));
}
}, [to, cc, bcc, subject, t]);
// Debounced so a draft is written after a pause in typing rather than on
// every keystroke. Skipped once the message is on its way out.
useEffect(() => {
if (sending) return;
if (!to && !subject) return;
const timer = setTimeout(() => {
setDraftStatus(t("mail.draftSaved"));
}, 2500);
const timer = setTimeout(() => void saveDraft(), 2500);
return () => clearTimeout(timer);
}, [to, subject]);
}, [to, subject, cc, bcc, sending, saveDraft]);
const executeFormat = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
@@ -104,20 +139,28 @@ export default function ComposePage({
const sendActualMessage = async (htmlBody: string) => {
try {
await fetch("/api/v1/mail/send", {
const res = await fetch("/api/v1/mail/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: to.split(",").map((s) => s.trim()),
cc: cc ? cc.split(",").map((s) => s.trim()) : [],
bcc: bcc ? bcc.split(",").map((s) => s.trim()) : [],
to: splitAddresses(to),
cc: splitAddresses(cc),
bcc: splitAddresses(bcc),
subject,
html: htmlBody,
attachments: attachments.map((a) => a.name),
}),
});
if (!res.ok) {
// Reporting success on a refused send is how a message silently
// disappears: the composer used to navigate away regardless.
const data = await res.json().catch(() => ({}));
setSendError(data.message ?? t("errors.serverError"));
setSending(false);
return;
}
window.location.href = "/user/mail/sent";
} catch {
setSendError(t("errors.serverError"));
setSending(false);
}
};
@@ -143,6 +186,12 @@ export default function ComposePage({
)}
</div>
{sendError && (
<div className="p-4 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
{sendError}
</div>
)}
{undoSeconds !== null && (
<div className="p-4 rounded-xl bg-indigo-600/20 border border-indigo-500/40 flex items-center justify-between text-indigo-300 text-xs">
<div className="flex items-center gap-2">