Files
warmbly/internal/repository/pg_email.go
T
Matthew Meszaros 222c9d2554 feat: scope campaign sender resolution to the campaign's organization instead of its owner so a multi-org user can no longer send organization A's campaign from an organization B mailbox: GetByTags/GetAllActiveInScope/GetByCampaignSenders now take a repository.AccountScope keyed on organization_id where a scope with no organization resolves to no mailboxes rather than widening to the owner (tags carry no organization of their own, so one user's tag legitimately spans workspaces and the predicate is the only thing holding the boundary), the campaign scheduler and the preflight tracking-domain check build that scope from campaign.OrganizationID, unibox compose scores only the current workspace's mailboxes, the 'all' lanes of AccountHasActiveCampaign/CountActiveCampaignsForAccount join ea.organization_id = c.organization_id instead of ea.user_id = c.user_id, dead and broken PauseAllByUserID (which wrote the reason string into status and had no callers) is removed, the campaigns/unibox guides now state that senders resolve inside the campaign's workspace, and TestLiveOrglessCampaignDoesNotSendToSuppressedRecipient is updated because an orgless campaign now finds no senders before routing is consulted while the send gate it covers still refuses; live-tested in TestLiveSenderResolutionStaysInsideTheCampaignOrg, TestLiveSenderSchedulerNeverPicksAnotherOrgMailbox, TestLiveSenderSchedulerPicksTheCampaignOrgMailbox, TestLiveSenderScopeWithoutAnOrganizationReachesNothing and TestLiveActiveCampaignLookupIsOrgScoped
2026-08-24 09:20:41 -07:00

1700 lines
58 KiB
Go

package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/encrypt"
"github.com/warmbly/warmbly/internal/utils"
"github.com/warmbly/warmbly/internal/utils/paging"
"github.com/warmbly/warmbly/internal/utils/validate"
)
// SMTPCredentials holds SMTP/IMAP server credentials
type SMTPCredentials struct {
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
IMAPHost string
IMAPPort int
IMAPUser string
IMAPPassword string
}
// OAuthCredentials holds OAuth token credentials
type OAuthCredentials struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// AccountScope confines a mailbox lookup to one tenant. The organization is the
// tenant boundary: a multi-org user must never send organization A's campaign
// from an organization B mailbox, so resolution keys on organization_id and
// never on the owner.
type AccountScope struct {
// OrgID is the tenant key. Nil is not a wildcard — it resolves to no
// mailboxes at all, matching how the campaign task halts an orgless
// campaign rather than sending it unchecked.
OrgID *uuid.UUID
}
// NewAccountScope builds the scope for an organization, treating the nil UUID
// the same as no organization.
func NewAccountScope(orgID *uuid.UUID) AccountScope {
if orgID != nil && *orgID == uuid.Nil {
orgID = nil
}
return AccountScope{OrgID: orgID}
}
// tenant returns the organization to query for, and false when the scope has
// none — the caller then answers "no mailboxes" instead of running a query with
// an unbound tenant.
func (s AccountScope) tenant() (uuid.UUID, bool) {
if s.OrgID == nil {
return uuid.Nil, false
}
return *s.OrgID, true
}
type EmailRepository interface {
Search(ctx context.Context, userID, search string, cursor, tag *string, limit int32, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error)
Get(ctx context.Context, userID, emailAccountID string) (*models.Email, *errx.Error)
GetByID(ctx context.Context, emailAccountID uuid.UUID) (*models.Email, *errx.Error)
// GetByTags returns the scope's active mailboxes carrying any of the tags.
GetByTags(ctx context.Context, scope AccountScope, tags []string) ([]models.Email, *errx.Error)
// GetAllActiveInScope returns every active mailbox in the scope (no
// tag/sender filter) — the "all" sender pool used when a campaign picks
// neither tags nor explicit accounts.
GetAllActiveInScope(ctx context.Context, scope AccountScope) ([]models.Email, *errx.Error)
// GetByCampaignSenders returns the active mailboxes in a campaign's explicit
// sender pool, carrying each sender's rotation metadata (weight,
// rotation_position, last_sent_at) for the scheduler's rotation modes.
GetByCampaignSenders(ctx context.Context, scope AccountScope, campaignID uuid.UUID) ([]CampaignSenderAccount, *errx.Error)
GetSMTPCredentials(ctx context.Context, emailAccountID uuid.UUID) (*SMTPCredentials, *errx.Error)
GetOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID) (*OAuthCredentials, *errx.Error)
GetWorkerID(ctx context.Context, emailAccountID uuid.UUID) (*uuid.UUID, *errx.Error)
SetWorkerID(ctx context.Context, emailAccountID, workerID uuid.UUID) *errx.Error
Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error)
// BulkUpdateTags adds/removes tag links across many of the user's
// mailboxes in one transaction; ownership of both mailboxes and tags is
// enforced in SQL, unknown ids are skipped. Returns how many of the
// requested mailboxes the caller owns.
BulkUpdateTags(ctx context.Context, userID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error)
// SetWarmupLifecycle starts, pauses, resumes, or disables warmup for a
// mailbox. "start"/"resume" preserve ramp progress (a paused mailbox
// resumes where it left off); "pause" keeps progress; "disable" turns
// warmup off entirely. The timestamp math runs in SQL so the transition
// is atomic and idempotent.
SetWarmupLifecycle(ctx context.Context, userID, emailAccountID, action string) (*models.Email, *errx.Error)
UpdateTrackingDomain(ctx context.Context, orgID, emailAccountID, domain string, verified bool, verifiedAt *time.Time) *errx.Error
// ListTrackingDomainCheckDue returns active mailboxes with a custom
// tracking domain that has not been resolved since staleBefore (or never),
// oldest-first. Drives the background re-verification sweep.
ListTrackingDomainCheckDue(ctx context.Context, staleBefore time.Time, limit int) ([]models.TrackingDomainTarget, *errx.Error)
// SetTrackingDomainVerified records a sweep's verdict for one mailbox. It
// never touches the domain itself.
SetTrackingDomainVerified(ctx context.Context, emailAccountID uuid.UUID, verified bool, verifiedAt *time.Time) *errx.Error
// ListAuthCheckDue returns active mailboxes whose sending-domain auth state
// has not been evaluated since staleBefore (or never), oldest-first, capped
// at limit. Drives the background SPF/DKIM/DMARC sweep.
ListAuthCheckDue(ctx context.Context, staleBefore time.Time, limit int) ([]models.EmailAuthTarget, *errx.Error)
// UpdateDomainAuthState records the SPF/DKIM/DMARC result for every active
// mailbox on the given sending domain in one write (auth is a per-domain
// property). checkedAt stamps the evaluation so the sweep can skip fresh
// domains. It returns the mailboxes that entered the failing state on THIS
// call, which is what the sweep notifies on.
UpdateDomainAuthState(ctx context.Context, domain, state string, spf, dkim, dmarc bool, dmarcPolicy, reason string, checkedAt time.Time) ([]models.EmailAuthTransition, *errx.Error)
Delete(ctx context.Context, userID, emailAccountID string) *errx.Error
NewOauthAccount(ctx context.Context, userID string, data models.NewOauthAccount) (*models.Email, *errx.Error)
NewSMTPIMAPAccount(ctx context.Context, userID string, data models.NewSMTPIMAPAccount) (*models.Email, *errx.Error)
RefreshBoxToken(ctx context.Context, id uuid.UUID, accessToken, refreshToken string, expiresAt time.Time) error
// ExistsForUser checks whether the given (user_id, email) pair is already connected.
ExistsForUser(ctx context.Context, userID, email string) (bool, *errx.Error)
// CountForOrganization returns the number of email accounts attached to the
// given organization. Used by the free-trial inbox cap.
CountForOrganization(ctx context.Context, orgID uuid.UUID) (int, *errx.Error)
// ListWarmupScheduleCandidates returns active mailboxes that should have a
// running warmup chain but currently have no pending warmup task: either
// actively warming, or backing a live campaign (the health-check lane).
// Used by the warmup reconciler to (re)seed chains.
ListWarmupScheduleCandidates(ctx context.Context, limit int) ([]uuid.UUID, error)
// ListActiveWorkerAccounts returns the ids of every active mailbox. The
// worker reconciler uses it to (re)load accounts onto their assigned workers
// after onboarding, worker restarts, or reassignment.
ListActiveWorkerAccounts(ctx context.Context) ([]uuid.UUID, error)
// ListActiveAccountsByWorker returns the ids of the active mailboxes
// assigned to one worker, for reloading them after that worker restarts.
ListActiveAccountsByWorker(ctx context.Context, workerID uuid.UUID) ([]uuid.UUID, error)
}
type emailRepository struct {
DB *db.DB
Encrypt *encrypt.Encrypter
}
// NewEmailRepostory builds the email repository. enc seals SMTP/IMAP and
// OAuth credentials at rest (CREDENTIALS_ENCRYPTION_KEY); nil is tolerated so
// deployments without the key keep booting, but credential reads/writes then
// fail with a captured error instead of a nil-pointer panic.
func NewEmailRepostory(db *db.DB, enc *encrypt.Encrypter) EmailRepository {
return &emailRepository{
DB: db,
Encrypt: enc,
}
}
// errNoCredentialEncrypter is returned when credential sealing is attempted
// without CREDENTIALS_ENCRYPTION_KEY configured.
var errNoCredentialEncrypter = errors.New("credential encrypter not configured (set CREDENTIALS_ENCRYPTION_KEY)")
// sealCredential encrypts a credential for storage. It fails closed: without an
// encrypter the caller must abort rather than fall back to a plaintext write.
func (r *emailRepository) sealCredential(plain string) (string, error) {
if r.Encrypt == nil {
return "", errNoCredentialEncrypter
}
return r.Encrypt.Encrypt(plain)
}
// openCredential returns the plaintext behind a stored credential, reporting
// whether the row was still in the pre-sealing plaintext format.
//
// Rows written before OAuth tokens were sealed hold the provider token verbatim
// ("ya29...", "1//0g...", a Graph JWT), none of which is valid hex, so a
// ParseHex/AEAD failure identifies a legacy row rather than corruption. Callers
// re-seal on the spot so the plaintext window closes on first read.
func (r *emailRepository) openCredential(stored string) (string, bool, error) {
if r.Encrypt == nil {
return "", false, errNoCredentialEncrypter
}
if plain, err := r.Encrypt.Decrypt(stored); err == nil {
return plain, false, nil
}
return stored, true, nil
}
func (r *emailRepository) ExistsForUser(ctx context.Context, userID, email string) (bool, *errx.Error) {
var exists bool
query := `SELECT EXISTS(SELECT 1 FROM email_accounts WHERE user_id = $1 AND email = $2)`
if err := r.DB.QueryRow(ctx, query, userID, email).Scan(&exists); err != nil {
db.CaptureError(err, query, []any{userID, email}, "queryrow")
return false, errx.InternalError()
}
return exists, nil
}
func (r *emailRepository) ListWarmupScheduleCandidates(ctx context.Context, limit int) ([]uuid.UUID, error) {
query := `
SELECT ea.id
FROM email_accounts ea
WHERE ea.status = 'active'
AND ea.worker_id IS NOT NULL
AND (
(ea.warmup IS NOT NULL AND ea.warmup_paused_at IS NULL)
OR EXISTS (
SELECT 1
FROM email_tags et
JOIN campaign_email_tags cet ON cet.tag_id = et.tag_id
JOIN campaigns c ON c.id = cet.campaign_id
WHERE et.email_id = ea.id AND c.status = 'active'
)
)
AND NOT EXISTS (
SELECT 1 FROM tasks t
WHERE t.email_account_id = ea.id
AND t.task_type = 'warmup'
AND t.status = 'pending'
)
LIMIT $1`
rows, err := r.DB.Query(ctx, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (r *emailRepository) ListActiveWorkerAccounts(ctx context.Context) ([]uuid.UUID, error) {
const query = `SELECT id FROM email_accounts WHERE status = 'active'`
rows, err := r.DB.Query(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (r *emailRepository) ListActiveAccountsByWorker(ctx context.Context, workerID uuid.UUID) ([]uuid.UUID, error) {
const query = `SELECT id FROM email_accounts WHERE status = 'active' AND worker_id = $1`
rows, err := r.DB.Query(ctx, query, workerID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (r *emailRepository) CountForOrganization(ctx context.Context, orgID uuid.UUID) (int, *errx.Error) {
var count int
query := `SELECT COUNT(*) FROM email_accounts WHERE organization_id = $1`
if err := r.DB.QueryRow(ctx, query, orgID).Scan(&count); err != nil {
db.CaptureError(err, query, []any{orgID}, "queryrow")
return 0, errx.InternalError()
}
return count, nil
}
func (r *emailRepository) NewOauthAccount(ctx context.Context, userID string, data models.NewOauthAccount) (*models.Email, *errx.Error) {
if data.Provider == models.InboxProviderSMTPIMAP {
sentry.CaptureException(errors.New("invalid inbox provider"))
return nil, errx.InternalError()
}
// Seal before opening the transaction so a misconfigured encrypter aborts
// the connect instead of writing provider tokens in the clear.
encAccessToken, encErr := r.sealCredential(data.AccessToken)
if encErr != nil {
db.CaptureError(encErr, "", nil, "encrypt-access-token")
return nil, errx.InternalError()
}
encRefreshToken, encErr := r.sealCredential(data.RefreshToken)
if encErr != nil {
db.CaptureError(encErr, "", nil, "encrypt-refresh-token")
return nil, errx.InternalError()
}
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
}
defer tx.Rollback(ctx)
sigplain := utils.GetSignaturePlain(data.Name)
sightml := utils.GetSignatureHTML(data.Name)
t := time.Now()
id := uuid.New()
// warmup_tag is the content segment (defaults to '' = generic). It used to
// be seeded with a random RID, which silently broke segment-aware content
// selection because a random tag never matches a real segment.
query := `
INSERT INTO email_accounts (id, user_id, organization_id, email, name, provider, signature_plain, signature_html, tracking_domain, last_synced_at, created_at, updated_at, warmup_tag)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10, $10, $11)
`
params := []any{
id,
userID,
data.OrganizationID,
data.Email,
data.Name,
data.Provider,
sigplain,
sightml,
"",
t,
"",
}
_, err = tx.Exec(
ctx,
query,
params...,
)
if err != nil {
db.CaptureError(err, query, nil, "queryrow")
return nil, errx.InternalError()
}
query = `
INSERT INTO email_accounts_oauth (email_account_id, access_token, refresh_token, expires_at)
VALUES ($1, $2, $3, $4)
`
params = []any{
id,
encAccessToken,
encRefreshToken,
data.ExpiresAt,
}
_, err = tx.Exec(
ctx,
query,
params...,
)
if err != nil {
// params holds the sealed tokens; keep them out of the error report.
db.CaptureError(err, query, nil, "exec")
return nil, errx.InternalError()
}
if err := tx.Commit(ctx); err != nil {
db.CaptureError(err, "", nil, "commit")
return nil, errx.InternalError()
}
return &models.Email{
ID: id,
UserID: userID,
OrganizationID: data.OrganizationID,
Email: data.Email,
Name: data.Name,
SignaturePlain: sigplain,
SignatureHTML: sightml,
SignatureSync: true,
SignatureCode: false,
Provider: string(data.Provider),
Status: "active",
LastSyncedAt: t,
CampaignLimit: config.CampaignLimitDefault,
MinWaitTime: config.MinWaitTimeDefault,
WarmupBase: config.WarmupBaseDefault,
WarmupMax: config.WarmupMaxDefault,
WarmupIncrease: config.WarmupIncreaseDefault,
WarmupStartTime: "08:00",
WarmupEndTime: "20:00",
WarmupDays: 0,
CreatedAt: t,
UpdatedAt: t,
}, nil
}
func (r *emailRepository) NewSMTPIMAPAccount(ctx context.Context, userID string, data models.NewSMTPIMAPAccount) (*models.Email, *errx.Error) {
if r.Encrypt == nil {
sentry.CaptureException(errNoCredentialEncrypter)
return nil, errx.InternalError()
}
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
}
defer tx.Rollback(ctx)
sigplain := utils.GetSignaturePlain(data.Name)
sightml := utils.GetSignatureHTML(data.Name)
id := uuid.New()
t := time.Now()
query := `
INSERT INTO email_accounts (id, user_id, organization_id, email, name, provider, signature_plain, signature_html, tracking_domain, last_synced_at, updated_at, created_at, warmup_tag)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10, $10, $11)
`
params := []any{
id,
userID,
data.OrganizationID,
data.Email,
data.Name,
"smtp_imap",
sigplain,
sightml,
"",
t,
"",
}
_, err = tx.Exec(
ctx,
query,
params...,
)
if err != nil {
db.CaptureError(err, query, nil, "exec")
return nil, errx.InternalError()
}
smtphost, err := r.Encrypt.Encrypt(data.SMTP.Host)
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
smtpuser, err := r.Encrypt.Encrypt(data.SMTP.Username)
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
smtppass, err := r.Encrypt.Encrypt(data.SMTP.Password)
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
imaphost, err := r.Encrypt.Encrypt(data.IMAP.Host)
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
imapuser, err := r.Encrypt.Encrypt(data.IMAP.Username)
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
imappass, err := r.Encrypt.Encrypt(data.IMAP.Password)
if err != nil {
sentry.CaptureException(err)
return nil, errx.InternalError()
}
query = `
INSERT INTO email_accounts_smtp_imap (
email_account_id,
smtp_host, smtp_port, smtp_user, smtp_password,
imap_host, imap_port, imap_user, imap_password
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9)
`
params = []any{
id, smtphost, data.SMTP.Port, smtpuser, smtppass,
imaphost, data.IMAP.Port, imapuser, imappass,
}
_, err = tx.Exec(
ctx,
query,
params...,
)
if err != nil {
db.CaptureError(err, query, nil, "exec")
return nil, errx.InternalError()
}
if err := tx.Commit(ctx); err != nil {
db.CaptureError(err, "", nil, "commit")
return nil, errx.InternalError()
}
return &models.Email{
ID: id,
UserID: userID,
OrganizationID: data.OrganizationID,
Email: data.Email,
Name: data.Name,
SignaturePlain: sigplain,
SignatureHTML: sightml,
SignatureSync: true,
SignatureCode: false,
Provider: "smtp_imap",
Status: "active",
LastSyncedAt: t,
CampaignLimit: config.CampaignLimitDefault,
MinWaitTime: config.MinWaitTimeDefault,
WarmupBase: config.WarmupBaseDefault,
WarmupMax: config.WarmupMaxDefault,
WarmupIncrease: config.WarmupIncreaseDefault,
WarmupStartTime: "08:00",
WarmupEndTime: "20:00",
WarmupDays: 0,
CreatedAt: t,
UpdatedAt: t,
}, nil
}
func (r *emailRepository) Search(ctx context.Context, orgID, search string, cursor, tag *string, limit int32, allowedAccountIDs []uuid.UUID) (*models.EmailsResult, *errx.Error) {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
}
// Read-only transaction — Commit is fine but Rollback at end is the
// safety net. Pool only has 4 connections; a single leaked tx here
// (under load) is enough to deadlock the whole backend, including
// /auth/refresh which blocks waiting for a connection.
defer tx.Rollback(ctx)
query := `
SELECT
ea.id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code,
ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at,
ea.auth_state, ea.auth_spf, ea.auth_dkim, ea.auth_dmarc, ea.auth_dmarc_policy, ea.auth_reason, ea.auth_checked_at, ea.auth_failing_since,
ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.save_to_sent,
ea.created_at, ea.updated_at,
COALESCE(
array_agg(eat.tag_id) FILTER (WHERE eat.tag_id IS NOT NULL), '{}'
) AS tags
FROM email_accounts ea
LEFT JOIN email_tags eat ON eat.email_id = ea.id
WHERE ea.organization_id = $1
AND ($2::uuid IS NULL OR (ea.created_at, ea.id) < (
SELECT created_at, id
FROM email_accounts
WHERE id = $2
))
AND (ea.name ILIKE $3 OR ea.email ILIKE $3)
AND ($4::uuid IS NULL OR EXISTS (
SELECT 1 FROM email_tags cf WHERE cf.email_id = ea.id AND cf.tag_id = $4
))
AND ($6::uuid[] IS NULL OR ea.id = ANY($6::uuid[]))
GROUP BY ea.id
ORDER BY ea.created_at DESC, ea.id DESC
LIMIT $5
`
var allowedAccountParam any
if len(allowedAccountIDs) > 0 {
allowedAccountParam = allowedAccountIDs
}
params := []any{
orgID,
cursor,
"%" + search + "%",
tag,
limit + 1,
allowedAccountParam,
}
rows, err := tx.Query(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "query")
return nil, errx.InternalError()
}
defer rows.Close()
inboxes := make([]models.Email, 0)
for rows.Next() {
var i models.Email
err := rows.Scan(
&i.ID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode, &i.Provider, &i.Status,
&i.LastSyncedAt, &i.LastID, &i.CampaignLimit, &i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt,
&i.AuthState, &i.AuthSPF, &i.AuthDKIM, &i.AuthDMARC, &i.AuthDMARCPolicy, &i.AuthReason, &i.AuthCheckedAt, &i.AuthFailingSince,
&i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, &i.WarmupMax, &i.WarmupIncrease,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.SaveToSent,
&i.CreatedAt, &i.UpdatedAt, &i.Tags,
)
if err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
inboxes = append(inboxes, i)
}
var total *int64
var nextCursor *string
var hasMore bool
if len(inboxes) > int(limit) {
hasMore = true
nextCursor = paging.EncodeUUID(inboxes[limit].ID)
inboxes = inboxes[:limit]
}
if cursor == nil {
query = `
SELECT COUNT(DISTINCT ea.id)
FROM email_accounts ea
LEFT JOIN email_tags et ON et.email_id = ea.id
WHERE ea.organization_id = $1
AND (ea.name ILIKE $2 OR ea.email ILIKE $2)
AND ($3::uuid IS NULL OR EXISTS (
SELECT 1 FROM email_tags cf WHERE cf.email_id = ea.id AND cf.tag_id = $3
))
AND ($4::uuid[] IS NULL OR ea.id = ANY($4::uuid[]))
`
params = []any{
orgID,
"%" + search + "%",
tag,
allowedAccountParam,
}
var tmp int64
err := tx.QueryRow(
ctx,
query,
params...,
).Scan(&tmp)
if err != nil {
db.CaptureError(err, query, params, "queryrow")
return nil, errx.InternalError()
}
total = &tmp
}
return &models.EmailsResult{
Data: inboxes,
Pagination: models.Pagination{
Total: total,
NextCursor: nextCursor,
HasMore: hasMore,
},
}, nil
}
func (r *emailRepository) Get(ctx context.Context, orgID, emailAccountID string) (*models.Email, *errx.Error) {
query := `
SELECT
ea.id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code,
ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at,
ea.auth_state, ea.auth_spf, ea.auth_dkim, ea.auth_dmarc, ea.auth_dmarc_policy, ea.auth_reason, ea.auth_checked_at, ea.auth_failing_since,
ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.save_to_sent,
ea.created_at, ea.updated_at,
COALESCE(array_agg(eat.tag_id) FILTER (WHERE eat.tag_id IS NOT NULL), '{}') AS tags
FROM email_accounts ea
LEFT JOIN email_tags eat ON eat.email_id = ea.id
WHERE ea.organization_id = $1 AND ea.id = $2
GROUP BY ea.id
`
params := []any{
orgID,
emailAccountID,
}
var i models.Email
err := r.DB.QueryRow(
ctx,
query,
params...,
).Scan(
&i.ID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode, &i.Provider, &i.Status,
&i.LastSyncedAt, &i.LastID, &i.CampaignLimit, &i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt,
&i.AuthState, &i.AuthSPF, &i.AuthDKIM, &i.AuthDMARC, &i.AuthDMARCPolicy, &i.AuthReason, &i.AuthCheckedAt, &i.AuthFailingSince,
&i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, &i.WarmupMax, &i.WarmupIncrease,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.SaveToSent,
&i.CreatedAt, &i.UpdatedAt, &i.Tags,
)
if err != nil {
// A mailbox that does not exist, or belongs to another organization, is
// a 404 — not a server error. Both cases land here as no-rows because
// the query is scoped by organization_id, which is also what keeps the
// tenancy boundary from leaking a "wrong org" signal.
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, query, params, "queryrow")
return nil, errx.InternalError()
}
return &i, nil
}
func (r *emailRepository) Update(ctx context.Context, userID, emailAccountID string, udata *models.UpdateEmail) (*models.Email, *errx.Error) {
setClauses := []string{}
args := []any{userID, emailAccountID}
argPos := 3
if udata.Name != nil {
if !validate.EmailName(udata.Name) {
return nil, errx.ErrEmailName
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "name", argPos))
args = append(args, *udata.Name)
argPos++
}
if udata.SignaturePlain != nil {
l := len(*udata.SignaturePlain)
if l > 1000 {
return nil, errx.ErrEmailSignaturePlain
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "signature_plain", argPos))
args = append(args, *udata.SignaturePlain)
argPos++
}
if udata.SignatureHTML != nil {
l := len(*udata.SignatureHTML)
if l > 1000 {
return nil, errx.ErrEmailSignatureHTML
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "signature_html", argPos))
args = append(args, *udata.SignatureHTML)
argPos++
}
if udata.SignatureSync != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "signature_sync", argPos))
args = append(args, *udata.SignatureSync)
argPos++
}
if udata.Timezone != nil {
tz := strings.TrimSpace(*udata.Timezone)
if verr := validate.EmailTimezone(tz); verr != nil {
return nil, verr
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "timezone", argPos))
args = append(args, tz)
argPos++
}
if udata.SaveToSent != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "save_to_sent", argPos))
args = append(args, *udata.SaveToSent)
argPos++
}
if udata.SignatureCode != nil {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "signature_code", argPos))
args = append(args, *udata.SignatureCode)
argPos++
}
if udata.Status != nil {
// Validate status - must be one of: active, inactive, revoked
status := *udata.Status
if status != "active" && status != "inactive" && status != "revoked" {
return nil, errx.ErrInvalid
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "status", argPos))
args = append(args, status)
argPos++
if status == "active" {
// Re-derive the verdict, which the sweep froze while inactive.
// auth_failing_since is deliberately NOT cleared: that would make
// deactivate-then-reactivate an indefinite way to restart the grace
// window on a domain that is still broken.
setClauses = append(setClauses, "auth_checked_at = NULL")
}
}
if udata.CampaignLimit != nil {
if *udata.CampaignLimit < 0 || *udata.CampaignLimit > 100 {
return nil, errx.ErrEmailCampaignLimit
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "campaign_limit", argPos))
args = append(args, *udata.CampaignLimit)
argPos++
}
if udata.MinWaitTime != nil {
if *udata.MinWaitTime < 0 || *udata.MinWaitTime > 86400 {
return nil, errx.ErrEmailMinWaitTime
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "min_wait_time", argPos))
args = append(args, *udata.MinWaitTime)
argPos++
}
if udata.ReplyTo != nil {
*udata.ReplyTo = strings.TrimSpace(*udata.ReplyTo)
if *udata.ReplyTo != "" && !validate.Email(*udata.ReplyTo) {
return nil, errx.ErrEmail
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "reply_to", argPos))
args = append(args, *udata.ReplyTo)
argPos++
}
// Cross-field guard: starting volume must not exceed the ceiling. Only
// enforced when both arrive together (the warmup form always sends both);
// each is still independently clamped below.
if udata.WarmupBase != nil && udata.WarmupMax != nil && *udata.WarmupBase > *udata.WarmupMax {
return nil, errx.ErrEmailWarmupBase
}
if udata.Warmup != nil {
var warmupTime *time.Time
if *udata.Warmup {
t := time.Now()
warmupTime = &t
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup", argPos))
args = append(args, warmupTime)
argPos++
// A direct warmup on/off via PATCH always clears the pause marker so
// state stays coherent (enable = fresh ramp, disable = off). Pause and
// resume that preserve ramp progress go through the lifecycle endpoints.
setClauses = append(setClauses, "warmup_paused_at = NULL")
}
if udata.WarmupBase != nil {
if *udata.WarmupBase < 0 || *udata.WarmupBase > 100 {
return nil, errx.ErrEmailWarmupBase
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_base", argPos))
args = append(args, *udata.WarmupBase)
argPos++
}
if udata.WarmupMax != nil {
if *udata.WarmupMax < 0 || *udata.WarmupMax > 100 {
return nil, errx.ErrEmailWarmupMax
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_max", argPos))
args = append(args, *udata.WarmupMax)
argPos++
}
if udata.WarmupIncrease != nil {
if *udata.WarmupIncrease < 0 || *udata.WarmupIncrease > 100 {
return nil, errx.ErrEmailWarmupIncrease
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_increase", argPos))
args = append(args, *udata.WarmupIncrease)
argPos++
}
if udata.WarmupReplyRate != nil {
if *udata.WarmupReplyRate < 0 || *udata.WarmupReplyRate > 100 {
return nil, errx.ErrEmailReplyRate
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_reply_rate", argPos))
args = append(args, *udata.WarmupReplyRate)
argPos++
}
if udata.WarmupTag != nil {
// warmup_tag is the content segment: a lowercase slug (e.g. "saas",
// "agency") that the segment-aware AI content bank is keyed on. Empty
// = generic content. Reject anything that isn't a simple slug so it
// can't smuggle arbitrary text into the content-selection path.
seg := strings.ToLower(strings.TrimSpace(*udata.WarmupTag))
if len(seg) > 32 {
return nil, errx.ErrInvalid
}
for _, r := range seg {
if !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-') {
return nil, errx.ErrInvalid
}
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_tag", argPos))
args = append(args, seg)
argPos++
}
if udata.WarmupStartTime != nil {
if err := validate.CampaignTime(*udata.WarmupStartTime); err != nil {
return nil, err
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_start_time", argPos))
args = append(args, *udata.WarmupStartTime)
argPos++
}
if udata.WarmupEndTime != nil {
if err := validate.CampaignTime(*udata.WarmupEndTime); err != nil {
return nil, err
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_end_time", argPos))
args = append(args, *udata.WarmupEndTime)
argPos++
}
if udata.WarmupDays != nil {
if *udata.WarmupDays < 0 || *udata.WarmupDays > 127 {
return nil, errx.ErrInvalid
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", "warmup_days", argPos))
args = append(args, *udata.WarmupDays)
argPos++
}
if argPos == 3 {
return nil, errx.ErrNotEnough
}
setClauses = append(setClauses, "updated_at = now()")
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return nil, errx.InternalError()
}
defer tx.Rollback(ctx)
query := fmt.Sprintf(`
UPDATE email_accounts
SET %s
WHERE user_id = $1 AND id = $2
RETURNING id, organization_id, email, name, signature_plain, signature_html, signature_sync, signature_code, provider, status,
COALESCE(last_synced_at, created_at) AS last_synced_at, last_id, campaign_limit, min_wait_time, reply_to, tracking_domain, tracking_domain_verified, tracking_domain_verified_at,
auth_state, auth_spf, auth_dkim, auth_dmarc, auth_dmarc_policy, auth_reason, auth_checked_at, auth_failing_since,
warmup, warmup_paused_at, warmup_base, warmup_max, warmup_increase, warmup_reply_rate, warmup_tag, warmup_pool_type,
warmup_start_time, warmup_end_time, warmup_days, save_to_sent, created_at, updated_at
`, strings.Join(setClauses, ", "))
var i models.Email
err = tx.QueryRow(ctx, query, args...).Scan(
&i.ID, &i.OrganizationID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode, &i.Provider, &i.Status,
&i.LastSyncedAt, &i.LastID, &i.CampaignLimit, &i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt,
// The client replaces its whole cached mailbox with this row, so an
// incomplete object here silently blanks the domain-auth state in the
// dashboard on every unrelated edit.
&i.AuthState, &i.AuthSPF, &i.AuthDKIM, &i.AuthDMARC, &i.AuthDMARCPolicy, &i.AuthReason, &i.AuthCheckedAt, &i.AuthFailingSince,
&i.Warmup, &i.WarmupPausedAt, &i.WarmupBase, &i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, &i.WarmupPoolType,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.SaveToSent,
&i.CreatedAt, &i.UpdatedAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, query, args, "queryrow")
return nil, errx.InternalError()
}
i.Tags = make([]string, 0)
if udata.Tags != nil {
var err *errx.Error
i.Tags, err = SyncEmailTags(ctx, tx, emailAccountID, udata.Tags)
if err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
db.CaptureError(err, "", nil, "commit")
return nil, errx.InternalError()
}
return &i, nil
}
func (r *emailRepository) BulkUpdateTags(ctx context.Context, userID string, emailIDs, addTags, removeTags []uuid.UUID) (int, *errx.Error) {
tx, err := r.DB.Begin(ctx)
if err != nil {
db.CaptureError(err, "", nil, "begin")
return 0, errx.InternalError()
}
defer tx.Rollback(ctx)
var owned int
countQuery := `SELECT count(*) FROM email_accounts WHERE user_id = $1 AND id = ANY($2)`
if err := tx.QueryRow(ctx, countQuery, userID, emailIDs).Scan(&owned); err != nil {
db.CaptureError(err, countQuery, []any{userID}, "queryrow")
return 0, errx.InternalError()
}
if len(addTags) > 0 {
// Cross join owned mailboxes with the caller's own tag definitions;
// the composite PK makes re-adding an existing link a no-op.
insertQuery := `
INSERT INTO email_tags (email_id, tag_id)
SELECT a.id, t.id
FROM email_accounts a
CROSS JOIN tags t
WHERE a.user_id = $1 AND a.id = ANY($2)
AND t.user_id = $1 AND t.id = ANY($3)
ON CONFLICT (email_id, tag_id) DO NOTHING`
if _, err := tx.Exec(ctx, insertQuery, userID, emailIDs, addTags); err != nil {
db.CaptureError(err, insertQuery, []any{userID}, "exec")
return 0, errx.InternalError()
}
}
if len(removeTags) > 0 {
deleteQuery := `
DELETE FROM email_tags
WHERE tag_id = ANY($3)
AND email_id IN (SELECT id FROM email_accounts WHERE user_id = $1 AND id = ANY($2))`
if _, err := tx.Exec(ctx, deleteQuery, userID, emailIDs, removeTags); err != nil {
db.CaptureError(err, deleteQuery, []any{userID}, "exec")
return 0, errx.InternalError()
}
}
if err := tx.Commit(ctx); err != nil {
db.CaptureError(err, "", nil, "commit")
return 0, errx.InternalError()
}
return owned, nil
}
// UpdateTrackingDomain writes the tracking domain and its verdict. Scoped by
// organization, like Get and Search: a mailbox is a workspace asset, and the
// route already admits any member holding manage_emails, so filtering on the
// user who happened to connect it turned that permission into a 404.
func (r *emailRepository) UpdateTrackingDomain(ctx context.Context, orgID, emailAccountID, domain string, verified bool, verifiedAt *time.Time) *errx.Error {
query := `
UPDATE email_accounts
SET tracking_domain = $1, tracking_domain_verified = $2, tracking_domain_verified_at = $3
WHERE organization_id = $4 AND id = $5
`
params := []any{
domain,
verified,
verifiedAt,
orgID,
emailAccountID,
}
cmd, err := r.DB.Exec(
ctx,
query,
params...,
)
if err != nil {
db.CaptureError(err, query, params, "exec")
return errx.InternalError()
}
if cmd.RowsAffected() == 0 {
return errx.ErrNotFound
}
return nil
}
// ListTrackingDomainCheckDue picks the mailboxes the tracking-domain sweep
// re-resolves next. Unverified ones sort first (their verified_at is NULL), so
// a record that has just propagated is picked up on the next pass without
// anybody pressing anything.
func (r *emailRepository) ListTrackingDomainCheckDue(ctx context.Context, staleBefore time.Time, limit int) ([]models.TrackingDomainTarget, *errx.Error) {
query := `
SELECT id, tracking_domain, tracking_domain_verified
FROM email_accounts
WHERE status = 'active'
AND COALESCE(tracking_domain, '') <> ''
AND (tracking_domain_verified_at IS NULL OR tracking_domain_verified_at < $1)
ORDER BY tracking_domain_verified_at ASC NULLS FIRST
LIMIT $2
`
params := []any{staleBefore, limit}
rows, err := r.DB.Query(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "query")
return nil, errx.InternalError()
}
defer rows.Close()
out := []models.TrackingDomainTarget{}
for rows.Next() {
var t models.TrackingDomainTarget
if err := rows.Scan(&t.ID, &t.Domain, &t.Verified); err != nil {
db.CaptureError(err, query, params, "scan")
return nil, errx.InternalError()
}
out = append(out, t)
}
if err := rows.Err(); err != nil {
db.CaptureError(err, query, params, "rows")
return nil, errx.InternalError()
}
return out, nil
}
func (r *emailRepository) SetTrackingDomainVerified(ctx context.Context, emailAccountID uuid.UUID, verified bool, verifiedAt *time.Time) *errx.Error {
query := `
UPDATE email_accounts
SET tracking_domain_verified = $1, tracking_domain_verified_at = $2
WHERE id = $3
`
params := []any{verified, verifiedAt, emailAccountID}
if _, err := r.DB.Exec(ctx, query, params...); err != nil {
db.CaptureError(err, query, params, "exec")
return errx.InternalError()
}
return nil
}
func (r *emailRepository) ListAuthCheckDue(ctx context.Context, staleBefore time.Time, limit int) ([]models.EmailAuthTarget, *errx.Error) {
query := `
SELECT ea.id, ea.email
FROM email_accounts ea
WHERE ea.status = 'active'
AND (ea.auth_checked_at IS NULL OR ea.auth_checked_at < $1)
ORDER BY ea.auth_checked_at ASC NULLS FIRST
LIMIT $2
`
rows, err := r.DB.Query(ctx, query, staleBefore, limit)
if err != nil {
db.CaptureError(err, query, []any{staleBefore, limit}, "query")
return nil, errx.InternalError()
}
defer rows.Close()
targets := make([]models.EmailAuthTarget, 0)
for rows.Next() {
var t models.EmailAuthTarget
if err := rows.Scan(&t.ID, &t.Email); err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
targets = append(targets, t)
}
if err := rows.Err(); err != nil {
db.CaptureError(err, "", nil, "rows")
return nil, errx.InternalError()
}
return targets, nil
}
func (r *emailRepository) UpdateDomainAuthState(ctx context.Context, domain, state string, spf, dkim, dmarc bool, dmarcPolicy, reason string, checkedAt time.Time) ([]models.EmailAuthTransition, *errx.Error) {
// Only 'passing' clears the grace clock; 'unknown' preserves it so a domain
// cannot flap through a transient DNS error to escape the gate.
// `before` keys on auth_failing_since, not auth_state, or that same flap
// would re-report every mailbox on the domain as newly failing.
query := `
WITH before AS (
SELECT id
FROM email_accounts
WHERE status = 'active'
AND lower(split_part(email, '@', 2)) = $8
AND auth_failing_since IS NOT NULL
),
updated AS (
UPDATE email_accounts
SET auth_state = $1, auth_spf = $2, auth_dkim = $3, auth_dmarc = $4,
auth_dmarc_policy = $5, auth_reason = $6, auth_checked_at = $7,
auth_failing_since = CASE
WHEN $1 = 'passing' THEN NULL
WHEN $1 = 'failing' AND auth_failing_since IS NOT NULL THEN auth_failing_since
WHEN $1 = 'failing' THEN $7
ELSE auth_failing_since
END
WHERE status = 'active' AND lower(split_part(email, '@', 2)) = $8
RETURNING id, email, organization_id, auth_state
)
SELECT u.id, u.email, u.organization_id
FROM updated u
WHERE u.auth_state = 'failing'
AND NOT EXISTS (SELECT 1 FROM before b WHERE b.id = u.id)
`
params := []any{
state,
spf,
dkim,
dmarc,
dmarcPolicy,
reason,
checkedAt,
strings.ToLower(strings.TrimSpace(domain)),
}
rows, err := r.DB.Query(ctx, query, params...)
if err != nil {
db.CaptureError(err, query, params, "query")
return nil, errx.InternalError()
}
defer rows.Close()
transitions := make([]models.EmailAuthTransition, 0)
for rows.Next() {
var t models.EmailAuthTransition
if serr := rows.Scan(&t.ID, &t.Email, &t.OrganizationID); serr != nil {
db.CaptureError(serr, "", nil, "scan")
return nil, errx.InternalError()
}
transitions = append(transitions, t)
}
if rerr := rows.Err(); rerr != nil {
db.CaptureError(rerr, "", nil, "rows")
return nil, errx.InternalError()
}
return transitions, nil
}
func (r *emailRepository) Delete(ctx context.Context, userID, emailAccountID string) *errx.Error {
query := `
DELETE FROM email_accounts
WHERE user_id = $1 AND id = $2
`
params := []any{
userID,
emailAccountID,
}
cmd, err := r.DB.Exec(
ctx,
query,
params...,
)
if err != nil {
db.CaptureError(err, query, params, "exec")
return errx.InternalError()
}
if cmd.RowsAffected() == 0 {
return errx.ErrNotFound
}
return nil
}
// GetByID retrieves an email account by ID without requiring userID (for internal service use)
// SetWarmupLifecycle applies a warmup lifecycle transition. The CASE math
// keeps every transition atomic and idempotent:
//
// - start/resume: a fresh mailbox (warmup IS NULL) anchors at now(); a
// paused mailbox shifts its anchor forward by the paused duration so the
// ramp continues where it left off; an already-active mailbox is a no-op.
// - pause: stamps warmup_paused_at only while actively warming.
// - disable: clears warmup entirely (next restart begins a fresh ramp).
func (r *emailRepository) SetWarmupLifecycle(ctx context.Context, userID, emailAccountID, action string) (*models.Email, *errx.Error) {
var setClause string
switch action {
case "start", "resume":
setClause = `warmup = CASE
WHEN warmup IS NULL THEN now()
WHEN warmup_paused_at IS NOT NULL THEN warmup + (now() - warmup_paused_at)
ELSE warmup
END,
warmup_paused_at = NULL`
case "pause":
setClause = `warmup_paused_at = CASE
WHEN warmup IS NOT NULL AND warmup_paused_at IS NULL THEN now()
ELSE warmup_paused_at
END`
case "disable", "stop":
setClause = `warmup = NULL, warmup_paused_at = NULL`
default:
return nil, errx.ErrInvalid
}
query := fmt.Sprintf(`
UPDATE email_accounts
SET %s, updated_at = now()
WHERE user_id = $1 AND id = $2
`, setClause)
tag, err := r.DB.Exec(ctx, query, userID, emailAccountID)
if err != nil {
db.CaptureError(err, query, []any{userID, emailAccountID}, "exec")
return nil, errx.InternalError()
}
if tag.RowsAffected() == 0 {
return nil, errx.ErrNotFound
}
return r.Get(ctx, userID, emailAccountID)
}
func (r *emailRepository) GetByID(ctx context.Context, emailAccountID uuid.UUID) (*models.Email, *errx.Error) {
query := `
SELECT
ea.id, ea.user_id, ea.organization_id, ea.worker_id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code,
ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag, ea.warmup_pool_type,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone, ea.save_to_sent,
ea.auth_state, ea.auth_failing_since,
ea.created_at, ea.updated_at,
COALESCE(array_agg(eat.tag_id) FILTER (WHERE eat.tag_id IS NOT NULL), '{}') AS tags
FROM email_accounts ea
LEFT JOIN email_tags eat ON eat.email_id = ea.id
WHERE ea.id = $1
GROUP BY ea.id
`
var i models.Email
err := r.DB.QueryRow(ctx, query, emailAccountID).Scan(
&i.ID, &i.UserID, &i.OrganizationID, &i.WorkerID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode,
&i.Provider, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit,
&i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase,
&i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag, &i.WarmupPoolType,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone, &i.SaveToSent,
&i.AuthState, &i.AuthFailingSince,
&i.CreatedAt, &i.UpdatedAt, &i.Tags,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, query, []any{emailAccountID}, "queryrow")
return nil, errx.InternalError()
}
return &i, nil
}
// GetByTags retrieves the scope's active mailboxes matching any of the tags.
// Tags themselves are owned by a user, not an organization, so a multi-org
// user's tag can span workspaces — the scope predicate is what keeps the
// resolved senders inside one tenant.
func (r *emailRepository) GetByTags(ctx context.Context, scope AccountScope, tags []string) ([]models.Email, *errx.Error) {
orgID, ok := scope.tenant()
if len(tags) == 0 || !ok {
return []models.Email{}, nil
}
query := `
SELECT DISTINCT ON (ea.id)
ea.id, ea.user_id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code,
ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone,
ea.auth_state, ea.auth_failing_since,
ea.created_at, ea.updated_at
FROM email_accounts ea
JOIN email_tags eat ON eat.email_id = ea.id
WHERE ea.organization_id = $1
AND eat.tag_id = ANY($2)
AND ea.status = 'active'
ORDER BY ea.id
`
rows, err := r.DB.Query(ctx, query, orgID, tags)
if err != nil {
db.CaptureError(err, query, []any{orgID, tags}, "query")
return nil, errx.InternalError()
}
defer rows.Close()
var emails []models.Email
for rows.Next() {
var i models.Email
err := rows.Scan(
&i.ID, &i.UserID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode,
&i.Provider, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit,
&i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase,
&i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone,
&i.AuthState, &i.AuthFailingSince,
&i.CreatedAt, &i.UpdatedAt,
)
if err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
i.Tags = []string{} // Tags not fetched in this query
emails = append(emails, i)
}
return emails, nil
}
// GetAllActiveInScope returns every active mailbox in the scope (the "all"
// sender pool). Same projection as GetByTags, without the tag join.
func (r *emailRepository) GetAllActiveInScope(ctx context.Context, scope AccountScope) ([]models.Email, *errx.Error) {
orgID, ok := scope.tenant()
if !ok {
return []models.Email{}, nil
}
query := `
SELECT
ea.id, ea.user_id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code,
ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone,
ea.auth_state, ea.auth_failing_since,
ea.created_at, ea.updated_at
FROM email_accounts ea
WHERE ea.organization_id = $1
AND ea.status = 'active'
ORDER BY ea.id
`
rows, err := r.DB.Query(ctx, query, orgID)
if err != nil {
db.CaptureError(err, query, []any{orgID}, "query")
return nil, errx.InternalError()
}
defer rows.Close()
var emails []models.Email
for rows.Next() {
var i models.Email
err := rows.Scan(
&i.ID, &i.UserID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode,
&i.Provider, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit,
&i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase,
&i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone,
&i.AuthState, &i.AuthFailingSince,
&i.CreatedAt, &i.UpdatedAt,
)
if err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
i.Tags = []string{}
emails = append(emails, i)
}
return emails, nil
}
// CampaignSenderAccount pairs an active sender mailbox with its per-campaign
// rotation metadata, so the scheduler's rotation modes (weighted / round_robin
// / least_recently_used) can pick among them without a second query.
type CampaignSenderAccount struct {
Account models.Email
Weight int
RotationPosition int
LastSentAt *time.Time
}
// GetByCampaignSenders mirrors GetByTags but resolves accounts through the
// explicit campaign_senders pool instead of email tags. Only enabled senders
// backing an active mailbox are returned; the per-sender weight/cursor/last-send
// ride along for rotation.
func (r *emailRepository) GetByCampaignSenders(ctx context.Context, scope AccountScope, campaignID uuid.UUID) ([]CampaignSenderAccount, *errx.Error) {
orgID, ok := scope.tenant()
if !ok {
return nil, nil
}
query := `
SELECT
ea.id, ea.user_id, ea.email, ea.name, ea.signature_plain, ea.signature_html, ea.signature_sync, ea.signature_code,
ea.provider, ea.status, COALESCE(ea.last_synced_at, ea.created_at) AS last_synced_at, ea.last_id, ea.campaign_limit,
ea.min_wait_time, ea.reply_to, ea.tracking_domain, ea.tracking_domain_verified, ea.tracking_domain_verified_at, ea.warmup, ea.warmup_paused_at, ea.warmup_base,
ea.warmup_max, ea.warmup_increase, ea.warmup_reply_rate, ea.warmup_tag,
ea.warmup_start_time, ea.warmup_end_time, ea.warmup_days, ea.timezone,
ea.auth_state, ea.auth_failing_since,
ea.created_at, ea.updated_at,
cs.weight, cs.rotation_position, cs.last_sent_at
FROM email_accounts ea
JOIN campaign_senders cs ON cs.email_account_id = ea.id
WHERE cs.campaign_id = $2
AND cs.enabled
AND ea.organization_id = $1
AND ea.status = 'active'
ORDER BY ea.id
`
rows, err := r.DB.Query(ctx, query, orgID, campaignID)
if err != nil {
db.CaptureError(err, query, []any{orgID, campaignID}, "query")
return nil, errx.InternalError()
}
defer rows.Close()
var out []CampaignSenderAccount
for rows.Next() {
var i models.Email
var sender CampaignSenderAccount
err := rows.Scan(
&i.ID, &i.UserID, &i.Email, &i.Name, &i.SignaturePlain, &i.SignatureHTML, &i.SignatureSync, &i.SignatureCode,
&i.Provider, &i.Status, &i.LastSyncedAt, &i.LastID, &i.CampaignLimit,
&i.MinWaitTime, &i.ReplyTo, &i.TrackingDomain, &i.TrackingDomainVerified, &i.TrackingDomainVerifiedAt, &i.Warmup, &i.WarmupPausedAt, &i.WarmupBase,
&i.WarmupMax, &i.WarmupIncrease, &i.WarmupReplyRate, &i.WarmupTag,
&i.WarmupStartTime, &i.WarmupEndTime, &i.WarmupDays, &i.Timezone,
&i.AuthState, &i.AuthFailingSince,
&i.CreatedAt, &i.UpdatedAt,
&sender.Weight, &sender.RotationPosition, &sender.LastSentAt,
)
if err != nil {
db.CaptureError(err, "", nil, "scan")
return nil, errx.InternalError()
}
i.Tags = []string{}
sender.Account = i
out = append(out, sender)
}
if err := rows.Err(); err != nil {
db.CaptureError(err, "", nil, "rows")
return nil, errx.InternalError()
}
return out, nil
}
// GetSMTPCredentials retrieves SMTP/IMAP credentials for an email account
func (r *emailRepository) GetSMTPCredentials(ctx context.Context, emailAccountID uuid.UUID) (*SMTPCredentials, *errx.Error) {
if r.Encrypt == nil {
sentry.CaptureException(errNoCredentialEncrypter)
return nil, errx.InternalError()
}
query := `
SELECT smtp_host, smtp_port, smtp_user, smtp_password,
imap_host, imap_port, imap_user, imap_password
FROM email_accounts_smtp_imap
WHERE email_account_id = $1
`
var creds SMTPCredentials
var smtpHost, smtpUser, smtpPassword, imapHost, imapUser, imapPassword string
err := r.DB.QueryRow(ctx, query, emailAccountID).Scan(
&smtpHost, &creds.SMTPPort, &smtpUser, &smtpPassword,
&imapHost, &creds.IMAPPort, &imapUser, &imapPassword,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, query, []any{emailAccountID}, "queryrow")
return nil, errx.InternalError()
}
// Decrypt credentials
var xerr error
creds.SMTPHost, xerr = r.Encrypt.Decrypt(smtpHost)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
creds.SMTPUser, xerr = r.Encrypt.Decrypt(smtpUser)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
creds.SMTPPassword, xerr = r.Encrypt.Decrypt(smtpPassword)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
creds.IMAPHost, xerr = r.Encrypt.Decrypt(imapHost)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
creds.IMAPUser, xerr = r.Encrypt.Decrypt(imapUser)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
creds.IMAPPassword, xerr = r.Encrypt.Decrypt(imapPassword)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
return &creds, nil
}
// GetOAuthCredentials retrieves OAuth credentials for an email account
func (r *emailRepository) GetOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID) (*OAuthCredentials, *errx.Error) {
if r.Encrypt == nil {
sentry.CaptureException(errNoCredentialEncrypter)
return nil, errx.InternalError()
}
query := `
SELECT access_token, refresh_token, expires_at
FROM email_accounts_oauth
WHERE email_account_id = $1
`
var accessToken, refreshToken string
var expiresAt time.Time
err := r.DB.QueryRow(ctx, query, emailAccountID).Scan(
&accessToken, &refreshToken, &expiresAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, query, []any{emailAccountID}, "queryrow")
return nil, errx.InternalError()
}
// Decrypt tokens, tolerating rows written before OAuth tokens were sealed.
decryptedAccessToken, accessLegacy, xerr := r.openCredential(accessToken)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
decryptedRefreshToken, refreshLegacy, xerr := r.openCredential(refreshToken)
if xerr != nil {
sentry.CaptureException(xerr)
return nil, errx.InternalError()
}
if accessLegacy || refreshLegacy {
r.resealOAuthCredentials(ctx, emailAccountID, decryptedAccessToken, decryptedRefreshToken)
}
return &OAuthCredentials{
AccessToken: decryptedAccessToken,
RefreshToken: decryptedRefreshToken,
ExpiresAt: expiresAt,
}, nil
}
// resealOAuthCredentials rewrites a pre-sealing row in its encrypted form. It
// is the migration path for tokens stored before sealing existed: there is no
// SQL-only migration for them because the key lives in the application, so the
// upgrade happens on first read instead. Best-effort by design, a failure here
// must not break the read that triggered it; the next read retries.
func (r *emailRepository) resealOAuthCredentials(ctx context.Context, emailAccountID uuid.UUID, accessToken, refreshToken string) {
encAccessToken, err := r.sealCredential(accessToken)
if err != nil {
sentry.CaptureException(err)
return
}
encRefreshToken, err := r.sealCredential(refreshToken)
if err != nil {
sentry.CaptureException(err)
return
}
query := `
UPDATE email_accounts_oauth
SET access_token = $1, refresh_token = $2
WHERE email_account_id = $3
`
if _, err := r.DB.Exec(ctx, query, encAccessToken, encRefreshToken, emailAccountID); err != nil {
db.CaptureError(err, query, nil, "reseal-oauth-credentials")
}
}
// GetWorkerID retrieves the worker ID assigned to an email account
func (r *emailRepository) GetWorkerID(ctx context.Context, emailAccountID uuid.UUID) (*uuid.UUID, *errx.Error) {
query := `SELECT worker_id FROM email_accounts WHERE id = $1`
var workerID *uuid.UUID
err := r.DB.QueryRow(ctx, query, emailAccountID).Scan(&workerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errx.ErrNotFound
}
db.CaptureError(err, query, []any{emailAccountID}, "queryrow")
return nil, errx.InternalError()
}
return workerID, nil
}
// SetWorkerID assigns a worker to an email account
func (r *emailRepository) SetWorkerID(ctx context.Context, emailAccountID, workerID uuid.UUID) *errx.Error {
query := `UPDATE email_accounts SET worker_id = $1, updated_at = NOW() WHERE id = $2`
cmd, err := r.DB.Exec(ctx, query, workerID, emailAccountID)
if err != nil {
db.CaptureError(err, query, []any{workerID, emailAccountID}, "exec")
return errx.InternalError()
}
if cmd.RowsAffected() == 0 {
return errx.ErrNotFound
}
return nil
}