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 }