#24: merge feature/24-webmail-send-and-drafts into main

This commit is contained in:
Lucas Rayan committed 2026-08-02 19:57:08 -03:00
commit 707e6ae80e
15 files changed
+629 -36

No files matched your search

+32
View File
@@ -238,6 +238,38 @@ for the delivery worker. A `4.7.5` deferral means the destination published a
TLS policy that could not be satisfied — that is deliberate; the message is
retried rather than sent in the clear.
**"Delivery Delay Notification" with `dial tcp …:25: i/o timeout`.** This is
step 1 of the prerequisites, and it is the single most common reason a new
deployment cannot send anywhere:
```
Reason: dial tcp alt4.gmail-smtp-in.l.google.com:25: dial tcp 192.178.213.26:25: i/o timeout
```
A *timeout* rather than a refusal is the signature of a provider block —
nothing is answering because the packets never leave. Receiving still works,
and the queued messages keep retrying, so nothing is lost while you sort it
out. Two ways forward:
1. Ask the provider to unblock outbound port 25 for the host.
2. Send through a relay, which needs no cooperation from them:
```bash
RELAY_HOST=smtp.your-provider.example
RELAY_PORT=587
RELAY_USER=...
RELAY_PASS=...
```
Delivery then goes to the relay for every destination, and the relay is
added to the published SPF record automatically — without that the relay's
IP is not authorised for your domain and everything it forwards fails SPF.
Credentials are only ever sent over STARTTLS; if the relay does not offer
it, the message is deferred rather than the password sent in the clear.
Watch the backlog drain on the admin console's outbound queue screen, or with
`make preflight` for the port check itself.
**`/health` returns `X-LambdaMail-Health: degraded`.** The stack is running but
a certificate needs attention: usually no certificate exists for
`PRIMARY_MAIL_HOST` because no router was declared for it, or one is expiring
@@ -165,9 +165,12 @@ func run(cfg config) {
// The webmail reads and sends through this service because the folder,
// UID, flag and blob logic already lives here; duplicating it in another
// service would mean two implementations of the same IMAP semantics.
// WithLocalFiling is what puts a copy of every sent message in Sent and
// lets drafts be stored at all. Without it the composer's mail left the
// building with no trace in the sender's own mailbox.
webmailUC := usecase.NewWebmailUseCase(
postgres.NewWebmailRepository(pool), blobReader, submissionUC, authRepo, cfg.PrimaryMailHost,
)
).WithLocalFiling(blobs, messages)
router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) })
if cfg.JwtSecret == "" {
@@ -0,0 +1,110 @@
package usecase
import (
"bytes"
"context"
"strings"
"testing"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/valueobject"
)
// junkingScanner files everything as spam, standing in for the real scanner
// scoring a bounce-shaped message with no SPF result.
type junkingScanner struct{ called bool }
func (s *junkingScanner) Scan(_ context.Context, _ port.ScanInput) (*valueobject.ScanResult, error) {
s.called = true
return &valueobject.ScanResult{
Verdict: valueobject.ScanVerdictSpamJunk,
Score: 12.0,
Symbols: map[string]float64{},
HeadersToAdd: map[string]string{},
}, nil
}
// A delivery status notification this server wrote itself must never be
// spam-filtered.
//
// The report goes through the same inbound path as mail from the internet, but
// it is injected with no client IP, so SPF can only be "none" and the content
// looks exactly like a bounce. The result was that "your message to X was
// delayed" arrived in the sender's own Junk folder - the single notification
// they most need to see, filed where they will not look.
func TestSystemGeneratedMessageIsNotSpamFiltered(t *testing.T) {
blobs := &stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "dsn", SizeBytes: 10}}
messages := &recordingMessageRepository{}
scanner := &junkingScanner{}
uc := NewProcessInboundEmailUseCase(nil, blobs, messages)
uc.SetScanner(scanner)
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "postmaster@mail.example.test",
Recipients: []port.MailboxRecord{{ID: uuid.New(), MaxMessageBytes: 100000}},
RecipientAddresses: []string{"user@example.test"},
Body: bytes.NewReader([]byte("Subject: Delivery Delay Notification\r\n\r\ndelayed\r\n")),
SystemGenerated: true,
})
if err != nil {
t.Fatalf("Handle: %v", err)
}
if scanner.called {
t.Error("the scanner ran on a message this server generated itself")
}
if len(messages.persisted) != 1 {
t.Fatalf("want 1 persisted message, got %d", len(messages.persisted))
}
if folder := messages.persisted[0].TargetFolderName; folder == "Junk" {
t.Errorf("a postmaster notification was filed as Junk")
}
}
// The exemption must be narrow: anything actually received from a peer is
// still scanned, or marking a message "system generated" would be a way past
// the spam filter.
func TestOrdinaryMessageIsStillScanned(t *testing.T) {
blobs := &stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "x", SizeBytes: 10}}
messages := &recordingMessageRepository{}
scanner := &junkingScanner{}
uc := NewProcessInboundEmailUseCase(nil, blobs, messages)
uc.SetScanner(scanner)
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
Sender: "stranger@spam.test",
Recipients: []port.MailboxRecord{{ID: uuid.New(), MaxMessageBytes: 100000}},
RecipientAddresses: []string{"user@example.test"},
Body: bytes.NewReader([]byte("Subject: buy pills\r\n\r\nspam\r\n")),
})
if err != nil {
t.Fatalf("Handle: %v", err)
}
if !scanner.called {
t.Fatal("an ordinary inbound message skipped the scanner")
}
if messages.persisted[0].TargetFolderName != "Junk" {
t.Errorf("a message the scanner called spam was not filed as Junk, got %q",
messages.persisted[0].TargetFolderName)
}
}
// A DSN announces itself as automatic so the far side's vacation responder
// does not answer it. RFC 3834 section 5 distinguishes the two values, and
// filters read the header.
func TestDsnDeclaresItselfAutoGenerated(t *testing.T) {
payload, loop := valueobject.BuildDsnReport(
valueobject.DsnActionDelayed, "mail.example.test",
"sender@example.test", "far@remote.test", "abc", "timeout",
)
if loop {
t.Fatal("unexpected loop prevention")
}
if !strings.Contains(string(payload), "Auto-Submitted: auto-generated") {
t.Errorf("a delivery report is auto-generated, not auto-replied:\n%s", payload)
}
}
@@ -256,6 +256,9 @@ func (w *OutboundWorkerUseCase) sendDsn(ctx context.Context, action valueobject.
Recipients: targets,
RecipientAddresses: addresses,
Body: bytes.NewReader(dsnBytes),
// This server wrote this message a moment ago; scanning our own
// postmaster notification only ever files it as Junk.
SystemGenerated: true,
})
}
@@ -40,6 +40,16 @@ type ProcessInboundEmailInput struct {
// produce a "none" verdict.
ClientIP net.IP
HeloDomain string
// SystemGenerated marks a message this server produced itself - a delivery
// status notification, for instance - rather than one accepted from a peer.
//
// Such a message must not be spam-scanned. It is injected without a client
// IP, so SPF can only come back "none", and its bounce-shaped content then
// scores highly: the effect was that a "your mail to X was delayed" warning
// landed in the sender's own Junk folder, which is precisely where the one
// notification they need to read must never go. It also has no envelope
// sender to greylist and nothing to quarantine.
SystemGenerated bool
}
// ProcessInboundEmailUseCase implements the inbound half of PLAN.md section
@@ -196,7 +206,7 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
}
}
if uc.scanner != nil {
if uc.scanner != nil && !input.SystemGenerated {
recipientAddr := ""
if len(input.RecipientAddresses) > 0 {
recipientAddr = input.RecipientAddresses[0]
@@ -0,0 +1,114 @@
package usecase
import (
"strings"
"testing"
)
// Every one of these reproduces a way the composer's mail was being mangled or
// lost between the browser and the queue.
func TestComposedMessageCarriesTheBody(t *testing.T) {
// The HTTP layer read a field called "body" while the composer had always
// sent "html", so the message that reached this function had an empty body
// and every mail sent from the webmail arrived blank.
payload := string(buildMimeMessage(ComposeInput{
From: "me@example.test",
To: []string{"you@example.test"},
Subject: "Hello",
HTML: "<p>Real content</p>",
}, []string{"you@example.test"}, "mail.example.test"))
if !strings.Contains(payload, "Real content") {
t.Fatalf("the body is missing from the message:\n%s", payload)
}
}
func TestHtmlIsSentAsHtml(t *testing.T) {
payload := string(buildMimeMessage(ComposeInput{
From: "me@example.test",
To: []string{"you@example.test"},
Subject: "Formatted",
HTML: "<p>Hello <b>there</b></p>",
}, []string{"you@example.test"}, "mail.example.test"))
if !strings.Contains(payload, "multipart/alternative") {
t.Errorf("want multipart/alternative for an HTML message, got:\n%s", payload)
}
if !strings.Contains(payload, "Content-Type: text/html; charset=utf-8") {
t.Errorf("the HTML part is not declared as text/html:\n%s", payload)
}
// A plain-text alternative must exist and must not be empty: an
// HTML-only multipart message scores badly with every spam filter.
if !strings.Contains(payload, "Content-Type: text/plain; charset=utf-8") {
t.Errorf("no plain-text alternative:\n%s", payload)
}
if !strings.Contains(payload, "Hello there") {
t.Errorf("the plain-text alternative did not get a readable fallback:\n%s", payload)
}
// text/plain must come first - least preferred first, RFC 2046 5.1.4.
if strings.Index(payload, "text/plain") > strings.Index(payload, "text/html") {
t.Error("text/plain must precede text/html in multipart/alternative")
}
}
func TestPlainTextOnlyMessageStaysSimple(t *testing.T) {
payload := string(buildMimeMessage(ComposeInput{
From: "me@example.test",
To: []string{"you@example.test"},
Subject: "Plain",
Text: "just words",
}, []string{"you@example.test"}, "mail.example.test"))
if strings.Contains(payload, "multipart") {
t.Errorf("a text-only message should not be multipart:\n%s", payload)
}
if !strings.Contains(payload, "just words") {
t.Errorf("body missing:\n%s", payload)
}
}
func TestBccNeverAppearsInTheHeaders(t *testing.T) {
// Bcc belongs to the envelope alone. Leaking it into the headers would
// show every recipient who else was blind-copied.
input := ComposeInput{
From: "me@example.test",
To: []string{"you@example.test"},
Bcc: []string{"secret@example.test"},
Subject: "Quiet",
Text: "hi",
}
recipients := normaliseRecipients(
append(append(append([]string{}, input.To...), input.Cc...), input.Bcc...))
// It does reach the envelope, or the blind copy is simply not delivered -
// which is what happened while Bcc was not read from the request at all.
found := false
for _, r := range recipients {
if r == "secret@example.test" {
found = true
}
}
if !found {
t.Fatalf("bcc recipient missing from the envelope: %v", recipients)
}
payload := string(buildMimeMessage(input, recipients, "mail.example.test"))
headers, _, _ := strings.Cut(payload, "\r\n\r\n")
if strings.Contains(strings.ToLower(headers), "bcc") ||
strings.Contains(headers, "secret@example.test") {
t.Errorf("bcc leaked into the headers:\n%s", headers)
}
}
func TestHtmlToPlainTextIsReadable(t *testing.T) {
got := htmlToPlainText("<p>First line</p><p>Second &amp; last</p><br><ul><li>one</li><li>two</li></ul>")
for _, want := range []string{"First line", "Second & last", "- one", "- two"} {
if !strings.Contains(got, want) {
t.Errorf("plain-text fallback missing %q, got:\n%s", want, got)
}
}
if strings.Contains(got, "<") || strings.Contains(got, "&amp;") {
t.Errorf("markup survived into the plain-text part:\n%s", got)
}
}
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"log"
"mime"
"net/mail"
"strings"
@@ -31,6 +32,12 @@ type WebmailUseCase struct {
submission *ProcessOutboundEmailUseCase
auth port.AuthRepository
localHost string
// blobStore and messages file the sender's own copy into Sent, and hold
// drafts. Both are optional: when either is nil the copy is skipped rather
// than the send failing, because a message that has already been queued
// for delivery must not be reported as an error.
blobStore port.BlobStorage
messages port.InboundMessageRepository
}
func NewWebmailUseCase(
@@ -46,6 +53,13 @@ func NewWebmailUseCase(
return &WebmailUseCase{repo: repo, blobs: blobs, submission: submission, auth: auth, localHost: localHost}
}
// WithLocalFiling enables the Sent copy and draft storage.
func (uc *WebmailUseCase) WithLocalFiling(store port.BlobStorage, messages port.InboundMessageRepository) *WebmailUseCase {
uc.blobStore = store
uc.messages = messages
return uc
}
// MailboxIDFor resolves a session's address to its mailbox, which the event
// stream needs so it can filter pushes per account.
func (uc *WebmailUseCase) MailboxIDFor(ctx context.Context, address string) (string, error) {
@@ -114,8 +128,16 @@ type ComposeInput struct {
From string
To []string
Cc []string
// Bcc reaches the envelope but never the headers - that is the whole
// point of it. It used not to exist here at all, so a blind copy typed
// into the webmail was silently dropped and nobody received it.
Bcc []string
Subject string
Body string
// Text is the plain-text body. HTML, when present, is what the composer
// produced; both are sent together as multipart/alternative so a reader
// that cannot or will not render HTML still has something to show.
Text string
HTML string
}
// ErrNoRecipients rejects a submission with nobody to deliver to, before it
@@ -126,7 +148,10 @@ var ErrNoRecipients = errors.New("webmail: at least one recipient is required")
// case the SMTP ports use, so webmail mail gets the identical treatment:
// send limits, DKIM signing and the durable queue.
func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
recipients := normaliseRecipients(append(append([]string{}, input.To...), input.Cc...))
// Bcc joins the envelope here and is left out of buildMimeMessage, which
// is what makes it blind.
recipients := normaliseRecipients(
append(append(append([]string{}, input.To...), input.Cc...), input.Bcc...))
if len(recipients) == 0 {
return ErrNoRecipients
}
@@ -141,16 +166,111 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
payload := buildMimeMessage(input, recipients, uc.localHost)
return uc.submission.Submit(ctx, ProcessOutboundEmailInput{
if err := uc.submission.Submit(ctx, ProcessOutboundEmailInput{
MailboxID: account.ID,
SenderAddr: account.EmailAddress,
DomainName: account.DomainName,
Recipients: recipients,
Body: bytes.NewReader(payload),
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
})
}); err != nil {
return err
}
// Only after the message is durably queued. Filing it first would leave a
// copy in Sent for a message that was then rejected by the send limit.
//
// A failure here is logged and swallowed: the mail is already on its way,
// and telling the composer the send failed would invite a duplicate.
if err := uc.fileLocalCopy(ctx, account.ID, account.EmailAddress, "Sent", payload); err != nil {
log.Printf("webmail: could not file the Sent copy for %s: %v", account.EmailAddress, err)
}
return nil
}
// fileLocalCopy stores a message the user themselves composed into one of
// their own folders. It reuses the inbound persistence path so the copy gets a
// real IMAP UID and shows up over IMAP as well as in the webmail.
func (uc *WebmailUseCase) fileLocalCopy(
ctx context.Context, mailboxID uuid.UUID, address, folder string, payload []byte,
) error {
if uc.blobStore == nil || uc.messages == nil {
return nil
}
blob, err := uc.blobStore.Store(ctx, bytes.NewReader(payload))
if err != nil {
return err
}
headers := ExtractMessageHeaders(payload)
_, err = uc.messages.PersistAll(ctx, []port.PersistInboundMessageInput{{
MailboxID: mailboxID,
Blob: blob,
SenderAddress: address,
RecipientAddress: address,
TargetFolderName: folder,
Subject: headers.Subject,
Snippet: headers.Snippet,
FromDisplayName: headers.FromDisplayName,
MessageIDHeader: headers.MessageID,
HasAttachments: headers.HasAttachments,
}})
return err
}
// SaveDraft files the message being composed into the Drafts folder.
//
// Nothing did this before: the composer displayed "draft saved automatically"
// from a setTimeout, with no request behind it, and closing the tab lost the
// message. Drafts are whole RFC 5322 messages like any other, so they are
// readable over IMAP too.
func (uc *WebmailUseCase) SaveDraft(ctx context.Context, input ComposeInput) (uint32, error) {
account, err := uc.auth.FindByAddress(ctx, input.From)
if err != nil {
return 0, err
}
if account == nil {
return 0, ErrNoSuchMailbox
}
if uc.blobStore == nil || uc.messages == nil {
return 0, ErrDraftsUnavailable
}
// A draft has no envelope, so recipients may legitimately be empty here -
// unlike Send, this must not reject a half-written message.
recipients := normaliseRecipients(
append(append(append([]string{}, input.To...), input.Cc...), input.Bcc...))
payload := buildMimeMessage(input, recipients, uc.localHost)
blob, err := uc.blobStore.Store(ctx, bytes.NewReader(payload))
if err != nil {
return 0, err
}
headers := ExtractMessageHeaders(payload)
uids, err := uc.messages.PersistAll(ctx, []port.PersistInboundMessageInput{{
MailboxID: account.ID,
Blob: blob,
SenderAddress: account.EmailAddress,
RecipientAddress: account.EmailAddress,
TargetFolderName: "Drafts",
Subject: headers.Subject,
Snippet: headers.Snippet,
FromDisplayName: headers.FromDisplayName,
MessageIDHeader: headers.MessageID,
HasAttachments: headers.HasAttachments,
}})
if err != nil {
return 0, err
}
if len(uids) == 0 {
return 0, nil
}
return uint32(uids[0]), nil
}
// ErrDraftsUnavailable reports that this deployment cannot store drafts,
// rather than accepting one and dropping it.
var ErrDraftsUnavailable = errors.New("webmail: draft storage is not configured")
func normaliseRecipients(raw []string) []string {
seen := map[string]bool{}
out := []string{}
@@ -172,13 +292,25 @@ func normaliseRecipients(raw []string) []string {
// buildMimeMessage assembles the headers a receiver expects. Date and
// Message-ID are set here because a message without them is scored as spam by
// most receivers (PLAN.md section 5).
//
// Bcc recipients are deliberately absent from the headers: they are carried in
// the envelope only, which is what "blind" means.
//
// A message with HTML goes out as multipart/alternative with a plain-text part
// first, in ascending order of preference per RFC 2046 section 5.1.4. Sending
// the composer's HTML under a text/plain header - which is what this did while
// the body was being dropped anyway - shows the recipient raw markup.
func buildMimeMessage(input ComposeInput, recipients []string, localHost string) []byte {
var b strings.Builder
b.WriteString("From: " + input.From + "\r\n")
if len(input.To) > 0 {
b.WriteString("To: " + strings.Join(input.To, ", ") + "\r\n")
} else {
b.WriteString("To: " + strings.Join(recipients, ", ") + "\r\n")
to := normaliseRecipients(input.To)
if len(to) == 0 {
to = recipients
}
b.WriteString("To: " + strings.Join(to, ", ") + "\r\n")
}
if len(input.Cc) > 0 {
b.WriteString("Cc: " + strings.Join(input.Cc, ", ") + "\r\n")
@@ -187,14 +319,82 @@ func buildMimeMessage(input ComposeInput, recipients []string, localHost string)
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString(fmt.Sprintf("Message-ID: <%s@%s>\r\n", uuid.NewString(), localHost))
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n")
b.WriteString("\r\n")
b.WriteString(strings.ReplaceAll(input.Body, "\n", "\r\n"))
if !strings.HasSuffix(input.Body, "\n") {
b.WriteString("\r\n")
text := input.Text
if text == "" && input.HTML != "" {
text = htmlToPlainText(input.HTML)
}
if input.HTML == "" {
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(normaliseEOL(text))
return []byte(b.String())
}
boundary := "=_alt_" + uuid.NewString()
b.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n\r\n")
b.WriteString("--" + boundary + "\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(normaliseEOL(text))
b.WriteString("--" + boundary + "\r\n")
b.WriteString("Content-Type: text/html; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(normaliseEOL(input.HTML))
b.WriteString("--" + boundary + "--\r\n")
return []byte(b.String())
}
// normaliseEOL converts to CRLF and guarantees the trailing break a MIME part
// boundary must be preceded by.
func normaliseEOL(s string) string {
s = strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\n", "\r\n")
if !strings.HasSuffix(s, "\r\n") {
s += "\r\n"
}
return s
}
// htmlToPlainText derives a readable fallback from composed HTML.
//
// Deliberately crude - block elements become line breaks, tags are dropped,
// the handful of entities a contenteditable actually emits are decoded. It
// exists so the text/plain part is not empty, which is what makes a
// multipart/alternative message look like spam to a filter.
func htmlToPlainText(html string) string {
replacer := strings.NewReplacer(
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
"</p>", "\n\n", "</div>", "\n", "</li>", "\n", "</tr>", "\n",
"<li>", "- ",
)
s := replacer.Replace(html)
var out strings.Builder
depth := 0
for _, r := range s {
switch {
case r == '<':
depth++
case r == '>' && depth > 0:
depth--
case depth == 0:
out.WriteRune(r)
}
}
text := strings.NewReplacer(
"&nbsp;", " ", "&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", "\"", "&#39;", "'",
).Replace(out.String())
// Collapse the runs of blank lines the block replacements leave behind.
for strings.Contains(text, "\n\n\n") {
text = strings.ReplaceAll(text, "\n\n\n", "\n\n")
}
return strings.TrimSpace(text)
}
// encodeHeaderValue RFC 2047-encodes a subject that is not plain ASCII, so an
@@ -58,7 +58,12 @@ func BuildDsnReport(action DsnAction, reportingMTA string, envelopeFrom string,
// RFC 3834 section 5: an automatic notification has to say so, or a
// vacation responder on the other side answers it and the two bounce back
// and forth forever.
sb.WriteString("Auto-Submitted: auto-replied\r\n")
//
// "auto-generated", not "auto-replied": the latter is for a message sent in
// response to a specific message (a vacation notice), while a DSN reports
// on a delivery attempt. Spam filters read this header, and the wrong value
// on a bounce-shaped message counts against it.
sb.WriteString("Auto-Submitted: auto-generated\r\n")
sb.WriteString(fmt.Sprintf("Content-Type: multipart/report; report-type=delivery-status; boundary=\"%s\"\r\n\r\n", boundary))
// Part 1: Human-readable narrative
@@ -58,7 +58,7 @@ func TestBuildDsnReport_UsesTheConfiguredMailHost(t *testing.T) {
func TestBuildDsnReport_MarksItselfAutomatic(t *testing.T) {
payload, _ := BuildDsnReport(DsnActionDelayed, "mail.example.test", "sender@example.test", "recipient@remote.test", "job-1", "451 deferred")
if !strings.Contains(string(payload), "Auto-Submitted: auto-replied") {
if !strings.Contains(string(payload), "Auto-Submitted: auto-generated") {
t.Errorf("the report is not marked as automatic: %s", payload)
}
}
@@ -246,32 +246,80 @@ func (m *mailAPI) handleSend(w http.ResponseWriter, r *http.Request) {
return
}
input, ok := m.decodeCompose(w, r, session.Email)
if !ok {
return
}
if err := m.useCase.Send(r.Context(), input); err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]bool{"queued": true})
}
// handleSaveDraft stores the message being composed in the Drafts folder.
func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
session, ok := m.authenticate(w, r)
if !ok {
return
}
input, ok := m.decodeCompose(w, r, session.Email)
if !ok {
return
}
uid, err := m.useCase.SaveDraft(r.Context(), input)
if err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"saved": true, "uid": uid})
}
// decodeCompose reads a composed message off the wire.
//
// "html" and "text" are both accepted, and both are new: the field was called
// "body" while the composer had always sent "html", so every message sent from
// the webmail was delivered with an empty body. "bcc" was likewise never read,
// so blind recipients were silently dropped.
func (m *mailAPI) decodeCompose(
w http.ResponseWriter, r *http.Request, sender string,
) (usecase.ComposeInput, bool) {
var body struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
Body string `json:"body"`
Text string `json:"text"`
HTML string `json:"html"`
// Accepted so an older client - or a script written against the
// previous shape - still sends a readable message rather than a blank
// one. Only used when neither text nor html is present.
LegacyBody string `json:"body"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxComposeBytes)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
return
return usecase.ComposeInput{}, false
}
text := body.Text
if text == "" && body.HTML == "" {
text = body.LegacyBody
}
// The sender is the session's address, never a field in the request:
// letting the body choose From is how a webmail becomes an open relay for
// spoofed mail.
err := m.useCase.Send(r.Context(), usecase.ComposeInput{
From: session.Email,
return usecase.ComposeInput{
From: sender,
To: body.To,
Cc: body.Cc,
Bcc: body.Bcc,
Subject: body.Subject,
Body: body.Body,
})
if err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]bool{"queued": true})
Text: text,
HTML: body.HTML,
}, true
}
// fail maps domain errors onto status codes without leaking internals.
@@ -285,6 +333,8 @@ func (m *mailAPI) fail(w http.ResponseWriter, err error) {
writeError(w, http.StatusTooManyRequests, "SEND_LIMIT_EXCEEDED", "Hourly recipient limit reached")
case errors.Is(err, usecase.ErrSenderNotOwned):
writeError(w, http.StatusForbidden, "FORBIDDEN", "Sender address does not belong to this account")
case errors.Is(err, usecase.ErrDraftsUnavailable):
writeError(w, http.StatusServiceUnavailable, "DRAFTS_UNAVAILABLE", "Draft storage is not configured")
default:
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Request could not be processed")
}
@@ -84,6 +84,7 @@ func (r *Router) registerRoutes() {
r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage)
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen)
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
r.mux.HandleFunc("/api/v1/reports/dmarc", r.handleDmarcIngest)
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
}
@@ -297,3 +298,13 @@ func (r *Router) handleMailSend(w http.ResponseWriter, req *http.Request) {
r.mail.handleSend(w, req)
}
}
func (r *Router) handleMailDraft(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
return
}
if r.mailReady(w) {
r.mail.handleSaveDraft(w, req)
}
}
+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">