Files
sgmail-skill/internal/service/service.go
2026-06-06 22:28:36 +08:00

1128 lines
31 KiB
Go

package service
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"internal-mail-skill/internal/db"
"internal-mail-skill/internal/model"
"internal-mail-skill/internal/parser"
mailsmtp "internal-mail-skill/internal/smtp"
)
type POP3Source interface {
Fetch(ctx context.Context) ([]model.FetchedMessage, error)
}
type MailSender interface {
Send(ctx context.Context, req mailsmtp.SendRequest) (mailsmtp.SendResult, error)
}
type Service struct {
store *db.Store
pop3 POP3Source
mailer MailSender
accountID string
rawDir string
attachmentDir string
fromAddress string
fromName string
}
type Options struct {
RawDir string
AttachmentDir string
FromAddress string
FromName string
}
func New(store *db.Store, pop3 POP3Source, mailer MailSender, accountID string) *Service {
return NewWithOptions(store, pop3, mailer, accountID, Options{})
}
func NewWithOptions(store *db.Store, pop3 POP3Source, mailer MailSender, accountID string, opts Options) *Service {
if accountID == "" {
accountID = "default"
}
return &Service{
store: store,
pop3: pop3,
mailer: mailer,
accountID: accountID,
rawDir: opts.RawDir,
attachmentDir: opts.AttachmentDir,
fromAddress: opts.FromAddress,
fromName: opts.FromName,
}
}
type SaveEmailInput struct {
POP3UID string
Raw []byte
Parsed model.ParsedEmail
}
type SearchFilters struct {
Q string
From string
To string
After string
Before string
HasAttachment *bool
Read *bool
Processed *bool
Archived *bool
Replied *bool
Limit int
Offset int
}
type MarkInput struct {
Read *bool `json:"read"`
Processed *bool `json:"processed"`
Archived *bool `json:"archived"`
Deleted *bool `json:"deleted"`
Replied *bool `json:"replied"`
}
type DraftInput struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
Body string `json:"body"`
ReplyToEmailID *int64 `json:"reply_to_email_id,omitempty"`
}
type SyncResult struct {
Total int `json:"total"`
Stored int `json:"stored"`
Skipped int `json:"skipped"`
Errors []SyncError `json:"errors"`
}
type SyncError struct {
UID string `json:"uid,omitempty"`
ServerID int `json:"server_id,omitempty"`
Error string `json:"error"`
}
type SendOutcome struct {
SentMessage model.SentMessage `json:"sent_message"`
Draft *model.Draft `json:"draft,omitempty"`
}
func (s *Service) Health(ctx context.Context) string {
if err := s.store.Ping(ctx); err != nil {
return "down"
}
return "ok"
}
func (s *Service) Sync(ctx context.Context) (SyncResult, error) {
if s.pop3 == nil {
return SyncResult{}, fmt.Errorf("POP3 client is not configured")
}
messages, err := s.pop3.Fetch(ctx)
if err != nil {
return SyncResult{}, err
}
result := SyncResult{Total: len(messages)}
for _, fetched := range messages {
if fetched.Error != "" {
result.Errors = append(result.Errors, SyncError{UID: fetched.UID, ServerID: fetched.ServerID, Error: fetched.Error})
continue
}
exists, err := s.hasPOP3UID(ctx, fetched.UID)
if err != nil {
result.Errors = append(result.Errors, SyncError{UID: fetched.UID, ServerID: fetched.ServerID, Error: err.Error()})
continue
}
if exists {
result.Skipped++
continue
}
parsed, err := parser.Parse(fetched.Raw)
if err != nil {
result.Errors = append(result.Errors, SyncError{UID: fetched.UID, ServerID: fetched.ServerID, Error: err.Error()})
continue
}
if _, err := s.SaveParsedEmail(ctx, SaveEmailInput{POP3UID: fetched.UID, Raw: fetched.Raw, Parsed: parsed}); err != nil {
result.Errors = append(result.Errors, SyncError{UID: fetched.UID, ServerID: fetched.ServerID, Error: err.Error()})
continue
}
result.Stored++
}
return result, nil
}
func (s *Service) SaveParsedEmail(ctx context.Context, input SaveEmailInput) (int64, error) {
if input.POP3UID == "" {
input.POP3UID = "local-" + hashBytes([]byte(input.Parsed.MessageIDHeader+input.Parsed.Subject+time.Now().String()))
}
if id, ok, err := s.findByPOP3UID(ctx, input.POP3UID); err != nil {
return 0, err
} else if ok {
return id, nil
}
now := time.Now().UTC()
rawPath := ""
if len(input.Raw) > 0 && s.rawDir != "" {
rawPath = filepath.Join(s.rawDir, s.accountID+"_"+hashBytes([]byte(input.POP3UID))+".eml")
}
toJSON, err := marshalJSON(input.Parsed.To)
if err != nil {
return 0, err
}
ccJSON, err := marshalJSON(input.Parsed.Cc)
if err != nil {
return 0, err
}
sentAt := timePtrString(input.Parsed.SentAt)
tx, err := s.store.DB.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
var writtenFiles []string
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
for _, path := range writtenFiles {
_ = os.Remove(path)
}
}
}()
res, err := tx.ExecContext(ctx, `
INSERT INTO mail_messages (
account_id, pop3_uid, message_id_header, in_reply_to, references_header,
subject, sender_name, sender_email, to_json, cc_json, date_header,
sent_at, received_at, body_text, body_html, raw_eml_path, has_attachment,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.accountID,
input.POP3UID,
input.Parsed.MessageIDHeader,
input.Parsed.InReplyTo,
input.Parsed.References,
input.Parsed.Subject,
input.Parsed.SenderName,
input.Parsed.SenderEmail,
toJSON,
ccJSON,
input.Parsed.DateHeader,
sentAt,
formatTime(now),
input.Parsed.BodyText,
input.Parsed.BodyHTML,
rawPath,
boolInt(len(input.Parsed.Attachments) > 0),
formatTime(now),
formatTime(now),
)
if err != nil {
return 0, fmt.Errorf("insert mail message: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, err
}
if rawPath != "" {
if err := os.MkdirAll(s.rawDir, 0750); err != nil {
return 0, fmt.Errorf("create raw mail dir: %w", err)
}
if err := os.WriteFile(rawPath, input.Raw, 0600); err != nil {
return 0, fmt.Errorf("write raw eml: %w", err)
}
writtenFiles = append(writtenFiles, rawPath)
}
if err := s.saveAttachments(ctx, tx, id, input.Parsed.Attachments, now, &writtenFiles); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
committed = true
return id, nil
}
func (s *Service) SearchEmails(ctx context.Context, filters SearchFilters) ([]model.EmailSummary, error) {
where := []string{"account_id = ?"}
args := []any{s.accountID}
if filters.Q != "" {
where = append(where, "(subject LIKE ? OR sender_email LIKE ? OR body_text LIKE ? OR body_html LIKE ?)")
like := "%" + filters.Q + "%"
args = append(args, like, like, like, like)
}
if filters.From != "" {
where = append(where, "(sender_email LIKE ? OR sender_name LIKE ?)")
like := "%" + filters.From + "%"
args = append(args, like, like)
}
if filters.To != "" {
where = append(where, "to_json LIKE ?")
args = append(args, "%"+filters.To+"%")
}
if after := normalizeDateFilter(filters.After, false); after != "" {
where = append(where, "sent_at >= ?")
args = append(args, after)
}
if before := normalizeDateFilter(filters.Before, true); before != "" {
where = append(where, "sent_at <= ?")
args = append(args, before)
}
addBoolFilter := func(name string, val *bool) {
if val != nil {
where = append(where, name+" = ?")
args = append(args, boolInt(*val))
}
}
addBoolFilter("has_attachment", filters.HasAttachment)
addBoolFilter("local_read", filters.Read)
addBoolFilter("local_processed", filters.Processed)
addBoolFilter("local_archived", filters.Archived)
addBoolFilter("local_replied", filters.Replied)
limit := filters.Limit
if limit <= 0 || limit > 100 {
limit = 50
}
offset := filters.Offset
if offset < 0 {
offset = 0
}
args = append(args, limit, offset)
query := `
SELECT id, subject, sender_name, sender_email, to_json, cc_json, date_header,
sent_at, received_at, body_text, has_attachment, local_read,
local_processed, local_archived, local_deleted, local_replied,
(SELECT COUNT(*) FROM attachments WHERE message_id = mail_messages.id)
FROM mail_messages
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY COALESCE(sent_at, received_at) DESC, id DESC
LIMIT ? OFFSET ?`
rows, err := s.store.DB.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.EmailSummary
for rows.Next() {
summary, err := scanSummary(rows)
if err != nil {
return nil, err
}
out = append(out, summary)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
out[i].Labels = s.labelsForMessage(ctx, out[i].ID)
}
return out, nil
}
func (s *Service) GetEmail(ctx context.Context, id int64) (model.EmailDetail, error) {
row := s.store.DB.QueryRowContext(ctx, `
SELECT id, message_id_header, in_reply_to, references_header, subject,
sender_name, sender_email, to_json, cc_json, date_header, sent_at,
received_at, body_text, body_html, local_read, local_processed,
local_archived, local_deleted, local_replied
FROM mail_messages WHERE account_id = ? AND id = ?`, s.accountID, id)
detail, err := scanDetail(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.EmailDetail{}, ErrNotFound
}
return model.EmailDetail{}, err
}
detail.Attachments = s.attachmentsForMessage(ctx, id)
detail.Labels = s.labelsForMessage(ctx, id)
return detail, nil
}
func (s *Service) GetThread(ctx context.Context, id int64) ([]model.EmailSummary, error) {
candidates, err := s.threadCandidates(ctx)
if err != nil {
return nil, err
}
if _, ok := candidates[id]; !ok {
return nil, ErrNotFound
}
selected := map[int64]bool{id: true}
changed := true
for changed {
changed = false
for candidateID, candidate := range candidates {
if selected[candidateID] {
continue
}
for selectedID := range selected {
if linked(candidate, candidates[selectedID]) {
selected[candidateID] = true
changed = true
break
}
}
}
}
out := make([]model.EmailSummary, 0, len(selected))
for selectedID := range selected {
summary := candidates[selectedID].summary
summary.Labels = s.labelsForMessage(ctx, selectedID)
out = append(out, summary)
}
sort.Slice(out, func(i, j int) bool {
ti := summaryTime(out[i])
tj := summaryTime(out[j])
if ti.Equal(tj) {
return out[i].ID < out[j].ID
}
return ti.Before(tj)
})
return out, nil
}
func (s *Service) MarkEmail(ctx context.Context, id int64, input MarkInput) error {
sets := []string{}
args := []any{}
add := func(field string, value *bool) {
if value != nil {
sets = append(sets, field+" = ?")
args = append(args, boolInt(*value))
}
}
add("local_read", input.Read)
add("local_processed", input.Processed)
add("local_archived", input.Archived)
add("local_deleted", input.Deleted)
add("local_replied", input.Replied)
if len(sets) == 0 {
return nil
}
sets = append(sets, "updated_at = ?")
args = append(args, formatTime(time.Now().UTC()), s.accountID, id)
res, err := s.store.DB.ExecContext(ctx, "UPDATE mail_messages SET "+strings.Join(sets, ", ")+" WHERE account_id = ? AND id = ?", args...)
if err != nil {
return err
}
return requireAffected(res)
}
func (s *Service) CreateLabel(ctx context.Context, name, color string) (model.Label, error) {
name = strings.TrimSpace(name)
if name == "" {
return model.Label{}, fmt.Errorf("label name is required")
}
now := formatTime(time.Now().UTC())
_, err := s.store.DB.ExecContext(ctx, `
INSERT INTO labels(name, color, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET color = excluded.color, updated_at = excluded.updated_at`,
name, color, now, now)
if err != nil {
return model.Label{}, err
}
var label model.Label
if err := s.store.DB.QueryRowContext(ctx, "SELECT id, name, color FROM labels WHERE name = ?", name).Scan(&label.ID, &label.Name, &label.Color); err != nil {
return model.Label{}, err
}
return label, nil
}
func (s *Service) ApplyLabels(ctx context.Context, emailIDs, labelIDs []int64, op string) error {
if len(emailIDs) == 0 || len(labelIDs) == 0 {
return fmt.Errorf("email_ids and label_ids are required")
}
op = strings.ToLower(strings.TrimSpace(op))
if op != "add" && op != "remove" {
return fmt.Errorf("op must be add or remove")
}
now := formatTime(time.Now().UTC())
for _, emailID := range emailIDs {
for _, labelID := range labelIDs {
if op == "add" {
if _, err := s.store.DB.ExecContext(ctx, `
INSERT OR IGNORE INTO message_labels(message_id, label_id, created_at)
VALUES (?, ?, ?)`, emailID, labelID, now); err != nil {
return err
}
} else {
if _, err := s.store.DB.ExecContext(ctx, `DELETE FROM message_labels WHERE message_id = ? AND label_id = ?`, emailID, labelID); err != nil {
return err
}
}
}
}
return nil
}
func (s *Service) CreateDraft(ctx context.Context, input DraftInput) (model.Draft, error) {
now := formatTime(time.Now().UTC())
toJSON, _ := marshalJSON(input.To)
ccJSON, _ := marshalJSON(input.Cc)
bccJSON, _ := marshalJSON(input.Bcc)
res, err := s.store.DB.ExecContext(ctx, `
INSERT INTO drafts(to_json, cc_json, bcc_json, subject, body, reply_to_email_id, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?)`,
toJSON, ccJSON, bccJSON, input.Subject, input.Body, input.ReplyToEmailID, now, now)
if err != nil {
return model.Draft{}, err
}
id, err := res.LastInsertId()
if err != nil {
return model.Draft{}, err
}
return s.GetDraft(ctx, id)
}
func (s *Service) ListDrafts(ctx context.Context) ([]model.Draft, error) {
rows, err := s.store.DB.QueryContext(ctx, `
SELECT id, to_json, cc_json, bcc_json, subject, body, reply_to_email_id,
status, last_error, sent_message_id, created_at, updated_at
FROM drafts ORDER BY updated_at DESC, id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var drafts []model.Draft
for rows.Next() {
draft, err := scanDraft(rows)
if err != nil {
return nil, err
}
drafts = append(drafts, draft)
}
return drafts, rows.Err()
}
func (s *Service) GetDraft(ctx context.Context, id int64) (model.Draft, error) {
row := s.store.DB.QueryRowContext(ctx, `
SELECT id, to_json, cc_json, bcc_json, subject, body, reply_to_email_id,
status, last_error, sent_message_id, created_at, updated_at
FROM drafts WHERE id = ?`, id)
draft, err := scanDraft(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Draft{}, ErrNotFound
}
return model.Draft{}, err
}
return draft, nil
}
func (s *Service) UpdateDraft(ctx context.Context, id int64, input DraftInput) (model.Draft, error) {
toJSON, _ := marshalJSON(input.To)
ccJSON, _ := marshalJSON(input.Cc)
bccJSON, _ := marshalJSON(input.Bcc)
res, err := s.store.DB.ExecContext(ctx, `
UPDATE drafts SET to_json = ?, cc_json = ?, bcc_json = ?, subject = ?,
body = ?, reply_to_email_id = ?, updated_at = ?
WHERE id = ? AND status != 'sent'`,
toJSON, ccJSON, bccJSON, input.Subject, input.Body, input.ReplyToEmailID, formatTime(time.Now().UTC()), id)
if err != nil {
return model.Draft{}, err
}
if err := requireAffected(res); err != nil {
return model.Draft{}, err
}
return s.GetDraft(ctx, id)
}
func (s *Service) SendDraft(ctx context.Context, id int64) (SendOutcome, error) {
draft, err := s.GetDraft(ctx, id)
if err != nil {
return SendOutcome{}, err
}
if draft.Status == "sent" {
if draft.SentMessageID == nil {
return SendOutcome{}, ErrDraftAlreadySent
}
sent, err := s.getSentMessage(ctx, *draft.SentMessageID)
if err != nil {
return SendOutcome{}, err
}
return SendOutcome{SentMessage: sent, Draft: &draft}, nil
}
req := mailsmtp.SendRequest{
FromAddress: s.fromAddress,
FromName: s.fromName,
To: draft.To,
Cc: draft.Cc,
Bcc: draft.Bcc,
Subject: draft.Subject,
Body: draft.Body,
}
if draft.ReplyToEmailID != nil {
original, err := s.GetEmail(ctx, *draft.ReplyToEmailID)
if err != nil {
return SendOutcome{}, err
}
req.InReplyTo = original.MessageIDHeader
req.References = buildReplyReferences(original)
}
var result mailsmtp.SendResult
if s.mailer == nil {
err = fmt.Errorf("SMTP client is not configured")
} else {
result, err = s.mailer.Send(ctx, req)
}
if err != nil {
sent, recordErr := s.recordSentMessage(ctx, &id, draft, req, "failed", "", err.Error(), nil)
if recordErr != nil {
return SendOutcome{}, recordErr
}
_, _ = s.store.DB.ExecContext(ctx, "UPDATE drafts SET last_error = ?, updated_at = ? WHERE id = ?", err.Error(), formatTime(time.Now().UTC()), id)
return SendOutcome{SentMessage: sent, Draft: &draft}, err
}
sentAt := result.SentAt
sent, err := s.recordSentMessage(ctx, &id, draft, req, "sent", result.MessageIDHeader, "", &sentAt)
if err != nil {
return SendOutcome{}, err
}
_, err = s.store.DB.ExecContext(ctx, `
UPDATE drafts SET status = 'sent', last_error = NULL, sent_message_id = ?, updated_at = ?
WHERE id = ?`, sent.ID, formatTime(time.Now().UTC()), id)
if err != nil {
return SendOutcome{}, err
}
if draft.ReplyToEmailID != nil {
_ = s.MarkEmail(ctx, *draft.ReplyToEmailID, MarkInput{Replied: ptrBool(true)})
}
updated, _ := s.GetDraft(ctx, id)
return SendOutcome{SentMessage: sent, Draft: &updated}, nil
}
func (s *Service) SendDirect(ctx context.Context, input DraftInput) (SendOutcome, error) {
draft, err := s.CreateDraft(ctx, input)
if err != nil {
return SendOutcome{}, err
}
return s.SendDraft(ctx, draft.ID)
}
type ForwardInput struct {
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
Body string `json:"body"`
IncludeAttachments bool `json:"include_attachments"`
}
func (s *Service) ForwardEmail(ctx context.Context, emailID int64, input ForwardInput) (SendOutcome, error) {
if input.IncludeAttachments {
return SendOutcome{}, ErrAttachmentsForwardUnsupported
}
original, err := s.GetEmail(ctx, emailID)
if err != nil {
return SendOutcome{}, err
}
subject := input.Subject
if strings.TrimSpace(subject) == "" {
subject = "Fwd: " + original.Subject
}
body := input.Body
if strings.TrimSpace(body) != "" {
body += "\n\n"
}
body += "---------- Forwarded message ----------\n"
body += "From: " + original.SenderEmail + "\n"
body += "Date: " + original.DateHeader + "\n"
body += "Subject: " + original.Subject + "\n\n"
if strings.TrimSpace(original.BodyText) != "" {
body += original.BodyText
} else {
body += original.BodyHTML
}
return s.SendDirect(ctx, DraftInput{To: input.To, Cc: input.Cc, Bcc: input.Bcc, Subject: subject, Body: body})
}
func (s *Service) hasPOP3UID(ctx context.Context, uid string) (bool, error) {
_, ok, err := s.findByPOP3UID(ctx, uid)
return ok, err
}
func (s *Service) findByPOP3UID(ctx context.Context, uid string) (int64, bool, error) {
var id int64
err := s.store.DB.QueryRowContext(ctx, `SELECT id FROM mail_messages WHERE account_id = ? AND pop3_uid = ?`, s.accountID, uid).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
return id, true, nil
}
func (s *Service) saveAttachments(ctx context.Context, execer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}, messageID int64, attachments []model.ParsedAttachment, now time.Time, writtenFiles *[]string) error {
if len(attachments) == 0 {
return nil
}
if s.attachmentDir == "" {
for _, attachment := range attachments {
if _, err := execer.ExecContext(ctx, `
INSERT INTO attachments(message_id, file_name, content_type, content_id, size_bytes, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
messageID, attachment.Filename, attachment.ContentType, attachment.ContentID, attachment.SizeBytes, "", formatTime(now)); err != nil {
return err
}
}
return nil
}
dir := filepath.Join(s.attachmentDir, fmt.Sprintf("%d", messageID))
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("create attachment dir: %w", err)
}
for i, attachment := range attachments {
filename := fmt.Sprintf("%03d_%s", i+1, attachment.Filename)
path := filepath.Join(dir, filename)
if err := os.WriteFile(path, attachment.Data, 0600); err != nil {
return fmt.Errorf("write attachment: %w", err)
}
*writtenFiles = append(*writtenFiles, path)
if _, err := execer.ExecContext(ctx, `
INSERT INTO attachments(message_id, file_name, content_type, content_id, size_bytes, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
messageID, attachment.Filename, attachment.ContentType, attachment.ContentID, attachment.SizeBytes, path, formatTime(now)); err != nil {
return err
}
}
return nil
}
func (s *Service) recordSentMessage(ctx context.Context, draftID *int64, draft model.Draft, req mailsmtp.SendRequest, status, messageID, errorMessage string, sentAt *time.Time) (model.SentMessage, error) {
toJSON, _ := marshalJSON(draft.To)
ccJSON, _ := marshalJSON(draft.Cc)
bccJSON, _ := marshalJSON(draft.Bcc)
sentAtValue := timePtrString(sentAt)
res, err := s.store.DB.ExecContext(ctx, `
INSERT INTO sent_messages(draft_id, to_json, cc_json, bcc_json, subject, body,
message_id_header, in_reply_to, references_header, smtp_from, status,
error_message, sent_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
draftID, toJSON, ccJSON, bccJSON, draft.Subject, draft.Body,
messageID, req.InReplyTo, req.References, req.FromAddress, status,
errorMessage, sentAtValue, formatTime(time.Now().UTC()))
if err != nil {
return model.SentMessage{}, err
}
id, err := res.LastInsertId()
if err != nil {
return model.SentMessage{}, err
}
return model.SentMessage{
ID: id,
DraftID: draftID,
Subject: draft.Subject,
MessageIDHeader: messageID,
Status: status,
ErrorMessage: errorMessage,
SentAt: sentAt,
}, nil
}
func (s *Service) getSentMessage(ctx context.Context, id int64) (model.SentMessage, error) {
var sent model.SentMessage
var draftID sql.NullInt64
var sentAt, errorMessage sql.NullString
err := s.store.DB.QueryRowContext(ctx, `
SELECT id, draft_id, subject, message_id_header, status, error_message, sent_at
FROM sent_messages WHERE id = ?`, id).Scan(
&sent.ID,
&draftID,
&sent.Subject,
&sent.MessageIDHeader,
&sent.Status,
&errorMessage,
&sentAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.SentMessage{}, ErrNotFound
}
return model.SentMessage{}, err
}
if draftID.Valid {
sent.DraftID = &draftID.Int64
}
if errorMessage.Valid {
sent.ErrorMessage = errorMessage.String
}
if sentAt.Valid {
sent.SentAt = parseStoredTime(sentAt.String)
}
return sent, nil
}
func scanSummary(rows interface{ Scan(dest ...any) error }) (model.EmailSummary, error) {
var toJSON, ccJSON, sentAt, receivedAt, body string
var hasAttachment, read, processed, archived, deleted, replied int
var summary model.EmailSummary
err := rows.Scan(
&summary.ID,
&summary.Subject,
&summary.SenderName,
&summary.SenderEmail,
&toJSON,
&ccJSON,
&summary.DateHeader,
&sentAt,
&receivedAt,
&body,
&hasAttachment,
&read,
&processed,
&archived,
&deleted,
&replied,
&summary.AttachmentCount,
)
if err != nil {
return model.EmailSummary{}, err
}
summary.To = decodeAddresses(toJSON)
summary.Cc = decodeAddresses(ccJSON)
summary.SentAt = parseStoredTime(sentAt)
summary.ReceivedAt = parseStoredTime(receivedAt)
summary.Snippet = snippet(body)
summary.HasAttachment = hasAttachment == 1
summary.LocalStatus = model.LocalStatus{Read: read == 1, Processed: processed == 1, Archived: archived == 1, Deleted: deleted == 1, Replied: replied == 1}
return summary, nil
}
func scanDetail(row interface{ Scan(dest ...any) error }) (model.EmailDetail, error) {
var toJSON, ccJSON, sentAt, receivedAt string
var read, processed, archived, deleted, replied int
var detail model.EmailDetail
err := row.Scan(
&detail.ID,
&detail.MessageIDHeader,
&detail.InReplyTo,
&detail.References,
&detail.Subject,
&detail.SenderName,
&detail.SenderEmail,
&toJSON,
&ccJSON,
&detail.DateHeader,
&sentAt,
&receivedAt,
&detail.BodyText,
&detail.BodyHTML,
&read,
&processed,
&archived,
&deleted,
&replied,
)
if err != nil {
return model.EmailDetail{}, err
}
detail.To = decodeAddresses(toJSON)
detail.Cc = decodeAddresses(ccJSON)
detail.SentAt = parseStoredTime(sentAt)
detail.ReceivedAt = parseStoredTime(receivedAt)
detail.LocalStatus = model.LocalStatus{Read: read == 1, Processed: processed == 1, Archived: archived == 1, Deleted: deleted == 1, Replied: replied == 1}
return detail, nil
}
func scanDraft(row interface{ Scan(dest ...any) error }) (model.Draft, error) {
var draft model.Draft
var toJSON, ccJSON, bccJSON string
var replyTo, sentMessage sql.NullInt64
var lastError sql.NullString
var createdAt, updatedAt string
err := row.Scan(
&draft.ID,
&toJSON,
&ccJSON,
&bccJSON,
&draft.Subject,
&draft.Body,
&replyTo,
&draft.Status,
&lastError,
&sentMessage,
&createdAt,
&updatedAt,
)
if err != nil {
return model.Draft{}, err
}
draft.To = decodeStrings(toJSON)
draft.Cc = decodeStrings(ccJSON)
draft.Bcc = decodeStrings(bccJSON)
if replyTo.Valid {
draft.ReplyToEmailID = &replyTo.Int64
}
if sentMessage.Valid {
draft.SentMessageID = &sentMessage.Int64
}
if lastError.Valid {
draft.LastError = lastError.String
}
draft.CreatedAt = parseStoredTime(createdAt)
draft.UpdatedAt = parseStoredTime(updatedAt)
return draft, nil
}
type threadCandidate struct {
summary model.EmailSummary
messageID string
inReplyTo string
references string
}
func (s *Service) threadCandidates(ctx context.Context) (map[int64]threadCandidate, error) {
rows, err := s.store.DB.QueryContext(ctx, `
SELECT id, subject, sender_name, sender_email, to_json, cc_json, date_header,
sent_at, received_at, body_text, has_attachment, local_read,
local_processed, local_archived, local_deleted, local_replied,
(SELECT COUNT(*) FROM attachments WHERE message_id = mail_messages.id)
FROM mail_messages WHERE account_id = ?`, s.accountID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int64]threadCandidate{}
for rows.Next() {
summary, err := scanSummary(rows)
if err != nil {
return nil, err
}
out[summary.ID] = threadCandidate{summary: summary}
}
if err := rows.Err(); err != nil {
return nil, err
}
refRows, err := s.store.DB.QueryContext(ctx, `SELECT id, message_id_header, in_reply_to, references_header FROM mail_messages WHERE account_id = ?`, s.accountID)
if err != nil {
return nil, err
}
defer refRows.Close()
for refRows.Next() {
var id int64
var messageID, inReplyTo, refs string
if err := refRows.Scan(&id, &messageID, &inReplyTo, &refs); err != nil {
return nil, err
}
c := out[id]
c.messageID = messageID
c.inReplyTo = inReplyTo
c.references = refs
out[id] = c
}
return out, refRows.Err()
}
func (s *Service) attachmentsForMessage(ctx context.Context, messageID int64) []model.Attachment {
rows, err := s.store.DB.QueryContext(ctx, `SELECT id, message_id, file_name, content_type, content_id, size_bytes FROM attachments WHERE message_id = ? ORDER BY id`, messageID)
if err != nil {
return nil
}
defer rows.Close()
var out []model.Attachment
for rows.Next() {
var item model.Attachment
if err := rows.Scan(&item.ID, &item.MessageID, &item.Filename, &item.ContentType, &item.ContentID, &item.SizeBytes); err == nil {
out = append(out, item)
}
}
return out
}
func (s *Service) labelsForMessage(ctx context.Context, messageID int64) []model.Label {
rows, err := s.store.DB.QueryContext(ctx, `
SELECT labels.id, labels.name, labels.color
FROM labels
JOIN message_labels ON message_labels.label_id = labels.id
WHERE message_labels.message_id = ?
ORDER BY labels.name`, messageID)
if err != nil {
return nil
}
defer rows.Close()
var out []model.Label
for rows.Next() {
var label model.Label
if err := rows.Scan(&label.ID, &label.Name, &label.Color); err == nil {
out = append(out, label)
}
}
return out
}
var messageIDPattern = regexp.MustCompile(`<[^>]+>`)
func linked(a, b threadCandidate) bool {
if a.messageID == "" || b.messageID == "" {
return false
}
return refsContain(a.inReplyTo+" "+a.references, b.messageID) || refsContain(b.inReplyTo+" "+b.references, a.messageID)
}
func refsContain(refs, messageID string) bool {
for _, ref := range extractMessageIDs(refs) {
if ref == messageID {
return true
}
}
return false
}
func extractMessageIDs(value string) []string {
matches := messageIDPattern.FindAllString(value, -1)
if len(matches) > 0 {
return matches
}
return strings.Fields(value)
}
func buildReplyReferences(original model.EmailDetail) string {
refs := strings.TrimSpace(original.References)
if original.MessageIDHeader == "" {
return refs
}
if refs == "" {
return original.MessageIDHeader
}
if refsContain(refs, original.MessageIDHeader) {
return refs
}
return refs + " " + original.MessageIDHeader
}
func normalizeDateFilter(value string, endOfDay bool) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return formatTime(t.UTC())
}
if t, err := time.Parse("2006-01-02", value); err == nil {
if endOfDay {
t = t.Add(24*time.Hour - time.Nanosecond)
}
return formatTime(t.UTC())
}
return value
}
func marshalJSON(v any) (string, error) {
data, err := json.Marshal(v)
if err != nil {
return "", err
}
return string(data), nil
}
func decodeAddresses(value string) []model.Address {
var out []model.Address
if err := json.Unmarshal([]byte(value), &out); err != nil {
return nil
}
return out
}
func decodeStrings(value string) []string {
var out []string
if err := json.Unmarshal([]byte(value), &out); err != nil {
return nil
}
return out
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func ptrBool(v bool) *bool {
return &v
}
func formatTime(t time.Time) string {
return t.UTC().Format(time.RFC3339Nano)
}
func timePtrString(t *time.Time) string {
if t == nil {
return ""
}
return formatTime(*t)
}
func parseStoredTime(value string) *time.Time {
if value == "" {
return nil
}
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return nil
}
return &t
}
func snippet(value string) string {
value = strings.Join(strings.Fields(value), " ")
if len(value) <= 160 {
return value
}
return value[:160]
}
func hashBytes(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:16])
}
func requireAffected(res sql.Result) error {
affected, err := res.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
func summaryTime(summary model.EmailSummary) time.Time {
if summary.SentAt != nil {
return *summary.SentAt
}
if summary.ReceivedAt != nil {
return *summary.ReceivedAt
}
return time.Unix(0, summary.ID)
}
var (
ErrNotFound = errors.New("not found")
ErrAttachmentsForwardUnsupported = errors.New("forwarding attachments is not supported in MVP")
ErrDraftAlreadySent = errors.New("draft is already sent")
)