mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
The send pipeline (publisher subject/body encryption, the S3 emsg blob, and the worker-side decrypt) now keys off models.SendEmail.OrgID instead of UserID. EmailMessage loses its UserID field entirely: emailSender.Send derives the cipher identity from the email account's OrganizationID and refuses to send for an account without one. This also fixes two latent bugs. Emails sent through user_email_task never set UserID, so they were silently encrypted under the zero-UUID platform key. campaign_task's discarded Encrypt() pair is replaced with an explicit DEK warm that fails fast when KMS is unavailable.
267 lines
9.6 KiB
Go
267 lines
9.6 KiB
Go
// Package placement implements seed inbox-placement testing: send a tokenized
|
|
// copy of a real template through a real sender to a panel of Warmbly-controlled
|
|
// SEED mailboxes, then classify where each landed (Inbox / Spam / Promotions /
|
|
// other) per provider.
|
|
//
|
|
// Classification reuses the warmup landing-folder detection (the same `\Junk`
|
|
// /spam-flag logic in the consumer) and reads it out of the UNIBOX — the
|
|
// received-mail store the worker syncs every mailbox into. A seed is an
|
|
// ordinary connected + synced email_account flagged is_seed, so its received
|
|
// mail lands in the unibox like any other mailbox; the poller (ClassifyPending)
|
|
// looks up the test token in the seed's unibox entries and reads the folder
|
|
// flags. No consumer hot-path hook is added.
|
|
package placement
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
"github.com/warmbly/warmbly/internal/tasks"
|
|
)
|
|
|
|
// classifyTimeout bounds how long a result stays pending before the poller
|
|
// gives up and records it as "other" (the message never showed up in the
|
|
// seed's unibox — dropped, blocked at the gateway, or sync lag past the
|
|
// window). Tests are marked completed once every result resolves or this
|
|
// elapses.
|
|
const classifyTimeout = 2 * time.Hour
|
|
|
|
// subjectTokenMarker prefixes the token embedded in the test subject. The
|
|
// worker injects only the warmup verify header (config.WarmupVerifyHeader),
|
|
// which this control-plane package can't change, so the subject is the token
|
|
// carrier that reliably survives sync into the unibox. Format:
|
|
//
|
|
// <subject> [wmpl:<token>]
|
|
//
|
|
// The marker is matched verbatim by the repo's FindTokenInUnibox subject LIKE.
|
|
const subjectTokenMarker = "wmpl:"
|
|
|
|
// Service is the seed inbox-placement testing service.
|
|
type Service interface {
|
|
// CreateTest generates a unique token, persists the test plus one pending
|
|
// result per active seed, and sends a tokenized copy of the template from
|
|
// the sender to every active seed address.
|
|
CreateTest(ctx context.Context, orgID *uuid.UUID, senderAccountID uuid.UUID, subject, bodyPlain, bodyHTML string) (*repository.PlacementTest, error)
|
|
// ClassifyPending resolves pending results by looking up each test's token
|
|
// in the seed's unibox entries and classifying the folder from flags. It
|
|
// marks a test completed once all its results resolve or the timeout passes.
|
|
ClassifyPending(ctx context.Context) error
|
|
}
|
|
|
|
type service struct {
|
|
repo repository.PlacementRepository
|
|
emailRepo repository.EmailRepository
|
|
sender tasks.EmailSender
|
|
}
|
|
|
|
// NewService wires the placement service. emailRepo resolves sender/seed
|
|
// mailbox rows; sender is the existing send primitive (publishes a SendEmail
|
|
// event to the assigned worker over Kafka — no Cloud Task / task row needed
|
|
// for a one-off probe).
|
|
func NewService(repo repository.PlacementRepository, emailRepo repository.EmailRepository, sender tasks.EmailSender) Service {
|
|
return &service{repo: repo, emailRepo: emailRepo, sender: sender}
|
|
}
|
|
|
|
func (s *service) CreateTest(ctx context.Context, orgID *uuid.UUID, senderAccountID uuid.UUID, subject, bodyPlain, bodyHTML string) (*repository.PlacementTest, error) {
|
|
subject = strings.TrimSpace(subject)
|
|
if subject == "" {
|
|
return nil, errors.New("subject is required")
|
|
}
|
|
if bodyPlain == "" && bodyHTML == "" {
|
|
return nil, errors.New("a plaintext or HTML body is required")
|
|
}
|
|
|
|
sender, xerr := s.emailRepo.GetByID(ctx, senderAccountID)
|
|
if xerr != nil || sender == nil {
|
|
return nil, fmt.Errorf("sender account not found")
|
|
}
|
|
if sender.WorkerID == nil {
|
|
return nil, fmt.Errorf("sender account %s has no assigned worker", senderAccountID)
|
|
}
|
|
|
|
seeds, err := s.repo.ListSeedAccounts(ctx, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list seed accounts: %w", err)
|
|
}
|
|
if len(seeds) == 0 {
|
|
return nil, errors.New("no active seed mailboxes are configured")
|
|
}
|
|
|
|
token := uuid.NewString()
|
|
test := &repository.PlacementTest{
|
|
ID: uuid.New(),
|
|
OrganizationID: orgID,
|
|
SenderAccountID: senderAccountID,
|
|
Subject: subject,
|
|
BodyPlain: bodyPlain,
|
|
BodyHTML: bodyHTML,
|
|
Token: token,
|
|
Status: repository.PlacementStatusPending,
|
|
}
|
|
if err := s.repo.CreateTest(ctx, test); err != nil {
|
|
return nil, fmt.Errorf("create test: %w", err)
|
|
}
|
|
|
|
// Tokenized subject: the token lives in a marker the unibox subject search
|
|
// can match. Keeping the original subject first means the seed inbox shows
|
|
// the real template subject for classification fidelity.
|
|
taggedSubject := fmt.Sprintf("%s [%s%s]", subject, subjectTokenMarker, token)
|
|
|
|
sentCount := 0
|
|
for _, seed := range seeds {
|
|
if err := s.repo.CreatePendingResult(ctx, test.ID, seed.ID, seed.Provider); err != nil {
|
|
log.Warn().Err(err).
|
|
Str("test_id", test.ID.String()).
|
|
Str("seed_id", seed.ID.String()).
|
|
Msg("placement: failed to create pending result")
|
|
continue
|
|
}
|
|
|
|
msg := tasks.EmailMessage{
|
|
From: sender.Email,
|
|
To: []string{seed.Email},
|
|
Subject: taggedSubject,
|
|
BodyPlain: bodyPlain,
|
|
BodyHTML: bodyHTML,
|
|
MessageID: fmt.Sprintf("<%s@%s>", uuid.NewString(), domainOf(sender.Email)),
|
|
// We also pass the token through the warmup verify header lane via
|
|
// WarmupToken so that, IF a future worker change starts persisting
|
|
// that header into unibox flags, the same token is already present.
|
|
// Today the header is consumed by the warmup detector and not stored,
|
|
// so the subject marker remains the authoritative carrier.
|
|
WarmupToken: token,
|
|
}
|
|
|
|
if err := s.sender.Send(ctx, uuid.New(), msg, *sender); err != nil {
|
|
log.Warn().Err(err).
|
|
Str("test_id", test.ID.String()).
|
|
Str("seed_id", seed.ID.String()).
|
|
Msg("placement: failed to publish test send to worker")
|
|
continue
|
|
}
|
|
sentCount++
|
|
}
|
|
|
|
if sentCount == 0 {
|
|
// Nothing went out — mark the test completed immediately so it doesn't
|
|
// dangle pending forever. Results remain "pending" for visibility.
|
|
now := time.Now()
|
|
_ = s.repo.SetTestStatus(ctx, test.ID, repository.PlacementStatusCompleted, &now)
|
|
return nil, errors.New("failed to send any placement probes")
|
|
}
|
|
|
|
return test, nil
|
|
}
|
|
|
|
func (s *service) ClassifyPending(ctx context.Context) error {
|
|
jobs, err := s.repo.ListPendingResults(ctx, 200)
|
|
if err != nil {
|
|
return fmt.Errorf("list pending results: %w", err)
|
|
}
|
|
|
|
touchedTests := map[uuid.UUID]struct{}{}
|
|
|
|
for _, j := range jobs {
|
|
touchedTests[j.TestID] = struct{}{}
|
|
|
|
match, err := s.repo.FindTokenInUnibox(ctx, j.SeedUserID, j.SeedAccountID, j.Token, j.TestCreatedAt)
|
|
if err != nil {
|
|
log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: unibox lookup failed")
|
|
continue
|
|
}
|
|
|
|
if match != nil {
|
|
folder := classifyFolder(match.Flags)
|
|
rawFlags := strings.Join(match.Flags, ",")
|
|
if err := s.repo.RecordResult(ctx, j.ResultID, folder, rawFlags, time.Now()); err != nil {
|
|
log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: failed to record result")
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Not found yet. If the test is older than the classify timeout, the
|
|
// probe almost certainly never landed in this seat — record "other"
|
|
// (could be a gateway block, a hard bounce, or a sync gap) so the test
|
|
// can complete instead of hanging pending forever.
|
|
if time.Since(j.TestCreatedAt) > classifyTimeout {
|
|
if err := s.repo.RecordResult(ctx, j.ResultID, repository.PlacementFolderOther, "timeout", time.Now()); err != nil {
|
|
log.Warn().Err(err).Str("result_id", j.ResultID.String()).Msg("placement: failed to timeout result")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark any touched test completed once it has no pending results left.
|
|
for testID := range touchedTests {
|
|
pending, err := s.repo.CountPendingForTest(ctx, testID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if pending == 0 {
|
|
now := time.Now()
|
|
_ = s.repo.SetTestStatus(ctx, testID, repository.PlacementStatusCompleted, &now)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// classifyFolder maps a synced message's flags/labels to a placement folder.
|
|
// It reuses the SAME spam-flag detection the warmup consumer uses
|
|
// (containsSpamFlag: \Junk / \Spam / SPAM / Junk).
|
|
//
|
|
// Promotions detection requires a Gmail CATEGORY_PROMOTIONS label to be present
|
|
// in the flags. NOTE: the current Gmail sync only maps UNREAD/STARRED/IMPORTANT
|
|
// /DRAFT into flags (internal/client/goog/message.go GmailMessageToEmailData)
|
|
// and does not capture CATEGORY_* tab labels. Capturing those needs a Gmail
|
|
// category-label sync change in the worker — a deliberate follow-up. Until then
|
|
// a Gmail-tab message reads as "inbox" here, which is the correct conservative
|
|
// default (it did reach the inbox, just a tab).
|
|
func classifyFolder(flags []string) string {
|
|
if containsSpamFlag(flags) {
|
|
return repository.PlacementFolderSpam
|
|
}
|
|
if hasPromotionsLabel(flags) {
|
|
return repository.PlacementFolderPromotions
|
|
}
|
|
return repository.PlacementFolderInbox
|
|
}
|
|
|
|
// containsSpamFlag mirrors internal/app/consumer/event_new_email.go's
|
|
// containsSpamFlag so placement classification and warmup spam-placement
|
|
// detection agree on what "landed in spam" means.
|
|
func containsSpamFlag(flags []string) bool {
|
|
spamFlags := []string{"\\Junk", "\\Spam", "SPAM", "Junk"}
|
|
for _, f := range flags {
|
|
if slices.Contains(spamFlags, f) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasPromotionsLabel reports whether a Gmail CATEGORY_PROMOTIONS label is
|
|
// present. See classifyFolder for the sync caveat.
|
|
func hasPromotionsLabel(flags []string) bool {
|
|
for _, f := range flags {
|
|
if strings.EqualFold(f, "CATEGORY_PROMOTIONS") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func domainOf(email string) string {
|
|
if at := strings.LastIndex(email, "@"); at >= 0 && at < len(email)-1 {
|
|
return email[at+1:]
|
|
}
|
|
return "localhost"
|
|
}
|