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

@@ -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):