#24: merge feature/24-webmail-send-and-drafts into main
This commit is contained in:
commit
707e6ae80e
15 files changed
+629
-36
No files matched your search
@@ -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 & 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, "&") {
|
||||
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(
|
||||
" ", " ", "&", "&", "<", "<", ">", ">", """, "\"", "'", "'",
|
||||
).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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user