first commit
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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, db.ProviderM365:
|
||||
providerKey := "google"
|
||||
if account.Provider == db.ProviderM365 {
|
||||
providerKey = "microsoft"
|
||||
}
|
||||
cfg := oauthConfigs[providerKey]
|
||||
return NewIMAPProviderOAuth2(account, mk, database, cfg), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("provider %q not supported", account.Provider)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user