Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
126 lines
3.7 KiB
Go
126 lines
3.7 KiB
Go
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
|
|
}
|