Files
warmbly/internal/repository/pg_warmup.go
T

747 lines
22 KiB
Go

package repository
import (
"context"
"database/sql"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/warmbly/warmbly/internal/models"
)
// WarmupPool represents a warmup pool
type WarmupPool struct {
ID uuid.UUID
PoolType string
Name string
Description string
MaxParticipants *int
CreatedAt time.Time
}
// WarmupPoolParticipant represents a participant in a warmup pool
type WarmupPoolParticipant struct {
PoolID uuid.UUID
EmailAccountID uuid.UUID
JoinedAt time.Time
BlockedAt *time.Time
BlockedUntil *time.Time
BlockedReason *string
SpamScore int
HealthState models.WarmupHealthState
LastHealthScore float64
LastHealthReason *string
LastHealthEvaluatedAt *time.Time
}
// SpamReport represents a spam report
type SpamReport struct {
ID uuid.UUID
ReporterAccountID uuid.UUID
ReportedAccountID uuid.UUID
MessageID string
ReportType string
CreatedAt time.Time
}
// WarmupStatistic represents daily warmup statistics
type WarmupStatistic struct {
EmailAccountID uuid.UUID
Date time.Time
EmailsSent int
EmailsReplied int
TargetVolume int
}
// WarmupReplyCandidate describes a previously sent warmup message that can be replied to.
type WarmupReplyCandidate struct {
MessageID string
Subject string
ThreadID *string
}
// WarmupRepository defines methods for warmup data access
type WarmupRepository interface {
// Pool management
GetPoolByType(ctx context.Context, poolType string) (*WarmupPool, error)
GetPoolParticipants(ctx context.Context, poolType string, excludeBlocked bool) ([]uuid.UUID, error)
JoinPool(ctx context.Context, poolID, accountID uuid.UUID) error
LeavePool(ctx context.Context, poolID, accountID uuid.UUID) error
BlockFromPool(ctx context.Context, accountID uuid.UUID, reason string) error
UnblockFromPool(ctx context.Context, accountID uuid.UUID) error
IsInPool(ctx context.Context, accountID uuid.UUID, poolType string) (bool, error)
GetParticipantHealth(ctx context.Context, accountID uuid.UUID, poolType string) (*models.WarmupParticipantHealth, error)
UpdateParticipantHealth(ctx context.Context, accountID uuid.UUID, state models.WarmupHealthState, blockedUntil *time.Time, reason string, score float64) error
CountSpamReportsSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
SumWarmupSentSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
CountDeliverabilityEventsByAccount(ctx context.Context, accountID uuid.UUID, eventType string, since time.Time) (int, error)
CountDeliveredByAccount(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
// Health sweep
GetAllParticipantAccountIDs(ctx context.Context) ([]uuid.UUID, error)
GetPoolHealthCounts(ctx context.Context) (map[string]int, float64, error)
// Spam tracking
RecordSpamReport(ctx context.Context, report *SpamReport) (bool, error)
GetSpamScore(ctx context.Context, accountID uuid.UUID) (int, error)
IncrementSpamScore(ctx context.Context, accountID uuid.UUID, amount int) (int, error)
ResetSpamScore(ctx context.Context, accountID uuid.UUID) error
// Statistics
IncrementDailyCount(ctx context.Context, accountID uuid.UUID, date time.Time) error
GetWarmupStatistics(ctx context.Context, accountID uuid.UUID, from, to time.Time) ([]WarmupStatistic, error)
GetOrCreateDailyStats(ctx context.Context, accountID uuid.UUID, date time.Time, targetVolume int) (*WarmupStatistic, error)
// Warmup token management
CreateWarmupToken(ctx context.Context, token *models.WarmupToken) error
GetWarmupToken(ctx context.Context, tokenID uuid.UUID) (*models.WarmupToken, error)
FindWarmupToken(ctx context.Context, tokenID uuid.UUID) (*models.WarmupToken, error)
ConsumeWarmupToken(ctx context.Context, tokenID uuid.UUID) error
RecordInvalidTokenAttempt(ctx context.Context, accountID uuid.UUID, attemptedToken string) error
CountRecentInvalidAttempts(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error)
// Warmup conversation support
GetRecentlyUsedPartners(ctx context.Context, accountID uuid.UUID, since time.Time) ([]uuid.UUID, error)
GetLatestReplyCandidate(ctx context.Context, senderAccountID, recipientAccountID uuid.UUID) (*WarmupReplyCandidate, error)
}
type warmupRepository struct {
db *pgxpool.Pool
}
// NewWarmupRepository creates a new warmup repository
func NewWarmupRepository(db *pgxpool.Pool) WarmupRepository {
return &warmupRepository{db: db}
}
// GetPoolByType retrieves a pool by type
func (r *warmupRepository) GetPoolByType(ctx context.Context, poolType string) (*WarmupPool, error) {
query := `
SELECT id, pool_type, name, description, max_participants, created_at
FROM warmup_pools
WHERE pool_type = $1
LIMIT 1
`
pool := &WarmupPool{}
err := r.db.QueryRow(ctx, query, poolType).Scan(
&pool.ID,
&pool.PoolType,
&pool.Name,
&pool.Description,
&pool.MaxParticipants,
&pool.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return pool, err
}
// GetPoolParticipants retrieves all participant account IDs from a pool
func (r *warmupRepository) GetPoolParticipants(ctx context.Context, poolType string, excludeBlocked bool) ([]uuid.UUID, error) {
query := `
SELECT wpp.email_account_id
FROM warmup_pool_participants wpp
JOIN warmup_pools wp ON wpp.pool_id = wp.id
WHERE wp.pool_type = $1
`
if excludeBlocked {
query += `
AND (
wpp.health_state IN ('healthy', 'watch', 'throttled')
OR (
wpp.health_state IN ('quarantined', 'blocked')
AND wpp.blocked_until IS NOT NULL
AND wpp.blocked_until <= NOW()
)
)
AND (
wpp.blocked_at IS NULL
OR (wpp.blocked_until IS NOT NULL AND wpp.blocked_until <= NOW())
)
`
}
rows, err := r.db.Query(ctx, query, poolType)
if err != nil {
return nil, err
}
defer rows.Close()
var accountIDs []uuid.UUID
for rows.Next() {
var accountID uuid.UUID
if err := rows.Scan(&accountID); err != nil {
return nil, err
}
accountIDs = append(accountIDs, accountID)
}
return accountIDs, rows.Err()
}
// JoinPool adds an account to a warmup pool
func (r *warmupRepository) JoinPool(ctx context.Context, poolID, accountID uuid.UUID) error {
query := `
INSERT INTO warmup_pool_participants (pool_id, email_account_id, joined_at, spam_score)
VALUES ($1, $2, NOW(), 0)
ON CONFLICT (pool_id, email_account_id) DO UPDATE
SET joined_at = warmup_pool_participants.joined_at
`
_, err := r.db.Exec(ctx, query, poolID, accountID)
return err
}
// LeavePool removes an account from a warmup pool
func (r *warmupRepository) LeavePool(ctx context.Context, poolID, accountID uuid.UUID) error {
query := `
DELETE FROM warmup_pool_participants
WHERE pool_id = $1 AND email_account_id = $2
`
_, err := r.db.Exec(ctx, query, poolID, accountID)
return err
}
// BlockFromPool blocks an account from all warmup pools
func (r *warmupRepository) BlockFromPool(ctx context.Context, accountID uuid.UUID, reason string) error {
query := `
UPDATE warmup_pool_participants
SET blocked_at = NOW(),
blocked_until = NULL,
blocked_reason = $1,
health_state = 'blocked',
last_health_reason = $1,
last_health_evaluated_at = NOW()
WHERE email_account_id = $2
AND blocked_at IS NULL
`
_, err := r.db.Exec(ctx, query, reason, accountID)
return err
}
// UnblockFromPool unblocks an account from all warmup pools
func (r *warmupRepository) UnblockFromPool(ctx context.Context, accountID uuid.UUID) error {
query := `
UPDATE warmup_pool_participants
SET blocked_at = NULL,
blocked_until = NULL,
blocked_reason = NULL,
health_state = 'healthy',
last_health_reason = NULL,
last_health_score = 0,
last_health_evaluated_at = NOW()
WHERE email_account_id = $1
`
_, err := r.db.Exec(ctx, query, accountID)
return err
}
// IsInPool checks if an account is in a specific pool type
func (r *warmupRepository) IsInPool(ctx context.Context, accountID uuid.UUID, poolType string) (bool, error) {
query := `
SELECT EXISTS(
SELECT 1
FROM warmup_pool_participants wpp
JOIN warmup_pools wp ON wpp.pool_id = wp.id
WHERE wpp.email_account_id = $1
AND wp.pool_type = $2
AND (
wpp.health_state IN ('healthy', 'watch')
OR (
wpp.health_state IN ('quarantined', 'blocked')
AND wpp.blocked_until IS NOT NULL
AND wpp.blocked_until <= NOW()
)
)
AND (
wpp.blocked_at IS NULL
OR (wpp.blocked_until IS NOT NULL AND wpp.blocked_until <= NOW())
)
)
`
var exists bool
err := r.db.QueryRow(ctx, query, accountID, poolType).Scan(&exists)
return exists, err
}
// RecordSpamReport records a spam report
func (r *warmupRepository) RecordSpamReport(ctx context.Context, report *SpamReport) (bool, error) {
query := `
INSERT INTO warmup_spam_reports (id, reporter_account_id, reported_account_id, message_id, report_type, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (reporter_account_id, message_id) DO NOTHING
`
cmd, err := r.db.Exec(ctx, query,
report.ID,
report.ReporterAccountID,
report.ReportedAccountID,
report.MessageID,
report.ReportType,
)
if err != nil {
return false, err
}
return cmd.RowsAffected() > 0, nil
}
// GetSpamScore retrieves the spam score for an account
func (r *warmupRepository) GetSpamScore(ctx context.Context, accountID uuid.UUID) (int, error) {
query := `
SELECT COALESCE(SUM(spam_score), 0)
FROM warmup_pool_participants
WHERE email_account_id = $1
`
var score int
err := r.db.QueryRow(ctx, query, accountID).Scan(&score)
return score, err
}
func (r *warmupRepository) GetParticipantHealth(ctx context.Context, accountID uuid.UUID, poolType string) (*models.WarmupParticipantHealth, error) {
query := `
SELECT
wpp.pool_id,
wp.pool_type,
wpp.email_account_id,
wpp.joined_at,
wpp.blocked_at,
wpp.blocked_until,
wpp.blocked_reason,
wpp.spam_score,
wpp.health_state,
wpp.last_health_score,
wpp.last_health_reason,
wpp.last_health_evaluated_at
FROM warmup_pool_participants wpp
JOIN warmup_pools wp ON wp.id = wpp.pool_id
WHERE wpp.email_account_id = $1
AND wp.pool_type = $2
LIMIT 1
`
var out models.WarmupParticipantHealth
var state string
if err := r.db.QueryRow(ctx, query, accountID, poolType).Scan(
&out.PoolID,
&out.PoolType,
&out.EmailAccountID,
&out.JoinedAt,
&out.BlockedAt,
&out.BlockedUntil,
&out.BlockedReason,
&out.SpamScore,
&state,
&out.LastHealthScore,
&out.LastHealthReason,
&out.LastHealthEvaluatedAt,
); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
out.HealthState = models.WarmupHealthState(state)
return &out, nil
}
func (r *warmupRepository) UpdateParticipantHealth(ctx context.Context, accountID uuid.UUID, state models.WarmupHealthState, blockedUntil *time.Time, reason string, score float64) error {
query := `
UPDATE warmup_pool_participants
SET
health_state = $1,
blocked_until = $2,
blocked_at = CASE
WHEN $2 IS NOT NULL AND (blocked_at IS NULL OR blocked_until IS DISTINCT FROM $2) THEN NOW()
WHEN $2 IS NULL AND blocked_until IS NOT NULL THEN NULL
ELSE blocked_at
END,
blocked_reason = CASE
WHEN $2 IS NOT NULL OR $1 = 'blocked' THEN $3
WHEN $1 = 'healthy' THEN NULL
ELSE COALESCE($3, blocked_reason)
END,
last_health_score = $4,
last_health_reason = NULLIF($3, ''),
last_health_evaluated_at = NOW()
WHERE email_account_id = $5
AND NOT (blocked_at IS NOT NULL AND blocked_until IS NULL AND health_state = 'blocked')
`
_, err := r.db.Exec(ctx, query, state, blockedUntil, reason, score, accountID)
return err
}
func (r *warmupRepository) CountSpamReportsSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error) {
query := `
SELECT COUNT(*)
FROM warmup_spam_reports
WHERE reported_account_id = $1
AND created_at >= $2
AND report_type IN ('spam', 'spam_folder')
`
var count int
err := r.db.QueryRow(ctx, query, accountID, since).Scan(&count)
return count, err
}
func (r *warmupRepository) SumWarmupSentSince(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error) {
query := `
SELECT COALESCE(SUM(emails_sent), 0)
FROM warmup_statistics
WHERE email_account_id = $1
AND date >= DATE($2)
`
var total int
err := r.db.QueryRow(ctx, query, accountID, since).Scan(&total)
return total, err
}
// CountDeliverabilityEventsByAccount counts deliverability events (bounce, complaint, etc.)
// for a specific email account by joining through the tasks table.
func (r *warmupRepository) CountDeliverabilityEventsByAccount(ctx context.Context, accountID uuid.UUID, eventType string, since time.Time) (int, error) {
query := `
SELECT COUNT(*)
FROM deliverability_events de
JOIN tasks t ON t.id = de.task_id
WHERE t.email_account_id = $1
AND de.event_type = $2
AND de.created_at >= $3
`
var count int
err := r.db.QueryRow(ctx, query, accountID, eventType, since).Scan(&count)
return count, err
}
// CountDeliveredByAccount counts completed tasks (sent emails) for an account since a given time.
func (r *warmupRepository) CountDeliveredByAccount(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error) {
query := `
SELECT COUNT(*)
FROM tasks
WHERE email_account_id = $1
AND status = 'completed'
AND completed_at >= $2
`
var count int
err := r.db.QueryRow(ctx, query, accountID, since).Scan(&count)
return count, err
}
// IncrementSpamScore increments the spam score for an account
func (r *warmupRepository) IncrementSpamScore(ctx context.Context, accountID uuid.UUID, amount int) (int, error) {
query := `
UPDATE warmup_pool_participants
SET spam_score = spam_score + $1
WHERE email_account_id = $2
RETURNING spam_score
`
var newScore int
err := r.db.QueryRow(ctx, query, amount, accountID).Scan(&newScore)
return newScore, err
}
// ResetSpamScore resets the spam score for an account
func (r *warmupRepository) ResetSpamScore(ctx context.Context, accountID uuid.UUID) error {
query := `
UPDATE warmup_pool_participants
SET spam_score = 0
WHERE email_account_id = $1
`
_, err := r.db.Exec(ctx, query, accountID)
return err
}
// IncrementDailyCount increments the daily email count for warmup
func (r *warmupRepository) IncrementDailyCount(ctx context.Context, accountID uuid.UUID, date time.Time) error {
query := `
INSERT INTO warmup_statistics (email_account_id, date, emails_sent, target_volume)
VALUES ($1, DATE($2), 1, 0)
ON CONFLICT (email_account_id, date)
DO UPDATE SET emails_sent = warmup_statistics.emails_sent + 1
`
_, err := r.db.Exec(ctx, query, accountID, date)
return err
}
// GetWarmupStatistics retrieves warmup statistics for a date range
func (r *warmupRepository) GetWarmupStatistics(ctx context.Context, accountID uuid.UUID, from, to time.Time) ([]WarmupStatistic, error) {
query := `
SELECT email_account_id, date, emails_sent, emails_replied, target_volume
FROM warmup_statistics
WHERE email_account_id = $1
AND date >= DATE($2)
AND date <= DATE($3)
ORDER BY date ASC
`
rows, err := r.db.Query(ctx, query, accountID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []WarmupStatistic
for rows.Next() {
stat := WarmupStatistic{}
err := rows.Scan(
&stat.EmailAccountID,
&stat.Date,
&stat.EmailsSent,
&stat.EmailsReplied,
&stat.TargetVolume,
)
if err != nil {
return nil, err
}
stats = append(stats, stat)
}
return stats, rows.Err()
}
// GetOrCreateDailyStats retrieves or creates daily warmup statistics
func (r *warmupRepository) GetOrCreateDailyStats(ctx context.Context, accountID uuid.UUID, date time.Time, targetVolume int) (*WarmupStatistic, error) {
query := `
INSERT INTO warmup_statistics (email_account_id, date, emails_sent, emails_replied, target_volume)
VALUES ($1, DATE($2), 0, 0, $3)
ON CONFLICT (email_account_id, date)
DO UPDATE SET target_volume = EXCLUDED.target_volume
RETURNING email_account_id, date, emails_sent, emails_replied, target_volume
`
stat := &WarmupStatistic{}
err := r.db.QueryRow(ctx, query, accountID, date, targetVolume).Scan(
&stat.EmailAccountID,
&stat.Date,
&stat.EmailsSent,
&stat.EmailsReplied,
&stat.TargetVolume,
)
return stat, err
}
// CreateWarmupToken creates a warmup verification token
func (r *warmupRepository) CreateWarmupToken(ctx context.Context, token *models.WarmupToken) error {
query := `
INSERT INTO warmup_tokens (token, task_id, sender_account_id, recipient_account_id, expires_at)
VALUES ($1, $2, $3, $4, $5)
`
_, err := r.db.Exec(ctx, query,
token.Token,
token.TaskID,
token.SenderAccountID,
token.RecipientAccountID,
token.ExpiresAt,
)
return err
}
// GetWarmupToken retrieves a valid (unconsumed, unexpired) warmup token
func (r *warmupRepository) GetWarmupToken(ctx context.Context, tokenID uuid.UUID) (*models.WarmupToken, error) {
query := `
SELECT token, task_id, sender_account_id, recipient_account_id, created_at, consumed_at, expires_at
FROM warmup_tokens
WHERE token = $1 AND consumed_at IS NULL AND expires_at > NOW()
`
t := &models.WarmupToken{}
err := r.db.QueryRow(ctx, query, tokenID).Scan(
&t.Token,
&t.TaskID,
&t.SenderAccountID,
&t.RecipientAccountID,
&t.CreatedAt,
&t.ConsumedAt,
&t.ExpiresAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return t, err
}
func (r *warmupRepository) FindWarmupToken(ctx context.Context, tokenID uuid.UUID) (*models.WarmupToken, error) {
query := `
SELECT token, task_id, sender_account_id, recipient_account_id, created_at, consumed_at, expires_at
FROM warmup_tokens
WHERE token = $1
`
t := &models.WarmupToken{}
err := r.db.QueryRow(ctx, query, tokenID).Scan(
&t.Token,
&t.TaskID,
&t.SenderAccountID,
&t.RecipientAccountID,
&t.CreatedAt,
&t.ConsumedAt,
&t.ExpiresAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
return t, err
}
// ConsumeWarmupToken marks a warmup token as consumed
func (r *warmupRepository) ConsumeWarmupToken(ctx context.Context, tokenID uuid.UUID) error {
query := `UPDATE warmup_tokens SET consumed_at = NOW() WHERE token = $1`
_, err := r.db.Exec(ctx, query, tokenID)
return err
}
// RecordInvalidTokenAttempt records an invalid warmup token attempt
func (r *warmupRepository) RecordInvalidTokenAttempt(ctx context.Context, accountID uuid.UUID, attemptedToken string) error {
query := `
INSERT INTO warmup_invalid_token_attempts (email_account_id, attempted_token)
VALUES ($1, $2)
`
_, err := r.db.Exec(ctx, query, accountID, attemptedToken)
return err
}
// CountRecentInvalidAttempts counts invalid token attempts since a given time
func (r *warmupRepository) CountRecentInvalidAttempts(ctx context.Context, accountID uuid.UUID, since time.Time) (int, error) {
query := `
SELECT COUNT(*)
FROM warmup_invalid_token_attempts
WHERE email_account_id = $1 AND created_at > $2
`
var count int
err := r.db.QueryRow(ctx, query, accountID, since).Scan(&count)
return count, err
}
// GetRecentlyUsedPartners returns partner account IDs the sender has targeted since the provided timestamp.
func (r *warmupRepository) GetRecentlyUsedPartners(ctx context.Context, accountID uuid.UUID, since time.Time) ([]uuid.UUID, error) {
query := `
SELECT DISTINCT recipient_account_id
FROM warmup_tokens
WHERE sender_account_id = $1
AND created_at >= $2
`
rows, err := r.db.Query(ctx, query, accountID, since)
if err != nil {
return nil, err
}
defer rows.Close()
var partnerIDs []uuid.UUID
for rows.Next() {
var partnerID uuid.UUID
if err := rows.Scan(&partnerID); err != nil {
return nil, err
}
partnerIDs = append(partnerIDs, partnerID)
}
return partnerIDs, rows.Err()
}
// GetLatestReplyCandidate finds the latest completed warmup email from sender to recipient.
func (r *warmupRepository) GetLatestReplyCandidate(ctx context.Context, senderAccountID, recipientAccountID uuid.UUID) (*WarmupReplyCandidate, error) {
query := `
SELECT t.message_id, COALESCE(et.subject, ''), et.thread_id
FROM warmup_tokens wt
JOIN tasks t ON t.id = wt.task_id
LEFT JOIN email_tasks et ON et.task_id = t.id
WHERE wt.sender_account_id = $1
AND wt.recipient_account_id = $2
AND t.status = 'completed'
AND t.message_id <> ''
AND t.completed_at IS NOT NULL
ORDER BY t.completed_at DESC
LIMIT 1
`
candidate := &WarmupReplyCandidate{}
err := r.db.QueryRow(ctx, query, senderAccountID, recipientAccountID).Scan(
&candidate.MessageID,
&candidate.Subject,
&candidate.ThreadID,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return candidate, nil
}
// GetAllParticipantAccountIDs returns all unique account IDs across all warmup pools
func (r *warmupRepository) GetAllParticipantAccountIDs(ctx context.Context) ([]uuid.UUID, error) {
rows, err := r.db.Query(ctx, `SELECT DISTINCT email_account_id FROM warmup_pool_participants`)
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()
}
// GetPoolHealthCounts returns counts per health state and average spam score
func (r *warmupRepository) GetPoolHealthCounts(ctx context.Context) (map[string]int, float64, error) {
query := `
SELECT health_state, COUNT(*), AVG(spam_score)
FROM warmup_pool_participants
GROUP BY health_state
`
rows, err := r.db.Query(ctx, query)
if err != nil {
return nil, 0, err
}
defer rows.Close()
counts := map[string]int{}
var totalScore float64
var totalCount int
for rows.Next() {
var state string
var count int
var avgScore float64
if err := rows.Scan(&state, &count, &avgScore); err != nil {
return nil, 0, err
}
counts[state] = count
totalScore += avgScore * float64(count)
totalCount += count
}
avgScore := 0.0
if totalCount > 0 {
avgScore = totalScore / float64(totalCount)
}
return counts, avgScore, rows.Err()
}