ultisuite-backend/internal/mail/threading/threading.go
R3D347HR4Y 95196f7777 Add mail attachment and draft management features
- Introduced new functionality for managing email attachments and drafts in the mail API.
- Added handlers for listing, uploading, and downloading message attachments in `internal/api/mail/handlers_attachments.go`.
- Implemented draft management endpoints for creating, updating, and deleting drafts in `internal/api/mail/handlers_drafts.go`.
- Created new service methods for handling draft and attachment operations in `internal/api/mail/drafts.go` and `internal/api/mail/storage.go`.
- Added validation and error handling for draft and attachment operations.
- Included unit tests for draft and folder functionalities in `internal/api/mail/drafts_test.go` and `internal/api/mail/folders_test.go`.
- Updated API routes to support new draft and attachment features, enhancing overall mail management capabilities.
2026-05-22 17:14:36 +02:00

123 lines
2.9 KiB
Go

package threading
import (
"context"
"errors"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var messageIDToken = regexp.MustCompile(`<[^>]+>`)
// NormalizeMessageID returns a canonical angle-bracket Message-ID when possible.
func NormalizeMessageID(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "<") && strings.HasSuffix(raw, ">") {
return raw
}
return "<" + strings.Trim(raw, "<>") + ">"
}
// ParseMessageIDs extracts Message-IDs from a References or In-Reply-To header value.
func ParseMessageIDs(header string) []string {
header = strings.TrimSpace(header)
if header == "" {
return nil
}
matches := messageIDToken.FindAllString(header, -1)
if len(matches) == 0 {
if id := NormalizeMessageID(header); id != "" {
return []string{id}
}
return nil
}
seen := make(map[string]struct{}, len(matches))
out := make([]string, 0, len(matches))
for _, m := range matches {
id := NormalizeMessageID(m)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// BuildReferences returns the References chain for a reply (ancestors + parent).
func BuildReferences(parentMessageID string, parentReferences []string) []string {
parentMessageID = NormalizeMessageID(parentMessageID)
if parentMessageID == "" {
return nil
}
seen := make(map[string]struct{}, len(parentReferences)+1)
out := make([]string, 0, len(parentReferences)+1)
for _, ref := range parentReferences {
id := NormalizeMessageID(ref)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
if _, ok := seen[parentMessageID]; !ok {
out = append(out, parentMessageID)
}
return out
}
func candidateMessageIDs(inReplyTo string, references []string) []string {
seen := make(map[string]struct{})
var out []string
add := func(id string) {
id = NormalizeMessageID(id)
if id == "" {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
out = append(out, id)
}
add(inReplyTo)
for _, ref := range references {
add(ref)
}
return out
}
// AssignThreadID picks an existing thread for the account or allocates a new one.
func AssignThreadID(ctx context.Context, db *pgxpool.Pool, accountID, inReplyTo string, references []string) (string, error) {
ids := candidateMessageIDs(inReplyTo, references)
if len(ids) > 0 {
var threadID *uuid.UUID
err := db.QueryRow(ctx, `
SELECT thread_id FROM messages
WHERE account_id = $1 AND message_id = ANY($2) AND thread_id IS NOT NULL
ORDER BY date ASC
LIMIT 1
`, accountID, ids).Scan(&threadID)
if err == nil && threadID != nil {
return threadID.String(), nil
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return "", err
}
}
return uuid.New().String(), nil
}