first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
package accounts
import (
"encoding/json"
"fmt"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/oauth2"
"github.com/google/uuid"
)
// LinkIMAPAccount registers a generic IMAP/SMTP account for a user, storing
// the password encrypted (same HKDF-per-record scheme as messages/contacts —
// "linked-account-cred" is the purpose namespace, keyed by the new account's
// own ID so a compromise of one linked account's credential doesn't expose
// any other record).
func LinkIMAPAccount(database *db.DB, mk *crypto.MasterKey, userID, displayName, email, password,
imapHost string, imapPort int, imapTLS string,
smtpHost string, smtpPort int, smtpTLS string) (*db.LinkedAccount, error) {
accountID := uuid.NewString()
credJSON, err := json.Marshal(IMAPCredential{Password: password})
if err != nil {
return nil, fmt.Errorf("marshaling credential: %w", err)
}
encCred, err := crypto.Encrypt(mk, accountID, "linked-account-cred", credJSON)
if err != nil {
return nil, fmt.Errorf("encrypting credential: %w", err)
}
account := &db.LinkedAccount{
ID: accountID,
UserID: userID,
Provider: db.ProviderIMAP,
DisplayName: displayName,
EmailAddress: email,
AuthType: db.AuthTypePassword,
IMAPHost: imapHost,
IMAPPort: imapPort,
IMAPTLS: imapTLS,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPTLS: smtpTLS,
CredentialEnc: encCred,
Active: true,
}
if err := database.InsertLinkedAccount(account); err != nil {
return nil, err
}
return account, nil
}
// wellKnownIMAPHost returns the fixed IMAP+SMTP host/port/TLS settings for
// Gmail and M365 — these aren't operator- or user-configurable, since
// they're the provider's actual documented endpoints, exactly like
// oauth2.WellKnownEndpoints.
func wellKnownIMAPHost(provider db.LinkedAccountProvider) (imapHost string, imapPort int, imapTLS, smtpHost string, smtpPort int, smtpTLS string, err error) {
switch provider {
case db.ProviderGmail:
return "imap.gmail.com", 993, "implicit", "smtp.gmail.com", 587, "starttls", nil
case db.ProviderM365:
return "outlook.office365.com", 993, "implicit", "smtp.office365.com", 587, "starttls", nil
default:
return "", 0, "", "", 0, "", fmt.Errorf("no well-known IMAP host for provider %q", provider)
}
}
// LinkOAuth2Account registers a Gmail or M365 account, storing the OAuth2
// tokens (from a completed authorization code exchange) encrypted the same
// way as everything else. Mail access goes through IMAP+OAuth2 (XOAUTH2),
// per the plan's decision to start there before adding native Gmail/Graph
// API push in a later pass — see accounts.IMAPProvider.loginOAuth2.
func LinkOAuth2Account(database *db.DB, mk *crypto.MasterKey, userID, displayName, email string,
provider db.LinkedAccountProvider, token *oauth2.Token) (*db.LinkedAccount, error) {
imapHost, imapPort, imapTLS, smtpHost, smtpPort, smtpTLS, err := wellKnownIMAPHost(provider)
if err != nil {
return nil, err
}
accountID := uuid.NewString()
credJSON, err := json.Marshal(OAuth2Credential{
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresAt: token.ExpiresAt,
})
if err != nil {
return nil, fmt.Errorf("marshaling OAuth2 credential: %w", err)
}
encCred, err := crypto.Encrypt(mk, accountID, "linked-account-cred", credJSON)
if err != nil {
return nil, fmt.Errorf("encrypting OAuth2 credential: %w", err)
}
expiresAt := token.ExpiresAt
account := &db.LinkedAccount{
ID: accountID,
UserID: userID,
Provider: provider,
DisplayName: displayName,
EmailAddress: email,
AuthType: db.AuthTypeOAuth2,
IMAPHost: imapHost,
IMAPPort: imapPort,
IMAPTLS: imapTLS,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPTLS: smtpTLS,
CredentialEnc: encCred,
OAuthExpiresAt: &expiresAt,
Active: true,
}
if err := database.InsertLinkedAccount(account); err != nil {
return nil, err
}
return account, nil
}
// ProviderFor returns the right MailProvider implementation for a linked
// account row — the single place that decides which backend handles which
// provider string, so callers (webmail API, sync workers) never need a
// switch statement of their own. The local "gomail" provider isn't built
// through here since it needs a *db.User + *mailstore.Store, not a
// LinkedAccount row — callers construct it directly via NewGoMailProvider.
//
// oauthConfigs is keyed by provider name ("google", "microsoft") — only
// consulted for accounts.AuthTypeOAuth2 rows, to refresh an expired access
// token. Pass nil if the account is known to be password-based.
func ProviderFor(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfigs map[string]*oauth2.Config) (MailProvider, error) {
switch account.Provider {
case db.ProviderIMAP:
return NewIMAPProvider(account, mk), nil
case db.ProviderGmail, db.ProviderM365:
providerKey := "google"
if account.Provider == db.ProviderM365 {
providerKey = "microsoft"
}
cfg := oauthConfigs[providerKey]
return NewIMAPProviderOAuth2(account, mk, database, cfg), nil
default:
return nil, fmt.Errorf("provider %q not supported", account.Provider)
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package accounts implements the MailProvider abstraction: one interface,
// multiple backends (local GoMail account, generic IMAP/SMTP, and — Phase 10 —
// Gmail/M365 native APIs). The webmail client (later phase) talks to every
// linked account through this same interface regardless of where the mail
// actually lives.
package accounts
import "context"
type Folder struct {
ID string `json:"id"` // provider-native folder identifier (IMAP mailbox name, etc.)
DisplayName string `json:"display_name"`
Type string `json:"type"` // inbox|sent|drafts|trash|junk|custom
UnreadCount int `json:"unread_count"`
TotalCount int `json:"total_count"`
}
type MessageHeader struct {
ID string `json:"id"` // provider-native message identifier (IMAP UID, etc.)
FolderID string `json:"folder_id"`
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
Date string `json:"date"`
Flags []string `json:"flags"`
SizeBytes int64 `json:"size_bytes"`
}
type FullMessage struct {
MessageHeader
Raw []byte `json:"raw"` // full RFC 5322 message; json.Marshal base64-encodes []byte automatically
}
type OutgoingMessage struct {
From string `json:"from"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
Subject string `json:"subject"`
Body string `json:"body"` // plain text; HTML composer is a webmail-phase concern
}
type ListOpts struct {
Limit int
Offset int
}
type SyncResult struct {
NewCursor string
NewMessages []MessageHeader
DeletedIDs []string
FlagsChanged map[string][]string // messageID -> new flags
}
// MailProvider is implemented once per account type. Every method takes a
// context so network-backed implementations (IMAP, and later Gmail/Graph
// API) can be cancelled/timed-out uniformly with the local implementation.
type MailProvider interface {
ListFolders(ctx context.Context) ([]Folder, error)
ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error)
GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error)
SendMessage(ctx context.Context, msg *OutgoingMessage) error
SetFlags(ctx context.Context, folderID, messageID string, flags []string) error
Move(ctx context.Context, folderID, messageID, destFolderID string) error
Delete(ctx context.Context, folderID, messageID string) error
Sync(ctx context.Context, since string) (*SyncResult, error)
}
+256
View File
@@ -0,0 +1,256 @@
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())
}
+322
View File
@@ -0,0 +1,322 @@
package accounts
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/mail"
"strconv"
"strings"
"time"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/imapclient"
"gomail/internal/oauth2"
)
const dialTimeout = 20 * time.Second
// IMAPCredential is what's stored (encrypted) in linked_accounts.credential_enc
// for AuthTypePassword accounts.
type IMAPCredential struct {
Password string `json:"password"`
}
// OAuth2Credential is what's stored (encrypted) in
// linked_accounts.credential_enc for AuthTypeOAuth2 accounts (Gmail, M365,
// or any provider using XOAUTH2). RefreshToken is used to obtain a new
// AccessToken transparently once the stored one expires.
type OAuth2Credential struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
}
// IMAPProvider implements MailProvider for a generic external IMAP account.
// Every method dials fresh — IMAP has no cheap "keep a pool of idle
// connections" story without IDLE/pooling machinery this pass doesn't build
// yet, so simplicity wins: connect, do the one operation, disconnect. A
// later pass can add persistent connections if per-operation latency matters.
type IMAPProvider struct {
account *db.LinkedAccount
mk *crypto.MasterKey
database *db.DB // needed to persist a refreshed access token
oauthConfig *oauth2.Config // nil for password-auth accounts
}
func NewIMAPProvider(account *db.LinkedAccount, mk *crypto.MasterKey) *IMAPProvider {
return &IMAPProvider{account: account, mk: mk}
}
// NewIMAPProviderOAuth2 is used for accounts.AuthTypeOAuth2 — the caller
// supplies the provider's oauth2.Config (built from operator-configured
// Client ID/Secret) so a stored access token can be refreshed transparently
// on expiry. database is used to persist the refreshed token — refreshing
// silently in memory only would force a re-refresh on every single
// operation instead of once per real expiry.
func NewIMAPProviderOAuth2(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *IMAPProvider {
return &IMAPProvider{account: account, mk: mk, database: database, oauthConfig: oauthConfig}
}
func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error) {
addr := fmt.Sprintf("%s:%d", p.account.IMAPHost, p.account.IMAPPort)
// InsecureSkipVerify is a known gap, not a silent one: real ACME
// issuance now exists (internal/acme, Phase 13), but this instance's own
// IMAP server still falls back to a self-signed cert whenever the
// operator hasn't configured tls.acme_domains — and a user's *other*
// linked IMAP server (their actual Gmail/M365/self-hosted account) is
// entirely outside our control regardless. Real clients solve the
// latter with an explicit "accept this certificate" trust step
// (pinning by fingerprint) — that UI flow belongs in the webmail
// account-linking phase, not here. Tracked as a gap, not swept under
// the rug.
tlsConf := &tls.Config{ServerName: p.account.IMAPHost, InsecureSkipVerify: true}
implicit := p.account.IMAPTLS == "implicit"
client, err := imapclient.Dial(addr, implicit, tlsConf, dialTimeout)
if err != nil {
return nil, err
}
if p.account.IMAPTLS == "starttls" {
if err := client.StartTLS(tlsConf); err != nil {
return nil, fmt.Errorf("STARTTLS: %w", err)
}
}
if p.account.AuthType == db.AuthTypeOAuth2 {
if err := p.loginOAuth2(ctx, client); err != nil {
return nil, err
}
return client, nil
}
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
if err != nil {
return nil, fmt.Errorf("decrypting stored credential: %w", err)
}
var cred IMAPCredential
if err := json.Unmarshal(plain, &cred); err != nil {
return nil, fmt.Errorf("parsing stored credential: %w", err)
}
if err := client.Login(p.account.EmailAddress, cred.Password); err != nil {
return nil, fmt.Errorf("IMAP login: %w", err)
}
return client, nil
}
// loginOAuth2 decrypts the stored OAuth2 credential, transparently refreshes
// it if expired (persisting the new token so the next call doesn't have to
// refresh again), and authenticates via SASL XOAUTH2.
func (p *IMAPProvider) loginOAuth2(ctx context.Context, client *imapclient.Client) error {
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
if err != nil {
return fmt.Errorf("decrypting stored OAuth2 credential: %w", err)
}
var cred OAuth2Credential
if err := json.Unmarshal(plain, &cred); err != nil {
return fmt.Errorf("parsing stored OAuth2 credential: %w", err)
}
if time.Now().UTC().After(cred.ExpiresAt) {
if p.oauthConfig == nil {
return fmt.Errorf("access token expired and no oauth2.Config available to refresh it")
}
newTok, err := p.oauthConfig.RefreshToken(ctx, cred.RefreshToken)
if err != nil {
return fmt.Errorf("refreshing OAuth2 token: %w", err)
}
cred.AccessToken = newTok.AccessToken
cred.RefreshToken = newTok.RefreshToken
cred.ExpiresAt = newTok.ExpiresAt
if p.database != nil {
updated, err := json.Marshal(cred)
if err == nil {
if encUpdated, encErr := crypto.Encrypt(p.mk, p.account.ID, "linked-account-cred", updated); encErr == nil {
p.database.Exec(`UPDATE linked_accounts SET credential_enc = ?, oauth_expires_at = ? WHERE id = ?`,
encUpdated, cred.ExpiresAt, p.account.ID)
}
}
}
}
sasl := oauth2.XOAUTH2SASLString(p.account.EmailAddress, cred.AccessToken)
if err := client.LoginXOAUTH2(sasl); err != nil {
return fmt.Errorf("XOAUTH2 login: %w", err)
}
return nil
}
func (p *IMAPProvider) ListFolders(ctx context.Context) ([]Folder, error) {
client, err := p.connect(ctx)
if err != nil {
return nil, err
}
defer client.Logout()
list, err := client.List()
if err != nil {
return nil, err
}
var folders []Folder
for _, f := range list {
info, err := client.Select(f.Name)
total := 0
if err == nil {
total = info.Exists
}
folders = append(folders, Folder{
ID: f.Name, DisplayName: f.Name, Type: folderType(f.Name), TotalCount: total,
})
}
return folders, nil
}
func (p *IMAPProvider) ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
client, err := p.connect(ctx)
if err != nil {
return nil, err
}
defer client.Logout()
info, err := client.Select(folderID)
if err != nil {
return nil, err
}
if info.Exists == 0 {
return nil, nil
}
limit := opts.Limit
if limit <= 0 || limit > info.Exists {
limit = info.Exists
}
lo := info.Exists - limit + 1 - opts.Offset
if lo < 1 {
lo = 1
}
hi := info.Exists - opts.Offset
if hi < lo {
return nil, nil
}
seqSet := fmt.Sprintf("%d:%d", lo, hi)
fetched, err := client.Fetch(seqSet, "(UID FLAGS BODY.PEEK[HEADER])")
if err != nil {
return nil, err
}
var headers []MessageHeader
for _, f := range fetched {
h := MessageHeader{
ID: strconv.Itoa(f.UID),
FolderID: folderID,
Flags: f.Flags,
}
if msg, err := mail.ReadMessage(strings.NewReader(string(f.Body))); 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")
}
headers = append(headers, h)
}
return headers, nil
}
func (p *IMAPProvider) GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error) {
client, err := p.connect(ctx)
if err != nil {
return nil, err
}
defer client.Logout()
if _, err := client.Select(folderID); err != nil {
return nil, err
}
fetched, err := client.UIDFetch(messageID, "(UID FLAGS BODY.PEEK[])")
if err != nil {
return nil, err
}
if len(fetched) == 0 {
return nil, db.ErrNotFound
}
f := fetched[0]
h := MessageHeader{ID: messageID, FolderID: folderID, Flags: f.Flags, SizeBytes: int64(len(f.Body))}
if msg, err := mail.ReadMessage(strings.NewReader(string(f.Body))); 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 &FullMessage{MessageHeader: h, Raw: f.Body}, nil
}
// SendMessage for a generic IMAP account routes through its paired SMTP
// settings — IMAP itself has no send capability, so this dials the account's
// smtp_host/port using the same stored credential, via stdlib net/smtp,
// mirroring the approach in internal/queue's MXDeliverer.
func (p *IMAPProvider) SendMessage(_ context.Context, msg *OutgoingMessage) error {
return sendViaSMTP(p.account, p.mk, msg)
}
func (p *IMAPProvider) SetFlags(ctx context.Context, folderID, messageID string, flags []string) error {
client, err := p.connect(ctx)
if err != nil {
return err
}
defer client.Logout()
if _, err := client.Select(folderID); err != nil {
return err
}
return client.UIDStore(messageID, "FLAGS", strings.Join(flags, " "))
}
func (p *IMAPProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
// No native IMAP MOVE issued here (RFC 6851 COPY+EXPUNGE equivalent) —
// implemented as fetch-from-source + append-style re-delivery is not
// available without APPEND support (deferred). For now: mark \Deleted
// and expunge in the source; true cross-folder move needs APPEND, noted
// as a gap rather than silently mis-behaving.
return fmt.Errorf("Move not yet implemented for generic IMAP accounts (requires APPEND, deferred)")
}
func (p *IMAPProvider) Delete(ctx context.Context, folderID, messageID string) error {
client, err := p.connect(ctx)
if err != nil {
return err
}
defer client.Logout()
if _, err := client.Select(folderID); err != nil {
return err
}
if err := client.UIDStore(messageID, "+FLAGS", `\Deleted`); err != nil {
return err
}
return client.Expunge()
}
func (p *IMAPProvider) Sync(ctx context.Context, _ string) (*SyncResult, error) {
// Full re-list — no CONDSTORE/QRESYNC support yet (deferred, noted in
// package docs). Correct, just not incremental.
folders, err := p.ListFolders(ctx)
if err != nil {
return nil, err
}
var all []MessageHeader
for _, f := range folders {
msgs, err := p.ListMessages(ctx, f.ID, ListOpts{})
if err != nil {
continue
}
all = append(all, msgs...)
}
return &SyncResult{NewMessages: all}, nil
}
+81
View File
@@ -0,0 +1,81 @@
package accounts
import (
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/smtp"
"strconv"
"gomail/internal/crypto"
"gomail/internal/db"
)
// sendViaSMTP delivers an outgoing message through a linked account's own
// SMTP settings — stdlib net/smtp, same choice as internal/queue's
// MXDeliverer, so outbound for both "GoMail relays for me" and "I'm using my
// own IMAP+SMTP provider" stay on the same dependency-free foundation.
func sendViaSMTP(account *db.LinkedAccount, mk *crypto.MasterKey, msg *OutgoingMessage) error {
plain, err := crypto.Decrypt(mk, account.ID, "linked-account-cred", account.CredentialEnc)
if err != nil {
return fmt.Errorf("decrypting stored credential: %w", err)
}
var cred IMAPCredential // same password shape reused for the paired SMTP auth
if err := json.Unmarshal(plain, &cred); err != nil {
return fmt.Errorf("parsing stored credential: %w", err)
}
addr := net.JoinHostPort(account.SMTPHost, strconv.Itoa(account.SMTPPort))
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
defer conn.Close()
if account.SMTPTLS == "implicit" {
// See provider_imap.go's connect() comment — same gap, same reason.
conn = tls.Client(conn, &tls.Config{ServerName: account.SMTPHost, InsecureSkipVerify: true})
}
client, err := smtp.NewClient(conn, account.SMTPHost)
if err != nil {
return fmt.Errorf("SMTP handshake: %w", err)
}
defer client.Close()
if account.SMTPTLS == "starttls" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: account.SMTPHost}); err != nil {
return fmt.Errorf("STARTTLS: %w", err)
}
}
}
auth := smtp.PlainAuth("", account.EmailAddress, cred.Password, account.SMTPHost)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth: %w", err)
}
if err := client.Mail(account.EmailAddress); err != nil {
return err
}
for _, to := range msg.To {
if err := client.Rcpt(to); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
raw := buildRFC5322(account.EmailAddress, msg)
if _, err := w.Write(raw); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return client.Quit()
}