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:
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 {
|
||||
|
||||
Reference in new issue
Block a user