package mail import ( "context" "errors" "strings" "github.com/jackc/pgx/v5" "github.com/ultisuite/ulti-backend/internal/mail/credentials" ) type accountRow struct { id, name, email, provider string imapHost, smtpHost string imapPort, smtpPort int imapTLS, smtpTLS bool credentials []byte } func (s *Service) loadAccountRow(ctx context.Context, externalID, accountID string) (accountRow, error) { var row accountRow err := s.db.QueryRow(ctx, ` SELECT ma.id, ma.name, ma.email, ma.provider, ma.imap_host, ma.imap_port, ma.imap_tls, ma.smtp_host, ma.smtp_port, ma.smtp_tls, ma.credentials FROM mail_accounts ma JOIN users u ON ma.user_id = u.id WHERE ma.id = $1 AND u.external_id = $2 `, accountID, externalID).Scan( &row.id, &row.name, &row.email, &row.provider, &row.imapHost, &row.imapPort, &row.imapTLS, &row.smtpHost, &row.smtpPort, &row.smtpTLS, &row.credentials, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return accountRow{}, ErrNotFound } return accountRow{}, err } return row, nil } func accountDetailFromRow(row accountRow, cred credentials.Credential) map[string]any { out := map[string]any{ "id": row.id, "name": row.name, "email": row.email, "provider": row.provider, "imap_host": row.imapHost, "imap_port": row.imapPort, "imap_tls": row.imapTLS, "smtp_host": row.smtpHost, "smtp_port": row.smtpPort, "smtp_tls": row.smtpTLS, "auth_type": string(credentials.AuthPassword), "username": cred.Username, } if cred.AuthType != "" { out["auth_type"] = string(cred.AuthType) } if cred.IsOAuth() { out["oauth_provider"] = cred.OAuthProvider } return out } func (s *Service) decryptAccountCredential(blob []byte) (credentials.Credential, error) { if s.credentials == nil { return credentials.Credential{}, ErrCredentialsUnavailable } if len(blob) == 0 { return credentials.Credential{}, nil } return s.credentials.DecryptCredential(blob) } func (s *Service) GetAccount(ctx context.Context, externalID, accountID string) (map[string]any, error) { row, err := s.loadAccountRow(ctx, externalID, accountID) if err != nil { return nil, err } cred, err := s.decryptAccountCredential(row.credentials) if err != nil { return nil, err } return accountDetailFromRow(row, cred), nil } func (s *Service) UpdateAccount(ctx context.Context, externalID, accountID string, req *updateAccountRequest) error { if s.credentials == nil { return ErrCredentialsUnavailable } row, err := s.loadAccountRow(ctx, externalID, accountID) if err != nil { return err } cred, err := s.decryptAccountCredential(row.credentials) if err != nil { return err } name := strings.TrimSpace(req.Name) if name == "" { name = row.name } provider := strings.TrimSpace(req.Provider) if provider == "" { provider = row.provider } if cred.IsOAuth() { if strings.TrimSpace(req.Password) != "" { return ErrOAuthPasswordNotAllowed } if u := strings.TrimSpace(req.Username); u != "" { cred.Username = u } } else { if u := strings.TrimSpace(req.Username); u != "" { cred.Username = u } if p := req.Password; p != "" { cred.Password = p } if strings.TrimSpace(cred.Username) == "" { return ErrInvalidAccountCredentials } } encrypted, err := s.credentials.EncryptCredential(cred) if err != nil { return err } result, err := s.db.Exec(ctx, ` UPDATE mail_accounts ma SET name = $1, email = $2, provider = $3, imap_host = $4, imap_port = $5, imap_tls = $6, smtp_host = $7, smtp_port = $8, smtp_tls = $9, credentials = $10, updated_at = NOW() FROM users u WHERE ma.id = $11 AND ma.user_id = u.id AND u.external_id = $12 `, name, req.Email, provider, req.IMAPHost, req.IMAPPort, req.IMAPTLS, req.SMTPHost, req.SMTPPort, req.SMTPTLS, encrypted, accountID, externalID) if err != nil { return err } if result.RowsAffected() == 0 { return ErrNotFound } return nil } func credentialFromTestRequest(req *testAccountRequest) (credentials.Credential, error) { if req.AuthType == string(credentials.AuthOAuth2) { return credentials.Credential{ AuthType: credentials.AuthOAuth2, Username: strings.TrimSpace(req.Username), AccessToken: strings.TrimSpace(req.AccessToken), OAuthProvider: strings.TrimSpace(req.OAuthProvider), }, nil } if strings.TrimSpace(req.Password) == "" { return credentials.Credential{}, ErrInvalidAccountCredentials } return credentials.Credential{ AuthType: credentials.AuthPassword, Username: strings.TrimSpace(req.Username), Password: req.Password, }, nil } func mergeTestCredential(stored credentials.Credential, req *testAccountRequest) (credentials.Credential, error) { cred := stored if u := strings.TrimSpace(req.Username); u != "" { cred.Username = u } if req.AuthType == string(credentials.AuthOAuth2) || cred.IsOAuth() { if t := strings.TrimSpace(req.AccessToken); t != "" { cred.AccessToken = t } if strings.TrimSpace(cred.AccessToken) == "" { return credentials.Credential{}, ErrInvalidAccountCredentials } return cred, nil } if req.Password != "" { cred.Password = req.Password } if strings.TrimSpace(cred.Password) == "" { return credentials.Credential{}, ErrInvalidAccountCredentials } if strings.TrimSpace(cred.Username) == "" { return credentials.Credential{}, ErrInvalidAccountCredentials } return cred, nil } func (s *Service) CredentialForConnectionTest(ctx context.Context, externalID string, req *testAccountRequest) (credentials.Credential, error) { accountID := strings.TrimSpace(req.AccountID) if accountID == "" { return credentialFromTestRequest(req) } row, err := s.loadAccountRow(ctx, externalID, accountID) if err != nil { return credentials.Credential{}, err } stored, err := s.decryptAccountCredential(row.credentials) if err != nil { return credentials.Credential{}, err } return mergeTestCredential(stored, req) }