Files

323 lines
10 KiB
Go
Raw Permalink Normal View History

2026-08-09 18:03:09 +01:00
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
}