2026-08-09 18:03:09 +01:00
|
|
|
package accounts
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-10 21:15:19 +01:00
|
|
|
"context"
|
2026-08-09 18:03:09 +01:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
2026-08-10 21:15:19 +01:00
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
2026-08-09 18:03:09 +01:00
|
|
|
|
|
|
|
|
"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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 21:15:19 +01:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 18:03:09 +01:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 21:15:19 +01:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 18:03:09 +01:00
|
|
|
// 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
|
2026-08-10 21:15:19 +01:00
|
|
|
case db.ProviderGmail:
|
|
|
|
|
return NewGmailAPIProvider(account, mk, database, oauthConfigs["google"]), nil
|
|
|
|
|
case db.ProviderM365:
|
|
|
|
|
return NewGraphAPIProvider(account, mk, database, oauthConfigs["microsoft"]), nil
|
2026-08-09 18:03:09 +01:00
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("provider %q not supported", account.Provider)
|
|
|
|
|
}
|
|
|
|
|
}
|