Files
warmbly/internal/app/email/onboarding.go
T
2026-05-24 11:21:40 +00:00

301 lines
8.2 KiB
Go

package email
import (
"context"
"encoding/json"
"io"
"net/http"
"net/mail"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/crypt"
"golang.org/x/oauth2"
)
// OAuthStart issues a fresh state nonce and returns the provider-specific authorization URL.
// The caller is expected to redirect the user to the URL and post back to OAuthFinish on return.
func (s *emailService) OAuthStart(ctx context.Context, userID string, orgID *uuid.UUID, provider models.InboxProvider) (*models.EmailOnboardingStartResponse, *errx.Error) {
cfg, xerr := s.oauthConfigFor(provider)
if xerr != nil {
return nil, xerr
}
state, err := crypt.Nonce()
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
if xerr := s.saveOnboardingState(ctx, state, &models.EmailOnboardingState{
UserID: userID,
OrganizationID: orgID,
Provider: string(provider),
Nonce: state,
}); xerr != nil {
return nil, xerr
}
url := cfg.AuthCodeURL(
state,
oauth2.AccessTypeOffline,
oauth2.ApprovalForce, // force refresh_token issuance on reconnect
)
return &models.EmailOnboardingStartResponse{URL: url, State: state}, nil
}
// OAuthFinish validates the state, exchanges the code for tokens, fetches the inbox owner,
// and persists a new email account.
func (s *emailService) OAuthFinish(ctx context.Context, userID, code, state string) (*models.Email, *errx.Error) {
if code = strings.TrimSpace(code); code == "" {
return nil, errx.ErrEmailOnboardCode
}
if state = strings.TrimSpace(state); state == "" {
return nil, errx.ErrEmailOnboardState
}
sess, xerr := s.takeOnboardingState(ctx, state)
if xerr != nil {
return nil, xerr
}
if sess.UserID != userID {
return nil, errx.ErrEmailOnboardState
}
provider := models.InboxProvider(sess.Provider)
cfg, xerr := s.oauthConfigFor(provider)
if xerr != nil {
return nil, xerr
}
tok, err := cfg.Exchange(ctx, code)
if err != nil {
return nil, errx.ErrEmailOnboardExchange
}
owner, xerr := fetchInboxOwner(ctx, provider, tok.AccessToken)
if xerr != nil {
return nil, xerr
}
if exists, xerr := s.emailRepository.ExistsForUser(ctx, userID, owner.Email); xerr != nil {
return nil, xerr
} else if exists {
return nil, errx.ErrEmailOnboardAlreadyExists
}
name := strings.TrimSpace(owner.Name)
if name == "" {
name = deriveNameFromEmail(owner.Email)
}
return s.emailRepository.NewOauthAccount(ctx, userID, models.NewOauthAccount{
OrganizationID: sess.OrganizationID,
Provider: provider,
Name: name,
Email: owner.Email,
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresAt: tok.Expiry,
})
}
// OnboardSMTPIMAP validates the supplied SMTP/IMAP credentials against a live worker, then
// persists the email account on success. Returns ErrEmailCredentials if the worker reports failure.
func (s *emailService) OnboardSMTPIMAP(ctx context.Context, userID string, orgID *uuid.UUID, data *models.NewSMTPIMAPAccount) (*models.Email, *errx.Error) {
if xerr := validateSMTPIMAPInput(data); xerr != nil {
return nil, xerr
}
if exists, xerr := s.emailRepository.ExistsForUser(ctx, userID, data.Email); xerr != nil {
return nil, xerr
} else if exists {
return nil, errx.ErrEmailOnboardAlreadyExists
}
if s.workerAssignment == nil {
return nil, errx.ErrEmailOnboardNoWorker
}
uid, err := uuid.Parse(userID)
if err != nil {
return nil, errx.ErrUser
}
// Pick any healthy worker for the one-shot validation handshake.
w, werr := s.workerAssignment.SelectSharedWorker(ctx, true)
if werr != nil || w == nil {
return nil, errx.ErrEmailOnboardNoWorker
}
creds := &models.SmtpImap{SMTP: data.SMTP, IMAP: data.IMAP}
if xerr := s.ValidateCredentials(ctx, uid, w.ID.String(), creds); xerr != nil {
return nil, xerr
}
data.OrganizationID = orgID
acc, xerr := s.emailRepository.NewSMTPIMAPAccount(ctx, userID, *data)
if xerr != nil {
return nil, xerr
}
// Assign the long-term worker (free vs paid tier). Failure here is non-fatal:
// the scheduler will pick the account up on its next pass.
if orgID != nil {
if _, err := s.workerAssignment.AssignWorkerToEmail(ctx, acc.ID, *orgID); err != nil {
sentry.CaptureException(err)
}
}
return acc, nil
}
func (s *emailService) oauthConfigFor(provider models.InboxProvider) (*oauth2.Config, *errx.Error) {
if s.oauthInbox == nil {
return nil, errx.InternalError()
}
switch provider {
case models.InboxProviderGoogle:
if s.oauthInbox.Google == nil {
return nil, errx.InternalError()
}
return s.oauthInbox.Google, nil
case models.InboxProviderOutlook:
if s.oauthInbox.Outlook == nil {
return nil, errx.InternalError()
}
return s.oauthInbox.Outlook, nil
default:
return nil, errx.ErrEmailOnboardProvider
}
}
func validateSMTPIMAPInput(data *models.NewSMTPIMAPAccount) *errx.Error {
if data == nil || data.SMTP == nil || data.IMAP == nil {
return errx.ErrEmailCredentialsRequired
}
data.Email = strings.TrimSpace(data.Email)
if _, err := mail.ParseAddress(data.Email); err != nil {
return errx.ErrEmail
}
if !validNameLen(&data.Name) {
return errx.ErrEmailName
}
if strings.TrimSpace(data.SMTP.Host) == "" {
return errx.ErrEmailSMTPHost
}
if data.SMTP.Port != 465 && data.SMTP.Port != 587 {
return errx.ErrEmailSMTPPort
}
if strings.TrimSpace(data.IMAP.Host) == "" {
return errx.ErrEmailIMAPHost
}
if data.IMAP.Port <= 0 {
return errx.ErrEmailIMAPPort
}
return nil
}
func validNameLen(name *string) bool {
*name = strings.TrimSpace(*name)
if *name == "" {
return false
}
r := []rune(*name)
return len(r) >= 2 && len(r) <= 100
}
func deriveNameFromEmail(email string) string {
at := strings.IndexByte(email, '@')
if at <= 0 {
return email
}
local := email[:at]
if local == "" {
return email
}
local = strings.ReplaceAll(local, ".", " ")
local = strings.ReplaceAll(local, "_", " ")
return strings.Title(local)
}
// inboxOwner is the per-provider user info shape we normalize on.
type inboxOwner struct {
Email string
Name string
}
func fetchInboxOwner(ctx context.Context, provider models.InboxProvider, accessToken string) (*inboxOwner, *errx.Error) {
switch provider {
case models.InboxProviderGoogle:
return fetchGmailOwner(ctx, accessToken)
case models.InboxProviderOutlook:
return fetchOutlookOwner(ctx, accessToken)
default:
return nil, errx.ErrEmailOnboardProvider
}
}
var httpClient = &http.Client{Timeout: 10 * time.Second}
func fetchGmailOwner(ctx context.Context, token string) (*inboxOwner, *errx.Error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://gmail.googleapis.com/gmail/v1/users/me/profile", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, errx.ErrEmailOnboardUserInfo
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, errx.ErrEmailOnboardUserInfo
}
var out struct {
EmailAddress string `json:"emailAddress"`
}
if err := json.Unmarshal(body, &out); err != nil {
return nil, errx.ErrEmailOnboardUserInfo
}
if out.EmailAddress == "" {
return nil, errx.ErrEmailOnboardUserInfo
}
return &inboxOwner{Email: out.EmailAddress}, nil
}
func fetchOutlookOwner(ctx context.Context, token string) (*inboxOwner, *errx.Error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://graph.microsoft.com/v1.0/me", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient.Do(req)
if err != nil {
return nil, errx.ErrEmailOnboardUserInfo
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, errx.ErrEmailOnboardUserInfo
}
var out struct {
Mail string `json:"mail"`
UserPrincipalName string `json:"userPrincipalName"`
DisplayName string `json:"displayName"`
}
if err := json.Unmarshal(body, &out); err != nil {
return nil, errx.ErrEmailOnboardUserInfo
}
addr := out.Mail
if addr == "" {
addr = out.UserPrincipalName
}
if addr == "" {
return nil, errx.ErrEmailOnboardUserInfo
}
return &inboxOwner{Email: addr, Name: out.DisplayName}, nil
}