package usecase import ( "bytes" "context" "errors" "fmt" "log" "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 // 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( 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} } // 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) { return uc.mailboxID(ctx, address) } 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 // 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 // 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 // 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 { // 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 } account, err := uc.auth.FindByAddress(ctx, input.From) if err != nil { return err } if account == nil { return ErrNoSuchMailbox } payload := buildMimeMessage(input, recipients, uc.localHost) 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{} 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). // // 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 { 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") } 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") 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( "
", "\n", "
", "\n", "
", "\n", "

", "\n\n", "", "\n", "", "\n", "", "\n", "
  • ", "- ", ) 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 // 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) }