Files
gomail/internal/accounts/provider_imap.go
T
2026-08-10 21:15:19 +01:00

264 lines
7.9 KiB
Go

package accounts
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/mail"
"strconv"
"strings"
"time"
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/imapclient"
)
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.
// IMAPProvider is password-authenticated only — db.ProviderIMAP accounts
// are always created via LinkIMAPAccount with AuthTypePassword.
// Gmail/M365 accounts (previously OAuth2-over-IMAP here) now use
// GmailAPIProvider/GraphAPIProvider instead — see link.go's ProviderFor.
type IMAPProvider struct {
account *db.LinkedAccount
mk *crypto.MasterKey
}
func NewIMAPProvider(account *db.LinkedAccount, mk *crypto.MasterKey) *IMAPProvider {
return &IMAPProvider{account: account, mk: mk}
}
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)
}
}
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
}
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
}