Files
descrybe/apps/api/internal/email/unsub_service.go
T

126 lines
3.7 KiB
Go
Raw Normal View History

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
}