323 lines
10 KiB
Go
323 lines
10 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"
|
||
|
|
"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
|
||
|
|
}
|