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 hold drafts. Both are optional: when either is nil // saving a draft reports ErrDraftsUnavailable. (The copy in Sent is filed by // the submission use case, not here.) blobStore port.BlobStorage messages port.InboundMessageRepository // conversations serves the conversation views and records which message a // reply answered. Optional; see SetConversations. conversations port.ConversationReader } 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 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) } // SetConversations enables the conversation views. Optional: without it the // thread routes answer "not configured" and the webmail falls back to the // plain message list. func (uc *WebmailUseCase) SetConversations(c port.ConversationReader) { uc.conversations = c } // ErrConversationsDisabled is returned when no conversation reader was wired. var ErrConversationsDisabled = errors.New("conversations are not enabled") func (uc *WebmailUseCase) ListThreads( ctx context.Context, address, folder, search string, limit, offset int, ) ([]port.WebmailThread, error) { if uc.conversations == nil { return nil, ErrConversationsDisabled } id, err := uc.mailboxID(ctx, address) if err != nil { return nil, err } return uc.conversations.ListThreads(ctx, id, folder, search, limit, offset) } func (uc *WebmailUseCase) ThreadMessages( ctx context.Context, address, folder, threadID string, ) ([]port.WebmailThreadMessage, error) { if uc.conversations == nil { return nil, ErrConversationsDisabled } id, err := uc.mailboxID(ctx, address) if err != nil { return nil, err } return uc.conversations.ThreadMessages(ctx, id, folder, threadID) } // 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) } // SetFlagged marks a message important, or takes the mark off. func (uc *WebmailUseCase) SetFlagged(ctx context.Context, address, folder string, uid uint32, flagged bool) error { id, err := uc.mailboxID(ctx, address) if err != nil { return err } return uc.repo.MarkFlagged(ctx, id, folder, uid, flagged) } // 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 // DraftUID is the autosaved draft this message grew out of, or 0 when it // was composed without one ever being stored. // // Sending used to ignore it entirely, so a message that had been autosaved // went out and its draft stayed in Drafts: a duplicate of mail already on // its way, which no screen in the webmail could then delete. DraftUID uint32 // Important sends the message with high priority, in the two spellings // mail clients read: Importance (RFC 4021) and the older X-Priority. Important bool // InReplyTo is the Message-ID of the message being answered. It is what // lets the recipient's client thread the reply under the original. InReplyTo 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) // The copy in Sent is filed by Submit, the same way it is for a client that // sends over SMTP: one rule, one place. It used to be filed here, after the // submission, and not at all for SMTP, so an account that sent by SMTP had an // empty Sent folder. 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, FileSentCopy: true, }); err != nil { return err } // The draft this message grew out of has been superseded by the real // thing. Swallowed like the Sent copy above and for the same reason: the // mail is already queued, and failing the request now would only invite a // second copy to be sent. // The message this one answers is marked answered. Swallowed for the same // reason: the reply is already on its way, and a failed flag only means the // list shows the conversation as unanswered until the next message. if uc.conversations != nil && cleanMessageID(input.InReplyTo) != "" { if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil { if err := uc.conversations.MarkAnsweredByMessageID(ctx, mailboxID, input.InReplyTo); err != nil { log.Printf("webmail: could not mark %s answered: %v", input.InReplyTo, err) } } } if input.DraftUID != 0 { if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil { if err := uc.repo.Expunge(ctx, mailboxID, "Drafts", input.DraftUID); err != nil { log.Printf("webmail: could not discard the sent draft %d: %v", input.DraftUID, err) } } } return nil } // ErrFolderNotATarget refuses a move into a folder that describes how a // message came to exist rather than where its reader filed it. var ErrFolderNotATarget = errors.New("webmail: messages cannot be moved into this folder") // ErrNoTargetFolder rejects a move with no destination named. var ErrNoTargetFolder = errors.New("webmail: a destination folder is required") // notAMoveTarget are the folders a message may never be moved into. // // Sent and Drafts are not places a reader files things; they record what a // message is. Moving arbitrary mail into Sent would make it claim the message // was sent from here, and a message in Drafts that cannot be edited would sit // among the unfinished ones forever. var notAMoveTarget = map[string]bool{"sent": true, "drafts": true} // Move files one message into another folder. // // The webmail could delete a message and it could read one, but it had no way // to put it anywhere: archiving something out of the inbox was impossible. func (uc *WebmailUseCase) Move(ctx context.Context, address, folder string, uid uint32, target string) error { target = strings.TrimSpace(target) if target == "" { return ErrNoTargetFolder } if notAMoveTarget[strings.ToLower(target)] { return ErrFolderNotATarget } // Moving a message where it already is is what a second click on the same // target produces; it is a no-op rather than an error. if strings.EqualFold(target, folder) { return nil } mailboxID, err := uc.mailboxID(ctx, address) if err != nil { return err } _, err = uc.repo.MoveToFolder(ctx, mailboxID, folder, uid, target) return err } // Delete removes one message the way a mail client does: from any ordinary // folder it goes to Trash, and from Trash it is really expunged. // // The webmail had no delete at all. Expunge existed but was reachable only // when an autosave superseded a draft, so anything the user wanted rid of - // most visibly the draft left behind by a sent message - stayed put. func (uc *WebmailUseCase) Delete(ctx context.Context, address, folder string, uid uint32) error { mailboxID, err := uc.mailboxID(ctx, address) if err != nil { return err } _, err = uc.repo.MoveToTrash(ctx, mailboxID, folder, uid) switch { case err == nil: return nil case errors.Is(err, port.ErrAlreadyInTrash), errors.Is(err, port.ErrNoTrashFolder): // Already in Trash, or nowhere to move it to. Either way the second // press is the one that removes it for good. return uc.repo.Expunge(ctx, mailboxID, folder, uid) default: return err } } // DiscardDraft removes a draft for good. // // Delete would move it to Trash, which is right for a message someone might // want back and wrong for "Discard": the composer's button promises the draft // is gone, and one waiting in Trash is a draft that is not. Only Drafts is // reachable from here, so this cannot be used to expunge anything else. func (uc *WebmailUseCase) DiscardDraft(ctx context.Context, address string, uid uint32) error { mailboxID, err := uc.mailboxID(ctx, address) if err != nil { return err } return uc.repo.Expunge(ctx, mailboxID, "Drafts", uid) } // 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. // replaceUID is the draft this one supersedes, or 0 for the first save of a // message. Without it every autosave would append another copy and a few // minutes of typing would leave the Drafts folder full of the same half-written // message. func (uc *WebmailUseCase) SaveDraft(ctx context.Context, input ComposeInput, replaceUID uint32) (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, InReplyTo: headers.InReplyTo, References: headers.References, AlreadySeen: true, }}) if err != nil { return 0, err } // Only after the replacement is committed, so a failure here leaves the // older draft in place rather than losing both. if replaceUID != 0 { mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress) if idErr == nil { if err := uc.repo.Expunge(ctx, mailboxID, "Drafts", replaceUID); err != nil { log.Printf("webmail: could not remove the superseded draft %d: %v", replaceUID, 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") } if id := cleanMessageID(input.InReplyTo); id != "" { b.WriteString("In-Reply-To: " + id + "\r\n") b.WriteString("References: " + id + "\r\n") } if input.Important { b.WriteString("Importance: high\r\nX-Priority: 1\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) } // cleanMessageID returns id only when it is a single, well-formed <...> // Message-ID. The value comes from the request and is written into a header, // so anything with a line break - which would start a new header - or // anything that is not an angle-bracketed token is dropped rather than // repaired. func cleanMessageID(id string) string { id = strings.TrimSpace(id) if len(id) < 3 || len(id) > 998 || id[0] != '<' || id[len(id)-1] != '>' { return "" } if strings.ContainsAny(id, "\r\n\x00 \t,;") || strings.Count(id, "<") != 1 || strings.Count(id, ">") != 1 { return "" } return id }