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()) }