package notification import ( "context" "errors" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/storage" ) // SuppressionSource categorises why an address landed on the suppression // list. Bounces + complaints come from provider webhooks; "user" is set // when a guest clicks an unsubscribe link. type SuppressionSource string const ( SuppressionBounce SuppressionSource = "bounce" SuppressionComplaint SuppressionSource = "complaint" SuppressionManual SuppressionSource = "manual" SuppressionUser SuppressionSource = "user" ) // SuppressionRepo manages the unsubscribes table — a flat suppression list // of email addresses that must never receive another email regardless of // notification type. type SuppressionRepo struct { pool *pgxpool.Pool } func NewSuppressionRepo(db *storage.DB) *SuppressionRepo { return &SuppressionRepo{pool: db.Pool} } // IsSuppressed returns true if `email` is on the suppression list. // Empty / unparseable addresses are treated as not-suppressed; the caller // is expected to validate before sending. func (r *SuppressionRepo) IsSuppressed(ctx context.Context, email string) (bool, error) { email = normaliseEmail(email) if email == "" { return false, nil } var exists bool err := r.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM unsubscribes WHERE email = $1)`, email, ).Scan(&exists) if err != nil { return false, err } return exists, nil } // Add records `email` on the suppression list. Idempotent — repeated calls // keep the earliest entry's timestamp. func (r *SuppressionRepo) Add(ctx context.Context, email, reason string, src SuppressionSource) error { email = normaliseEmail(email) if email == "" { return errors.New("empty email") } _, err := r.pool.Exec(ctx, ` INSERT INTO unsubscribes (email, reason, source) VALUES ($1, NULLIF($2, ''), $3) ON CONFLICT (email) DO NOTHING `, email, reason, string(src)) return err } // Get returns the suppression record for `email`, or pgx.ErrNoRows if not // found. Mostly used by tests / admin tooling. func (r *SuppressionRepo) Get(ctx context.Context, email string) (string, SuppressionSource, error) { var reason *string var source string err := r.pool.QueryRow(ctx, `SELECT reason, source FROM unsubscribes WHERE email = $1`, normaliseEmail(email), ).Scan(&reason, &source) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", "", err } return "", "", err } r2 := "" if reason != nil { r2 = *reason } return r2, SuppressionSource(source), nil } func normaliseEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) }