This commit is contained in:
2026-08-10 21:15:19 +01:00
parent d7ca591b76
commit 4da942786e
97 changed files with 105039 additions and 3370 deletions
+6 -65
View File
@@ -13,7 +13,6 @@ import (
"gomail/internal/crypto"
"gomail/internal/db"
"gomail/internal/imapclient"
"gomail/internal/oauth2"
)
const dialTimeout = 20 * time.Second
@@ -39,27 +38,19 @@ type OAuth2Credential struct {
// 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
database *db.DB // needed to persist a refreshed access token
oauthConfig *oauth2.Config // nil for password-auth accounts
account *db.LinkedAccount
mk *crypto.MasterKey
}
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
@@ -85,13 +76,6 @@ func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error)
}
}
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)
@@ -106,49 +90,6 @@ func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error)
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 {