#21: serve the webmail mail API from the protocols service
The mail screens had no backend at all: they rendered a hardcoded array, and the storage service that ADR-001 assigned this to is still a health stub. The API lands here instead, because the folder, UID, MODSEQ, flag and blob logic already exists and is tested in this service for IMAP. Reimplementing it in TypeScript would mean two implementations of the same semantics and two chances to disagree about what a UID means. Reads use a projected read model rather than hydrated aggregates, which is the CQRS exception PLAN.md section 2 carves out for webmail queries. Every query is scoped to the mailbox resolved from the verified session, never to an identifier taken from the request, so one account cannot read another's mail by guessing a UID - covered by a test that tries exactly that and expects 404. Sending reuses ProcessOutboundEmailUseCase, so mail written in the browser gets the same treatment as mail submitted over 587: send limits, DKIM signing, and the durable queue. The From address comes from the session and never from the request body; letting the body choose it is how a webmail becomes a relay for spoofed mail. MIME is parsed server-side because this service already depends on a MIME library. A message that will not parse still returns its raw text - an unreadable message beats an inbox that refuses to open one. Marking a message read updates the flag and the folder counter in one transaction, and only on a real transition, so re-opening a message cannot drive unread_count negative. Verified against the running stack: a message delivered over port 25 appears in the list with its decoded subject and sender, opens, marks itself read, is found by search, and a message composed in the webmail reaches outbound_jobs with the session's address as the envelope sender. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d33068a43c
commit
48fe3c68f3
11 files changed
+1293
No files matched your search
@@ -0,0 +1,64 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// WebmailFolder is a folder as the message list shows it.
|
||||
type WebmailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SpecialUse string `json:"special_use"`
|
||||
UnreadCount int `json:"unread_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
// WebmailMessage is one row of the message list.
|
||||
//
|
||||
// This is a read model projected straight from SQL rather than a hydrated
|
||||
// aggregate, which is the CQRS exception PLAN.md section 2 carves out for
|
||||
// webmail reads.
|
||||
type WebmailMessage struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
SenderAddress string `json:"sender_address"`
|
||||
FromName string `json:"from_display_name"`
|
||||
Snippet string `json:"snippet"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
HasAttachments bool `json:"has_attachments"`
|
||||
Seen bool `json:"seen"`
|
||||
Flagged bool `json:"flagged"`
|
||||
Answered bool `json:"answered"`
|
||||
SpamVerdict string `json:"spam_verdict"`
|
||||
DmarcResult string `json:"dmarc_result"`
|
||||
}
|
||||
|
||||
// WebmailMessageBody carries what is needed to render one message.
|
||||
type WebmailMessageBody struct {
|
||||
UID uint32 `json:"uid"`
|
||||
BlobID uuid.UUID `json:"-"`
|
||||
Raw []byte `json:"-"`
|
||||
}
|
||||
|
||||
// WebmailRepository serves the webmail's read side.
|
||||
//
|
||||
// Every method takes the authenticated mailbox ID and scopes its query to it.
|
||||
// Taking only a folder or a UID would let one account read another's mail by
|
||||
// guessing an identifier, so the ownership check lives in the SQL rather than
|
||||
// in the caller.
|
||||
type WebmailRepository interface {
|
||||
FindMailboxIDByAddress(ctx context.Context, address string) (string, error)
|
||||
ListFolders(ctx context.Context, mailboxID string) ([]WebmailFolder, error)
|
||||
// ListMessages returns the folder's messages newest first. search, when
|
||||
// non-empty, filters on subject, sender and snippet.
|
||||
ListMessages(ctx context.Context, mailboxID, folderName, search string, limit, offset int) ([]WebmailMessage, error)
|
||||
// GetMessageBlob resolves one message to its stored blob, or returns
|
||||
// ErrMessageNotFound when it does not belong to this mailbox.
|
||||
GetMessageBlob(ctx context.Context, mailboxID, folderName string, uid uint32) (uuid.UUID, error)
|
||||
// MarkSeen records that the user opened the message.
|
||||
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-message"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
// maxRenderedPartBytes bounds how much of one part is returned to the browser.
|
||||
// A message can legitimately be tens of megabytes; the reader pane cannot use
|
||||
// that, and holding it in memory per request is how a few open tabs turn into
|
||||
// an out-of-memory kill.
|
||||
const maxRenderedPartBytes = 2 << 20 // 2 MiB
|
||||
|
||||
// RenderedMessage is a message prepared for display: headers the reader shows
|
||||
// and the two body flavours, already decoded from their transfer encoding.
|
||||
type RenderedMessage struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
Cc []string `json:"cc"`
|
||||
Date string `json:"date"`
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
Attachments []string `json:"attachments"`
|
||||
}
|
||||
|
||||
// RenderMessage turns raw RFC 5322 bytes into what the reader pane needs.
|
||||
//
|
||||
// Parsing happens here rather than in the browser because this service already
|
||||
// depends on a MIME library, and a second implementation in TypeScript would
|
||||
// be a second set of edge cases to get wrong. A message that cannot be parsed
|
||||
// still yields its raw text rather than an error - unreadable formatting beats
|
||||
// an inbox that refuses to open a message.
|
||||
func RenderMessage(raw []byte, uid uint32) RenderedMessage {
|
||||
out := RenderedMessage{UID: uid, To: []string{}, Cc: []string{}, Attachments: []string{}}
|
||||
|
||||
entity, err := message.Read(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
out.Text = string(raw)
|
||||
return out
|
||||
}
|
||||
|
||||
header := mail.Header{Header: entity.Header}
|
||||
out.Subject, _ = header.Subject()
|
||||
if addrs, err := header.AddressList("From"); err == nil && len(addrs) > 0 {
|
||||
out.From = addrs[0].String()
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
dst *[]string
|
||||
}{{"To", &out.To}, {"Cc", &out.Cc}} {
|
||||
if addrs, err := header.AddressList(field.name); err == nil {
|
||||
for _, a := range addrs {
|
||||
*field.dst = append(*field.dst, a.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
// A missing Date header parses without error into the zero time, which
|
||||
// would render as the year 0001 in the reader. Left empty, the UI falls
|
||||
// back to the received timestamp it already has.
|
||||
if date, err := header.Date(); err == nil && !date.IsZero() {
|
||||
out.Date = date.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
|
||||
collectParts(entity, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// collectParts walks the MIME tree, keeping the first text and HTML bodies and
|
||||
// naming everything else as an attachment.
|
||||
func collectParts(entity *message.Entity, out *RenderedMessage) {
|
||||
if multipart := entity.MultipartReader(); multipart != nil {
|
||||
for {
|
||||
part, err := multipart.NextPart()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
collectParts(part, out)
|
||||
}
|
||||
}
|
||||
|
||||
mediaType, _, err := entity.Header.ContentType()
|
||||
if err != nil {
|
||||
mediaType = "text/plain"
|
||||
}
|
||||
|
||||
if disposition, params, err := entity.Header.ContentDisposition(); err == nil && disposition == "attachment" {
|
||||
name := params["filename"]
|
||||
if name == "" {
|
||||
name = "attachment"
|
||||
}
|
||||
out.Attachments = append(out.Attachments, name)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(entity.Body, maxRenderedPartBytes))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.EqualFold(mediaType, "text/plain") && out.Text == "":
|
||||
out.Text = string(body)
|
||||
case strings.EqualFold(mediaType, "text/html") && out.HTML == "":
|
||||
out.HTML = string(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package usecase
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRenderMessage_ExtractsHeadersAndText(t *testing.T) {
|
||||
raw := "From: Alice <alice@remote.test>\r\n" +
|
||||
"To: bob@example.test\r\n" +
|
||||
"Subject: =?UTF-8?Q?Ol=C3=A1?=\r\n" +
|
||||
"Date: Mon, 02 Jan 2006 15:04:05 -0700\r\n" +
|
||||
"Content-Type: text/plain; charset=utf-8\r\n\r\nCorpo.\r\n"
|
||||
|
||||
got := RenderMessage([]byte(raw), 7)
|
||||
if got.UID != 7 || got.Subject != "Olá" {
|
||||
t.Errorf("UID/Subject = %d/%q", got.UID, got.Subject)
|
||||
}
|
||||
if got.Text != "Corpo.\r\n" {
|
||||
t.Errorf("Text = %q", got.Text)
|
||||
}
|
||||
if got.Date == "" {
|
||||
t.Error("Date was not parsed")
|
||||
}
|
||||
}
|
||||
|
||||
// A message with no Date header parses into the zero time without an error;
|
||||
// rendering that verbatim showed the year 0001 in the reader.
|
||||
func TestRenderMessage_LeavesDateEmptyWhenHeaderAbsent(t *testing.T) {
|
||||
got := RenderMessage([]byte("From: a@b.test\r\nSubject: s\r\n\r\nbody\r\n"), 1)
|
||||
if got.Date != "" {
|
||||
t.Errorf("Date = %q, want empty so the UI falls back to received_at", got.Date)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMessage_PrefersPlainTextAndListsAttachments(t *testing.T) {
|
||||
raw := "From: a@b.test\r\nSubject: s\r\n" +
|
||||
"Content-Type: multipart/mixed; boundary=B\r\n\r\n" +
|
||||
"--B\r\nContent-Type: text/plain\r\n\r\nplain part\r\n" +
|
||||
"--B\r\nContent-Type: text/html\r\n\r\n<p>html part</p>\r\n" +
|
||||
"--B\r\nContent-Type: application/pdf\r\n" +
|
||||
"Content-Disposition: attachment; filename=\"report.pdf\"\r\n\r\nPDFDATA\r\n--B--\r\n"
|
||||
|
||||
got := RenderMessage([]byte(raw), 1)
|
||||
if got.Text != "plain part" {
|
||||
t.Errorf("Text = %q", got.Text)
|
||||
}
|
||||
if got.HTML != "<p>html part</p>" {
|
||||
t.Errorf("HTML = %q", got.HTML)
|
||||
}
|
||||
if len(got.Attachments) != 1 || got.Attachments[0] != "report.pdf" {
|
||||
t.Errorf("Attachments = %v", got.Attachments)
|
||||
}
|
||||
}
|
||||
|
||||
// Garbage must still open: an unreadable message beats an inbox that errors.
|
||||
func TestRenderMessage_FallsBackToRawOnParseFailure(t *testing.T) {
|
||||
got := RenderMessage([]byte("this is not a message"), 3)
|
||||
if got.Text == "" {
|
||||
t.Error("a malformed message produced no body at all")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// ErrNoSuchMailbox is returned when the session names an address this server
|
||||
// does not serve, which happens after an account is deleted while a token is
|
||||
// still valid.
|
||||
var ErrNoSuchMailbox = errors.New("webmail: no active mailbox for this session")
|
||||
|
||||
// WebmailUseCase serves the webmail's mail screens.
|
||||
//
|
||||
// Every operation starts from the address in the verified session and resolves
|
||||
// it to a mailbox ID here, so no handler can be tricked into acting on an
|
||||
// identifier that came from the request.
|
||||
type WebmailUseCase struct {
|
||||
repo port.WebmailRepository
|
||||
blobs port.BlobReader
|
||||
submission *ProcessOutboundEmailUseCase
|
||||
auth port.AuthRepository
|
||||
localHost string
|
||||
}
|
||||
|
||||
func NewWebmailUseCase(
|
||||
repo port.WebmailRepository,
|
||||
blobs port.BlobReader,
|
||||
submission *ProcessOutboundEmailUseCase,
|
||||
auth port.AuthRepository,
|
||||
localHost string,
|
||||
) *WebmailUseCase {
|
||||
if localHost == "" {
|
||||
localHost = "localhost"
|
||||
}
|
||||
return &WebmailUseCase{repo: repo, blobs: blobs, submission: submission, auth: auth, localHost: localHost}
|
||||
}
|
||||
|
||||
func (uc *WebmailUseCase) mailboxID(ctx context.Context, address string) (string, error) {
|
||||
id, err := uc.repo.FindMailboxIDByAddress(ctx, address)
|
||||
if err != nil || id == "" {
|
||||
return "", ErrNoSuchMailbox
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (uc *WebmailUseCase) ListFolders(ctx context.Context, address string) ([]port.WebmailFolder, error) {
|
||||
id, err := uc.mailboxID(ctx, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.ListFolders(ctx, id)
|
||||
}
|
||||
|
||||
func (uc *WebmailUseCase) ListMessages(
|
||||
ctx context.Context, address, folder, search string, limit, offset int,
|
||||
) ([]port.WebmailMessage, error) {
|
||||
id, err := uc.mailboxID(ctx, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.ListMessages(ctx, id, folder, search, limit, offset)
|
||||
}
|
||||
|
||||
// GetMessage returns the raw RFC 5322 bytes and marks the message read.
|
||||
func (uc *WebmailUseCase) GetMessage(ctx context.Context, address, folder string, uid uint32) ([]byte, error) {
|
||||
id, err := uc.mailboxID(ctx, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blobID, err := uc.repo.GetMessageBlob(ctx, id, folder, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := uc.blobs.ReadByID(ctx, blobID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Opening a message marks it read, but a failure to record that must not
|
||||
// stop it being displayed.
|
||||
_ = uc.repo.MarkSeen(ctx, id, folder, uid, true)
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (uc *WebmailUseCase) SetSeen(ctx context.Context, address, folder string, uid uint32, seen bool) error {
|
||||
id, err := uc.mailboxID(ctx, address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.MarkSeen(ctx, id, folder, uid, seen)
|
||||
}
|
||||
|
||||
// ComposeInput is one message written in the webmail.
|
||||
type ComposeInput struct {
|
||||
From string
|
||||
To []string
|
||||
Cc []string
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
// ErrNoRecipients rejects a submission with nobody to deliver to, before it
|
||||
// reaches the queue.
|
||||
var ErrNoRecipients = errors.New("webmail: at least one recipient is required")
|
||||
|
||||
// Send builds an RFC 5322 message and hands it to the same submission use
|
||||
// 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...))
|
||||
if len(recipients) == 0 {
|
||||
return ErrNoRecipients
|
||||
}
|
||||
|
||||
account, err := uc.auth.FindByAddress(ctx, input.From)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if account == nil {
|
||||
return ErrNoSuchMailbox
|
||||
}
|
||||
|
||||
payload := buildMimeMessage(input, recipients, uc.localHost)
|
||||
|
||||
return uc.submission.Submit(ctx, ProcessOutboundEmailInput{
|
||||
MailboxID: account.ID,
|
||||
SenderAddr: account.EmailAddress,
|
||||
DomainName: account.DomainName,
|
||||
Recipients: recipients,
|
||||
Body: bytes.NewReader(payload),
|
||||
MaxRecipientsPerHour: account.MaxRecipientsPerHour,
|
||||
})
|
||||
}
|
||||
|
||||
func normaliseRecipients(raw []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, entry := range raw {
|
||||
for _, part := range strings.FieldsFunc(entry, func(r rune) bool { return r == ',' || r == ';' }) {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(part))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !seen[addr.Address] {
|
||||
seen[addr.Address] = true
|
||||
out = append(out, addr.Address)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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).
|
||||
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")
|
||||
}
|
||||
if len(input.Cc) > 0 {
|
||||
b.WriteString("Cc: " + strings.Join(input.Cc, ", ") + "\r\n")
|
||||
}
|
||||
b.WriteString("Subject: " + encodeHeaderValue(input.Subject) + "\r\n")
|
||||
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")
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// encodeHeaderValue RFC 2047-encodes a subject that is not plain ASCII, so an
|
||||
// accented subject does not arrive as mojibake.
|
||||
func encodeHeaderValue(value string) string {
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] > 127 {
|
||||
return mimeEncode(value)
|
||||
}
|
||||
}
|
||||
// A bare CR or LF here would let the caller inject extra headers.
|
||||
return strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
|
||||
}
|
||||
|
||||
// mimeEncode wraps a non-ASCII header value as an RFC 2047 encoded word.
|
||||
func mimeEncode(value string) string {
|
||||
clean := strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
|
||||
return mime.BEncoding.Encode("utf-8", clean)
|
||||
}
|
||||
Reference in new issue
Block a user