From 67d0c8aa5a6d10948d5c621d059d3d6b70fb4892 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 16:38:36 -0300 Subject: [PATCH 1/5] feat(protocols): conversations - thread_id and in_reply_to written at delivery, list and read APIs A reply now joins the conversation of the message it answers (Message-ID, In-Reply-To, References), falling back to the subject for replies with no headers. /mail/threads lists a folder as conversations and /mail/thread/{id} returns one. Replying from the webmail flags the answered message. Co-Authored-By: Claude Sonnet 5.5 --- .../0017_conversation_threading.down.sql | 3 + migrations/0017_conversation_threading.up.sql | 41 +++ .../cmd/lambdamail-protocols/main.go | 1 + .../application/port/conversations.go | 68 ++++ .../port/inbound_message_repository.go | 4 + .../application/usecase/message_headers.go | 37 ++- .../usecase/message_headers_test.go | 40 ++- .../usecase/process_inbound_email.go | 2 + .../usecase/process_outbound_email.go | 2 + .../application/usecase/webmail_mail.go | 51 +++ .../postgres/conversation_thread.go | 96 ++++++ .../postgres/conversation_thread_test.go | 308 +++++++++++++++++ .../infrastructure/postgres/conversations.go | 310 ++++++++++++++++++ .../postgres/inbound_message_repository.go | 12 +- .../infrastructure/postgres/mail_store.go | 2 +- .../internal/presentation/http/mail_api.go | 43 +++ .../internal/presentation/http/router.go | 14 + 17 files changed, 1025 insertions(+), 9 deletions(-) create mode 100644 migrations/0017_conversation_threading.down.sql create mode 100644 migrations/0017_conversation_threading.up.sql create mode 100644 services/protocols/internal/application/port/conversations.go create mode 100644 services/protocols/internal/infrastructure/postgres/conversation_thread.go create mode 100644 services/protocols/internal/infrastructure/postgres/conversation_thread_test.go create mode 100644 services/protocols/internal/infrastructure/postgres/conversations.go diff --git a/migrations/0017_conversation_threading.down.sql b/migrations/0017_conversation_threading.down.sql new file mode 100644 index 0000000..a8ee189 --- /dev/null +++ b/migrations/0017_conversation_threading.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_msg_mailbox_thread; +DROP FUNCTION IF EXISTS lm_has_reply_prefix(TEXT); +DROP FUNCTION IF EXISTS lm_subject_key(TEXT); diff --git a/migrations/0017_conversation_threading.up.sql b/migrations/0017_conversation_threading.up.sql new file mode 100644 index 0000000..001742a --- /dev/null +++ b/migrations/0017_conversation_threading.up.sql @@ -0,0 +1,41 @@ +-- Conversations. +-- +-- email_messages.thread_id has existed since the first migration and nothing +-- filled it, and in_reply_to was only ever copied, never written. A reply and +-- the message it answered were therefore unrelated rows, which is why the +-- webmail could neither show a conversation nor say whether it had been +-- answered. +-- +-- The subject key lives in the database so delivery, the backfill below and +-- anything later share one definition of "the same subject": the prefixes mail +-- clients add in several languages are not part of it. +CREATE FUNCTION lm_subject_key(subject TEXT) RETURNS TEXT AS $$ + SELECT btrim(regexp_replace( + lower(coalesce(subject, '')), + '^(\s*(re|fw|fwd|enc|res|rv|aw|wg|tr|antw)(\[\d+\])?\s*:)+', '')) +$$ LANGUAGE SQL IMMUTABLE; + +CREATE FUNCTION lm_has_reply_prefix(subject TEXT) RETURNS BOOLEAN AS $$ + SELECT lower(coalesce(subject, '')) ~ '^\s*(re|fw|fwd|enc|res|rv|aw|wg|tr|antw)(\[\d+\])?\s*:' +$$ LANGUAGE SQL IMMUTABLE; + +-- Mail that arrived before replies carried their headers cannot be linked by +-- Message-ID, so it is grouped by subject, and only where a reply is actually +-- in the mailbox: two unrelated messages that merely share a subject stay apart. +-- Everything else keeps thread_id NULL, which the webmail reads as "a +-- conversation of its own" (COALESCE(thread_id, id)). +WITH groups AS ( + SELECT DISTINCT mailbox_id, lm_subject_key(subject) AS k + FROM email_messages + WHERE lm_has_reply_prefix(subject) AND lm_subject_key(subject) <> '' +) +UPDATE email_messages m + SET thread_id = md5(m.mailbox_id::text || '|' || g.k)::uuid + FROM groups g + WHERE m.mailbox_id = g.mailbox_id + AND lm_subject_key(m.subject) = g.k + AND m.thread_id IS NULL; + +-- The listing groups by this and filters by mailbox and folder. +CREATE INDEX idx_msg_mailbox_thread ON email_messages (mailbox_id, (COALESCE(thread_id, id))) + WHERE expunged_at IS NULL; diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index 056fe25..2779c19 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -209,6 +209,7 @@ func run(cfg config) { if cfg.JwtSecret == "" { log.Printf("JWT_SECRET is not set: the webmail mail API stays disabled") } + webmailUC.SetConversations(postgres.NewConversations(pool)) router.SetMailAPI(webmailUC, cfg.JwtSecret) // Folders the mailbox owner keeps for themselves, which the filing rules // can then name as a destination. diff --git a/services/protocols/internal/application/port/conversations.go b/services/protocols/internal/application/port/conversations.go new file mode 100644 index 0000000..264d493 --- /dev/null +++ b/services/protocols/internal/application/port/conversations.go @@ -0,0 +1,68 @@ +package port + +import ( + "context" + "time" +) + +// WebmailParticipant is someone who wrote in a conversation. +type WebmailParticipant struct { + Name string `json:"name"` + Address string `json:"address"` + // Me marks the mailbox owner, so the list can say "me" instead of a name + // the reader knows perfectly well. + Me bool `json:"me"` +} + +// WebmailThread is one row of the conversation list: the newest message in the +// folder being looked at, with what the rest of the conversation adds. +type WebmailThread struct { + ThreadID string `json:"thread_id"` + UID uint32 `json:"uid"` + Subject string `json:"subject"` + SenderAddress string `json:"sender_address"` + FromName string `json:"from_display_name"` + Snippet string `json:"snippet"` + ReceivedAt time.Time `json:"received_at"` + HasAttachments bool `json:"has_attachments"` + MessageCount int `json:"message_count"` + UnseenCount int `json:"unseen_count"` + Seen bool `json:"seen"` + Flagged bool `json:"flagged"` + Answered bool `json:"answered"` + Participants []WebmailParticipant `json:"participants"` +} + +// WebmailThreadMessage is one message inside a conversation. +type WebmailThreadMessage struct { + UID uint32 `json:"uid"` + Folder string `json:"folder"` + Subject string `json:"subject"` + SenderAddress string `json:"sender_address"` + FromName string `json:"from_display_name"` + Snippet string `json:"snippet"` + ReceivedAt time.Time `json:"received_at"` + HasAttachments bool `json:"has_attachments"` + Seen bool `json:"seen"` + Flagged bool `json:"flagged"` + // Answered is true when the mailbox owner replied, whether the \Answered + // flag was set or the reply is simply there in the conversation - a reply + // written in another mail program leaves no flag, only the message. + Answered bool `json:"answered"` + // Own marks a message the mailbox owner wrote. + Own bool `json:"own"` +} + +// ConversationReader serves the conversation views. +type ConversationReader interface { + // ListThreads returns the conversations that have a message in the folder, + // newest first by that message. + ListThreads(ctx context.Context, mailboxID, folderName, search string, limit, offset int) ([]WebmailThread, error) + // ThreadMessages returns every message of one conversation, oldest first. + // folderName is the folder being viewed: a conversation's messages in Trash + // and Junk are left out unless that is the folder. + ThreadMessages(ctx context.Context, mailboxID, folderName, threadID string) ([]WebmailThreadMessage, error) + // MarkAnsweredByMessageID sets \Answered on the message a reply was written + // to, wherever in the mailbox it is filed. + MarkAnsweredByMessageID(ctx context.Context, mailboxID, messageID string) error +} diff --git a/services/protocols/internal/application/port/inbound_message_repository.go b/services/protocols/internal/application/port/inbound_message_repository.go index 27e219a..aecec27 100644 --- a/services/protocols/internal/application/port/inbound_message_repository.go +++ b/services/protocols/internal/application/port/inbound_message_repository.go @@ -22,6 +22,10 @@ type PersistInboundMessageInput struct { FromDisplayName string MessageIDHeader string HasAttachments bool + // InReplyTo and References place the message in its conversation. Both + // hold Message-IDs without angle brackets. + InReplyTo string + References []string // AlreadySeen marks a copy the user themselves composed - their own Sent // copy, or a draft. It is not unread mail, and counting it as such is why // the Sent folder carried an unread badge that nothing could clear: no diff --git a/services/protocols/internal/application/usecase/message_headers.go b/services/protocols/internal/application/usecase/message_headers.go index 65c685e..b4cbd93 100644 --- a/services/protocols/internal/application/usecase/message_headers.go +++ b/services/protocols/internal/application/usecase/message_headers.go @@ -19,10 +19,15 @@ type MessageHeaders struct { Subject string FromDisplayName string // FromAddress is the address in From, lowercased, or empty when it does not parse. - FromAddress string - MessageID string - Snippet string - HasAttachments bool + FromAddress string + MessageID string + Snippet string + HasAttachments bool + // InReplyTo and References are the Message-IDs this message answers, with + // the angle brackets removed so they compare equal to MessageID. They are + // what ties a reply to the conversation it belongs to. + InReplyTo string + References []string } // wordDecoder tolerates the charsets that turn up in real mail. An unknown @@ -57,6 +62,10 @@ func ExtractMessageHeaders(payload []byte) MessageHeaders { out.Subject = truncateUTF8(decodeHeader(msg.Header.Get("Subject")), 998) out.MessageID = truncateUTF8(strings.Trim(msg.Header.Get("Message-ID"), "<>"), 998) + if ids := messageIDsIn(msg.Header.Get("In-Reply-To")); len(ids) > 0 { + out.InReplyTo = ids[0] + } + out.References = messageIDsIn(msg.Header.Get("References")) if from := msg.Header.Get("From"); from != "" { if addr, err := mail.ParseAddress(from); err == nil { @@ -152,3 +161,23 @@ func truncateUTF8(s string, max int) string { } return cut } + +// maxReferences bounds how much of a long chain is kept. The ids nearest the +// end name the messages closest to this one, which is where its parent is. +const maxReferences = 20 + +// messageIDsIn lists the tokens of an In-Reply-To or References header, +// without their brackets, in the order written. +func messageIDsIn(header string) []string { + var ids []string + for _, field := range strings.Fields(header) { + id := strings.Trim(field, "<>,;") + if id != "" && len(id) <= 998 && strings.Contains(field, "<") { + ids = append(ids, id) + } + } + if len(ids) > maxReferences { + ids = ids[len(ids)-maxReferences:] + } + return ids +} diff --git a/services/protocols/internal/application/usecase/message_headers_test.go b/services/protocols/internal/application/usecase/message_headers_test.go index e7ff4c8..bebeb3f 100644 --- a/services/protocols/internal/application/usecase/message_headers_test.go +++ b/services/protocols/internal/application/usecase/message_headers_test.go @@ -1,6 +1,10 @@ package usecase -import "testing" +import ( + "fmt" + "strings" + "testing" +) func TestExtractMessageHeaders_ReadsSubjectSenderAndSnippet(t *testing.T) { msg := "From: Alice Example \r\n" + @@ -142,3 +146,37 @@ func TestExtractMessageHeaders_AttachmentDetection(t *testing.T) { }) } } + +func TestHeadersCarryWhatAMessageAnswers(t *testing.T) { + raw := "Message-ID: \r\nIn-Reply-To: \r\nReferences: \r\n \r\nSubject: Re: Plan\r\n\r\nbody" + h := ExtractMessageHeaders([]byte(raw)) + if h.InReplyTo != "b@x.test" { + t.Errorf("InReplyTo = %q, want b@x.test", h.InReplyTo) + } + if len(h.References) != 2 || h.References[0] != "a@x.test" || h.References[1] != "b@x.test" { + t.Errorf("References = %v, want [a@x.test b@x.test]", h.References) + } + if h.MessageID != "c@x.test" { + t.Errorf("MessageID = %q", h.MessageID) + } +} + +func TestAMessageThatAnswersNothingHasNoReferences(t *testing.T) { + h := ExtractMessageHeaders([]byte("Subject: Hi\r\n\r\nbody")) + if h.InReplyTo != "" || len(h.References) != 0 { + t.Errorf("a first message claims to answer %q %v", h.InReplyTo, h.References) + } +} + +// A chain can run to hundreds of ids. The ones nearest the end name the parent. +func TestAVeryLongChainKeepsTheNewestIDs(t *testing.T) { + var b strings.Builder + b.WriteString("References:") + for i := 0; i < 50; i++ { + fmt.Fprintf(&b, " ", i) + } + h := ExtractMessageHeaders([]byte(b.String() + "\r\nSubject: x\r\n\r\nbody")) + if len(h.References) != maxReferences || h.References[len(h.References)-1] != "m49@x.test" { + t.Errorf("kept %d ids ending %q", len(h.References), h.References[len(h.References)-1]) + } +} diff --git a/services/protocols/internal/application/usecase/process_inbound_email.go b/services/protocols/internal/application/usecase/process_inbound_email.go index a341112..b0953f3 100644 --- a/services/protocols/internal/application/usecase/process_inbound_email.go +++ b/services/protocols/internal/application/usecase/process_inbound_email.go @@ -347,6 +347,8 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI FromDisplayName: headers.FromDisplayName, MessageIDHeader: headers.MessageID, HasAttachments: headers.HasAttachments, + InReplyTo: headers.InReplyTo, + References: headers.References, AlreadySeen: alreadySeen, SPFResult: authResult.SPF, DKIMResult: authResult.DKIM, diff --git a/services/protocols/internal/application/usecase/process_outbound_email.go b/services/protocols/internal/application/usecase/process_outbound_email.go index 05c6bf8..f5d1d8e 100644 --- a/services/protocols/internal/application/usecase/process_outbound_email.go +++ b/services/protocols/internal/application/usecase/process_outbound_email.go @@ -187,6 +187,8 @@ func (uc *ProcessOutboundEmailUseCase) fileSentCopy(ctx context.Context, input P FromDisplayName: headers.FromDisplayName, MessageIDHeader: headers.MessageID, HasAttachments: headers.HasAttachments, + InReplyTo: headers.InReplyTo, + References: headers.References, // The sender's own copy is not unread mail AlreadySeen: true, }}) diff --git a/services/protocols/internal/application/usecase/webmail_mail.go b/services/protocols/internal/application/usecase/webmail_mail.go index 81aa880..0a908cb 100644 --- a/services/protocols/internal/application/usecase/webmail_mail.go +++ b/services/protocols/internal/application/usecase/webmail_mail.go @@ -37,6 +37,9 @@ type WebmailUseCase struct { // 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( @@ -91,6 +94,42 @@ func (uc *WebmailUseCase) ListMessages( 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) @@ -207,6 +246,16 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error { // 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 { @@ -341,6 +390,8 @@ func (uc *WebmailUseCase) SaveDraft(ctx context.Context, input ComposeInput, rep FromDisplayName: headers.FromDisplayName, MessageIDHeader: headers.MessageID, HasAttachments: headers.HasAttachments, + InReplyTo: headers.InReplyTo, + References: headers.References, AlreadySeen: true, }}) if err != nil { diff --git a/services/protocols/internal/infrastructure/postgres/conversation_thread.go b/services/protocols/internal/infrastructure/postgres/conversation_thread.go new file mode 100644 index 0000000..f13e893 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/conversation_thread.go @@ -0,0 +1,96 @@ +package postgres + +import ( + "context" + "crypto/md5" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "lambdamail/protocols/internal/application/port" +) + +// resolveThread decides which conversation a message being filed belongs to. +// +// In order of how much each is to be trusted: +// +// 1. A message already in this mailbox with the same Message-ID. The same +// message filed twice - a copy in Sent and one delivered back to oneself, +// or a copy moved to another folder - is one conversation, not two. +// 2. The message it answers, found through In-Reply-To and then References. +// This is what actually ties a reply to its original. +// 3. A subject that starts with Re:, Fwd: or a translation of them, when the +// headers found nothing. The same key the migration used for older mail, +// so a reply to a message from before this existed still lands with it. +// 4. Otherwise it starts a conversation of its own. +// +// A message with no stored thread_id is read as a conversation of its own +// (COALESCE(thread_id, id)), so a parent from before threading is adopted by +// its own id and needs no rewrite. +func resolveThread(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessageInput) (uuid.UUID, error) { + // The message's own id goes first so that it outranks the ones it answers. + var candidates []string + if input.MessageIDHeader != "" { + candidates = append(candidates, input.MessageIDHeader) + } + parents := make([]string, 0, len(input.References)+1) + if input.InReplyTo != "" { + parents = append(parents, input.InReplyTo) + } + // Nearest ancestor first: the last reference is the direct parent. + for i := len(input.References) - 1; i >= 0; i-- { + parents = append(parents, input.References[i]) + } + candidates = append(candidates, parents...) + + if len(candidates) > 0 { + var found uuid.UUID + err := tx.QueryRow(ctx, ` + SELECT COALESCE(thread_id, id) + FROM email_messages + WHERE mailbox_id = $1 + AND message_id_header = ANY($2) + AND expunged_at IS NULL + ORDER BY array_position($2::text[], message_id_header), received_at DESC + LIMIT 1`, input.MailboxID, candidates).Scan(&found) + if err == nil { + return found, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return uuid.Nil, fmt.Errorf("find the conversation: %w", err) + } + } + + if key := subjectKey(input.Subject); hasReplyPrefix(input.Subject) && key != "" { + return derivedThread(input.MailboxID, key), nil + } + return uuid.New(), nil +} + +// The subject helpers are written here as well as in the migration's SQL +// functions on purpose: delivery must keep working against a database whose +// migrations have not all run, so it cannot call functions a later migration +// creates. A test holds the two spellings to the same answers. +var replyPrefix = regexp.MustCompile(`^(\s*(re|fw|fwd|enc|res|rv|aw|wg|tr|antw)(\[\d+\])?\s*:)+`) + +func hasReplyPrefix(subject string) bool { + return replyPrefix.MatchString(strings.ToLower(subject)) +} + +// subjectKey is the subject with the prefixes mail clients add taken off. +func subjectKey(subject string) string { + return strings.TrimSpace(replyPrefix.ReplaceAllString(strings.ToLower(subject), "")) +} + +// derivedThread names the conversation a subject belongs to when nothing in +// the headers does. md5 over the same text as the migration's backfill, so +// mail filed before and after agree on it. +func derivedThread(mailboxID uuid.UUID, key string) uuid.UUID { + sum := md5.Sum([]byte(mailboxID.String() + "|" + key)) + id, _ := uuid.FromBytes(sum[:]) + return id +} diff --git a/services/protocols/internal/infrastructure/postgres/conversation_thread_test.go b/services/protocols/internal/infrastructure/postgres/conversation_thread_test.go new file mode 100644 index 0000000..f59821e --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/conversation_thread_test.go @@ -0,0 +1,308 @@ +package postgres + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +type threadFixture struct { + pool *pgxpool.Pool + repo *InboundMessageRepository + mailboxID uuid.UUID + domain string + blob port.BlobRef +} + +func newThreadFixture(t *testing.T) *threadFixture { + t.Helper() + pool := testPool(t) + t.Cleanup(pool.Close) + ctx := context.Background() + + domainID := uuid.New() + name := "thread-test-" + domainID.String() + ".invalid" + if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, domainID, name, name); err != nil { + t.Fatalf("seed domain: %v", err) + } + t.Cleanup(func() { pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) }) + + mailboxID := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'me', $3, 'x')`, + mailboxID, domainID, "me@"+name); err != nil { + t.Fatalf("seed mailbox: %v", err) + } + for _, f := range [][2]string{{"INBOX", "inbox"}, {"Sent", "sent"}} { + if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, $3, $4)`, + uuid.New(), mailboxID, f[0], f[1]); err != nil { + t.Fatalf("seed folder %s: %v", f[0], err) + } + } + blobID := uuid.New() + if _, err := pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`, + blobID, testBlobDigest()); err != nil { + t.Fatalf("seed blob: %v", err) + } + return &threadFixture{pool: pool, repo: NewInboundMessageRepository(pool), mailboxID: mailboxID, domain: name, + blob: port.BlobRef{ID: blobID, SHA256: "deadbeef", SizeBytes: 10}} +} + +// file stores one message and returns the conversation it landed in. +func (f *threadFixture) file(t *testing.T, in port.PersistInboundMessageInput) uuid.UUID { + t.Helper() + in.MailboxID = f.mailboxID + in.Blob = f.blob + if in.SenderAddress == "" { + in.SenderAddress = "someone@example.test" + } + in.RecipientAddress = "me@" + f.domain + in.SPFResult, in.DKIMResult, in.DMARCResult = "none", "none", "none" + if _, err := f.repo.PersistAll(context.Background(), []port.PersistInboundMessageInput{in}); err != nil { + t.Fatalf("PersistAll: %v", err) + } + var thread uuid.UUID + err := f.pool.QueryRow(context.Background(), ` + SELECT COALESCE(thread_id, id) FROM email_messages + WHERE mailbox_id = $1 AND subject = $2 ORDER BY received_at DESC, uid DESC LIMIT 1`, + f.mailboxID, in.Subject).Scan(&thread) + if err != nil { + t.Fatalf("read the conversation back: %v", err) + } + return thread +} + +func TestAReplyJoinsTheMessageItAnswers(t *testing.T) { + f := newThreadFixture(t) + root := f.file(t, port.PersistInboundMessageInput{Subject: "Lunch?", MessageIDHeader: "a@x.test"}) + reply := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Lunch?", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"}) + if reply != root { + t.Fatalf("the reply started a conversation of its own") + } +} + +// The reply carries no Re: and a different subject, so only the headers can +// tie it to the conversation. +func TestHeadersOutrankTheSubject(t *testing.T) { + f := newThreadFixture(t) + root := f.file(t, port.PersistInboundMessageInput{Subject: "Lunch?", MessageIDHeader: "a@x.test"}) + reply := f.file(t, port.PersistInboundMessageInput{Subject: "Actually dinner", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"}) + if reply != root { + t.Fatalf("a reply with a changed subject left the conversation") + } +} + +// Only References survives some clients, and a long chain names the parent last. +func TestReferencesAloneFindTheConversation(t *testing.T) { + f := newThreadFixture(t) + root := f.file(t, port.PersistInboundMessageInput{Subject: "Plan", MessageIDHeader: "a@x.test"}) + f.file(t, port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"}) + third := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Re: Plan", MessageIDHeader: "c@x.test", + References: []string{"a@x.test", "b@x.test"}}) + if third != root { + t.Fatalf("a message known only by References left the conversation") + } +} + +// What was sent and what came back are one conversation. +func TestTheSentCopyJoinsTheConversation(t *testing.T) { + f := newThreadFixture(t) + root := f.file(t, port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"}) + sent := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "mine@x.test", + InReplyTo: "a@x.test", TargetFolderName: "Sent", AlreadySeen: true}) + if sent != root { + t.Fatalf("my own reply is not in the conversation it answers") + } +} + +func TestUnrelatedMessagesStayApart(t *testing.T) { + f := newThreadFixture(t) + one := f.file(t, port.PersistInboundMessageInput{Subject: "Invoice", MessageIDHeader: "a@x.test"}) + two := f.file(t, port.PersistInboundMessageInput{Subject: "Invoice", MessageIDHeader: "b@x.test"}) + if one == two { + t.Fatalf("two unrelated messages that share a subject were merged") + } +} + +// A reply that names nothing is still a reply by its subject. +func TestAReplyWithNoHeadersFallsBackToItsSubject(t *testing.T) { + f := newThreadFixture(t) + a := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Status"}) + b := f.file(t, port.PersistInboundMessageInput{Subject: "RE: Re: STATUS"}) + if a != b { + t.Fatalf("replies to the same subject were not grouped") + } +} + +// The same message filed twice, such as a copy sent to oneself, is one conversation. +func TestTheSameMessageFiledTwiceIsOneConversation(t *testing.T) { + f := newThreadFixture(t) + a := f.file(t, port.PersistInboundMessageInput{Subject: "Note", MessageIDHeader: "n@x.test", TargetFolderName: "Sent", AlreadySeen: true}) + b := f.file(t, port.PersistInboundMessageInput{Subject: "Note", MessageIDHeader: "n@x.test"}) + if a != b { + t.Fatalf("the sent copy and the delivered copy are two conversations") + } +} + +// Mail from before threading has no thread_id and is its own conversation, so +// a reply must adopt it by id rather than orphan it. +func TestAReplyAdoptsAMessageFromBeforeThreading(t *testing.T) { + f := newThreadFixture(t) + f.file(t, port.PersistInboundMessageInput{Subject: "Old mail", MessageIDHeader: "old@x.test"}) + if _, err := f.pool.Exec(context.Background(), `UPDATE email_messages SET thread_id = NULL WHERE mailbox_id = $1`, f.mailboxID); err != nil { + t.Fatalf("simulate legacy: %v", err) + } + var legacyID uuid.UUID + if err := f.pool.QueryRow(context.Background(), `SELECT id FROM email_messages WHERE mailbox_id = $1`, f.mailboxID).Scan(&legacyID); err != nil { + t.Fatalf("read legacy id: %v", err) + } + reply := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Old mail", MessageIDHeader: "new@x.test", InReplyTo: "old@x.test"}) + if reply != legacyID { + t.Fatalf("the reply did not adopt the older message") + } +} + +// --- reading conversations -------------------------------------------------- + +func (f *threadFixture) own(t *testing.T, in port.PersistInboundMessageInput) { + t.Helper() + in.TargetFolderName = "Sent" + in.AlreadySeen = true + in.SenderAddress = "me@" + f.domain + f.file(t, in) +} + +func (f *threadFixture) inbox(t *testing.T, from string, in port.PersistInboundMessageInput) { + t.Helper() + in.SenderAddress = from + f.file(t, in) +} + +func TestTheListHasOneRowPerConversationWithItsCount(t *testing.T) { + f := newThreadFixture(t) + f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Plan", MessageIDHeader: "a@x.test"}) + f.own(t, port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"}) + f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "c@x.test", InReplyTo: "b@x.test"}) + f.inbox(t, "bob@x.test", port.PersistInboundMessageInput{Subject: "Other", MessageIDHeader: "z@x.test"}) + + threads, err := NewConversations(f.pool).ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0) + if err != nil { + t.Fatalf("ListThreads: %v", err) + } + if len(threads) != 2 { + t.Fatalf("got %d rows, want 2 conversations", len(threads)) + } + plan := threads[0] + if !strings.Contains(plan.Subject, "Plan") { + plan = threads[1] + } + // The reply written from Sent counts towards the conversation shown in the inbox. + if plan.MessageCount != 3 { + t.Errorf("the conversation has %d messages, want 3 (two received, one sent)", plan.MessageCount) + } +} + +// What a reader wants to know at a glance. +func TestAConversationIsAnsweredWhenIReplied(t *testing.T) { + f := newThreadFixture(t) + f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"}) + conv := NewConversations(f.pool) + before, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0) + if before[0].Answered { + t.Fatalf("an unanswered conversation was shown as answered") + } + + // A reply written in another mail program leaves no \Answered flag, only the message. + f.own(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"}) + after, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0) + if !after[0].Answered { + t.Fatalf("the conversation I replied to is not shown as answered") + } +} + +func TestANewMessageAfterMyReplyIsUnansweredAgain(t *testing.T) { + f := newThreadFixture(t) + f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"}) + f.own(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"}) + f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "c@x.test", InReplyTo: "b@x.test"}) + threads, _ := NewConversations(f.pool).ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0) + if threads[0].Answered { + t.Fatalf("a conversation waiting on my reply was shown as answered") + } +} + +func TestMarkingAMessageAnsweredFlagsIt(t *testing.T) { + f := newThreadFixture(t) + f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Ask", MessageIDHeader: "a@x.test"}) + conv := NewConversations(f.pool) + if err := conv.MarkAnsweredByMessageID(context.Background(), f.mailboxID.String(), ""); err != nil { + t.Fatalf("MarkAnsweredByMessageID: %v", err) + } + threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0) + msgs, err := conv.ThreadMessages(context.Background(), f.mailboxID.String(), "INBOX", threads[0].ThreadID) + if err != nil || len(msgs) != 1 || !msgs[0].Answered { + t.Fatalf("the flag did not show: %v %+v", err, msgs) + } +} + +func TestAConversationOfAnotherMailboxCannotBeRead(t *testing.T) { + f := newThreadFixture(t) + f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Private", MessageIDHeader: "a@x.test"}) + conv := NewConversations(f.pool) + threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0) + + other := newThreadFixture(t) + if _, err := conv.ThreadMessages(context.Background(), other.mailboxID.String(), "INBOX", threads[0].ThreadID); err == nil { + t.Fatalf("another mailbox read this conversation") + } +} + +func TestThreadMessagesAreOldestFirstAndLeaveOutTrash(t *testing.T) { + f := newThreadFixture(t) + f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Topic", MessageIDHeader: "a@x.test"}) + f.own(t, port.PersistInboundMessageInput{Subject: "Re: Topic", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"}) + conv := NewConversations(f.pool) + threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0) + msgs, err := conv.ThreadMessages(context.Background(), f.mailboxID.String(), "INBOX", threads[0].ThreadID) + if err != nil || len(msgs) != 2 { + t.Fatalf("got %d messages (%v), want 2", len(msgs), err) + } + if msgs[0].Own || !msgs[1].Own || !msgs[0].Answered { + t.Errorf("order or flags wrong: %+v", msgs) + } +} + +// Delivery computes the subject key itself, and the migration backfilled older +// mail with SQL. If the two ever disagreed, a reply would miss the conversation +// its older messages were put in. +func TestGoAndSQLAgreeOnTheSubjectKey(t *testing.T) { + pool := testPool(t) + defer pool.Close() + mailbox := uuid.New() + for _, subject := range []string{ + "Plan", "Re: Plan", "RE: Re[2]: Fwd: Plan ", " re :plan", "AW: Plan", "ENC: Plan", "Reunion", "Re:", "", + "Fw:Fw: invoice 12", "Resumo: vendas", "Antw: Re: Hallo", + } { + var sqlKey string + var sqlPrefix bool + var sqlThread uuid.UUID + if err := pool.QueryRow(context.Background(), + `SELECT lm_subject_key($1), lm_has_reply_prefix($1), md5($2::text || '|' || lm_subject_key($1))::uuid`, + subject, mailbox).Scan(&sqlKey, &sqlPrefix, &sqlThread); err != nil { + t.Fatalf("%q: %v", subject, err) + } + if got := subjectKey(subject); got != sqlKey { + t.Errorf("%q: Go key %q, SQL key %q", subject, got, sqlKey) + } + if got := hasReplyPrefix(subject); got != sqlPrefix { + t.Errorf("%q: Go prefix %v, SQL prefix %v", subject, got, sqlPrefix) + } + if got := derivedThread(mailbox, sqlKey); got != sqlThread { + t.Errorf("%q: Go thread %v, SQL thread %v", subject, got, sqlThread) + } + } +} diff --git a/services/protocols/internal/infrastructure/postgres/conversations.go b/services/protocols/internal/infrastructure/postgres/conversations.go new file mode 100644 index 0000000..6c02649 --- /dev/null +++ b/services/protocols/internal/infrastructure/postgres/conversations.go @@ -0,0 +1,310 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "lambdamail/protocols/internal/application/port" +) + +// Conversations reads the mailbox as conversations rather than as single +// messages. Writing is MailStore's job, as for every other change. +type Conversations struct { + pool *pgxpool.Pool + store *MailStore +} + +func NewConversations(pool *pgxpool.Pool) *Conversations { + return &Conversations{pool: pool, store: NewMailStore(pool)} +} + +// scopeRow is one message that counts towards a conversation. +type scopeRow struct { + threadID uuid.UUID + uid uint32 + folder string + folderID string + subject string + sender string + name string + snippet string + receivedAt time.Time + hasAttach bool + seen bool + flagged bool + answeredFlag bool + own bool +} + +// scopeSQL selects the messages of the given conversations that belong in a +// view of one folder: that folder's own messages, and the rest of the +// conversation wherever it is filed, except what has been thrown away. Trash, +// Junk and Drafts are left out of a conversation seen from elsewhere - a +// reply still being written, or one deleted, is not part of the thread a reader +// is looking at - and are the whole story when that is the folder viewed. +const scopeSQL = ` + SELECT COALESCE(e.thread_id, e.id), e.uid, fo.name, fo.id::text, + COALESCE(e.subject, ''), e.sender_address::text, + COALESCE(e.from_display_name, ''), COALESCE(e.snippet, ''), + e.received_at, e.has_attachments, + EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Seen'), + EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Flagged'), + EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Answered'), + e.sender_address::text = mb.email_address::text + FROM email_messages e + JOIN folders fo ON fo.id = e.folder_id + JOIN mailboxes mb ON mb.id = e.mailbox_id + WHERE e.mailbox_id = $1 + AND e.expunged_at IS NULL + AND COALESCE(e.thread_id, e.id) = ANY($3::uuid[]) + AND (fo.id::text = $2 + OR (COALESCE(fo.special_use, '') NOT IN ('trash', 'junk', 'drafts') + AND lower(fo.name) NOT IN ('trash', 'junk', 'drafts'))) + ORDER BY e.received_at ASC, e.uid ASC` + +func (c *Conversations) scope(ctx context.Context, mailboxID, folderID string, threads []uuid.UUID) (map[uuid.UUID][]scopeRow, error) { + rows, err := c.pool.Query(ctx, scopeSQL, mailboxID, folderID, threads) + if err != nil { + return nil, fmt.Errorf("read the conversations: %w", err) + } + defer rows.Close() + out := make(map[uuid.UUID][]scopeRow, len(threads)) + for rows.Next() { + var r scopeRow + var uid int64 + if err := rows.Scan(&r.threadID, &uid, &r.folder, &r.folderID, &r.subject, &r.sender, &r.name, + &r.snippet, &r.receivedAt, &r.hasAttach, &r.seen, &r.flagged, &r.answeredFlag, &r.own); err != nil { + return nil, err + } + r.uid = uint32(uid) + out[r.threadID] = append(out[r.threadID], r) + } + return out, rows.Err() +} + +func (c *Conversations) ListThreads( + ctx context.Context, mailboxID, folderName, search string, limit, offset int, +) ([]port.WebmailThread, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + folderID, err := c.store.ResolveFolder(ctx, mailboxID, folderName) + if errors.Is(err, port.ErrFolderMissing) { + return []port.WebmailThread{}, nil + } + if err != nil { + return nil, err + } + + // The page is chosen first, by the newest message each conversation has in + // this folder, and only then are those conversations read in full. + rows, err := c.pool.Query(ctx, ` + SELECT COALESCE(thread_id, id) + FROM email_messages + WHERE folder_id = $1 AND expunged_at IS NULL + AND ($2 = '' OR subject ILIKE '%' || $2 || '%' + OR sender_address::text ILIKE '%' || $2 || '%' + OR snippet ILIKE '%' || $2 || '%') + GROUP BY COALESCE(thread_id, id) + ORDER BY max(received_at) DESC, max(uid) DESC + LIMIT $3 OFFSET $4`, folderID, search, limit, offset) + if err != nil { + return nil, fmt.Errorf("list conversations: %w", err) + } + var page []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + page = append(page, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if len(page) == 0 { + return []port.WebmailThread{}, nil + } + + byThread, err := c.scope(ctx, mailboxID, folderID, page) + if err != nil { + return nil, err + } + out := make([]port.WebmailThread, 0, len(page)) + for _, id := range page { + if t, ok := summarise(id, byThread[id], folderID); ok { + out = append(out, t) + } + } + return out, nil +} + +// summarise folds a conversation's messages into its list row. It reports +// false for a conversation with nothing in the folder viewed. +func summarise(id uuid.UUID, msgs []scopeRow, folderID string) (port.WebmailThread, bool) { + var latestHere *scopeRow + for i := range msgs { + if msgs[i].folderID == folderID { + latestHere = &msgs[i] // oldest first, so the last one wins + } + } + if latestHere == nil { + return port.WebmailThread{}, false + } + + t := port.WebmailThread{ + ThreadID: id.String(), UID: latestHere.uid, Subject: latestHere.subject, + SenderAddress: latestHere.sender, FromName: latestHere.name, Snippet: latestHere.snippet, + ReceivedAt: latestHere.receivedAt, MessageCount: len(msgs), + Answered: answered(msgs), + Participants: participants(msgs), + } + for _, m := range msgs { + if !m.seen { + t.UnseenCount++ + } + t.Flagged = t.Flagged || m.flagged + t.HasAttachments = t.HasAttachments || m.hasAttach + } + t.Seen = t.UnseenCount == 0 + return t, true +} + +// answered reports whether the last thing someone else wrote has been replied +// to - by the flag, or by a message of the owner's that came after it. +func answered(msgs []scopeRow) bool { + lastIncoming := -1 + for i, m := range msgs { + if !m.own { + lastIncoming = i + } + } + if lastIncoming < 0 { + return false + } + if msgs[lastIncoming].answeredFlag { + return true + } + for _, m := range msgs[lastIncoming+1:] { + if m.own { + return true + } + } + return false +} + +// participants lists who wrote, in the order they first did, at most four. +func participants(msgs []scopeRow) []port.WebmailParticipant { + seen := map[string]bool{} + var out []port.WebmailParticipant + for _, m := range msgs { + key := strings.ToLower(m.sender) + if seen[key] || len(out) == 4 { + continue + } + seen[key] = true + name := m.name + if name == "" { + name = m.sender + } + out = append(out, port.WebmailParticipant{Name: name, Address: m.sender, Me: m.own}) + } + return out +} + +func (c *Conversations) ThreadMessages( + ctx context.Context, mailboxID, folderName, threadID string, +) ([]port.WebmailThreadMessage, error) { + id, err := uuid.Parse(threadID) + if err != nil { + return nil, ErrMessageNotFound + } + folderID, err := c.store.ResolveFolder(ctx, mailboxID, folderName) + if errors.Is(err, port.ErrFolderMissing) { + return nil, ErrMessageNotFound + } + if err != nil { + return nil, err + } + byThread, err := c.scope(ctx, mailboxID, folderID, []uuid.UUID{id}) + if err != nil { + return nil, err + } + msgs := byThread[id] + if len(msgs) == 0 { + // Another mailbox's conversation and one that does not exist look alike. + return nil, ErrMessageNotFound + } + + out := make([]port.WebmailThreadMessage, 0, len(msgs)) + for i, m := range msgs { + out = append(out, port.WebmailThreadMessage{ + UID: m.uid, Folder: m.folder, Subject: m.subject, SenderAddress: m.sender, FromName: m.name, + Snippet: m.snippet, ReceivedAt: m.receivedAt, HasAttachments: m.hasAttach, + Seen: m.seen, Flagged: m.flagged, Own: m.own, + Answered: !m.own && (m.answeredFlag || ownAfter(msgs, i)), + }) + } + sort.SliceStable(out, func(a, b int) bool { return out[a].ReceivedAt.Before(out[b].ReceivedAt) }) + return out, nil +} + +func ownAfter(msgs []scopeRow, i int) bool { + for _, m := range msgs[i+1:] { + if m.own { + return true + } + } + return false +} + +// MarkAnsweredByMessageID sets \Answered on the message a reply was written to. +func (c *Conversations) MarkAnsweredByMessageID(ctx context.Context, mailboxID, messageID string) error { + messageID = strings.Trim(strings.TrimSpace(messageID), "<>") + if messageID == "" { + return nil + } + rows, err := c.pool.Query(ctx, ` + SELECT fo.id::text, e.uid + FROM email_messages e JOIN folders fo ON fo.id = e.folder_id + WHERE e.mailbox_id = $1 AND e.message_id_header = $2 AND e.expunged_at IS NULL + AND COALESCE(fo.special_use, '') NOT IN ('sent', 'drafts', 'trash')`, mailboxID, messageID) + if err != nil { + return fmt.Errorf("find the message that was answered: %w", err) + } + type target struct { + folder string + uid int64 + } + var targets []target + for rows.Next() { + var t target + if err := rows.Scan(&t.folder, &t.uid); err != nil { + rows.Close() + return err + } + targets = append(targets, t) + } + rows.Close() + for _, t := range targets { + if err := func() error { + _, e := c.store.SetFlags(ctx, t.folder, uint32(t.uid), port.FlagOpAdd, []string{`\Answered`}, 0) + return e + }(); err != nil { + return fmt.Errorf("flag the answered message: %w", err) + } + } + return nil +} diff --git a/services/protocols/internal/infrastructure/postgres/inbound_message_repository.go b/services/protocols/internal/infrastructure/postgres/inbound_message_repository.go index a37a1df..d7f440a 100644 --- a/services/protocols/internal/infrastructure/postgres/inbound_message_repository.go +++ b/services/protocols/internal/infrastructure/postgres/inbound_message_repository.go @@ -124,6 +124,11 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage // id and received_at come back because message_flags is keyed on both: // email_messages is partitioned by received_at, so the flag rows cannot be // written without the same timestamp the row landed on. + threadID, err := resolveThread(ctx, tx, input) + if err != nil { + return 0, err + } + var messageID uuid.UUID var receivedAt time.Time err = tx.QueryRow(ctx, ` @@ -131,8 +136,8 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage mailbox_id, folder_id, uid, modseq, blob_id, sender_address, recipient_addresses, size_bytes, subject, snippet, from_display_name, message_id_header, has_attachments, - spf_result, dkim_result, dmarc_result - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + spf_result, dkim_result, dmarc_result, in_reply_to, thread_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING id, received_at `, input.MailboxID, folderID, uid, modseq, input.Blob.ID, input.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes, @@ -147,7 +152,8 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage // Sent copy and drafts have no authentication results by definition - // and it failed the whole insert. Sent stayed empty because that write // is deliberately non-fatal, and saving a draft reported an error. - nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult)). + nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult), + nullIfEmpty(input.InReplyTo), threadID). Scan(&messageID, &receivedAt) if err != nil { return 0, fmt.Errorf("insert email_messages: %w", err) diff --git a/services/protocols/internal/infrastructure/postgres/mail_store.go b/services/protocols/internal/infrastructure/postgres/mail_store.go index 1a0fb30..24375e3 100644 --- a/services/protocols/internal/infrastructure/postgres/mail_store.go +++ b/services/protocols/internal/infrastructure/postgres/mail_store.go @@ -426,7 +426,7 @@ func (s *MailStore) copyOne(ctx context.Context, sourceFolderID string, sourceUI SELECT $2::uuid, $3::uuid, $4::bigint, $5::bigint, blob_id, sender_address, recipient_addresses, size_bytes, spf_result, dkim_result, dmarc_result, - from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments, + from_display_name, subject, snippet, message_id_header, in_reply_to, COALESCE(thread_id, id), has_attachments, arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name FROM email_messages WHERE id = $1 AND received_at = $6 RETURNING id, received_at`, diff --git a/services/protocols/internal/presentation/http/mail_api.go b/services/protocols/internal/presentation/http/mail_api.go index cf45825..7ab8635 100644 --- a/services/protocols/internal/presentation/http/mail_api.go +++ b/services/protocols/internal/presentation/http/mail_api.go @@ -102,6 +102,47 @@ func (m *mailAPI) handleMessages(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, messages) } +// handleThreads lists the folder as conversations, one row each. +func (m *mailAPI) handleThreads(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")) + + threads, err := m.useCase.ListThreads(r.Context(), session.Email, folder, query.Get("q"), limit, offset) + if err != nil { + m.fail(w, err) + return + } + writeJSON(w, http.StatusOK, threads) +} + +// handleThread returns the messages of one conversation, oldest first. +func (m *mailAPI) handleThread(w http.ResponseWriter, r *http.Request) { + session, ok := m.authenticate(w, r) + if !ok { + return + } + threadID := strings.TrimPrefix(r.URL.Path, "/api/v1/mail/thread/") + folder := r.URL.Query().Get("folder") + if folder == "" { + folder = "inbox" + } + messages, err := m.useCase.ThreadMessages(r.Context(), session.Email, folder, threadID) + 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 { @@ -482,6 +523,8 @@ func (m *mailAPI) decodeCompose( // fail maps domain errors onto status codes without leaking internals. func (m *mailAPI) fail(w http.ResponseWriter, err error) { switch { + case errors.Is(err, usecase.ErrConversationsDisabled): + writeError(w, http.StatusNotImplemented, "CONVERSATIONS_DISABLED", "Conversations are not enabled") 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): diff --git a/services/protocols/internal/presentation/http/router.go b/services/protocols/internal/presentation/http/router.go index 37354c2..34253b1 100644 --- a/services/protocols/internal/presentation/http/router.go +++ b/services/protocols/internal/presentation/http/router.go @@ -98,6 +98,8 @@ func (r *Router) registerRoutes() { 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/threads", r.handleMailThreads) + r.mux.HandleFunc("/api/v1/mail/thread/", r.handleMailThread) r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen) r.mux.HandleFunc("/api/v1/mail/flagged", r.handleMailFlagged) r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend) @@ -287,6 +289,18 @@ func (r *Router) handleMailFolders(w http.ResponseWriter, req *http.Request) { } } +func (r *Router) handleMailThreads(w http.ResponseWriter, req *http.Request) { + if r.mailReady(w) { + r.mail.handleThreads(w, req) + } +} + +func (r *Router) handleMailThread(w http.ResponseWriter, req *http.Request) { + if r.mailReady(w) { + r.mail.handleThread(w, req) + } +} + func (r *Router) handleMailMessages(w http.ResponseWriter, req *http.Request) { if r.mailReady(w) { r.mail.handleMessages(w, req) From 997dd8848fe9d2194229d8310a3471bf55dea2ff Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 16:40:36 -0300 Subject: [PATCH 2/5] feat(auth): conversation_view preference, on by default Co-Authored-By: Claude Sonnet 5.5 --- ...0018_conversation_view_preference.down.sql | 1 + .../0018_conversation_view_preference.up.sql | 3 +++ services/auth/src/repository.ts | 23 ++++++++++++++----- services/auth/src/router.test.ts | 15 ++++++++++++ services/auth/src/router.ts | 3 ++- 5 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 migrations/0018_conversation_view_preference.down.sql create mode 100644 migrations/0018_conversation_view_preference.up.sql diff --git a/migrations/0018_conversation_view_preference.down.sql b/migrations/0018_conversation_view_preference.down.sql new file mode 100644 index 0000000..aeff770 --- /dev/null +++ b/migrations/0018_conversation_view_preference.down.sql @@ -0,0 +1 @@ +ALTER TABLE mailboxes DROP COLUMN IF EXISTS conversation_view; diff --git a/migrations/0018_conversation_view_preference.up.sql b/migrations/0018_conversation_view_preference.up.sql new file mode 100644 index 0000000..8e251fa --- /dev/null +++ b/migrations/0018_conversation_view_preference.up.sql @@ -0,0 +1,3 @@ +-- Whether the webmail lists mail as conversations. On by default: it is what +-- people coming from other mail programs expect, and it is one switch away. +ALTER TABLE mailboxes ADD COLUMN conversation_view BOOLEAN NOT NULL DEFAULT true; diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 406bf84..c66f072 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -1195,21 +1195,32 @@ export async function deleteAlias(scope: AdminScope, aliasId: string): Promise { - const row = await queryOne<{ signature: string | null; auto_save_drafts: boolean | null }>( - `SELECT signature, auto_save_drafts FROM mailboxes WHERE id = $1`, +export async function getUserPreferences( + mailboxId: string, +): Promise<{ signature: string; auto_save_drafts: boolean; conversation_view: boolean }> { + const row = await queryOne<{ signature: string | null; auto_save_drafts: boolean | null; conversation_view: boolean | null }>( + `SELECT signature, auto_save_drafts, conversation_view FROM mailboxes WHERE id = $1`, [mailboxId], ); return { signature: row?.signature ?? "", auto_save_drafts: row?.auto_save_drafts ?? true, + conversation_view: row?.conversation_view ?? true, }; } -export async function updateUserPreferences(mailboxId: string, signature: string, autoSaveDrafts: boolean): Promise { +/** conversationView is left as it is when the caller does not say. */ +export async function updateUserPreferences( + mailboxId: string, + signature: string, + autoSaveDrafts: boolean, + conversationView: boolean | null = null, +): Promise { const rows = await query<{ id: string }>( - `UPDATE mailboxes SET signature = $2, auto_save_drafts = $3, updated_at = NOW() WHERE id = $1 RETURNING id`, - [mailboxId, signature, autoSaveDrafts], + `UPDATE mailboxes SET signature = $2, auto_save_drafts = $3, + conversation_view = COALESCE($4::boolean, conversation_view), updated_at = NOW() + WHERE id = $1 RETURNING id`, + [mailboxId, signature, autoSaveDrafts, conversationView], ); return rows.length === 1; } diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index faab669..b531ef1 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -1218,6 +1218,21 @@ describeDb("moving between the surfaces", () => { expect((await send("GET", "/api/v1/user/trusted-senders")).body.senders).toEqual([]); }, 60000); + it("lists conversations by default and remembers a switch back, without being reset by other saves", async () => { + const prefs = async () => + (await send("GET", "/api/v1/user/preferences")).body as { conversation_view: boolean; signature: string }; + expect((await prefs()).conversation_view).toBe(true); + + await send("POST", "/api/v1/user/preferences", { signature: "x", auto_save_drafts: true, conversation_view: false }); + expect((await prefs()).conversation_view).toBe(false); + + // A save from an older screen that does not mention it must not flip it back. + await send("POST", "/api/v1/user/preferences", { signature: "y", auto_save_drafts: true }); + const after = await prefs(); + expect(after.conversation_view).toBe(false); + expect(after.signature).toBe("y"); + }, 60000); + it("refuses something that is not an address", async () => { expect((await send("PUT", "/api/v1/user/trusted-senders", { address: "not an address" })).status).toBe(400); expect((await send("PUT", "/api/v1/user/trusted-senders", {})).status).toBe(400); diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 4493e72..82c2be5 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -1224,7 +1224,8 @@ async function userUpdatePreferences(req: IncomingMessage, res: ServerResponse, const body = await parseJsonBody(req); const signature = typeof body?.signature === "string" ? body.signature : ""; const autoSaveDrafts = body?.auto_save_drafts !== false; - await repo.updateUserPreferences(session.sub, signature, autoSaveDrafts); + const conversationView = typeof body?.conversation_view === "boolean" ? body.conversation_view : null; + await repo.updateUserPreferences(session.sub, signature, autoSaveDrafts, conversationView); sendJson(res, 200, { ok: true }); } From 86a69d1a96568d066ec740b604b4a124a3564b04 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 16:41:59 -0300 Subject: [PATCH 3/5] refactor(webmail): the composer is a component, so a reply can be written under a conversation Co-Authored-By: Claude Sonnet 5.5 --- .../src/app/(user)/user/compose/page.tsx | 593 +---------------- services/webmail/src/components/Composer.tsx | 603 ++++++++++++++++++ .../src/compose-draft-integrity.test.ts | 2 +- 3 files changed, 621 insertions(+), 577 deletions(-) create mode 100644 services/webmail/src/components/Composer.tsx diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index 4da1ef4..abeae66 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -1,583 +1,24 @@ "use client"; -import React, { useCallback, useEffect, useRef, useState, use } from "react"; -import Link from "next/link"; -import { - Send, - Paperclip, - Bold, - Italic, - Underline, - Strikethrough, - List, - ListOrdered, - Link as LinkIcon, - Undo2, - X, - Sparkles, - CheckCircle2, - Flag, -} from "lucide-react"; -import { useTranslations } from "../../../../i18n/provider"; -import { Button } from "../../../../components/ui/Button"; -import { notifyMailStateChanged } from "../../../../lib/useFolders"; -import { signatureToHtml } from "../../../../lib/signature"; -import { RecipientInput } from "../../../../components/RecipientInput"; -import { parseAddress, parseAddressList, prefixSubject } from "../../../../lib/recipients"; +import { use } from "react"; +import { Composer, type ComposerInitial } from "../../../../components/Composer"; -/** Entries from a query-string list, which may be several addresses. */ -function entriesFrom(value: string | undefined): string[] { - return value ? parseAddressList(value).map((p) => p.entry) : []; -} - -interface AttachedFile { - id: string; - name: string; - size: number; - type: string; -} - -export default function ComposePage({ - searchParams, -}: { - searchParams?: Promise<{ - replyTo?: string; - cc?: string; - subject?: string; - inReplyTo?: string; - mode?: string; - draft?: string; - }>; -}) { - const t = useTranslations(); +/** + * The full compose screen. The composer itself lives in components/Composer so + * the reply written under a conversation is the same code, not a copy of it. + */ +export default function ComposePage({ searchParams }: { searchParams?: Promise }) { const params = searchParams ? use(searchParams) : {}; - - // Recipients are lists of entries, not one string to be split later: a - // reply-all arrives with several people already in To and Cc. - const [to, setTo] = useState(() => entriesFrom(params?.replyTo)); - const [cc, setCc] = useState(() => entriesFrom(params?.cc)); - const [bcc, setBcc] = useState([]); - const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0); - const [subject, setSubject] = useState( - params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "", - ); - // The Message-ID being answered, so the reply threads under it. - const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || ""); - const [important, setImportant] = useState(false); - const [attachments, setAttachments] = useState([]); - const [sending, setSending] = useState(false); - const [undoSeconds, setUndoSeconds] = useState(null); - const [draftStatus, setDraftStatus] = useState(""); - const [discarding, setDiscarding] = useState(false); - const [sendError, setSendError] = useState(null); - // The UID of the draft already stored for this message. Sent back on each - // autosave so the server replaces it instead of leaving another copy. - const draftUidRef = useRef(0); - // Set once the writer has discarded the message. An autosave already on its - // way when that happens would otherwise land afterwards and bring the draft - // back, so every save checks this before and after it talks to the server. - const discardedRef = useRef(false); - - const editorRef = useRef(null); - // The body, mirrored into React state. - // - // It used to live only inside the contenteditable, read through the ref at - // save time. Nothing observed it, so the autosave effect - which depends on - // what it reads - never re-ran when the message itself was typed. The draft - // was written once, moments after the address was entered, and never again: - // that is why a draft left behind by a sent message was empty. - const [body, setBody] = useState(""); - - useEffect(() => { - const savedSig = localStorage.getItem("lm_user_signature"); - // signatureToHtml, not the raw value: the signature may be the plain text - // of the old field, whose newlines are whitespace once assigned as HTML - - // which is why every line break in a signature used to vanish here. - const signatureHtml = signatureToHtml(savedSig ?? ""); - if (editorRef.current && signatureHtml) { - const initial = `

--
${signatureHtml}`; - editorRef.current.innerHTML = initial; - setBody(initial); - } - }, []); - - // Reopening a draft loads what was written into the composer, and adopts its - // UID so carrying on writing replaces that draft instead of leaving the old - // copy behind and starting a second one. - // - // Opening a draft used to land in the reader, which offered to reply to the - // half-written message and gave no way to finish it. - const draftParam = params?.draft; - useEffect(() => { - const uid = Number(draftParam); - if (!Number.isFinite(uid) || uid <= 0) return; - - let cancelled = false; - void (async () => { - try { - const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" }); - if (!res.ok || cancelled) return; - const message = await res.json(); - if (cancelled) return; - - draftUidRef.current = uid; - setTo(message.to ?? []); - setCc(message.cc ?? []); - setImportant(Boolean(message.important)); - setInReplyTo(message.in_reply_to ?? ""); - if ((message.cc ?? []).length > 0) setShowCcBcc(true); - setSubject(message.subject ?? ""); - const html = message.html || message.text || ""; - if (editorRef.current) editorRef.current.innerHTML = html; - setBody(html); - } catch { - // A draft that cannot be loaded leaves an empty composer rather than - // a broken screen; the draft itself is untouched on the server. - } - })(); - return () => { - cancelled = true; - }; - }, [draftParam]); - - // Removes a stored draft for good. discard_draft is what keeps it out of - // Trash: an ordinary delete moves it there, and a draft sitting in Trash is - // not discarded. - const removeDraft = async (uid: number): Promise => { - try { - const res = await fetch("/api/v1/mail/delete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }), - }); - return res.ok; - } catch { - return false; - } - }; - - // Saves the draft for real. This used to be a setTimeout that set the words - // "draft saved automatically" and nothing else - no request, no storage - so - // closing the tab lost the message while the screen had just said otherwise. - const saveDraft = useCallback(async () => { - if (discardedRef.current) return; - const html = editorRef.current?.innerHTML ?? body; - if (to.length === 0 && !subject && !body.trim() && !html.trim()) return; - setDraftStatus(t("mail.draftSaving")); - try { - const res = await fetch("/api/v1/mail/draft", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - to, - cc, - bcc, - subject, - html, - important, - in_reply_to: inReplyTo, - replace_uid: draftUidRef.current, - }), - }); - if (res.ok) { - const data = await res.json().catch(() => ({})); - if (typeof data.uid === "number") draftUidRef.current = data.uid; - // Discarded while this save was in flight: what it just stored is a - // draft nobody wants, so it goes too. - if (discardedRef.current && draftUidRef.current > 0) { - void removeDraft(draftUidRef.current); - return; - } - } - setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed")); - } catch { - setDraftStatus(t("mail.draftSaveFailed")); - } - }, [to, cc, bcc, subject, body, important, inReplyTo, t]); - - // Debounced so a draft is written after a pause in typing rather than on - // every keystroke. Skipped once the message is on its way out. - useEffect(() => { - if (sending) return; - // The body alone is enough: a message typed before its recipient is still - // worth keeping, and requiring an address here is half of why drafts were - // being stored with nothing in them. - if (to.length === 0 && !subject && !body.trim()) return; - const timer = setTimeout(() => void saveDraft(), 2500); - return () => clearTimeout(timer); - }, [to, subject, cc, bcc, body, important, sending, saveDraft]); - - // Throws the message away: stops the autosave, removes the draft already - // stored for it, and only then leaves. Leaving first was the old behaviour - - // a link to the inbox - and left the draft in Drafts, as if nothing had - // been discarded. - const discardMessage = async () => { - discardedRef.current = true; - setDiscarding(true); - setSendError(null); - const uid = draftUidRef.current; - if (uid > 0 && !(await removeDraft(uid))) { - // Still there, so say so and let the autosave carry on rather than - // leaving the page with a draft the writer thinks is gone. - discardedRef.current = false; - setDiscarding(false); - setSendError(t("mail.discardFailed")); - return; - } - draftUidRef.current = 0; - notifyMailStateChanged(); - window.location.href = "/user/mail/inbox"; - }; - - const executeFormat = (command: string, value: string | undefined = undefined) => { - document.execCommand(command, false, value); - editorRef.current?.focus(); - // execCommand edits the DOM directly, so the mirrored body is re-read - // here rather than relying on an input event that may not be dispatched. - setBody(editorRef.current?.innerHTML ?? ""); - }; - - const handleFileUpload = (e: React.ChangeEvent) => { - if (!e.target.files) return; - const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({ - id: Math.random().toString(36).substring(2, 9), - name: f.name, - size: f.size, - type: f.type || "application/octet-stream", - })); - setAttachments((prev) => [...prev, ...newFiles]); - }; - - const removeAttachment = (id: string) => { - setAttachments((prev) => prev.filter((a) => a.id !== id)); - }; - - // Anything in a recipient field that is not an address. These stay on screen - // as red chips; sending with one would queue mail to a destination that - // cannot exist and report success. - const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid); - const hasRecipient = to.length + cc.length + bcc.length > 0; - - const handleSend = async (e: React.FormEvent) => { - e.preventDefault(); - if (!hasRecipient) { - setSendError(t("mail.noRecipients")); - return; - } - if (invalidRecipients.length > 0) { - setSendError(t("mail.fixRecipients")); - setShowCcBcc(true); - return; - } - setSendError(null); - setSending(true); - setUndoSeconds(10); - - const bodyHtml = editorRef.current?.innerHTML || body; - - const interval = setInterval(() => { - setUndoSeconds((prev) => { - if (prev === null || prev <= 1) { - clearInterval(interval); - void sendActualMessage(bodyHtml); - return null; - } - return prev - 1; - }); - }, 1000); - }; - - const sendActualMessage = async (htmlBody: string) => { - try { - const res = await fetch("/api/v1/mail/send", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - to, - cc, - bcc, - subject, - html: htmlBody, - important, - in_reply_to: inReplyTo, - // The draft this message grew out of, so the server can discard it. - // Without this the draft outlived the message it became and sat in - // Drafts as a duplicate of mail already sent. - draft_uid: draftUidRef.current, - }), - }); - if (!res.ok) { - // Reporting success on a refused send is how a message silently - // disappears: the composer used to navigate away regardless. - const data = await res.json().catch(() => ({})); - setSendError(data.message ?? t("errors.serverError")); - setSending(false); - return; - } - window.location.href = "/user/mail/sent"; - } catch { - setSendError(t("errors.serverError")); - setSending(false); - } - }; - - const handleUndo = () => { - setUndoSeconds(null); - setSending(false); - }; - - /** One row of the compose sheet: a fixed-width label beside the control. */ - const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]"; - const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400"; - const bareInput = - "min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none"; - const toolButton = - "flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"; - return ( -
-
-
-
-

{t("mail.compose")}

- {draftStatus && ( -
- - {draftStatus} -
- )} -
-
- - - -
-
- - {sendError && ( -
- {sendError} -
- )} - - {undoSeconds !== null && ( -
-
- - {/* The countdown is a placeholder inside the message, not text - bolted on after it. It used to be both: the bundle's own - "{seconds}" was never substituted and a second, hardcoded - count was appended, so this read "Undo Send ({seconds}s - remaining) (9s remaining)". */} - {t("mail.undoSend", { seconds: undoSeconds })} -
- -
- )} - - {/* One sheet, the fields stacked as rows inside it, rather than a stack - of separately outlined inputs: the message is one object. */} -
-
- - !parseAddress(e).valid)} - /> - -
- - {showCcBcc && ( - <> -
- - !parseAddress(e).valid)} - /> -
-
- - !parseAddress(e).valid)} - /> -
- - )} - -
- - setSubject(e.target.value)} - placeholder={t("ui.subject")} - required - disabled={sending} - className={bareInput} - /> -
- - {/* Formatting Toolbar */} -
- - - - - - - - - -
- -
setBody((e.target as HTMLDivElement).innerHTML)} - onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)} - aria-label={t("ui.messageBody")} - role="textbox" - aria-multiline="true" - className="min-h-[280px] px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none" - /> - - {/* Attachment Selector & Display */} -
- - - {attachments.map((file) => ( -
- - {file.name} - {(file.size / 1024).toFixed(1)} KB - -
- ))} - - {/* Says so plainly rather than letting someone attach a contract - and watch it silently not arrive: /mail/send is handed the file - names only, because there is no upload endpoint behind this - picker yet. */} - {attachments.length > 0 && ( - - {t("mail.attachmentsUnsupported")} - - )} -
-
- -
+ { + window.location.href = "/user/mail/sent"; + }} + onDiscarded={() => { + window.location.href = "/user/mail/inbox"; + }} + /> ); } diff --git a/services/webmail/src/components/Composer.tsx b/services/webmail/src/components/Composer.tsx new file mode 100644 index 0000000..00bfd81 --- /dev/null +++ b/services/webmail/src/components/Composer.tsx @@ -0,0 +1,603 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Send, + Paperclip, + Bold, + Italic, + Underline, + Strikethrough, + List, + ListOrdered, + Link as LinkIcon, + Undo2, + X, + Sparkles, + CheckCircle2, + Flag, +} from "lucide-react"; +import { useTranslations } from "../i18n/provider"; +import { Button } from "../components/ui/Button"; +import { notifyMailStateChanged } from "../lib/useFolders"; +import { signatureToHtml } from "../lib/signature"; +import { RecipientInput } from "../components/RecipientInput"; +import { parseAddress, parseAddressList, prefixSubject } from "../lib/recipients"; + +/** Entries from a query-string list, which may be several addresses. */ +function entriesFrom(value: string | undefined): string[] { + return value ? parseAddressList(value).map((p) => p.entry) : []; +} + +interface AttachedFile { + id: string; + name: string; + size: number; + type: string; +} + +export interface ComposerInitial { + replyTo?: string; + cc?: string; + subject?: string; + inReplyTo?: string; + mode?: string; + draft?: string; +} + +interface ComposerProps { + initial?: ComposerInitial; + /** + * "page" is the full compose screen. "inline" is the reply written under the + * conversation it answers: no title, no subject row (a reply keeps its + * subject) and a shorter editor, so the messages stay in view above it. + */ + variant?: "page" | "inline"; + onSent: () => void; + onDiscarded: () => void; +} + +export function Composer({ initial, variant = "page", onSent, onDiscarded }: ComposerProps) { + const t = useTranslations(); + const inline = variant === "inline"; + const params = initial ?? {}; + + // Recipients are lists of entries, not one string to be split later: a + // reply-all arrives with several people already in To and Cc. + const [to, setTo] = useState(() => entriesFrom(params?.replyTo)); + const [cc, setCc] = useState(() => entriesFrom(params?.cc)); + const [bcc, setBcc] = useState([]); + const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0); + const [subject, setSubject] = useState( + params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "", + ); + // The Message-ID being answered, so the reply threads under it. + const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || ""); + const [important, setImportant] = useState(false); + const [attachments, setAttachments] = useState([]); + const [sending, setSending] = useState(false); + const [undoSeconds, setUndoSeconds] = useState(null); + const [draftStatus, setDraftStatus] = useState(""); + const [discarding, setDiscarding] = useState(false); + const [sendError, setSendError] = useState(null); + // The UID of the draft already stored for this message. Sent back on each + // autosave so the server replaces it instead of leaving another copy. + const draftUidRef = useRef(0); + // Set once the writer has discarded the message. An autosave already on its + // way when that happens would otherwise land afterwards and bring the draft + // back, so every save checks this before and after it talks to the server. + const discardedRef = useRef(false); + + const editorRef = useRef(null); + // The body, mirrored into React state. + // + // It used to live only inside the contenteditable, read through the ref at + // save time. Nothing observed it, so the autosave effect - which depends on + // what it reads - never re-ran when the message itself was typed. The draft + // was written once, moments after the address was entered, and never again: + // that is why a draft left behind by a sent message was empty. + const [body, setBody] = useState(""); + + // An inline reply is opened to be written in, so the cursor starts in it. + useEffect(() => { + if (inline) editorRef.current?.focus(); + }, [inline]); + + useEffect(() => { + const savedSig = localStorage.getItem("lm_user_signature"); + // signatureToHtml, not the raw value: the signature may be the plain text + // of the old field, whose newlines are whitespace once assigned as HTML - + // which is why every line break in a signature used to vanish here. + const signatureHtml = signatureToHtml(savedSig ?? ""); + if (editorRef.current && signatureHtml) { + const initial = `

--
${signatureHtml}`; + editorRef.current.innerHTML = initial; + setBody(initial); + } + }, []); + + // Reopening a draft loads what was written into the composer, and adopts its + // UID so carrying on writing replaces that draft instead of leaving the old + // copy behind and starting a second one. + // + // Opening a draft used to land in the reader, which offered to reply to the + // half-written message and gave no way to finish it. + const draftParam = params?.draft; + useEffect(() => { + const uid = Number(draftParam); + if (!Number.isFinite(uid) || uid <= 0) return; + + let cancelled = false; + void (async () => { + try { + const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" }); + if (!res.ok || cancelled) return; + const message = await res.json(); + if (cancelled) return; + + draftUidRef.current = uid; + setTo(message.to ?? []); + setCc(message.cc ?? []); + setImportant(Boolean(message.important)); + setInReplyTo(message.in_reply_to ?? ""); + if ((message.cc ?? []).length > 0) setShowCcBcc(true); + setSubject(message.subject ?? ""); + const html = message.html || message.text || ""; + if (editorRef.current) editorRef.current.innerHTML = html; + setBody(html); + } catch { + // A draft that cannot be loaded leaves an empty composer rather than + // a broken screen; the draft itself is untouched on the server. + } + })(); + return () => { + cancelled = true; + }; + }, [draftParam]); + + // Removes a stored draft for good. discard_draft is what keeps it out of + // Trash: an ordinary delete moves it there, and a draft sitting in Trash is + // not discarded. + const removeDraft = async (uid: number): Promise => { + try { + const res = await fetch("/api/v1/mail/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }), + }); + return res.ok; + } catch { + return false; + } + }; + + // Saves the draft for real. This used to be a setTimeout that set the words + // "draft saved automatically" and nothing else - no request, no storage - so + // closing the tab lost the message while the screen had just said otherwise. + const saveDraft = useCallback(async () => { + if (discardedRef.current) return; + const html = editorRef.current?.innerHTML ?? body; + if (to.length === 0 && !subject && !body.trim() && !html.trim()) return; + setDraftStatus(t("mail.draftSaving")); + try { + const res = await fetch("/api/v1/mail/draft", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + to, + cc, + bcc, + subject, + html, + important, + in_reply_to: inReplyTo, + replace_uid: draftUidRef.current, + }), + }); + if (res.ok) { + const data = await res.json().catch(() => ({})); + if (typeof data.uid === "number") draftUidRef.current = data.uid; + // Discarded while this save was in flight: what it just stored is a + // draft nobody wants, so it goes too. + if (discardedRef.current && draftUidRef.current > 0) { + void removeDraft(draftUidRef.current); + return; + } + } + setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed")); + } catch { + setDraftStatus(t("mail.draftSaveFailed")); + } + }, [to, cc, bcc, subject, body, important, inReplyTo, t]); + + // Debounced so a draft is written after a pause in typing rather than on + // every keystroke. Skipped once the message is on its way out. + useEffect(() => { + if (sending) return; + // The body alone is enough: a message typed before its recipient is still + // worth keeping, and requiring an address here is half of why drafts were + // being stored with nothing in them. + if (to.length === 0 && !subject && !body.trim()) return; + const timer = setTimeout(() => void saveDraft(), 2500); + return () => clearTimeout(timer); + }, [to, subject, cc, bcc, body, important, sending, saveDraft]); + + // Throws the message away: stops the autosave, removes the draft already + // stored for it, and only then leaves. Leaving first was the old behaviour - + // a link to the inbox - and left the draft in Drafts, as if nothing had + // been discarded. + const discardMessage = async () => { + discardedRef.current = true; + setDiscarding(true); + setSendError(null); + const uid = draftUidRef.current; + if (uid > 0 && !(await removeDraft(uid))) { + // Still there, so say so and let the autosave carry on rather than + // leaving the page with a draft the writer thinks is gone. + discardedRef.current = false; + setDiscarding(false); + setSendError(t("mail.discardFailed")); + return; + } + draftUidRef.current = 0; + notifyMailStateChanged(); + onDiscarded(); + }; + + const executeFormat = (command: string, value: string | undefined = undefined) => { + document.execCommand(command, false, value); + editorRef.current?.focus(); + // execCommand edits the DOM directly, so the mirrored body is re-read + // here rather than relying on an input event that may not be dispatched. + setBody(editorRef.current?.innerHTML ?? ""); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + if (!e.target.files) return; + const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({ + id: Math.random().toString(36).substring(2, 9), + name: f.name, + size: f.size, + type: f.type || "application/octet-stream", + })); + setAttachments((prev) => [...prev, ...newFiles]); + }; + + const removeAttachment = (id: string) => { + setAttachments((prev) => prev.filter((a) => a.id !== id)); + }; + + // Anything in a recipient field that is not an address. These stay on screen + // as red chips; sending with one would queue mail to a destination that + // cannot exist and report success. + const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid); + const hasRecipient = to.length + cc.length + bcc.length > 0; + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + if (!hasRecipient) { + setSendError(t("mail.noRecipients")); + return; + } + if (invalidRecipients.length > 0) { + setSendError(t("mail.fixRecipients")); + setShowCcBcc(true); + return; + } + setSendError(null); + setSending(true); + setUndoSeconds(10); + + const bodyHtml = editorRef.current?.innerHTML || body; + + const interval = setInterval(() => { + setUndoSeconds((prev) => { + if (prev === null || prev <= 1) { + clearInterval(interval); + void sendActualMessage(bodyHtml); + return null; + } + return prev - 1; + }); + }, 1000); + }; + + const sendActualMessage = async (htmlBody: string) => { + try { + const res = await fetch("/api/v1/mail/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + to, + cc, + bcc, + subject, + html: htmlBody, + important, + in_reply_to: inReplyTo, + // The draft this message grew out of, so the server can discard it. + // Without this the draft outlived the message it became and sat in + // Drafts as a duplicate of mail already sent. + draft_uid: draftUidRef.current, + }), + }); + if (!res.ok) { + // Reporting success on a refused send is how a message silently + // disappears: the composer used to navigate away regardless. + const data = await res.json().catch(() => ({})); + setSendError(data.message ?? t("errors.serverError")); + setSending(false); + return; + } + onSent(); + } catch { + setSendError(t("errors.serverError")); + setSending(false); + } + }; + + const handleUndo = () => { + setUndoSeconds(null); + setSending(false); + }; + + /** One row of the compose sheet: a fixed-width label beside the control. */ + const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]"; + const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400"; + const bareInput = + "min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none"; + const toolButton = + "flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"; + + return ( +
+
+
+
+ {!inline &&

{t("mail.compose")}

} + {draftStatus && ( +
+ + {draftStatus} +
+ )} +
+
+ + + +
+
+ + {sendError && ( +
+ {sendError} +
+ )} + + {undoSeconds !== null && ( +
+
+ + {/* The countdown is a placeholder inside the message, not text + bolted on after it. It used to be both: the bundle's own + "{seconds}" was never substituted and a second, hardcoded + count was appended, so this read "Undo Send ({seconds}s + remaining) (9s remaining)". */} + {t("mail.undoSend", { seconds: undoSeconds })} +
+ +
+ )} + + {/* One sheet, the fields stacked as rows inside it, rather than a stack + of separately outlined inputs: the message is one object. */} +
+
+ + !parseAddress(e).valid)} + /> + +
+ + {showCcBcc && ( + <> +
+ + !parseAddress(e).valid)} + /> +
+
+ + !parseAddress(e).valid)} + /> +
+ + )} + + {!inline && ( +
+ + setSubject(e.target.value)} + placeholder={t("ui.subject")} + required + disabled={sending} + className={bareInput} + /> +
+ )} + + {/* Formatting Toolbar */} +
+ + + + + + + + + +
+ +
setBody((e.target as HTMLDivElement).innerHTML)} + onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)} + aria-label={t("ui.messageBody")} + role="textbox" + aria-multiline="true" + className={`${inline ? "min-h-[150px]" : "min-h-[280px]"} px-[22px] py-5 font-sans text-[14.5px] leading-relaxed text-slate-200 focus:outline-none`} + /> + + {/* Attachment Selector & Display */} +
+ + + {attachments.map((file) => ( +
+ + {file.name} + {(file.size / 1024).toFixed(1)} KB + +
+ ))} + + {/* Says so plainly rather than letting someone attach a contract + and watch it silently not arrive: /mail/send is handed the file + names only, because there is no upload endpoint behind this + picker yet. */} + {attachments.length > 0 && ( + + {t("mail.attachmentsUnsupported")} + + )} +
+
+ +
+ ); +} diff --git a/services/webmail/src/compose-draft-integrity.test.ts b/services/webmail/src/compose-draft-integrity.test.ts index 0eb95f5..d89619f 100644 --- a/services/webmail/src/compose-draft-integrity.test.ts +++ b/services/webmail/src/compose-draft-integrity.test.ts @@ -18,7 +18,7 @@ const code = (path: string) => .replace(/\{\/\*[\s\S]*?\*\/\}/g, "") .replace(/^\s*\/\/.*$/gm, ""); -const COMPOSE = "src/app/(user)/user/compose/page.tsx"; +const COMPOSE = "src/components/Composer.tsx"; const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx"; describe("the draft a sent message leaves behind", () => { From 20804893193d9beccfe74353829b6c39ce2a3e46 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 16:46:11 -0300 Subject: [PATCH 4/5] feat(webmail): conversations - list, stacked reader, inline reply, settings switch Co-Authored-By: Claude Sonnet 5.5 --- .wolf/anatomy-index.json | 70 ++- .wolf/anatomy.md | 5 +- .wolf/memory.md | 1 + services/webmail/messages/en.json | 16 + services/webmail/messages/es.json | 16 + services/webmail/messages/pt-BR.json | 16 + .../app/(user)/user/mail/[folder]/page.tsx | 194 +++++- .../src/app/(user)/user/settings/page.tsx | 20 + .../webmail/src/components/MessageFrame.tsx | 45 ++ .../webmail/src/components/ThreadReader.tsx | 590 ++++++++++++++++++ services/webmail/src/conversations.test.ts | 93 +++ services/webmail/src/lib/conversations.ts | 152 +++++ services/webmail/src/lib/renderMessage.ts | 34 + .../webmail/src/mail-state-integrity.test.ts | 12 +- 14 files changed, 1225 insertions(+), 39 deletions(-) create mode 100644 services/webmail/src/components/MessageFrame.tsx create mode 100644 services/webmail/src/components/ThreadReader.tsx create mode 100644 services/webmail/src/conversations.test.ts create mode 100644 services/webmail/src/lib/conversations.ts create mode 100644 services/webmail/src/lib/renderMessage.ts diff --git a/.wolf/anatomy-index.json b/.wolf/anatomy-index.json index 3e83f4e..6e26c52 100644 --- a/.wolf/anatomy-index.json +++ b/.wolf/anatomy-index.json @@ -1,13 +1,13 @@ { "version": 1, "meta": { - "lastScanned": "2026-10-22T17:41:17.634Z", - "fileCount": 439, + "lastScanned": "2026-10-22T19:44:13.602Z", + "fileCount": 440, "hits": 0, "misses": 0, - "renderedHash": "6ed8dc6b806503f41452e51121e5f78379f1c88655d4934d082d2aa065428b8c", - "storeUpdatedAt": "2026-10-22T17:41:17.650Z", - "rootHash": "098b934a6205dce4" + "renderedHash": "4c74546f856ca02c0dd5eeb8a90c610c161275126dc91d9fc7f169a6424d61d6", + "storeUpdatedAt": "2026-10-22T19:44:13.627Z", + "rootHash": "d8862ac30fea5b61" }, "files": { ".github/workflows/ci.yml": { @@ -19624,6 +19624,66 @@ "tokens": 36 } ] + }, + "services/webmail/src/components/ThreadReader.tsx": { + "description": "One message as the mail API returns it with its body.", + "tokens": 7350, + "hash": "54dad9430ffc2edb", + "size": 25722, + "mtimeMs": 1792698253418.4153, + "updatedAt": "2026-10-22T19:44:13.601Z", + "source": "hook", + "symbols": [ + { + "name": "FullMessage", + "kind": "section", + "startLine": 39, + "endLine": 53, + "tokens": 74 + }, + { + "name": "MoveTarget", + "kind": "section", + "startLine": 54, + "endLine": 58, + "tokens": 17 + }, + { + "name": "Trusted", + "kind": "section", + "startLine": 59, + "endLine": 64, + "tokens": 44 + }, + { + "name": "Props", + "kind": "section", + "startLine": 65, + "endLine": 84, + "tokens": 314 + }, + { + "name": "splitSender", + "kind": "fn", + "startLine": 85, + "endLine": 90, + "tokens": 74 + }, + { + "name": "initialsOf", + "kind": "fn", + "startLine": 91, + "endLine": 96, + "tokens": 78 + }, + { + "name": "ThreadReader", + "kind": "fn", + "startLine": 97, + "endLine": 591, + "tokens": 6441 + } + ] } }, "preamble": [ diff --git a/.wolf/anatomy.md b/.wolf/anatomy.md index 30de6cb..9ebeef2 100644 --- a/.wolf/anatomy.md +++ b/.wolf/anatomy.md @@ -1,7 +1,7 @@ # anatomy.md -> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T17:41:17.634Z -> Files: 439 tracked | Anatomy hits: 0 | Misses: 0 +> Auto-maintained by OpenWolf. Last scanned: 2026-10-22T19:44:13.602Z +> Files: 440 tracked | Anatomy hits: 0 | Misses: 0 > Project structure index. Auto-maintained by OpenWolf hooks and daemon. > Run `openwolf scan` to generate, or wait for the first Claude Code session. @@ -610,6 +610,7 @@ - `AvatarEditor.tsx` — The frame is square; the mask over it is what makes the photo a circle. (~2510 tok) - `MessageReader.tsx` — The document the body frame renders, or null for a text-only message. (~4736 tok) - `RecipientInput.tsx` — Entries as they will be sent: `Name ` or a bare address. (~1486 tok) +- `ThreadReader.tsx` — One message as the mail API returns it with its body. (~7350 tok) - `TotpEnrolment.tsx` — The scannable half of TOTP enrolment, shared by the webmail settings page and (~1644 tok) ## services/webmail/src/components/ui/ diff --git a/.wolf/memory.md b/.wolf/memory.md index ad75938..964021f 100644 --- a/.wolf/memory.md +++ b/.wolf/memory.md @@ -31,3 +31,4 @@ description: chronological action log per session, consolidated weekly | Time | Action | File(s) | Outcome | ~Tokens | |------|--------|---------|---------|--------| | 14:41 | Created services/auth/src/accounts.ts | — | ~715 | +| 16:44 | Created services/webmail/src/components/ThreadReader.tsx | — | ~7350 | diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 42b9f42..ebf1590 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -548,5 +548,21 @@ "signOutAll": "Sign out of all accounts", "active": "Active", "settings": "Account settings" + }, + "conversations": { + "conversationView": "Group mail into conversations", + "conversationViewHint": "Show a message and its replies together, and whether you have answered.", + "messagesInConversation": "{count} messages", + "youReplied": "You replied", + "notReplied": "Not answered yet", + "expandAll": "Expand all", + "collapseOlder": "Collapse older", + "loadingMessage": "Loading message…", + "replyBelow": "Reply", + "conversationEmpty": "This conversation is empty.", + "me": "me", + "draftDiscarded": "Reply discarded", + "sentReply": "Reply sent", + "moreMessages": "Show {count} earlier messages" } } diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 6e66db1..3adcd61 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -548,5 +548,21 @@ "signOutAll": "Cerrar sesión en todas las cuentas", "active": "Activa", "settings": "Ajustes de la cuenta" + }, + "conversations": { + "conversationView": "Agrupar el correo en conversaciones", + "conversationViewHint": "Muestra un mensaje y sus respuestas juntos, y si ya respondiste.", + "messagesInConversation": "{count} mensajes", + "youReplied": "Respondiste", + "notReplied": "Aún sin responder", + "expandAll": "Expandir todo", + "collapseOlder": "Contraer anteriores", + "loadingMessage": "Cargando mensaje…", + "replyBelow": "Responder", + "conversationEmpty": "Esta conversación está vacía.", + "me": "yo", + "draftDiscarded": "Respuesta descartada", + "sentReply": "Respuesta enviada", + "moreMessages": "Mostrar {count} mensajes anteriores" } } diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index c03ef86..9b35718 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -548,5 +548,21 @@ "signOutAll": "Sair de todas as contas", "active": "Ativa", "settings": "Configurações da conta" + }, + "conversations": { + "conversationView": "Agrupar e-mails em conversas", + "conversationViewHint": "Mostra uma mensagem e as respostas juntas, e se você já respondeu.", + "messagesInConversation": "{count} mensagens", + "youReplied": "Você respondeu", + "notReplied": "Ainda não respondida", + "expandAll": "Expandir tudo", + "collapseOlder": "Recolher anteriores", + "loadingMessage": "Carregando mensagem…", + "replyBelow": "Responder", + "conversationEmpty": "Esta conversa está vazia.", + "me": "eu", + "draftDiscarded": "Resposta descartada", + "sentReply": "Resposta enviada", + "moreMessages": "Mostrar {count} mensagens anteriores" } } diff --git a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx index 3fc8d20..81bd696 100644 --- a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx +++ b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx @@ -12,6 +12,7 @@ import { Clock, RefreshCw, Trash2, + CornerUpLeft, } from "lucide-react"; import { useI18n } from "../../../../../i18n/provider"; import { useMailEvents } from "../../../../../lib/useMailEvents"; @@ -27,10 +28,16 @@ import { moveTargets, type ListFilter, } from "../../../../../lib/mailCounts"; -import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer"; import { plainSnippet } from "../../../../../lib/snippet"; import { useTrustedSenders } from "../../../../../lib/trustedSenders"; -import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; +import type { InlineImages } from "../../../../../lib/emailBody"; +import { messageDocument, hasBlockedRemoteContent } from "../../../../../lib/renderMessage"; +import { + participantsLine, + useConversationView, + type ThreadParticipant, + type ThreadSummary, +} from "../../../../../lib/conversations"; import { Button } from "../../../../../components/ui/Button"; import { clampListWidth, @@ -40,6 +47,7 @@ import { } from "../../../../../lib/paneWidth"; import { useAccount } from "../../../../../lib/useAccount"; import { MessageReader } from "../../../../../components/MessageReader"; +import { ThreadReader } from "../../../../../components/ThreadReader"; import { Avatar } from "../../../../../components/Avatar"; interface MessageSummary { @@ -55,6 +63,34 @@ interface MessageSummary { flagged: boolean; spam_verdict: string; dmarc_result: string; + // Present when the list is showing conversations: the row is then a whole + // conversation, and uid is its newest message in this folder. + thread_id?: string; + message_count?: number; + answered?: boolean; + participants?: ThreadParticipant[]; +} + +/** A conversation row in the shape the list already knows how to draw. */ +function rowFromThread(thread: ThreadSummary): MessageSummary { + return { + uid: thread.uid, + subject: thread.subject, + sender_address: thread.sender_address, + from_display_name: thread.from_display_name, + snippet: thread.snippet, + size_bytes: 0, + received_at: thread.received_at, + has_attachments: thread.has_attachments, + seen: thread.seen, + flagged: thread.flagged, + spam_verdict: "", + dmarc_result: "", + thread_id: thread.thread_id, + message_count: thread.message_count, + answered: thread.answered, + participants: thread.participants, + }; } interface RenderedMessage { @@ -121,6 +157,10 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s const [messages, setMessages] = useState([]); const [selected, setSelected] = useState(null); + // The open conversation, when the list is showing conversations. + const [selectedThread, setSelectedThread] = useState(null); + const conversations = useConversationView(); + const conversationMode = conversations.enabled; const [loadRemoteImages, setLoadRemoteImages] = useState(false); // The senders this account trusts, kept on the server so the decision // follows the account to every browser and also reaches the spam filter. @@ -163,25 +203,38 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s const actions = readerActions(folder); const loadMessages = useCallback( - async (search: string) => { - setLoading(true); + async (search: string, { quiet = false }: { quiet?: boolean } = {}) => { + // A quiet reload refreshes what is shown without the spinner, for the + // changes the reader itself made. + if (!quiet) setLoading(true); setError(null); try { const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) }); - const res = await fetch(`/api/v1/mail/messages?${query}`, { cache: "no-store" }); + const res = await fetch(`/api/v1/mail/${conversationMode ? "threads" : "messages"}?${query}`, { + cache: "no-store", + }); if (res.status === 401) { window.location.href = "/user/login"; return; } + // A server that has no conversations falls back to the plain list + // rather than showing an empty folder. + if (res.status === 501 && conversationMode) { + void conversations.setEnabled(false); + return; + } if (!res.ok) throw new Error(t("errors.serverError")); - setMessages(await res.json()); + const data = await res.json(); + setMessages(conversationMode ? (data as ThreadSummary[]).map(rowFromThread) : data); } catch (err) { setError(err instanceof Error ? err.message : t("errors.serverError")); } finally { setLoading(false); } }, - [folder, t] + // conversations.setEnabled is stable enough for this one call + // eslint-disable-next-line react-hooks/exhaustive-deps + [folder, t, conversationMode] ); useMailEvents( @@ -199,9 +252,18 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s // separate mount effect as well meant every folder change fired two requests // and the later one could be the stale answer. useEffect(() => { + // Waits for the account's answer on how to list, or the folder would load + // one way and immediately reload the other. + if (!conversations.ready) return; const handle = setTimeout(() => void loadMessages(searchQuery), searchQuery ? 250 : 0); return () => clearTimeout(handle); - }, [searchQuery, loadMessages]); + }, [searchQuery, loadMessages, conversations.ready]); + + // Changing between the two lists closes whatever was open in the other. + useEffect(() => { + setSelected(null); + setSelectedThread(null); + }, [conversationMode, folder]); // The reader frame reports its own height; nothing outside a sandboxed frame // can measure it. @@ -302,6 +364,39 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s [folder, t, loadMessages, searchQuery] ); + /** Deletes every message a conversation row stands for in this folder. */ + const deleteConversationRow = useCallback( + async (row: MessageSummary) => { + setMessages((current) => current.filter((m) => m.thread_id !== row.thread_id)); + setSelectedThread((current) => (current?.thread_id === row.thread_id ? null : current)); + try { + const res = await fetch( + `/api/v1/mail/thread/${encodeURIComponent(row.thread_id ?? "")}?folder=${encodeURIComponent(folder)}`, + { cache: "no-store" }, + ); + if (!res.ok) throw new Error(t("errors.serverError")); + const infos: { uid: number; folder: string }[] = await res.json(); + await Promise.all( + infos + .filter((m) => m.folder.toLowerCase() === folder.toLowerCase()) + .map((m) => + fetch("/api/v1/mail/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder, uid: m.uid }), + }), + ), + ); + } catch (err) { + setError(err instanceof Error ? err.message : t("errors.serverError")); + void loadMessages(searchQuery); + } finally { + notifyMailStateChanged(); + } + }, + [folder, t, loadMessages, searchQuery], + ); + /** Files one message into another folder. */ const moveMessage = useCallback( async (uid: number, target: string) => { @@ -332,6 +427,14 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s router.push(`/user/compose?draft=${uid}`); return; } + if (conversationMode) { + const row = messages.find((m) => m.uid === uid); + if (row?.thread_id) { + setSelected(null); + setSelectedThread(row); + return; + } + } setLoadRemoteImages(false); setReaderHeight(420); try { @@ -357,6 +460,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s searching: searchQuery.trim().length > 0, }); const selectedRow = messages.find((m) => m.uid === selected?.uid); + const reading = Boolean(selected || selectedThread); const senderTrusted = selected ? trustedSenders.isTrusted(selected.from ?? "") : false; /** @@ -368,27 +472,15 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s */ const readerDocument = useMemo(() => { if (!selected) return null; - if (!selected.html) return null; - const sanitized = sanitizeEmailHtml(selected.html); - if (!sanitized) return null; - const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {}); // A trusted sender's images load without asking; anyone else's wait for // the reader's choice, one message at a time. const showImages = loadRemoteImages || trustedSenders.isTrusted(selected.from ?? ""); - const body = showImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline); - return buildReaderDocument(body); + return messageDocument(selected, showImages); }, [selected, loadRemoteImages, trustedSenders]); // Whether anything was actually held back, so the privacy banner is not shown // over a message that has no remote content at all. - const hasBlockedContent = useMemo(() => { - if (!selected?.html) return false; - const sanitized = resolveInlineImages( - sanitizeEmailHtml(selected.html), - selected.inline_images ?? {} - ); - return blockRemoteImages(sanitized) !== sanitized; - }, [selected]); + const hasBlockedContent = useMemo(() => (selected ? hasBlockedRemoteContent(selected) : false), [selected]); const chips: { key: ListFilter; label: string; count: number }[] = [ { key: null, label: t("mail.allMessages"), count: header.count }, @@ -416,7 +508,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s {/* Messages List Column */}
{/* Folder heading, with the counts that belong to the folder rather than to the page of it that happens to be loaded. */} @@ -494,7 +586,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
) : ( filteredMessages.map((msg) => { - const isSelected = selected?.uid === msg.uid; + const isSelected = msg.thread_id ? selectedThread?.thread_id === msg.thread_id : selected?.uid === msg.uid; return ( /* The row and its delete control are siblings inside a positioned wrapper rather than nested: a button inside a @@ -524,8 +616,24 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s msg.seen ? "text-slate-300" : "text-slate-100" }`} > - {msg.from_display_name || msg.sender_address} + {msg.participants && msg.participants.length > 0 + ? participantsLine(msg.participants, t("conversations.me")) + : msg.from_display_name || msg.sender_address} + {msg.message_count && msg.message_count > 1 ? ( + + {msg.message_count} + + ) : null} + {msg.answered && ( + + )} {listTimestamp(msg.received_at, locale)} @@ -578,7 +686,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s reading in it, and no other way to get rid of it. */}
{/* Message Reader Column */} -
+
- {selected ? ( + {selectedThread ? ( + ({ + name: target.name, + label: FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name, + }))} + deleteIsPermanent={actions.deleteIsPermanent} + onClose={() => setSelectedThread(null)} + onChanged={() => void loadMessages(searchQuery, { quiet: true })} + /> + ) : selected ? ( (null); // execCommand edits the DOM directly, so the mirrored value is re-read here @@ -989,6 +991,24 @@ export default function UserSettingsPage() {
+ + {/* How the list is read, which belongs with how it is shown. */} + )}
diff --git a/services/webmail/src/components/MessageFrame.tsx b/services/webmail/src/components/MessageFrame.tsx new file mode 100644 index 0000000..344f2e3 --- /dev/null +++ b/services/webmail/src/components/MessageFrame.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import { useTranslations } from "../i18n/provider"; + +/** + * One message body in its sandboxed frame, sized to its own content. + * + * Several of these sit on one page when a conversation is open, and every frame + * reports its height the same way, so a report is only taken from the frame + * that sent it. The sandbox has no allow-same-origin: the frame stays on an + * opaque origin and can read nothing of this page. + */ +export function MessageFrame({ document: html }: { document: string }) { + const t = useTranslations(); + const frame = useRef(null); + const [height, setHeight] = useState(160); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + if (event.source !== frame.current?.contentWindow) return; + const data = event.data as { type?: string; height?: number } | null; + if (data?.type !== "lm:reader-height" || typeof data.height !== "number") return; + // Clamped so a message that reports something absurd cannot produce a + // frame kilometres tall or one collapsed to nothing. + setHeight(Math.min(20000, Math.max(80, Math.ceil(data.height)))); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + return ( +
+