package email import ( "context" "errors" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) func (s *Service) IsUnsubscribed(ctx context.Context, companyID uuid.UUID, emailAddr string) (bool, error) { emailAddr = normalizeEmail(emailAddr) var at *time.Time err := s.Pool.QueryRow(ctx, ` SELECT unsubscribed_at FROM email_unsubscribes WHERE company_id = $1 AND email_hash = $2`, companyID, hashEmail(emailAddr)).Scan(&at) if errors.Is(err, pgx.ErrNoRows) { return false, nil } if err != nil { return false, err } return at != nil, nil } func (s *Service) ensureUnsubscribeToken(ctx context.Context, companyID uuid.UUID, emailAddr string) (token, pageURL, apiURL string, err error) { emailAddr = normalizeEmail(emailAddr) h := hashEmail(emailAddr) err = s.Pool.QueryRow(ctx, ` SELECT token FROM email_unsubscribes WHERE company_id = $1 AND email_hash = $2`, companyID, h).Scan(&token) if err == nil { pageURL = UnsubscribePageURL(s.Env.WebOrigin, token) apiURL = UnsubscribeURL(s.Env.PublicAPIURL, token) return token, pageURL, apiURL, nil } if !errors.Is(err, pgx.ErrNoRows) { return "", "", "", err } token, err = newUnsubscribeToken() if err != nil { return "", "", "", err } _, err = s.Pool.Exec(ctx, ` INSERT INTO email_unsubscribes (company_id, email, email_hash, token, unsubscribed_at) VALUES ($1, $2, $3, $4, NULL) ON CONFLICT (company_id, email_hash) DO NOTHING`, companyID, emailAddr, h, token) if err != nil { return "", "", "", err } err = s.Pool.QueryRow(ctx, ` SELECT token FROM email_unsubscribes WHERE company_id = $1 AND email_hash = $2`, companyID, h).Scan(&token) if err != nil { return "", "", "", err } pageURL = UnsubscribePageURL(s.Env.WebOrigin, token) apiURL = UnsubscribeURL(s.Env.PublicAPIURL, token) return token, pageURL, apiURL, nil } const maxUnsubscribeReasonLen = 500 // UnsubscribeInfo is the public unsubscribe API response. It must not leak // tenant identifiers or recipient emails (even masked) to unauthenticated callers. type UnsubscribeInfo struct { AlreadyDone bool `json:"already_unsubscribed"` OK bool `json:"ok"` Message string `json:"message"` } func (s *Service) LookupUnsubscribeToken(ctx context.Context, token string) (companyID uuid.UUID, emailAddr string, unsubscribed bool, err error) { token = strings.TrimSpace(token) if token == "" { return uuid.Nil, "", false, pgx.ErrNoRows } var at *time.Time err = s.Pool.QueryRow(ctx, ` SELECT company_id, email, unsubscribed_at FROM email_unsubscribes WHERE token = $1`, token).Scan( &companyID, &emailAddr, &at) if err != nil { return uuid.Nil, "", false, err } return companyID, emailAddr, at != nil, nil } func (s *Service) UnsubscribeByToken(ctx context.Context, token, reason string) (UnsubscribeInfo, error) { _, _, already, err := s.LookupUnsubscribeToken(ctx, token) if errors.Is(err, pgx.ErrNoRows) { return UnsubscribeInfo{OK: false, Message: "invalid or expired unsubscribe link"}, nil } if err != nil { return UnsubscribeInfo{}, err } if already { return UnsubscribeInfo{ OK: true, AlreadyDone: true, Message: "already unsubscribed", }, nil } reason = clampUnsubscribeReason(reason) now := time.Now().UTC() _, err = s.Pool.Exec(ctx, ` UPDATE email_unsubscribes SET unsubscribed_at = $2, reason = $3 WHERE token = $1 AND unsubscribed_at IS NULL`, token, now, reason) if err != nil { return UnsubscribeInfo{}, err } return UnsubscribeInfo{ OK: true, Message: "unsubscribed", }, nil } func clampUnsubscribeReason(reason string) string { reason = strings.TrimSpace(reason) if len(reason) > maxUnsubscribeReasonLen { return reason[:maxUnsubscribeReasonLen] } return reason }