Files
gomail/internal/accounts/provider_gomail.go
T
2026-08-09 18:03:09 +01:00

257 lines
7.5 KiB
Go

package accounts
import (
"context"
"fmt"
"net/mail"
"strconv"
"strings"
"time"
"gomail/internal/db"
"gomail/internal/mailstore"
"github.com/google/uuid"
)
// GoMailProvider implements MailProvider for the user's own local account —
// direct function calls against the database and Maildir store, no network
// round-trip. This is what the webmail client uses for "your own" mailbox;
// Phase 9's JMAP server exposes the same data over HTTP for third-party
// clients, but the webmail's internal path stays this direct route since it's
// strictly faster for same-process access.
type GoMailProvider struct {
database *db.DB
store *mailstore.Store
user *db.User
}
func NewGoMailProvider(database *db.DB, store *mailstore.Store, user *db.User) *GoMailProvider {
return &GoMailProvider{database: database, store: store, user: user}
}
func (p *GoMailProvider) ListFolders(_ context.Context) ([]Folder, error) {
names, err := p.database.ListMailboxNames(p.user.ID)
if err != nil {
return nil, err
}
var folders []Folder
for _, name := range names {
entries, err := p.database.ListMailboxEntries(p.user.ID, name)
if err != nil {
continue
}
unread := 0
for _, e := range entries {
if !strings.Contains(e.Flags, "\\Seen") {
unread++
}
}
folders = append(folders, Folder{
ID: name,
DisplayName: name,
Type: folderType(name),
UnreadCount: unread,
TotalCount: len(entries),
})
}
return folders, nil
}
func (p *GoMailProvider) ListMessages(_ context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
if err != nil {
return nil, err
}
// Newest first, matching typical mail client default sort.
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
entries[i], entries[j] = entries[j], entries[i]
}
start := opts.Offset
if start > len(entries) {
start = len(entries)
}
end := len(entries)
if opts.Limit > 0 && start+opts.Limit < end {
end = start + opts.Limit
}
page := entries[start:end]
var headers []MessageHeader
for _, e := range page {
raw, err := p.store.Read(e.EMLPath)
if err != nil {
continue
}
headers = append(headers, headerFromRaw(strconv.Itoa(e.UID), folderID, raw, e.Flags, e.SizeBytes))
}
return headers, nil
}
func (p *GoMailProvider) GetMessage(_ context.Context, folderID, messageID string) (*FullMessage, error) {
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
if err != nil {
return nil, err
}
uid, _ := strconv.Atoi(messageID)
for _, e := range entries {
if e.UID == uid {
raw, err := p.store.Read(e.EMLPath)
if err != nil {
return nil, err
}
hdr := headerFromRaw(messageID, folderID, raw, e.Flags, e.SizeBytes)
return &FullMessage{MessageHeader: hdr, Raw: raw}, nil
}
}
return nil, db.ErrNotFound
}
// SendMessage delivers locally if the recipient is a GoMail user on this
// instance, otherwise stages it in the outbound queue — same routing logic
// SMTP submission uses, exposed here so webmail compose doesn't need to loop
// back through the SMTP port to send its own account's mail.
func (p *GoMailProvider) SendMessage(_ context.Context, msg *OutgoingMessage) error {
raw := buildRFC5322(p.user.Email, msg)
for _, to := range msg.To {
domain := domainOf(to)
localDomain, err := p.database.LookupDomainByName(domain)
if err == nil && localDomain != nil {
if recipUser, err := p.database.LookupUserByEmail(to); err == nil {
if _, err := p.store.Deliver(recipUser.ID, recipUser.Email, "INBOX", raw); err != nil {
return fmt.Errorf("local delivery to %s failed: %w", to, err)
}
continue
}
}
_, queuePath, err := p.store.WriteQueueFile(raw)
if err != nil {
return fmt.Errorf("staging outbound message: %w", err)
}
entry := &db.OutboundQueueEntry{
ID: uuid.NewString(),
UserID: p.user.ID,
FromAddress: p.user.Email,
ToAddress: to,
EMLPath: queuePath,
NextAttemptAt: time.Now().UTC(),
}
if err := p.database.InsertOutboundQueueEntry(entry); err != nil {
return fmt.Errorf("enqueueing outbound message to %s: %w", to, err)
}
}
return nil
}
func (p *GoMailProvider) SetFlags(_ context.Context, folderID, messageID string, flags []string) error {
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
if err != nil {
return err
}
uid, _ := strconv.Atoi(messageID)
for _, e := range entries {
if e.UID == uid {
return p.database.UpdateMailboxFlags(e.ID, strings.Join(flags, " "))
}
}
return db.ErrNotFound
}
// Move re-delivers the message into the destination folder and removes it
// from the source — GoMail's Maildir store has no native "move" primitive
// (each mailbox is its own directory tree), so this is copy+delete rather
// than an atomic rename. Acceptable since both halves are local and fast;
// a future optimization could hardlink instead of re-encrypting.
func (p *GoMailProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
full, err := p.GetMessage(ctx, folderID, messageID)
if err != nil {
return err
}
if _, err := p.store.Deliver(p.user.ID, p.user.Email, destFolderID, full.Raw); err != nil {
return fmt.Errorf("delivering to destination folder: %w", err)
}
return p.Delete(ctx, folderID, messageID)
}
func (p *GoMailProvider) Delete(_ context.Context, folderID, messageID string) error {
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
if err != nil {
return err
}
uid, _ := strconv.Atoi(messageID)
for _, e := range entries {
if e.UID == uid {
return p.database.DeleteMailboxEntry(e.ID)
}
}
return db.ErrNotFound
}
// Sync for the local provider is a no-op in the SyncResult sense — the
// caller already has live DB access via ListMessages, there's no remote
// state to reconcile. Implemented to satisfy the interface for callers that
// treat every linked account uniformly (the future unified-inbox sync loop).
func (p *GoMailProvider) Sync(_ context.Context, _ string) (*SyncResult, error) {
return &SyncResult{NewCursor: ""}, nil
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func folderType(name string) string {
switch strings.ToUpper(name) {
case "INBOX":
return "inbox"
case "SENT":
return "sent"
case "DRAFTS":
return "drafts"
case "TRASH":
return "trash"
case "JUNK":
return "junk"
default:
return "custom"
}
}
func headerFromRaw(id, folderID string, raw []byte, flags string, size int64) MessageHeader {
h := MessageHeader{ID: id, FolderID: folderID, SizeBytes: size}
if flags != "" {
h.Flags = strings.Fields(flags)
}
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err == nil {
h.From = msg.Header.Get("From")
h.To = msg.Header.Get("To")
h.Subject = msg.Header.Get("Subject")
h.Date = msg.Header.Get("Date")
}
return h
}
func domainOf(email string) string {
parts := strings.SplitN(email, "@", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
func buildRFC5322(from string, msg *OutgoingMessage) []byte {
var b strings.Builder
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + strings.Join(msg.To, ", ") + "\r\n")
if len(msg.CC) > 0 {
b.WriteString("Cc: " + strings.Join(msg.CC, ", ") + "\r\n")
}
b.WriteString("Subject: " + msg.Subject + "\r\n")
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("\r\n")
b.WriteString(msg.Body)
b.WriteString("\r\n")
return []byte(b.String())
}