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
+116 -7
View File
@@ -1,8 +1,12 @@
package accounts
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"gomail/internal/crypto"
"gomail/internal/db"
@@ -66,6 +70,71 @@ func wellKnownIMAPHost(provider db.LinkedAccountProvider) (imapHost string, imap
}
}
// FetchOAuth2Email looks up the real email address for a just-authorized
// account via the provider's userinfo/profile endpoint — replacing the
// earlier ?email= query-param stand-in (see webmail's oauthCallback). This
// is the account's real identity, so a failure here is returned as an
// error rather than silently falling back to a placeholder.
func FetchOAuth2Email(ctx context.Context, provider, accessToken string) (string, error) {
var endpoint string
switch provider {
case "google":
endpoint = "https://openidconnect.googleapis.com/v1/userinfo"
case "microsoft":
endpoint = "https://graph.microsoft.com/v1.0/me"
default:
return "", fmt.Errorf("unknown provider %q", provider)
}
req, err := oauth2.NewAuthedRequest(ctx, http.MethodGet, endpoint, accessToken, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("userinfo request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading userinfo response: %w", err)
}
if resp.StatusCode >= 300 {
return "", fmt.Errorf("userinfo endpoint returned status %d", resp.StatusCode)
}
if provider == "google" {
var r struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", fmt.Errorf("parsing userinfo response: %w", err)
}
if r.Email == "" {
return "", fmt.Errorf("userinfo response had no email")
}
return r.Email, nil
}
// microsoft
var r struct {
Mail string `json:"mail"`
UserPrincipalName string `json:"userPrincipalName"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", fmt.Errorf("parsing /me response: %w", err)
}
if r.Mail != "" {
return r.Mail, nil
}
if r.UserPrincipalName != "" {
// Some Graph account types return a null `mail` field —
// userPrincipalName is a documented, reliable fallback.
return r.UserPrincipalName, nil
}
return "", fmt.Errorf("/me response had no mail or userPrincipalName")
}
// LinkOAuth2Account registers a Gmail or M365 account, storing the OAuth2
// tokens (from a completed authorization code exchange) encrypted the same
// way as everything else. Mail access goes through IMAP+OAuth2 (XOAUTH2),
@@ -115,6 +184,49 @@ func LinkOAuth2Account(database *db.DB, mk *crypto.MasterKey, userID, displayNam
return account, nil
}
// refreshedCredential decrypts account's stored OAuth2 credential and, if
// expired, transparently refreshes it via cfg and persists the new token —
// shared by every OAuth2-authenticated provider (IMAPProvider,
// GmailAPIProvider, GraphAPIProvider) so "is this expired, and if so
// refresh and persist" lives in exactly one place instead of once per
// provider. database may be nil (refresh then happens in-memory only, for
// the rare caller that doesn't have persistence available); cfg may be nil
// only if the credential is known not to be expired yet.
func refreshedCredential(ctx context.Context, database *db.DB, mk *crypto.MasterKey, account *db.LinkedAccount, cfg *oauth2.Config) (*OAuth2Credential, error) {
plain, err := crypto.Decrypt(mk, account.ID, "linked-account-cred", account.CredentialEnc)
if err != nil {
return nil, fmt.Errorf("decrypting stored OAuth2 credential: %w", err)
}
var cred OAuth2Credential
if err := json.Unmarshal(plain, &cred); err != nil {
return nil, fmt.Errorf("parsing stored OAuth2 credential: %w", err)
}
if !time.Now().UTC().After(cred.ExpiresAt) {
return &cred, nil
}
if cfg == nil {
return nil, fmt.Errorf("access token expired and no oauth2.Config available to refresh it")
}
newTok, err := cfg.RefreshToken(ctx, cred.RefreshToken)
if err != nil {
return nil, fmt.Errorf("refreshing OAuth2 token: %w", err)
}
cred.AccessToken = newTok.AccessToken
cred.RefreshToken = newTok.RefreshToken
cred.ExpiresAt = newTok.ExpiresAt
if database != nil {
if updated, err := json.Marshal(cred); err == nil {
if encUpdated, encErr := crypto.Encrypt(mk, account.ID, "linked-account-cred", updated); encErr == nil {
database.Exec(`UPDATE linked_accounts SET credential_enc = ?, oauth_expires_at = ? WHERE id = ?`,
encUpdated, cred.ExpiresAt, account.ID)
}
}
}
return &cred, nil
}
// ProviderFor returns the right MailProvider implementation for a linked
// account row — the single place that decides which backend handles which
// provider string, so callers (webmail API, sync workers) never need a
@@ -129,13 +241,10 @@ func ProviderFor(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.D
switch account.Provider {
case db.ProviderIMAP:
return NewIMAPProvider(account, mk), nil
case db.ProviderGmail, db.ProviderM365:
providerKey := "google"
if account.Provider == db.ProviderM365 {
providerKey = "microsoft"
}
cfg := oauthConfigs[providerKey]
return NewIMAPProviderOAuth2(account, mk, database, cfg), nil
case db.ProviderGmail:
return NewGmailAPIProvider(account, mk, database, oauthConfigs["google"]), nil
case db.ProviderM365:
return NewGraphAPIProvider(account, mk, database, oauthConfigs["microsoft"]), nil
default:
return nil, fmt.Errorf("provider %q not supported", account.Provider)
}