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

@@ -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 <id> 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
}