package accounts import ( "context" "encoding/json" "fmt" "io" "net/http" "time" "gomail/internal/crypto" "gomail/internal/db" "gomail/internal/oauth2" "github.com/google/uuid" ) // LinkIMAPAccount registers a generic IMAP/SMTP account for a user, storing // the password encrypted (same HKDF-per-record scheme as messages/contacts — // "linked-account-cred" is the purpose namespace, keyed by the new account's // own ID so a compromise of one linked account's credential doesn't expose // any other record). func LinkIMAPAccount(database *db.DB, mk *crypto.MasterKey, userID, displayName, email, password, imapHost string, imapPort int, imapTLS string, smtpHost string, smtpPort int, smtpTLS string) (*db.LinkedAccount, error) { accountID := uuid.NewString() credJSON, err := json.Marshal(IMAPCredential{Password: password}) if err != nil { return nil, fmt.Errorf("marshaling credential: %w", err) } encCred, err := crypto.Encrypt(mk, accountID, "linked-account-cred", credJSON) if err != nil { return nil, fmt.Errorf("encrypting credential: %w", err) } account := &db.LinkedAccount{ ID: accountID, UserID: userID, Provider: db.ProviderIMAP, DisplayName: displayName, EmailAddress: email, AuthType: db.AuthTypePassword, IMAPHost: imapHost, IMAPPort: imapPort, IMAPTLS: imapTLS, SMTPHost: smtpHost, SMTPPort: smtpPort, SMTPTLS: smtpTLS, CredentialEnc: encCred, Active: true, } if err := database.InsertLinkedAccount(account); err != nil { return nil, err } return account, nil } // wellKnownIMAPHost returns the fixed IMAP+SMTP host/port/TLS settings for // Gmail and M365 — these aren't operator- or user-configurable, since // they're the provider's actual documented endpoints, exactly like // oauth2.WellKnownEndpoints. func wellKnownIMAPHost(provider db.LinkedAccountProvider) (imapHost string, imapPort int, imapTLS, smtpHost string, smtpPort int, smtpTLS string, err error) { switch provider { case db.ProviderGmail: return "imap.gmail.com", 993, "implicit", "smtp.gmail.com", 587, "starttls", nil case db.ProviderM365: return "outlook.office365.com", 993, "implicit", "smtp.office365.com", 587, "starttls", nil default: return "", 0, "", "", 0, "", fmt.Errorf("no well-known IMAP host for provider %q", provider) } } // 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), // per the plan's decision to start there before adding native Gmail/Graph // API push in a later pass — see accounts.IMAPProvider.loginOAuth2. func LinkOAuth2Account(database *db.DB, mk *crypto.MasterKey, userID, displayName, email string, provider db.LinkedAccountProvider, token *oauth2.Token) (*db.LinkedAccount, error) { imapHost, imapPort, imapTLS, smtpHost, smtpPort, smtpTLS, err := wellKnownIMAPHost(provider) if err != nil { return nil, err } accountID := uuid.NewString() credJSON, err := json.Marshal(OAuth2Credential{ AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresAt: token.ExpiresAt, }) if err != nil { return nil, fmt.Errorf("marshaling OAuth2 credential: %w", err) } encCred, err := crypto.Encrypt(mk, accountID, "linked-account-cred", credJSON) if err != nil { return nil, fmt.Errorf("encrypting OAuth2 credential: %w", err) } expiresAt := token.ExpiresAt account := &db.LinkedAccount{ ID: accountID, UserID: userID, Provider: provider, DisplayName: displayName, EmailAddress: email, AuthType: db.AuthTypeOAuth2, IMAPHost: imapHost, IMAPPort: imapPort, IMAPTLS: imapTLS, SMTPHost: smtpHost, SMTPPort: smtpPort, SMTPTLS: smtpTLS, CredentialEnc: encCred, OAuthExpiresAt: &expiresAt, Active: true, } if err := database.InsertLinkedAccount(account); err != nil { return nil, err } 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 // switch statement of their own. The local "gomail" provider isn't built // through here since it needs a *db.User + *mailstore.Store, not a // LinkedAccount row — callers construct it directly via NewGoMailProvider. // // oauthConfigs is keyed by provider name ("google", "microsoft") — only // consulted for accounts.AuthTypeOAuth2 rows, to refresh an expired access // token. Pass nil if the account is known to be password-based. func ProviderFor(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfigs map[string]*oauth2.Config) (MailProvider, error) { switch account.Provider { case db.ProviderIMAP: return NewIMAPProvider(account, mk), 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) } }