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 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 16:38:36 -03:00
1 parent ff89e9dc44
commit 67d0c8aa5a
17 files changed
+1025 -9

No files matched your search

@@ -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 {