diff --git a/docker-compose.yaml b/docker-compose.yaml index b173c22..b2a5641 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -194,6 +194,9 @@ services: environment: DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?sslmode=disable REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + # Shared with the auth service: it mints the webmail sessions and this + # service verifies them on /api/v1/mail/*. + JWT_SECRET: ${JWT_SECRET:?required} RSPAMD_URL: http://rspamd:11333 CLAMAV_ADDR: clamav:3310 PRIMARY_MAIL_HOST: ${PRIMARY_MAIL_HOST:?required} @@ -262,6 +265,9 @@ services: PORT: 3000 AUTH_SERVICE_URL: http://auth:3001 STORAGE_SERVICE_URL: http://storage:3002 + # The mail screens read and send through the protocols service, which + # owns the folder, UID, flag and blob logic. + PROTOCOLS_SERVICE_URL: http://protocols:8080 NEXT_PUBLIC_APP_URL: https://${WEBMAIL_HOST:?required} # Isolation of the two surfaces (section 14.1). With ADMIN_HOST set the # console answers only on that host; otherwise it lives under @@ -276,6 +282,7 @@ services: depends_on: auth: { condition: service_healthy } storage: { condition: service_healthy } + protocols: { condition: service_healthy } labels: - traefik.enable=true # With the service on two networks Traefik may route via the wrong one diff --git a/services/protocols/cmd/lambdamail-protocols/config.go b/services/protocols/cmd/lambdamail-protocols/config.go index 0324db4..45d0397 100644 --- a/services/protocols/cmd/lambdamail-protocols/config.go +++ b/services/protocols/cmd/lambdamail-protocols/config.go @@ -14,6 +14,7 @@ type config struct { MailDomain string SpoolDir string + JwtSecret string MasterKey string TLSMode string @@ -61,6 +62,9 @@ func loadConfig() config { SpoolDir: envOrDefault("PROTOCOLS_SPOOL_DIR", "/var/lambdamail/spool"), MasterKey: os.Getenv("LAMBDAMAIL_MASTER_KEY"), + // Shared with the auth service, which mints the webmail sessions this + // service verifies on /api/v1/mail/*. + JwtSecret: os.Getenv("JWT_SECRET"), TLSMode: strings.ToLower(envOrDefault("TLS_MODE", "traefik")), TraefikAcmeDir: os.Getenv("TRAEFIK_ACME_DIR"), diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index 931dd18..e1d7fc0 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -162,7 +162,18 @@ func run(cfg config) { } // ------------------------------------------------------------- HTTP API + // 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. + webmailUC := usecase.NewWebmailUseCase( + postgres.NewWebmailRepository(pool), blobReader, submissionUC, authRepo, cfg.PrimaryMailHost, + ) + router := httppresentation.NewRouter(usecase.NewIngestReportsUseCase(reportRepo), func() error { return pool.Ping(ctx) }) + if cfg.JwtSecret == "" { + log.Printf("JWT_SECRET is not set: the webmail mail API stays disabled") + } + router.SetMailAPI(webmailUC, cfg.JwtSecret) router.SetDegradedCheck(certDegraded) applyMtaStsMode(router, cfg) diff --git a/services/protocols/internal/application/port/webmail_repository.go b/services/protocols/internal/application/port/webmail_repository.go new file mode 100644 index 0000000..d2a159f --- /dev/null +++ b/services/protocols/internal/application/port/webmail_repository.go @@ -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 +} diff --git a/services/protocols/internal/application/usecase/message_render.go b/services/protocols/internal/application/usecase/message_render.go new file mode 100644 index 0000000..9aece69 --- /dev/null +++ b/services/protocols/internal/application/usecase/message_render.go @@ -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) + } +} diff --git a/services/protocols/internal/application/usecase/message_render_test.go b/services/protocols/internal/application/usecase/message_render_test.go new file mode 100644 index 0000000..9d4c527 --- /dev/null +++ b/services/protocols/internal/application/usecase/message_render_test.go @@ -0,0 +1,59 @@ +package usecase + +import "testing" + +func TestRenderMessage_ExtractsHeadersAndText(t *testing.T) { + raw := "From: Alice \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

html part

\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 != "

html part

" { + 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") + } +} diff --git a/services/protocols/internal/application/usecase/webmail_mail.go b/services/protocols/internal/application/usecase/webmail_mail.go new file mode 100644 index 0000000..c5a3c7b --- /dev/null +++ b/services/protocols/internal/application/usecase/webmail_mail.go @@ -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) +} diff --git a/services/protocols/internal/e2e/webmail_api_e2e_test.go b/services/protocols/internal/e2e/webmail_api_e2e_test.go new file mode 100644 index 0000000..feef7dc --- /dev/null +++ b/services/protocols/internal/e2e/webmail_api_e2e_test.go @@ -0,0 +1,359 @@ +package e2e + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/google/uuid" + + appusecase "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/infrastructure/diskstorage" + "lambdamail/protocols/internal/infrastructure/postgres" + httppresentation "lambdamail/protocols/internal/presentation/http" +) + +const webmailSecret = "webmail-e2e-shared-secret" + +// mintSession builds the token the auth service would issue, using the same +// HS256 construction, so this test exercises the real verification path. +func mintSession(t *testing.T, email, surface string, ttl time.Duration) string { + t.Helper() + + encode := func(v any) string { + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(raw) + } + + header := encode(map[string]string{"alg": "HS256", "typ": "JWT"}) + payload := encode(map[string]any{ + "sub": uuid.NewString(), "email": email, "role": "USER", "domainId": uuid.NewString(), + "surface": surface, "aud": "lambdamail:" + surface, "mfaSatisfied": true, + "purpose": "session", "iat": time.Now().Unix(), "exp": time.Now().Add(ttl).Unix(), + }) + + mac := hmac.New(sha256.New, []byte(webmailSecret)) + mac.Write([]byte(header + "." + payload)) + return header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +func TestWebmailApiEndToEnd(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + runtimeDir := t.TempDir() + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig(). + Port(54346). + RuntimePath(runtimeDir). + StartTimeout(120 * time.Second)) + if err := pg.Start(); err != nil { + t.Fatalf("start embedded postgres: %v", err) + } + defer pg.Stop() + + pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:54346/postgres?sslmode=disable") + if err != nil { + t.Fatalf("connect pool: %v", err) + } + defer pool.Close() + applyMigrations(t, ctx, pool) + + // ------------------------------------------------------------ fixtures + domainID, mailboxID, folderID := uuid.New(), uuid.New(), uuid.New() + const address = "reader@webmail.example" + if _, err := pool.Exec(ctx, + `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'webmail.example', 'webmail.example')`, domainID); err != nil { + t.Fatalf("seed domain: %v", err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) + VALUES ($1, $2, 'reader', $3, 'x')`, mailboxID, domainID, address); err != nil { + t.Fatalf("seed mailbox: %v", err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`, + folderID, mailboxID); err != nil { + t.Fatalf("seed folder: %v", err) + } + + // ------------------------------------------------- deliver a real message + blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir()) + inbound := appusecase.NewProcessInboundEmailUseCase( + postgres.NewMailboxRepository(pool), blobs, postgres.NewInboundMessageRepository(pool)) + + raw := "From: Alice Sender \r\n" + + "To: " + address + "\r\n" + + "Subject: =?UTF-8?Q?Relat=C3=B3rio?=\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n\r\n" + + "Corpo da mensagem de teste.\r\n" + + targets, err := inbound.ResolveRecipient(ctx, address) + if err != nil { + t.Fatalf("resolve recipient: %v", err) + } + if err := inbound.Handle(ctx, appusecase.ProcessInboundEmailInput{ + Sender: "alice@remote.test", + Recipients: targets, + RecipientAddresses: []string{address}, + Body: strings.NewReader(raw), + }); err != nil { + t.Fatalf("deliver: %v", err) + } + + // ------------------------------------------------------------ the API + webmailUC := appusecase.NewWebmailUseCase( + postgres.NewWebmailRepository(pool), + diskstorage.NewLocalDiskBlobReader(pool), + nil, nil, "mail.webmail.example", + ) + router := httppresentation.NewRouter(nil, func() error { return nil }) + router.SetMailAPI(webmailUC, webmailSecret) + server := httptest.NewServer(router) + defer server.Close() + + token := mintSession(t, address, "user", time.Hour) + call := func(method, path string, body any) (*http.Response, []byte) { + var reader *bytes.Reader + if body != nil { + encoded, _ := json.Marshal(body) + reader = bytes.NewReader(encoded) + } else { + reader = bytes.NewReader(nil) + } + req, err := http.NewRequest(method, server.URL+path, reader) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + return resp, buf.Bytes() + } + + // 1. Folders come back with the seeded INBOX and its unread counter. + resp, body := call(http.MethodGet, "/api/v1/mail/folders", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("folders: status %d body %s", resp.StatusCode, body) + } + var folders []struct { + Name string `json:"name"` + SpecialUse string `json:"special_use"` + UnreadCount int `json:"unread_count"` + } + if err := json.Unmarshal(body, &folders); err != nil { + t.Fatalf("decode folders: %v (%s)", err, body) + } + if len(folders) != 1 || folders[0].SpecialUse != "inbox" || folders[0].UnreadCount != 1 { + t.Fatalf("folders = %+v, want one inbox with 1 unread", folders) + } + + // 2. The listing carries the header metadata, decoded. + resp, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("messages: status %d body %s", resp.StatusCode, body) + } + var messages []struct { + UID uint32 `json:"uid"` + Subject string `json:"subject"` + FromName string `json:"from_display_name"` + Snippet string `json:"snippet"` + Seen bool `json:"seen"` + } + if err := json.Unmarshal(body, &messages); err != nil { + t.Fatalf("decode messages: %v (%s)", err, body) + } + if len(messages) != 1 { + t.Fatalf("got %d messages, want 1", len(messages)) + } + if messages[0].Subject != "Relatório" { + t.Errorf("Subject = %q, want the decoded encoded-word", messages[0].Subject) + } + if messages[0].FromName != "Alice Sender" { + t.Errorf("FromName = %q", messages[0].FromName) + } + if messages[0].Snippet == "" { + t.Error("Snippet is empty; the list would show no preview") + } + if messages[0].Seen { + t.Error("a freshly delivered message is already marked seen") + } + + // 3. Fetching the body returns the original bytes and marks it read. + resp, body = call(http.MethodGet, fmt.Sprintf("/api/v1/mail/message/%d?folder=inbox", messages[0].UID), nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("message: status %d body %s", resp.StatusCode, body) + } + var message struct { + Subject string `json:"subject"` + From string `json:"from"` + Text string `json:"text"` + } + if err := json.Unmarshal(body, &message); err != nil { + t.Fatalf("decode message: %v", err) + } + if !strings.Contains(message.Text, "Corpo da mensagem de teste.") { + t.Errorf("the rendered body is not the stored message: %q", message.Text) + } + if message.Subject != "Relatório" { + t.Errorf("rendered Subject = %q, want the decoded encoded-word", message.Subject) + } + if !strings.Contains(message.From, "alice@remote.test") { + t.Errorf("rendered From = %q", message.From) + } + + _, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox", nil) + _ = json.Unmarshal(body, &messages) + if !messages[0].Seen { + t.Error("opening the message did not mark it seen") + } + + var unread int + if err := pool.QueryRow(ctx, `SELECT unread_count FROM folders WHERE id = $1`, folderID).Scan(&unread); err != nil { + t.Fatal(err) + } + if unread != 0 { + t.Errorf("unread_count = %d after reading the only message, want 0", unread) + } + + // 4. Search matches on the indexed columns. + _, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox&q=Relat", nil) + _ = json.Unmarshal(body, &messages) + if len(messages) != 1 { + t.Errorf("search returned %d messages, want 1", len(messages)) + } + _, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox&q=nothing-matches-this", nil) + _ = json.Unmarshal(body, &messages) + if len(messages) != 0 { + t.Errorf("search returned %d messages for a non-matching term", len(messages)) + } +} + +// One account must not be able to read another's mail by guessing a UID. +func TestWebmailApiRejectsCrossMailboxAccess(t *testing.T) { + if testing.Short() { + t.Skip("e2e test skipped in -short mode") + } + ctx := context.Background() + + runtimeDir := t.TempDir() + pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig(). + Port(54347). + RuntimePath(runtimeDir). + StartTimeout(120 * time.Second)) + if err := pg.Start(); err != nil { + t.Fatalf("start embedded postgres: %v", err) + } + defer pg.Stop() + + pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:54347/postgres?sslmode=disable") + if err != nil { + t.Fatalf("connect pool: %v", err) + } + defer pool.Close() + applyMigrations(t, ctx, pool) + + domainID := uuid.New() + if _, err := pool.Exec(ctx, + `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'iso.example', 'iso.example')`, domainID); err != nil { + t.Fatal(err) + } + + victim, attacker := uuid.New(), uuid.New() + for id, local := range map[uuid.UUID]string{victim: "victim", attacker: "attacker"} { + if _, err := pool.Exec(ctx, + `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) + VALUES ($1, $2, $3, $4, 'x')`, id, domainID, local, local+"@iso.example"); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, 'INBOX', 'inbox')`, id); err != nil { + t.Fatal(err) + } + } + + blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir()) + inbound := appusecase.NewProcessInboundEmailUseCase( + postgres.NewMailboxRepository(pool), blobs, postgres.NewInboundMessageRepository(pool)) + targets, err := inbound.ResolveRecipient(ctx, "victim@iso.example") + if err != nil { + t.Fatal(err) + } + if err := inbound.Handle(ctx, appusecase.ProcessInboundEmailInput{ + Sender: "someone@remote.test", + Recipients: targets, + RecipientAddresses: []string{"victim@iso.example"}, + Body: strings.NewReader("From: s@remote.test\r\nSubject: private\r\n\r\nsecret\r\n"), + }); err != nil { + t.Fatal(err) + } + + webmailUC := appusecase.NewWebmailUseCase( + postgres.NewWebmailRepository(pool), diskstorage.NewLocalDiskBlobReader(pool), nil, nil, "mail.iso.example") + router := httppresentation.NewRouter(nil, func() error { return nil }) + router.SetMailAPI(webmailUC, webmailSecret) + server := httptest.NewServer(router) + defer server.Close() + + // The victim's only message has UID 1; the attacker asks for exactly that. + req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/mail/message/1?folder=inbox", nil) + req.Header.Set("Authorization", "Bearer "+mintSession(t, "attacker@iso.example", "user", time.Hour)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status = %d, want 404: one mailbox read another's message", resp.StatusCode) + } +} + +// An admin token must not open the user mail API, and neither must no token. +func TestWebmailApiRequiresUserSession(t *testing.T) { + router := httppresentation.NewRouter(nil, func() error { return nil }) + router.SetMailAPI(appusecase.NewWebmailUseCase(nil, nil, nil, nil, "mail.test"), webmailSecret) + server := httptest.NewServer(router) + defer server.Close() + + for _, tc := range []struct{ name, token string }{ + {"no token", ""}, + {"admin token", mintSession(t, "admin@iso.example", "admin", time.Hour)}, + {"forged token", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.AAAA"}, + } { + t.Run(tc.name, func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/mail/folders", nil) + if tc.token != "" { + req.Header.Set("Authorization", "Bearer "+tc.token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", resp.StatusCode) + } + }) + } +} diff --git a/services/protocols/internal/infrastructure/postgres/webmail_repository.go b/services/protocols/internal/infrastructure/postgres/webmail_repository.go new file mode 100644 index 0000000..21d6936 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/webmail_repository.go @@ -0,0 +1,202 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +// ErrMessageNotFound is returned when a message does not exist, or exists but +// belongs to another mailbox. The two are deliberately indistinguishable: +// telling them apart would let a caller probe for other people's UIDs. +var ErrMessageNotFound = errors.New("webmail: message not found") + +// WebmailRepository implements the webmail read side against Postgres. +type WebmailRepository struct { + pool *pgxpool.Pool +} + +func NewWebmailRepository(pool *pgxpool.Pool) *WebmailRepository { + return &WebmailRepository{pool: pool} +} + +func (r *WebmailRepository) FindMailboxIDByAddress(ctx context.Context, address string) (string, error) { + var id string + err := r.pool.QueryRow(ctx, ` + SELECT m.id::text FROM mailboxes m + JOIN domains d ON d.id = m.domain_id + WHERE m.email_address = $1 AND m.is_active AND d.is_active + `, address).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrMessageNotFound + } + return id, err +} + +func (r *WebmailRepository) ListFolders(ctx context.Context, mailboxID string) ([]port.WebmailFolder, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id::text, name, COALESCE(special_use, ''), unread_count, total_count + FROM folders WHERE mailbox_id = $1 + ORDER BY CASE special_use + WHEN 'inbox' THEN 0 WHEN 'drafts' THEN 1 WHEN 'sent' THEN 2 + WHEN 'archive' THEN 3 WHEN 'junk' THEN 4 WHEN 'trash' THEN 5 + ELSE 6 END, name + `, mailboxID) + if err != nil { + return nil, fmt.Errorf("list folders: %w", err) + } + defer rows.Close() + + folders := []port.WebmailFolder{} + for rows.Next() { + var f port.WebmailFolder + if err := rows.Scan(&f.ID, &f.Name, &f.SpecialUse, &f.UnreadCount, &f.TotalCount); err != nil { + return nil, err + } + folders = append(folders, f) + } + return folders, rows.Err() +} + +// folderCondition matches either the special-use role ("inbox") or the literal +// folder name, so the UI can address a folder the same way IMAP does. +const folderCondition = `(f.special_use = LOWER($2) OR LOWER(f.name) = LOWER($2))` + +func (r *WebmailRepository) ListMessages( + ctx context.Context, mailboxID, folderName, search string, limit, offset int, +) ([]port.WebmailMessage, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + + rows, err := r.pool.Query(ctx, ` + SELECT e.uid, COALESCE(e.subject, ''), e.sender_address::text, + COALESCE(e.from_display_name, ''), COALESCE(e.snippet, ''), + e.size_bytes, e.received_at, e.has_attachments, + EXISTS (SELECT 1 FROM message_flags mf + WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Seen'), + EXISTS (SELECT 1 FROM message_flags mf + WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Flagged'), + EXISTS (SELECT 1 FROM message_flags mf + WHERE mf.message_id = e.id AND mf.received_at = e.received_at AND mf.flag = '\Answered'), + COALESCE(e.spam_verdict, ''), COALESCE(e.dmarc_result, '') + FROM email_messages e + JOIN folders f ON f.id = e.folder_id + WHERE e.mailbox_id = $1 AND `+folderCondition+` + AND e.expunged_at IS NULL + AND ($3 = '' OR e.subject ILIKE '%%' || $3 || '%%' + OR e.sender_address::text ILIKE '%%' || $3 || '%%' + OR e.snippet ILIKE '%%' || $3 || '%%') + ORDER BY e.received_at DESC, e.uid DESC + LIMIT $4 OFFSET $5 + `, mailboxID, folderName, search, limit, offset) + if err != nil { + return nil, fmt.Errorf("list messages: %w", err) + } + defer rows.Close() + + messages := []port.WebmailMessage{} + for rows.Next() { + var m port.WebmailMessage + if err := rows.Scan( + &m.UID, &m.Subject, &m.SenderAddress, &m.FromName, &m.Snippet, + &m.SizeBytes, &m.ReceivedAt, &m.HasAttachments, + &m.Seen, &m.Flagged, &m.Answered, &m.SpamVerdict, &m.DmarcResult, + ); err != nil { + return nil, err + } + messages = append(messages, m) + } + return messages, rows.Err() +} + +func (r *WebmailRepository) GetMessageBlob( + ctx context.Context, mailboxID, folderName string, uid uint32, +) (uuid.UUID, error) { + var blobID uuid.UUID + err := r.pool.QueryRow(ctx, ` + SELECT e.blob_id FROM email_messages e + JOIN folders f ON f.id = e.folder_id + WHERE e.mailbox_id = $1 AND `+folderCondition+` + AND e.uid = $3 AND e.expunged_at IS NULL + `, mailboxID, folderName, int64(uid)).Scan(&blobID) + if errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, ErrMessageNotFound + } + return blobID, err +} + +// MarkSeen keeps the \Seen flag and the folder's unread counter in step. They +// are updated together in one transaction, because a counter that disagrees +// with the flags is what makes an inbox show unread mail that is not there. +func (r *WebmailRepository) MarkSeen( + ctx context.Context, mailboxID, folderName string, uid uint32, seen bool, +) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var messageID uuid.UUID + var receivedAt any + var folderID string + err = tx.QueryRow(ctx, ` + SELECT e.id, e.received_at, f.id::text FROM email_messages e + JOIN folders f ON f.id = e.folder_id + WHERE e.mailbox_id = $1 AND `+folderCondition+` + AND e.uid = $3 AND e.expunged_at IS NULL + `, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID) + if errors.Is(err, pgx.ErrNoRows) { + return ErrMessageNotFound + } + if err != nil { + return err + } + + var changed int64 + if seen { + tag, err := tx.Exec(ctx, ` + INSERT INTO message_flags (message_id, received_at, flag) + VALUES ($1, $2, '\Seen') ON CONFLICT DO NOTHING + `, messageID, receivedAt) + if err != nil { + return err + } + changed = tag.RowsAffected() + } else { + tag, err := tx.Exec(ctx, ` + DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = '\Seen' + `, messageID, receivedAt) + if err != nil { + return err + } + changed = tag.RowsAffected() + } + + // Only a real transition moves the counter, so opening an already-read + // message repeatedly cannot drive unread_count negative. + if changed > 0 { + delta := 1 + if seen { + delta = -1 + } + if _, err := tx.Exec(ctx, ` + UPDATE folders SET unread_count = GREATEST(0, unread_count + $2), highest_modseq = highest_modseq + 1 + WHERE id = $1 + `, folderID, delta); err != nil { + return err + } + } + + return tx.Commit(ctx) +} diff --git a/services/protocols/internal/presentation/http/mail_api.go b/services/protocols/internal/presentation/http/mail_api.go new file mode 100644 index 0000000..f82e5f3 --- /dev/null +++ b/services/protocols/internal/presentation/http/mail_api.go @@ -0,0 +1,199 @@ +package httppresentation + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "lambdamail/protocols/internal/application/usecase" + "lambdamail/protocols/internal/infrastructure/postgres" +) + +// maxComposeBytes caps a single submission from the webmail. The SMTP ports +// have their own limit; this one stops an authenticated browser session from +// buffering an unbounded body in this process. +const maxComposeBytes = 32 << 20 // 32 MiB + +// mailAPI serves /api/v1/mail/* for the webmail's message screens. +type mailAPI struct { + useCase *usecase.WebmailUseCase + sessions *WebSessionVerifier +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]string{"error": code, "message": message}) +} + +// authenticate resolves the caller's session, refusing anything that is not a +// /user session token signed by the auth service. +func (m *mailAPI) authenticate(w http.ResponseWriter, r *http.Request) (*WebSession, bool) { + token := "" + if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { + token = strings.TrimPrefix(header, "Bearer ") + } else if cookie, err := r.Cookie("lm_user_session"); err == nil { + token = cookie.Value + } + + if token == "" { + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "User session required") + return nil, false + } + + session, err := m.sessions.RequireSurface(token, "user") + if err != nil { + // The reason is deliberately not echoed back: whether a token expired, + // was forged or belongs to the other surface is not the caller's + // business. + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "User session required") + return nil, false + } + return session, true +} + +func (m *mailAPI) handleFolders(w http.ResponseWriter, r *http.Request) { + session, ok := m.authenticate(w, r) + if !ok { + return + } + folders, err := m.useCase.ListFolders(r.Context(), session.Email) + if err != nil { + m.fail(w, err) + return + } + writeJSON(w, http.StatusOK, folders) +} + +func (m *mailAPI) handleMessages(w http.ResponseWriter, r *http.Request) { + session, ok := m.authenticate(w, r) + if !ok { + return + } + + query := r.URL.Query() + folder := query.Get("folder") + if folder == "" { + folder = "inbox" + } + limit, _ := strconv.Atoi(query.Get("limit")) + offset, _ := strconv.Atoi(query.Get("offset")) + + messages, err := m.useCase.ListMessages(r.Context(), session.Email, folder, query.Get("q"), limit, offset) + if err != nil { + m.fail(w, err) + return + } + writeJSON(w, http.StatusOK, messages) +} + +func (m *mailAPI) handleMessage(w http.ResponseWriter, r *http.Request) { + session, ok := m.authenticate(w, r) + if !ok { + return + } + + uid, err := strconv.ParseUint(strings.TrimPrefix(r.URL.Path, "/api/v1/mail/message/"), 10, 32) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_UID", "Message UID must be a number") + return + } + + folder := r.URL.Query().Get("folder") + if folder == "" { + folder = "inbox" + } + + raw, err := m.useCase.GetMessage(r.Context(), session.Email, folder, uint32(uid)) + if err != nil { + m.fail(w, err) + return + } + + // Parsed here rather than in the browser: this service already depends on + // a MIME library, and a second parser in TypeScript would be a second set + // of edge cases to get wrong. + writeJSON(w, http.StatusOK, usecase.RenderMessage(raw, uint32(uid))) +} + +func (m *mailAPI) handleSeen(w http.ResponseWriter, r *http.Request) { + session, ok := m.authenticate(w, r) + if !ok { + return + } + + var body struct { + Folder string `json:"folder"` + UID uint32 `json:"uid"` + Seen bool `json:"seen"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON") + return + } + if body.Folder == "" { + body.Folder = "inbox" + } + + if err := m.useCase.SetSeen(r.Context(), session.Email, body.Folder, body.UID, body.Seen); err != nil { + m.fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (m *mailAPI) handleSend(w http.ResponseWriter, r *http.Request) { + session, ok := m.authenticate(w, r) + if !ok { + return + } + + var body struct { + To []string `json:"to"` + Cc []string `json:"cc"` + Subject string `json:"subject"` + Body 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 + } + + // 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, + To: body.To, + Cc: body.Cc, + Subject: body.Subject, + Body: body.Body, + }) + if err != nil { + m.fail(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]bool{"queued": true}) +} + +// fail maps domain errors onto status codes without leaking internals. +func (m *mailAPI) fail(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, postgres.ErrMessageNotFound), errors.Is(err, usecase.ErrNoSuchMailbox): + writeError(w, http.StatusNotFound, "NOT_FOUND", "Message not found") + case errors.Is(err, usecase.ErrNoRecipients): + writeError(w, http.StatusBadRequest, "NO_RECIPIENTS", "At least one valid recipient is required") + case errors.Is(err, usecase.ErrSendLimitExceeded): + 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") + default: + writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Request could not be processed") + } +} diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index 2f8167e..35b602e 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -22,6 +22,10 @@ type Router struct { reportUseCase *appusecase.IngestReportsUseCase pingFunc func() error mtaStsMode valueobject.MtaStsMode + // mail serves the webmail's message screens. It stays nil when no session + // secret is configured, and the routes then answer 503 rather than + // pretending to be open. + mail *mailAPI // degradedFunc reports a condition that leaves the service running but // not fit for production, the clearest case being a self-signed // certificate standing in for one Traefik never issued @@ -63,6 +67,11 @@ func (r *Router) registerRoutes() { r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig) r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig) r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover) + r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders) + r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages) + 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/reports/dmarc", r.handleDmarcIngest) r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest) } @@ -219,3 +228,60 @@ func baseDomain(host string) string { } return host } + +// SetMailAPI wires the webmail message API. Without it the mail routes exist +// but report that the feature is not configured, which is a clearer failure +// than a 404 that looks like a typo in the path. +func (r *Router) SetMailAPI(useCase *appusecase.WebmailUseCase, sessionSecret string) { + if useCase == nil || sessionSecret == "" { + return + } + r.mail = &mailAPI{useCase: useCase, sessions: NewWebSessionVerifier(sessionSecret)} +} + +func (r *Router) mailReady(w http.ResponseWriter) bool { + if r.mail == nil { + writeError(w, http.StatusServiceUnavailable, "MAIL_API_DISABLED", + "The mail API needs JWT_SECRET to verify webmail sessions") + return false + } + return true +} + +func (r *Router) handleMailFolders(w http.ResponseWriter, req *http.Request) { + if r.mailReady(w) { + r.mail.handleFolders(w, req) + } +} + +func (r *Router) handleMailMessages(w http.ResponseWriter, req *http.Request) { + if r.mailReady(w) { + r.mail.handleMessages(w, req) + } +} + +func (r *Router) handleMailMessage(w http.ResponseWriter, req *http.Request) { + if r.mailReady(w) { + r.mail.handleMessage(w, req) + } +} + +func (r *Router) handleMailSeen(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.handleSeen(w, req) + } +} + +func (r *Router) handleMailSend(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.handleSend(w, req) + } +}