feat: merge main into the placement comparison branch, keeping both the rendered-copy and the placement-batch sentences in the workspace export guide's placement row

This commit is contained in:
Matthew Meszaros
2026-09-30 08:20:58 +02:00
228 changed files with 19071 additions and 1352 deletions
+4
View File
@@ -148,6 +148,10 @@ jobs:
run: |
go test -race -coverprofile=coverage.out -covermode=atomic ./...
# The Avro codec's registry handling only compiles with the kafka tag.
- name: Test the Avro codec
run: go test -tags kafka ./internal/infrastructure/codec/
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
+1 -1
View File
@@ -768,7 +768,7 @@ Signals used:
- every warmup email carries a verification token, minted by the platform, single-use, bound to its recipient
- no inbound token is evidence against the mailbox that received it. It did not present the token; its worker synced whatever landed in its inbox, and inbound mail is attacker-controlled: every pool member holds tokens naming itself and a partner, and forwarding three to another member used to block that member for 30 days. The recipient check already makes a token worthless anywhere but its own destination, so nothing is charged on that path (#468, #481). Do not reintroduce a charge there, whether gated by a window, a folder check, a clock or by which pair the token names; each of those was tried and each was a way to be wrong (#477, #480)
- tampering with warmup mail a mailbox verifiably received (deleting it, flagging it as spam) is attributed to that mailbox, because only its owner can do it. It is a ladder, not a first-strike ban: `evaluateMetrics` weighs a deletion as one strike and a spam flag as two over the seven-day window, and warns at one, quarantines at two and blocks at four (`tampering*Strikes` in `internal/app/warmup/service.go`). One deletion is someone tidying the folder by hand until proven otherwise (#635). `RecordTampering` only records the event and re-evaluates, so a sweep reaches the same answer; a tampering block carries a term like every other band and never requires review
- tampering with warmup mail a mailbox verifiably received (deleting it, flagging it as spam) is attributed to that mailbox, because only its owner can do it. It is a ladder, not a first-strike ban: `evaluateMetrics` counts a deletion and a spam move as one strike each over the seven-day window, and warns at one, quarantines at two and blocks at four. No provider names who moved a message into spam (Microsoft's ZAP, Workspace post-delivery scanning and client junk filters look exactly like a user's report), so a spam move is never charged on sight. A spam label on mail that arrived in spam is the filter's (`warmup_received.landed_spam`; Gmail can report it as a later label change) and is not even held. Any other move is held in `warmup_spam_moves` and `attributeSpamMove` (`internal/app/consumer/warmup_spam_attribution.go`) decides it after `config.WarmupSpamMoveSettleMinutes`: provider when the same sender was junked in another workspace within a day (which also withdraws owner verdicts it explains) or the move came straight after arrival with nobody there; owner when `mailbox_owner_activity` shows the owner at the mailbox around it, or a mailbox in use keeps junking many senders nobody else does; nobody otherwise. Owner activity is only a read, unread or star change the provider reported that our store did not already hold, on mail past its arrival grace, so Warmbly's own echoes and a filter finishing delivery never count. Only an owner verdict strikes the recipient and files a complaint against the sender; the rest are placement (`tampering*Strikes` in `internal/app/warmup/service.go`). One deletion is someone tidying the folder by hand until proven otherwise (#635). `RecordTampering` only records the event and re-evaluates, so a sweep reaches the same answer; a tampering block carries a term like every other band and never requires review
- a deletion is a strike only inside `config.WarmupDeletionStrikeHours` of arrival (`warmupDeletionCounts` in `internal/app/consumer/event_remove_email.go`), and never for a receipt the retention sweep has retired. The engagement a message earns happens in its first hours; after that the platform deletes it itself (#637), so a later removal, whichever of the owner, Gmail's Trash purge, a server retention rule or our own sweep did it, is housekeeping. Gmail's Delete arrives as the `TRASH` label and is judged there on the same rule, because the `messagesDeleted` history record only comes when Trash is emptied, weeks later and in a burst. Do not widen the window or count a removal past it: every mailbox on a fixed quota has to be able to clear the folder
- a removal is never a strike on its own. Graph reports a move exactly like a delete, and a provider's filter, a mailbox rule, another Warmbly instance syncing the same mailbox (a self-hosted instance warming in Warmbly Cloud) or our own filing can all move warmup mail. A fresh removal publishes a `verify_removal` warmup action; the worker searches the whole mailbox by Message-ID (`internal/app/worker/event_warmup_verify.go`) and answers `WARMUP_REMOVAL_CHECKED`, and `HandleWarmupRemovalChecked` (`internal/app/consumer/warmup_removal_check.go`) records a strike only for a message in the trash or gone. Found anywhere else withdraws any strike for it (`WithdrawTampering`), and a tampering pause or block is re-decided on the strikes left in the seven days before it was imposed (never on today's window, which old strikes have aged out of), then lowered, shortened from its original decision time, or lifted. The revision lands on the pool row, or on the address's ledger row when no mailbox with the address is in a pool, the one write to `warmup_reputation_ledger` outside its trigger, so a withdrawn hold is not seeded back on rejoin. A search that fails or cannot tell (IMAP only sees synced folders) charges nothing. `warmup_tampering_events.verified_at` is NULL only on strikes from before the search; `StartWarmupTamperingRecheck` asks for those (`Recheck`), stamps `verify_requested_at` and asks again after six hours while unanswered. A recheck never adds a strike: present or retired-by-retention withdraws it, anything else stamps `verified_at` and it stands. Tampering events are kept at least `config.WarmupTamperingKeepDays` so the strikes behind a live hold are there to re-decide it. Gmail's `TRASH` label needs no search, since it is the message entering the trash. Do not add another path that records a deletion without the search; the self-move marker is only a shortcut that skips it for our own filing
- warmup mail is retained by the platform, not the owner: `StartWarmupMailRetention` (`internal/app/consumer/warmup_mail_retention.go`) retires every received copy and every sender's copy past the mailbox's window (`email_accounts.warmup_retention_days`, else `retention.warmup_mail_days`) and publishes `WarmupActionDelete` to the worker, which trashes it on Gmail, deletes it on Graph, expunges it on IMAP and drops the stored body. The row is retired only after the action is on the bus, so a failed publish is re-offered. The same loop prunes tokens, receipts, tampering events and spam reports past `retention.warmup_event_days`; `warmup_statistics` carries the analytics and is never pruned
+6 -1
View File
@@ -37,7 +37,7 @@ PROTOC_GEN_GO_GRPC_VERSION ?= v1.6.1
PROTO_DIR := internal/tasks/proto
PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go
.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt lint check-migrations join-check split-cloud-check pages-check kafka-check proto check-proto \
.PHONY: poollink-dev poollink-dev-down poollink-dev-reset setup-tools fmt schemas lint check-migrations join-check split-cloud-check pages-check kafka-check proto check-proto \
up upgrade claim doctor cli seed-demo seed seed-plan sandbox sandbox-seed sandbox-simulate reset logs status stop down test-seed \
restart restart-go restart-all infra infra-down app app-down app-logs \
backend forms forms-web consumer worker run dev tracking realtime web \
@@ -82,6 +82,11 @@ cli-check:
fmt:
gofmt -w ./cmd ./internal
# Record the bus schemas the next release publishes. CI refuses a change the
# registry would refuse, and a compatible one until it is recorded here.
schemas:
go test ./internal/app/eventschemas -run TestPublishedSchemasStayCompatible -update
lint: check-migrations join-check split-cloud-check pages-check check-dockerfiles
./scripts/check-forms-mirror.sh
$(GO_BIN)/golangci-lint run --timeout=5m
@@ -230,6 +230,38 @@ const PLACEMENT_FIELDS = [
max: 10000,
help: "What a test costs in credits once a workspace has used its free tests for the month. The workspace agrees to the price before each paid test, and a test that delivers no copy is refunded. 0 turns paid tests off, so a workspace waits for next month instead. Only a hosted (DEPLOYMENT_MODE=cloud) instance charges.",
},
{
key: "batchSendersMax",
setting: "batch_senders_max",
label: "Most senders in one batch",
min: 1,
max: 100000,
help: "The largest sender list one placement batch may hold. It limits how big a batch can be, not how many senders run at once, so it can sit well above any fleet a workspace runs.",
},
{
key: "batchSenderConcurrency",
setting: "batch_sender_concurrency",
label: "Batch senders sending at once",
min: 1,
max: 500,
help: "How many of one workspace's batch senders may be sending their copies at the same time, across all its batches. The next sender starts when one has sent every copy. Each mailbox still keeps its own daily limit and spacing.",
},
{
key: "batchInstanceConcurrency",
setting: "batch_instance_concurrency",
label: "Batch senders sending at once, instance-wide",
min: 1,
max: 5000,
help: "The same limit across every workspace together. It bounds how much batch mail the instance seed panel receives at once, so a seed never takes in enough in an hour to trip the sync flood rule that deactivates a mailbox.",
},
{
key: "batchStartsPerMinute",
setting: "batch_starts_per_minute",
label: "Batch senders started per minute",
min: 1,
max: 600,
help: "How fast one batch starts its senders, so a large batch ramps up instead of starting its whole concurrency at once.",
},
] as const;
type PlacementFieldKey = (typeof PLACEMENT_FIELDS)[number]["key"];
@@ -241,6 +273,10 @@ const PLACEMENT_DEFAULTS: InstanceSettings["placement"] = {
seeds_per_test: 20,
spacing_seconds: 60,
credits_per_test: 25,
batch_senders_max: 10000,
batch_sender_concurrency: 20,
batch_instance_concurrency: 200,
batch_starts_per_minute: 10,
};
interface FormState {
@@ -285,6 +321,10 @@ function toForm(s: InstanceSettings): FormState {
seedsPerTest: String(placement.seeds_per_test),
spacingSeconds: String(placement.spacing_seconds),
creditsPerTest: String(placement.credits_per_test ?? PLACEMENT_DEFAULTS.credits_per_test),
batchSendersMax: String(placement.batch_senders_max ?? PLACEMENT_DEFAULTS.batch_senders_max),
batchSenderConcurrency: String(placement.batch_sender_concurrency ?? PLACEMENT_DEFAULTS.batch_sender_concurrency),
batchInstanceConcurrency: String(placement.batch_instance_concurrency ?? PLACEMENT_DEFAULTS.batch_instance_concurrency),
batchStartsPerMinute: String(placement.batch_starts_per_minute ?? PLACEMENT_DEFAULTS.batch_starts_per_minute),
},
enforceDomainAuth: s.deliverability.enforce_domain_auth,
authGraceHours: String(s.deliverability.auth_grace_hours),
@@ -349,7 +389,7 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
!!form &&
PLACEMENT_FIELDS.some(
(f) =>
form.placement[f.key] !== String((server.placement ?? PLACEMENT_DEFAULTS)[f.setting]),
form.placement[f.key] !== String(server.placement?.[f.setting] ?? PLACEMENT_DEFAULTS[f.setting]),
);
const dirty =
!!server &&
@@ -463,6 +503,10 @@ export function SettingsTab({ onDirtyChange, onSwitchTab }: SettingsTabProps) {
seeds_per_test: Number(form.placement.seedsPerTest),
spacing_seconds: Number(form.placement.spacingSeconds),
credits_per_test: Number(form.placement.creditsPerTest),
batch_senders_max: Number(form.placement.batchSendersMax),
batch_sender_concurrency: Number(form.placement.batchSenderConcurrency),
batch_instance_concurrency: Number(form.placement.batchInstanceConcurrency),
batch_starts_per_minute: Number(form.placement.batchStartsPerMinute),
},
});
}
@@ -18,6 +18,7 @@ export const ORIGIN_LABEL: Record<string, string> = {
monitor: "Monitor",
admin: "Admin",
remote: "Linked instance",
batch: "Batch",
};
// The backend's `error` field is the HTTP status text; the sentence worth
@@ -162,6 +162,14 @@ export interface InstanceSettings {
spacing_seconds: number;
/** Credits a test past the monthly allowance costs; 0 turns paid tests off. */
credits_per_test: number;
/** Most senders one placement batch may hold. */
batch_senders_max: number;
/** A workspace's batch senders sending probes at the same time. */
batch_sender_concurrency: number;
/** Batch senders sending at once across every workspace. */
batch_instance_concurrency: number;
/** Senders one batch starts per minute. */
batch_starts_per_minute: number;
};
// Operator notification channels. Targets and secrets are redacted on
// read: a chat webhook URL is a bearer credential, so the server returns a
+1 -1
View File
@@ -5,7 +5,7 @@
import { Request } from "@/lib/api/client";
export type PlacementPanel = "instance" | "workspace" | "cloud";
export type PlacementOrigin = "manual" | "monitor" | "admin" | "remote";
export type PlacementOrigin = "manual" | "monitor" | "admin" | "remote" | "batch";
export type PlacementStatus = "running" | "completed" | "cancelled" | "failed";
export type PlacementTracking = "campaign" | "on" | "off" | "compare";
export type PlacementFolder =
+13
View File
@@ -56,6 +56,7 @@ import (
"github.com/warmbly/warmbly/internal/app/email"
"github.com/warmbly/warmbly/internal/app/emailsend"
emailverifyapp "github.com/warmbly/warmbly/internal/app/emailverify"
"github.com/warmbly/warmbly/internal/app/eventschemas"
"github.com/warmbly/warmbly/internal/app/feature"
"github.com/warmbly/warmbly/internal/app/fleet"
"github.com/warmbly/warmbly/internal/app/fleetnode"
@@ -149,6 +150,7 @@ import (
"github.com/warmbly/warmbly/internal/tasks"
"github.com/warmbly/warmbly/internal/tasks/proto"
"github.com/warmbly/warmbly/internal/tasksched"
"github.com/warmbly/warmbly/internal/version"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
@@ -596,6 +598,15 @@ func main() {
errs.CaptureFatal(err)
log.Fatal(err)
}
// Register this release's bus schemas before any node publishes them.
go func() {
rctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
if err := eventschemas.Register(rctx, codecImpl); err != nil {
errs.CaptureException(err)
log.Printf("event schemas: %v", err)
}
}()
bus, err := eventbus.FromEnv(kafkaBootstrapServers, kafkaSaslConfig)
if err != nil {
@@ -1154,6 +1165,7 @@ func main() {
GithubRepo: getenvDefault("RELEASES_GITHUB_REPO", "warmbly/warmbly"),
WorkerImageRepo: getenvDefault("RELEASES_WORKER_IMAGE_REPO", "ghcr.io/warmbly/warmbly/worker"),
GithubToken: os.Getenv("RELEASES_GITHUB_TOKEN"),
SchemaGate: eventschemas.Gate(codecImpl, version.Version),
},
fleetSettingsRepo,
)
@@ -2004,6 +2016,7 @@ func main() {
Notifier: notificationService,
Mailboxes: emailService,
Pauser: guardrailService,
Batches: repository.NewPlacementBatchRepository(primaryDB),
}
if streamingPublisher != nil {
placementDeps.Publisher = streamingPublisher
+133 -1
View File
@@ -318,6 +318,46 @@ the hold. Resuming a lead that is not held succeeds and changes nothing.`,
{Name: "contact", Help: "The contact's id"},
},
},
{
Name: "lead-cc", Short: "Contacts copied on one lead's emails",
Method: http.MethodGet, Path: "/campaigns/{id}/leads/{contact}/cc",
Args: []argSpec{
{Name: "id", Help: "The campaign's id"},
{Name: "contact", Help: "The lead's contact id"},
},
Table: output.Table{Root: "cc", Columns: []output.Column{
col("CONTACT", "contact_id"), col("EMAIL", "email"), col("STATUS", "status"),
}, Empty: "This lead copies nobody."},
},
{
Name: "set-lead-cc", Short: "Replace the contacts copied on one lead's emails",
Long: `Copy up to two contacts on every email this campaign sends one lead, follow-ups
included, so colleagues at one company share a single thread. The list replaces
the current one. A copied contact who is also a lead of the campaign has their
own sequence held while any lead copies them, so they never get two threads.`,
Example: " $ warmbly campaign set-lead-cc CAMPAIGN_ID CONTACT_ID --cc COLLEAGUE_ID\n" +
" $ warmbly campaign set-lead-cc CAMPAIGN_ID CONTACT_ID --input '{\"contact_ids\":[]}' # copy nobody",
Method: http.MethodPut, Path: "/campaigns/{id}/leads/{contact}/cc", Body: bodyRequired,
Args: []argSpec{
{Name: "id", Help: "The campaign's id"},
{Name: "contact", Help: "The lead's contact id"},
},
Flag: []flagSpec{
{Name: "cc", Help: "A contact id to copy (repeatable, at most 2)", Kind: flagStrings, Key: "contact_ids"},
},
Success: "Lead CC replaced.",
},
{
Name: "lead-cc-suggestions", Short: "The lead's likely colleagues to copy",
Method: http.MethodGet, Path: "/campaigns/{id}/leads/{contact}/cc/suggestions",
Args: []argSpec{
{Name: "id", Help: "The campaign's id"},
{Name: "contact", Help: "The lead's contact id"},
},
Table: output.Table{Root: "data", Columns: []output.Column{
col("CONTACT", "contact_id"), col("EMAIL", "email"), col("COMPANY", "company"), col("MATCH", "reason"),
}, Empty: "No contacts share the lead's company or email domain."},
},
{
Name: "logs", Short: "The campaign's send log",
Method: http.MethodGet, Path: "/campaigns/{id}/logs", Paginate: true,
@@ -1506,6 +1546,47 @@ func placementSpec() resource {
},
Empty: "No placement tests yet. Start one with `warmbly placement test --mailbox MAILBOX_ID --campaign CAMPAIGN_ID`.",
}
batchTable := output.Table{
Root: "data",
Columns: []output.Column{
col("ID", "id"),
colt("SUBJECT", "subject", 30),
col("MAILBOXES", "sender_count"),
col("STATUS", "status"),
col("DONE", "progress.completed"),
col("SKIPPED", "progress.skipped"),
col("INBOX", "summary.inbox"),
col("SPAM", "summary.spam"),
colf("CREATED", "created_at", "time"),
},
Empty: "No placement batches yet. Start one with `warmbly placement batch-start --scope campaign --scope-campaign CAMPAIGN_ID --campaign CAMPAIGN_ID`.",
}
batchFlags := []flagSpec{
{Name: "mailbox", Help: "Test from this mailbox's id (repeatable); instead of --scope", Kind: flagStrings, Key: "sender_account_ids"},
{Name: "scope", Help: "campaign (a campaign's mailboxes) or workspace (every mailbox)", Key: "sender_scope[type]"},
{Name: "scope-campaign", Help: "The campaign whose mailboxes to test, with --scope campaign", Key: "sender_scope[campaign_id]"},
{Name: "only-provider", Help: "Only mailboxes hosted by this provider, e.g. google_workspace (repeatable)", Kind: flagStrings, Key: "sender_scope[providers]"},
{Name: "only-domain", Help: "Only mailboxes sending from this domain (repeatable)", Kind: flagStrings, Key: "sender_scope[domains]"},
{Name: "only-tag", Help: "Only mailboxes with this tag id (repeatable)", Kind: flagStrings, Key: "sender_scope[tag_ids]"},
{Name: "include-inactive", Help: "Keep disconnected mailboxes", Kind: flagBool, Key: "sender_scope[include_inactive]"},
{Name: "untested-days", Help: "Only mailboxes with no finished placement test in this many days", Kind: flagInt, Key: "sender_scope[untested_days]"},
{Name: "sample", Help: "all, random, percent, per_domain or per_provider", Key: "sample[mode]"},
{Name: "sample-count", Help: "Mailboxes for random, or per group for per_domain and per_provider", Kind: flagInt, Key: "sample[count]"},
{Name: "sample-percent", Help: "Share of mailboxes for percent, 1 to 100", Kind: flagInt, Key: "sample[percent]"},
{Name: "stratify", Help: "Spread a random or percent sample across provider or domain", Key: "sample[stratify]"},
{Name: "campaign", Help: "Test this campaign's copy", Key: "campaign_id"},
{Name: "step", Help: "The step to test; with --campaign", Key: "sequence_id"},
{Name: "contact", Help: "Render the copy for this contact's id", Key: "contact_id"},
{Name: "subject", Help: "Subject of an ad-hoc template", Key: "subject"},
{Name: "text", Help: "Plain-text body of an ad-hoc template", Key: "body_plain"},
{Name: "html", Help: "HTML body of an ad-hoc template", Key: "body_html"},
{Name: "tracking", Help: "campaign, on, off or compare (sends twice, with and without)", Key: "tracking"},
{Name: "panel", Help: "instance, workspace or cloud", Key: "panel"},
{Name: "seed", Help: "Only this seed inbox's id, with --panel workspace (repeatable)", Kind: flagStrings, Key: "seed_ids"},
{Name: "provider", Help: "Only seeds at this provider, e.g. gmail or outlook (repeatable)", Kind: flagStrings, Key: "families"},
{Name: "on-unavailable", Help: "defer (retry a mailbox that cannot send yet, the default) or skip", Key: "on_unavailable"},
{Name: "max-credits", Help: "Pay up to this many credits in total for tests past this month's free ones", Kind: flagInt, Key: "max_credits"},
}
return resource{
Name: "placement",
Aliases: []string{"placement-test", "placement-tests"},
@@ -1515,7 +1596,8 @@ func placementSpec() resource {
seed inboxes and see where each copy landed.
Every copy is a real send from that mailbox, counted against its daily limit,
so starting a test asks before it does it.`,
so starting a test asks before it does it. A batch runs the same test from many
mailboxes, a few at a time.`,
Endpoints: []endpoint{
{Name: "overview", Short: "The seed panels you can test on and this month's allowance", Method: http.MethodGet, Path: "/placement/overview"},
{
@@ -1598,6 +1680,56 @@ so starting a test asks before it does it.`,
Args: []argSpec{{Name: "id", Help: "The campaign's id"}},
Success: "Placement monitor removed.",
},
{
Name: "batches", Short: "List placement batches: one test run from many mailboxes",
Method: http.MethodGet, Path: "/placement/batches", Paginate: true,
Flag: withPaging(),
Table: batchTable,
},
{
Name: "batch", Aliases: []string{"batch-view"}, Short: "Show a batch with its placement by domain and provider",
Method: http.MethodGet, Path: "/placement/batches/{id}",
Args: []argSpec{{Name: "id", Help: "The batch's id"}},
},
{
Name: "batch-preview", Short: "Count the mailboxes, tests, copies and credits a batch would take",
Method: http.MethodPost, Path: "/placement/batches/preview", Body: bodyRequired,
Example: " $ warmbly placement batch-preview --scope campaign --scope-campaign CAMPAIGN_ID --sample percent --sample-percent 10 --stratify provider --campaign CAMPAIGN_ID --step STEP_ID",
Flag: batchFlags,
},
{
Name: "batch-start", Aliases: []string{"batch-test"}, Short: "Run a placement test from many mailboxes, a few at a time",
Method: http.MethodPost, Path: "/placement/batches", Body: bodyRequired, Sends: true, Idempotent: true,
Example: " $ warmbly placement batch-start --scope campaign --scope-campaign CAMPAIGN_ID --campaign CAMPAIGN_ID --step STEP_ID\n" +
" $ warmbly placement batch-start --scope workspace --untested-days 30 --sample per_domain --sample-count 2 --subject \"Quick question\" --text \"Hi there\"",
Flag: batchFlags,
Table: batchTable,
},
{
Name: "batch-senders", Short: "List a batch's mailboxes, worst inbox rate first",
Method: http.MethodGet, Path: "/placement/batches/{id}/senders", Paginate: true,
Args: []argSpec{{Name: "id", Help: "The batch's id"}},
Flag: withPaging(
flagSpec{Name: "sort", Help: "worst (default), best, email or status", Query: true},
flagSpec{Name: "status", Help: "Only mailboxes in this status: queued, deferred, running, completed, skipped, failed or cancelled", Query: true},
flagSpec{Name: "search", Help: "Only addresses containing this text", Query: true, Key: "q"},
),
Table: output.Table{Root: "data", Columns: []output.Column{
colt("MAILBOX", "sender_email", 34), col("PROVIDER", "sender_family_label"), col("STATUS", "status"),
col("INBOX", "summary.inbox"), col("SPAM", "summary.spam"), col("MISSING", "summary.missing"),
colt("REASON", "detail", 40),
}, Empty: "No mailboxes match."},
},
{
Name: "batch-cancel", Short: "Stop a batch; copies already sent keep being classified",
Method: http.MethodPost, Path: "/placement/batches/{id}/cancel", Body: bodyOptional,
Args: []argSpec{{Name: "id", Help: "The batch's id"}},
Success: "Placement batch cancelled.",
},
{
Name: "coverage", Short: "How many mailboxes finished a placement test in the last 7 and 30 days",
Method: http.MethodGet, Path: "/placement/coverage",
},
},
}
}
+2
View File
@@ -525,6 +525,8 @@ func main() {
// Searches the mailbox for deletion strikes recorded before removals were
// checked, withdrawing any whose message is still there.
go jobsService.StartWarmupTamperingRecheck(ctx)
// Attributes each warmup email moved to spam once the activity around it settles.
go jobsService.StartWarmupSpamMoveAttribution(ctx)
go jobsService.StartWarmupPlacementSweep(ctx)
go jobsService.StartPendingWarmupVerification(ctx)
// Re-offers inbound mail that reply processing never claimed, so a
+5
View File
@@ -72,6 +72,11 @@ var apiSpecs = []apiSpec{
{name: "campaign lead-hold", summary: "Whether one lead's flow is held", method: "GET", path: "/campaigns/{id}/leads/{child}/hold", child: "contact"},
{name: "campaign pause-lead", summary: "Hold one lead's flow until a date, or until resumed", method: "POST", path: "/campaigns/{id}/leads/{child}/pause", body: bodyOptional, child: "contact"},
{name: "campaign resume-lead", summary: "Lift one lead's hold now", method: "POST", path: "/campaigns/{id}/leads/{child}/resume", child: "contact"},
// Contacts copied on every email to one lead. --data carries
// {"contact_ids": ["<contact>", ...]}, at most two; [] copies nobody.
{name: "campaign lead-cc", summary: "Contacts copied on one lead's emails", method: "GET", path: "/campaigns/{id}/leads/{child}/cc", child: "contact"},
{name: "campaign set-lead-cc", summary: "Replace the contacts copied on one lead's emails", method: "PUT", path: "/campaigns/{id}/leads/{child}/cc", body: bodyRequired, child: "contact"},
{name: "campaign lead-cc-suggestions", summary: "The lead's likely colleagues to copy", method: "GET", path: "/campaigns/{id}/leads/{child}/cc/suggestions", child: "contact"},
// Contacts.
{name: "contact list", summary: "List or search contacts; --data carries the filter body", method: "POST", path: "/contacts/search", body: bodyOptional, query: []string{"limit", "cursor"}},
+7 -3
View File
@@ -227,7 +227,7 @@ $ warmbly campaign start 6f1c…
! `warmbly campaign start` sends real mail. Continue? [y/N]
```
`--yes` is the only way past it, which makes it the flag to grep for in a script review. The commands that behave this way are `campaign start`, `campaign test`, `placement test`, `mailbox send`, `inbox reply`, `inbox compose` and `inbox approve-draft`.
`--yes` is the only way past it, which makes it the flag to grep for in a script review. The commands that behave this way are `campaign start`, `campaign test`, `placement test`, `placement batch-start`, `mailbox send`, `inbox reply`, `inbox compose` and `inbox approve-draft`.
## Commands
@@ -238,7 +238,7 @@ Run `warmbly <command> --help` for the flags, and `warmbly <command> <subcommand
| `auth` | login, logout, status, token, switch, refresh |
| `status` | one screen: mailboxes needing attention, what is sending, what is unread |
| `browse` | open the dashboard, or one record, in a browser |
| `campaign` | list, view, create, edit, steps, senders, segments, preflight, test, start, stop, logs, plan, pause-lead / resume-lead / lead-hold |
| `campaign` | list, view, create, edit, steps, senders, segments, preflight, test, start, stop, logs, plan, pause-lead / resume-lead / lead-hold, lead-cc / set-lead-cc / lead-cc-suggestions |
| `contact` | list, view, create, edit, delete, lookup, timeline, notes, import, export, verify |
| `mailbox` | list, view, edit, health checks, sync state, send-as addresses, sending behaviour, warmup, hold, send |
| `inbox` | list, view, threads, read, reply, compose, drafts, scheduled sends, snoozes |
@@ -249,7 +249,7 @@ Run `warmbly <command> --help` for the flags, and `warmbly <command> <subcommand
| `form` | lead capture forms, submissions and stats |
| `deal`, `pipeline`, `task` | the CRM |
| `analytics` | dashboard, deliverability, warmup, per-mailbox and per-campaign numbers |
| `placement` | [inbox placement tests](/guides/placement-tests/): overview, test, list, view, cancel, seed inboxes, a campaign's scheduled test |
| `placement` | [inbox placement tests](/guides/placement-tests/): overview, test, list, view, cancel, seed inboxes, a campaign's scheduled test, [batches](/guides/placement-tests/#testing-a-fleet-with-batches) across many mailboxes (`batch-preview`, `batch-start`, `batches`, `batch`, `batch-senders`, `batch-cancel`) and fleet `coverage` |
| `audit` | the workspace's audit trail |
| `advisor` | recommendations, and applying or dismissing them |
| `webhook` | endpoints, deliveries, redelivery, event types |
@@ -289,6 +289,10 @@ warmbly mailbox edit MAILBOX_ID --send-as hello@acme.com
warmbly campaign pause-lead CAMPAIGN_ID CONTACT_ID --until 2026-09-21T17:00:00Z --reason "On holiday"
warmbly campaign resume-lead CAMPAIGN_ID CONTACT_ID
# Two people at one company: copy the second on the first lead's emails
warmbly campaign lead-cc-suggestions CAMPAIGN_ID CONTACT_ID
warmbly campaign set-lead-cc CAMPAIGN_ID CONTACT_ID --cc COLLEAGUE_ID
# The inbox
warmbly inbox list --unseen --limit 20
warmbly inbox thread --email-id EMAIL_ID
+18 -4
View File
@@ -78,6 +78,9 @@ Campaigns, steps and activity logs are scoped to the selected organization for s
| GET | `/campaigns/:id/leads/:contactId/hold` | `READ_CAMPAIGNS` |
| POST | `/campaigns/:id/leads/:contactId/pause` | `WRITE_CAMPAIGNS` |
| POST | `/campaigns/:id/leads/:contactId/resume` | `WRITE_CAMPAIGNS` |
| GET | `/campaigns/:id/leads/:contactId/cc` | `READ_CAMPAIGNS` + `READ_CONTACTS` |
| PUT | `/campaigns/:id/leads/:contactId/cc` | `WRITE_CAMPAIGNS` + `READ_CONTACTS` |
| GET | `/campaigns/:id/leads/:contactId/cc/suggestions` | `READ_CAMPAIGNS` + `READ_CONTACTS` |
| GET | `/campaigns/:id/logs` | `READ_CAMPAIGNS` |
| GET | `/campaigns/:id/send-plan` | `READ_CAMPAIGNS` |
| GET | `/campaigns/:id/forms` | `READ_CAMPAIGNS` |
@@ -90,7 +93,9 @@ Campaigns, steps and activity logs are scoped to the selected organization for s
| POST | `/email-images` | `WRITE_CAMPAIGNS` |
| DELETE | `/email-images/:id` | `WRITE_CAMPAIGNS` |
The three `/campaigns/:id/leads/:contactId/…` calls hold one contact's flow inside one campaign: an out-of-office auto-reply writes one automatically, and a member can write one by hand. The contact stays subscribed and stays a lead, so this is not an unsubscribe and not a suppression. Both writes state an absolute hold rather than applying a delta, and replacing a live hold keeps its original start, so a retry lands on the same row and neither needs an `Idempotency-Key`. See [pause a lead](/api/reference/campaigns/#pause-a-lead).
The `hold`, `pause` and `resume` calls under `/campaigns/:id/leads/:contactId/` hold one contact's flow inside one campaign: an out-of-office auto-reply writes one automatically, and a member can write one by hand. The contact stays subscribed and stays a lead, so this is not an unsubscribe and not a suppression. Both writes state an absolute hold rather than applying a delta, and replacing a live hold keeps its original start, so a retry lands on the same row and neither needs an `Idempotency-Key`. See [pause a lead](/api/reference/campaigns/#pause-a-lead).
The `cc` calls under the same path set the contacts copied on every email to one lead. Their answers carry contact names and addresses, so they need contact read access as well as the campaign scope. `PUT` sends the whole list, so a retry lands on the same state and needs no `Idempotency-Key`. See [set a lead's CC](/api/reference/campaigns/#set-a-leads-cc).
`GET /campaigns/:id/forms` reports the forms this campaign links to and what its recipients did with them: personalized links handed out, who opened one, who started filling it in and who submitted. See the [forms guide](/guides/forms/).
@@ -301,10 +306,19 @@ A [placement test](/guides/placement-tests/) sends real mail from one of the wor
| POST | `/placement/tests/:id/cancel` | `SEND_CAMPAIGNS` |
| GET | `/placement/seeds` | `READ_EMAILS` |
| PUT | `/placement/seeds/:email_account_id` | `WRITE_EMAILS` |
| GET | `/placement/batches` | `READ_ANALYTICS` |
| GET | `/placement/batches/:id` | `READ_ANALYTICS` |
| GET | `/placement/batches/:id/senders` | `READ_ANALYTICS` |
| POST | `/placement/batches/preview` | `SEND_CAMPAIGNS` |
| POST | `/placement/batches` | `SEND_CAMPAIGNS` |
| POST | `/placement/batches/:id/cancel` | `SEND_CAMPAIGNS` |
| GET | `/placement/coverage` | `READ_ANALYTICS` |
For session callers the matching organization permissions are **View analytics** for the three reads, **Send campaigns** for starting and cancelling, **View campaigns** for listing seed inboxes and **Manage mailboxes** for marking one. The campaign's placement monitor (`/campaigns/:id/placement-monitor`, listed under campaigns above) is read with **View campaigns** and changed with **Send campaigns**.
For session callers the matching organization permissions are **View analytics** for the reads (tests, batches, a batch's senders and coverage), **Send campaigns** for starting, previewing and cancelling a test or a batch, **View campaigns** for listing seed inboxes and **Manage mailboxes** for marking one. The campaign's placement monitor (`/campaigns/:id/placement-monitor`, listed under campaigns above) is read with **View campaigns** and changed with **Send campaigns**.
A key restricted to certain mailboxes can only start a test from one of them and only sees and marks those mailboxes as seeds. `POST /placement/tests` accepts an `Idempotency-Key`; `PUT /placement/seeds/:email_account_id` and `PUT /campaigns/:id/placement-monitor` state an absolute value, so a retry lands on the same state. `GET /placement/tests` takes `limit` (`1` to `100`, default `25`), an opaque `cursor` and an optional `campaign_id`, and returns `data` plus `pagination`; an invalid cursor or limit is a `400`. See the [placement endpoint reference](/api/reference/placement/).
A key restricted to certain mailboxes can only start a test from one of them and only sees and marks those mailboxes as seeds. `POST /placement/tests` accepts an `Idempotency-Key`; `PUT /placement/seeds/:email_account_id` and `PUT /campaigns/:id/placement-monitor` state an absolute value, so a retry lands on the same state. `GET /placement/tests` takes `limit` (`1` to `100`, default `25`), an opaque `cursor` and an optional `campaign_id`, and returns `data` plus `pagination`; an invalid cursor or limit is a `400`. It leaves out the tests a batch started, which are read through their batch.
A [placement batch](/guides/placement-tests/#testing-a-fleet-with-batches) runs the same test from many mailboxes. `POST /placement/batches` takes the copy, panel, tracking and pace a single test takes, plus exactly one of `sender_account_ids` or `sender_scope` (`{"type": "campaign", "campaign_id": ...}` or `{"type": "workspace"}`, with optional `providers`, `domains`, `tag_ids`, `include_inactive` and `untested_days`), an optional `sample`, `on_unavailable` (`defer` or `skip`) and `max_credits` for the whole batch. It answers `201` with the batch `queued` as soon as the senders are written down; the backend starts them a few at a time. `POST /placement/batches/preview` takes the same body and returns the counts it would come to (senders, tests, the most copies sent, free and paid tests, credits) without starting anything or writing a row. A key restricted to certain mailboxes can only name those in `sender_account_ids` (another is a `403`), and a server-side scope resolves to those alone. `POST /placement/batches` accepts an `Idempotency-Key`; cancelling states an end state, so a retry answers `placement_batch_not_running` and changes nothing. `GET /placement/batches` and `GET /placement/batches/:id/senders` take `limit` and `cursor` like the tests list; the senders list also takes `sort` (`worst`, the default, `best`, `email` or `status`), `status` and `q` (a substring of the address), and an unknown value of any of them is a `400`. See the [placement endpoint reference](/api/reference/placement/).
### Advisor
@@ -400,7 +414,7 @@ Mutating API requests may include an `Idempotency-Key` header. Warmbly stores th
### Labels
Folders (on campaigns), tags (on mailboxes) and categories (on contacts and inbox conversations) are three registries with the same shape. Each belongs to the **workspace**, not to whoever created it. Every member sees the same set, whoever created it, and reads it from `GET /auth/me`, which returns `folders`, `tags` and `categories` for the session's selected workspace; there is no separate list endpoint and no read scope of its own. Changing a registry is gated like the records it labels, so it takes the API permission in the table below, or the matching JWT permission `MANAGE_CAMPAIGNS`, `MANAGE_EMAILS` or `MANAGE_CONTACTS`. Membership alone is read-only.
Folders (on campaigns), tags (on mailboxes) and labels (on contacts, inbox conversations and forms, managed through the `/categories` endpoints) are three registries with the same shape. Each belongs to the **workspace**, not to whoever created it. Every member sees the same set, whoever created it, and reads it from `GET /auth/me`, which returns `folders`, `tags` and `categories` for the session's selected workspace; there is no separate list endpoint and no read scope of its own. Changing a registry is gated like the records it labels, so it takes the API permission in the table below, or the matching JWT permission `MANAGE_CAMPAIGNS`, `MANAGE_EMAILS` or `MANAGE_CONTACTS`. Membership alone is read-only.
`position` is the order within its own registry, `0`-based and contiguous. A move returns the full new ordering. A registry holds at most `100` entries.
+15 -3
View File
@@ -33,7 +33,7 @@ All errors follow this structure:
| 404 | Not Found | Resource doesn't exist |
| 409 | Conflict | Resource already exists |
| 422 | Unprocessable | Validation failed |
| 429 | Too Many Requests | Rate limit or AI usage cap exceeded (`rate_limit_exceeded`, `usage_cap_exceeded`), or too many placement tests running at once (`placement_too_many_running`) |
| 429 | Too Many Requests | Rate limit or AI usage cap exceeded (`rate_limit_exceeded`, `usage_cap_exceeded`), or too many placement tests or batches running at once (`placement_too_many_running`, `placement_too_many_batches`) |
### Server errors (5xx)
@@ -105,12 +105,15 @@ Fields are named by their JSON key, with nested fields as a dotted path (`inner.
| `no_leads` | `POST /campaigns/:id/start` on a campaign that has never had a lead, with `continuous` off. Add contacts, or set `continuous` so it starts empty and waits for them. A campaign whose leads have all finished is a different case: it starts and waits |
| `no_remaining_leads` | A platform-initiated restart of a campaign with nothing left to send and `continuous` off found nothing to do; the campaign is `completed` again. A start you request never answers this: it turns `continuous` on and waits |
| `too_many_tasks` | `PATCH /crm/tasks` or `DELETE /crm/tasks` was given more than `1000` ids in one request, or more than `50,000` exclusions. Split it into batches |
| `selection_too_large` | A `"all": true` bulk selection resolved to more than `50,000` rows. Narrow the filter and run it in parts; nothing was changed |
| `too_many_contacts` | A contact bulk action was given more than `10,000` contact ids in one request, or more than `250,000` exclusions. Split it into batches, or send a filter selection instead of ids |
| `selection_too_large` | A `"all": true` bulk selection resolved to more than its limit: `250,000` contacts, or `50,000` CRM tasks. Narrow the filter and run it in parts; nothing was changed |
| `invalid_filter` | A task filter carried an id that is not one: `assigned_to`, `contact_id` and `deal_id` name records, and are matched against id columns. Sent by `POST /crm/tasks/search`, `POST /crm/tasks/summary`, and the `filters` of a `"all": true` bulk selection |
| `invalid_setting` | `PATCH /outreach/settings` (or a campaign's advanced settings) carried a value outside the documented vocabulary, for example a `reply_intent.crm_task_intents` entry that is not a reply intent, or an `inbox_tagging.questions` entry with no question text, a label that is missing, repeated or built in, or a choice question with fewer than two options, or an `inbox_tagging.languages` entry that is not a supported language code |
| `invalid_slug` | `PATCH /organization/current` was given a `slug` that is not 2 to 80 lowercase letters, numbers or dashes starting and ending with a letter or number |
| `invalid_sync_folder` | `PUT /emails/:id/sync` was given a folder the sync always follows (`INBOX`, or a sent, drafts, spam, trash or archive folder by attribute or name), a name that is empty after trimming, longer than 255 characters or carrying a control character, more than 50 names, or a mailbox that is not IMAP. The `message` names the entry refused |
| `no_organization` | The request needs a workspace and the caller has none selected. Every entitlement, limit and suppression rule is scoped to a workspace, so a write that would run unscoped is refused rather than run without those checks. API keys always carry their workspace; a dashboard session picks one at sign-in, so this normally means the session predates the workspace being chosen. Select a workspace and retry |
| `lead_cc_limit` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) was given more than `2` contacts |
| `lead_cc_self` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) named the lead itself as a copy |
#### Password refusals
@@ -247,6 +250,12 @@ The [inbox placement test](/guides/placement-tests/) routes (`/placement/*` and
| `placement_seed_unavailable` | 409 | The mailbox cannot be marked as a seed inbox: it is on the instance panel, or a placement test is still sending from it |
| `placement_seed_limit` | 409 | The workspace already has `50` seed inboxes. Unmark one first |
| `mailbox_is_seed` | 409 | Warmup was started or resumed on a seed inbox. A seed never warms up; unmark it first with `PUT /placement/seeds/:email_account_id` |
| `placement_batch_empty` | 400 | `POST /placement/batches` resolved to no sending mailbox: the scope, its filters and the sample left nothing |
| `placement_batch_too_large` | 400 | The batch would hold more mailboxes than the instance allows in one batch (`10,000` by default, an operator setting). The `message` gives both numbers; narrow the selection or take a sample |
| `placement_too_many_batches` | 429 | The workspace already has `5` batches running. Wait for one to finish, or cancel one |
| `placement_batch_not_running` | 409 | `POST /placement/batches/:id/cancel` on a batch that has already finished or been cancelled |
A batch runs every check that applies to the whole batch (the copy, the panel, the tracking, the allowance and the credits agreed) before it is created, and refuses with the codes above. The checks for each mailbox run when its turn comes: a mailbox refused then is deferred or skipped, and its sender row in `GET /placement/batches/:id/senders` carries the code as `reason` with its sentence as `detail`. Besides the single-test codes, a sender row can carry `placement_sender_deleted` (the mailbox was removed from the workspace), `placement_batch_retry_expired` (still unavailable when the seven-day retry window closed), `placement_batch_start_failed` (the test could not be started after several tries) and `placement_batch_copy_unavailable` (the campaign or step being tested was deleted, which skips every mailbox not started yet).
```json
{
@@ -257,7 +266,7 @@ The [inbox placement test](/guides/placement-tests/) routes (`/placement/*` and
}
```
A `400` without one of these codes is a malformed request: an unknown `panel` or `tracking`, a `sequence_id` without its `campaign_id`, a missing subject or body on an ad-hoc test, a monitor `interval_days` outside `1` to `30` or an `alert_below` outside `0` to `100`.
A `400` without one of these codes is a malformed request: an unknown `panel` or `tracking`, a `sequence_id` without its `campaign_id`, a missing subject or body on an ad-hoc test, a monitor `interval_days` outside `1` to `30` or an `alert_below` outside `0` to `100`, or on a batch `pace` `quick`, both or neither of `sender_account_ids` and `sender_scope`, an unknown `sender_scope.type`, `sample.mode` or `on_unavailable`, or `sample.stratify` on a sample that is not random or percent.
### 401 Unauthorized
@@ -439,6 +448,7 @@ Returned when the requested resource doesn't exist.
| `code` | Meaning |
|--------|---------|
| `unknown_view` | `/me/views/:view` was given a view name other than `contacts` or `campaign_leads` |
| `lead_cc_contact_not_found` | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) named a contact that is not in the workspace |
### 409 Conflict
@@ -467,6 +477,8 @@ A few conflicts carry their own `code`. The mailbox import's are under [mailbox
| `code` | Status | Meaning |
|--------|--------|---------|
| `contact_email_taken` | 409 | The address given to [update a contact](/api/reference/contacts/#update-a-contact) already belongs to another contact |
| `lead_cc_lead_is_copied` | 409 | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) on a lead that is itself copied on another lead in the campaign, so it sends nothing of its own to copy anyone on |
| `lead_cc_has_copies` | 409 | [Set a lead's CC](/api/reference/campaigns/#set-a-leads-cc) names a contact that has copies of their own in the campaign |
| `mailbox_is_seed` | 409 | Warmup was started or resumed on a placement seed inbox. See [placement test refusals](#placement-test-refusals) |
| `mailbox_not_google_signin` | 409 | `POST /emails/onboarding/app-password/:id` on a mailbox that is not connected with per-mailbox Google sign-in. See [app password switch refusals](#app-password-switch-refusals) |
| `mailbox_cloud_unenroll_failed` | 409 | The mailbox is linked to [Warmbly Cloud](/guides/warmbly-cloud/) and its link could not be released, so `DELETE /emails/{id}` would leave Warmbly Cloud holding its credential or its claim on it. The mailbox record remains, and restoration onto its worker is attempted |
+4 -2
View File
@@ -102,7 +102,7 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y
| `search_contacts` | Find contacts by text | `READ_CONTACTS` |
| `get_contact` | Read one contact | `READ_CONTACTS` |
| `update_contact_fields` | Update contact fields | `WRITE_CONTACTS` |
| `add_tag` / `remove_tag` | Tag a contact | `WRITE_CONTACTS` |
| `add_tag` / `remove_tag` | Add or remove a label on a contact | `WRITE_CONTACTS` |
| `list_campaigns` | List campaigns | `READ_CAMPAIGNS` |
| `get_campaign_stats` | Campaign stats, all time or for the emails sent from `from` to `to` | `READ_ANALYTICS` |
| `list_campaign_leads` | A campaign's leads with derived status + totals | `READ_CONTACTS` |
@@ -113,8 +113,10 @@ Every tool is gated by its API permission. `tools/list` returns only the tools y
| `list_mailboxes` | Sender mailboxes with health + warmup state | `READ_ANALYTICS` |
| `list_placement_tests` / `get_placement_test` | Inbox placement tests and where each copy landed | `READ_ANALYTICS` |
| `run_placement_test` | Send a step or template to the seed inboxes from one mailbox | `SEND_CAMPAIGNS` |
| `list_placement_batches` / `get_placement_batch` | Placement batches, their progress, and placement by sending domain, sending provider and recipient provider | `READ_ANALYTICS` |
| `run_placement_batch` | Run the same placement test from a campaign's senders or the whole workspace, optionally sampled. Signed-in members only: an API key starts a batch with `POST /placement/batches`, which applies the key's mailbox limits | `SEND_CAMPAIGNS` |
| `add_contact` / `delete_contact` | Create or delete a contact | `WRITE_CONTACTS` |
| `bulk_edit_contacts` | Tag or subscribe many contacts at once | `BULK_CONTACTS` |
| `bulk_edit_contacts` | Label or subscribe many contacts at once | `BULK_CONTACTS` |
| `get_contact_timeline` / `get_contact_sent_emails` | Read a contact's activity and sent mail | `READ_CONTACTS` |
| `list_segments` / `get_segment` / `list_segment_fields` | Read saved audiences, and the fields a condition can name | `READ_CONTACTS` |
| `preview_segment` | Count what a set of conditions would match, saving nothing | `READ_CONTACTS` |
+1 -1
View File
@@ -49,7 +49,7 @@ Events arrive as channel messages whose event name is the event type, for exampl
`CONTACT_IMPORT_PROGRESS` fires on the org channel as a [background contact import](/api/reference/contacts/#background-imports) moves: when it is queued and starts, at most once a second while its rows settle, and when it completes, fails, or is cancelled. It carries `org_id`, `import_id` and `status` (`queued`, `running`, `completed`, `failed` or `cancelled`), not the rows, so refetch `GET /contacts/imports/:id` on receipt. It requires `view_contacts`.
`PLACEMENT_TEST_UPDATED` fires on the org channel when an [inbox placement test](/guides/placement-tests/) starts, each time one of its copies gets a verdict, and when it finishes or is cancelled. It carries `org_id`, `test_id`, `status` (`running`, `completed`, `cancelled` or `failed`) and, for a test of a campaign step, `campaign_id`, not the results, so refetch `GET /placement/tests/:id` on receipt. It requires `view_analytics`, like the endpoints that read results.
`PLACEMENT_TEST_UPDATED` fires on the org channel when an [inbox placement test](/guides/placement-tests/) starts, each time one of its copies gets a verdict, and when it finishes or is cancelled. It carries `org_id`, `test_id`, `status` (`running`, `completed`, `cancelled` or `failed`) and, for a test of a campaign step, `campaign_id`, not the results, so refetch `GET /placement/tests/:id` on receipt. A [placement batch](/guides/placement-tests/#testing-a-fleet-with-batches) sends the same event with `batch_id` in place of `test_id` and the batch's status when it starts, skips or defers a mailbox, and finishes; its tests send their own events as they run, each with its `test_id`. It requires `view_analytics`, like the endpoints that read results.
Permission filtering happens per event on the org channel: for example inbox events require `access_unibox`, campaign pulses require `view_campaigns`, placement tests require `view_analytics`, mailbox and mailbox import events require `manage_emails`, member changes require `manage_team`, and billing events require `manage_billing`. A member without the permission simply never receives the event.
@@ -375,9 +375,9 @@ Auth: Session only (not available to API keys).
`GET /auth/me`
Returns the signed-in user, including admin flags and the label registries (folders, tags, categories) the dashboard needs on initial load.
Returns the signed-in user, including admin flags and the label registries (folders, tags, and the workspace labels returned as `categories`) the dashboard needs on initial load.
The registries belong to the workspace, not to the caller: every member of an organization sees the same folders, tags and categories, whoever created them. They are read for the session's currently selected workspace, so switching workspaces changes what comes back.
The registries belong to the workspace, not to the caller: every member of an organization sees the same folders, tags and labels, whoever created them. They are read for the session's currently selected workspace, so switching workspaces changes what comes back.
Auth: Session only (not available to API keys).
+104 -3
View File
@@ -194,8 +194,8 @@ Create a campaign. Only `name` is required, every other field is optional and ap
| `daily_limit` | integer | no | Per-campaign daily send cap. |
| `unsubscribe_header` | boolean | no | Add the RFC 8058 one-click unsubscribe header. |
| `risky_emails` | boolean | no | Allow sending to risky/unverified addresses. |
| `cc` | string[] | no | Static CC list. |
| `bcc` | string[] | no | Static BCC list. |
| `cc` | string[] | no | Static CC list, on every email to every lead. An address that is suppressed, is the lead's own, or has bounced on this campaign is left off that email. To copy someone on one lead only, see [set a lead's CC](#set-a-leads-cc). |
| `bcc` | string[] | no | Static BCC list, filtered the same way. |
| `start_date` | string (RFC 3339), nullable | no | Earliest send time. Today or later; omit or send `null` to start as soon as the campaign is active. |
| `end_date` | string (RFC 3339), nullable | no | Latest send time. Must be in the future; omit or send `null` for an open-ended campaign. |
| `timezone` | string | no | IANA timezone for the schedule. Omit or send `""` to follow the workspace timezone, resolved on every read (UTC when none is set). The response carries the zone in use as `effective_timezone`. |
@@ -815,7 +815,7 @@ Read whether one contact's flow inside this campaign is currently held. A hold p
}
```
`hold` is absent when the lead is not held, including for a dated hold that has since expired. `until` is absent when the hold has no end, in which case only a resume lifts it. `source` is `out_of_office` or `manual`.
`hold` is absent when the lead is not held, including for a dated hold that has since expired. `until` is absent when the hold has no end, in which case only a resume lifts it. `source` is `out_of_office`, `inbox_tagging`, `manual`, or `cc` while the contact is [copied on another lead's emails](#set-a-leads-cc) in this campaign; a `cc` hold's `reason` is that lead's address.
### Errors
@@ -885,6 +885,107 @@ Resuming a lead that is not held succeeds and changes nothing, so a retry is saf
| --- | --- | --- |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
## Get a lead's CC
`GET /campaigns/:id/leads/:contact_id/cc`
List the contacts copied on every email this campaign sends one lead. See [copying colleagues on one lead](/guides/campaigns/#copying-colleagues-on-one-lead). **Scope** `READ_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `view_campaigns` and `view_contacts`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. Must already be a lead of the campaign. |
### Response
```json
{
"campaign_id": "8f1d6b2e-2b7a-4c9e-9a1f-0e6d4c3b2a10",
"contact_id": "3a5e9c71-4f2b-4d88-9a0c-1b7e5d2f6c34",
"cc": [
{
"contact_id": "b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59",
"email": "jonas@acme.example",
"first_name": "Jonas",
"last_name": "Weber",
"company": "Acme GmbH",
"status": "active"
}
]
}
```
`status` says whether the next email copies them: `active` does; `unsubscribed` (opted out or suppressed), `bounced` (bounced on this thread or on a campaign email of their own) and `undeliverable` (failed verification under the campaign's rules) are left off until that changes. `bounced_at` is set when a bounce was attributed to this copy on this lead's thread. The list is in the order it was set.
### Errors
| Status | Code | When |
| --- | --- | --- |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
## Set a lead's CC
`PUT /campaigns/:id/leads/:contact_id/cc`
Replace the contacts copied on every email this campaign sends one lead, follow-ups included. An empty list removes them all. **Scope** `WRITE_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `manage_campaigns` and `view_contacts`.
The body is the whole list, so a retry lands on the same state and no `Idempotency-Key` is needed.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
| `id` | path | uuid | Campaign id. |
| `contact_id` | path | uuid | Contact id. Must already be a lead of the campaign. |
| `contact_ids` | body | array of uuid | Contacts of the workspace to copy, at most `2`. Duplicates are ignored. |
A copied contact who is also a lead of this campaign has their own sequence held with `source` `cc` for as long as any lead copies them, so they never get two threads from one campaign. Removing the copy releases the hold.
### Request body
```json
{ "contact_ids": ["b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59"] }
```
### Response
Same shape as [get a lead's CC](#get-a-leads-cc).
### Errors
| Status | Code | When |
| --- | --- | --- |
| `400` | `bad_request` | A `contact_ids` entry is not a uuid. |
| `400` | `lead_cc_limit` | More than `2` contacts. |
| `400` | `lead_cc_self` | The lead is in its own list. |
| `404` | `not_found` | The campaign is not the caller's organization's, or the contact is not a lead of it. |
| `404` | `lead_cc_contact_not_found` | A contact to copy is not a contact of the workspace. |
| `409` | `lead_cc_lead_is_copied` | The lead is copied on another lead in this campaign, so it sends nothing of its own to copy anyone on. |
| `409` | `lead_cc_has_copies` | A contact to copy has copies of their own in this campaign. |
## Suggest colleagues to CC
`GET /campaigns/:id/leads/:contact_id/cc/suggestions`
Up to eight contacts who look like the lead's colleagues: the same company name, or the same email domain when that domain belongs to a company rather than a personal mail service. Unsubscribed contacts and ones already copied are left out. **Scope** `READ_CAMPAIGNS` and `READ_CONTACTS` · **Org permission** `view_campaigns` and `view_contacts`.
### Response
```json
{
"data": [
{
"contact_id": "b61c0e84-2d9f-4a57-8e3b-6f0a1c2d4e59",
"email": "jonas@acme.example",
"first_name": "Jonas",
"last_name": "Weber",
"company": "Acme GmbH",
"reason": "company"
}
]
}
```
`reason` is `company` when the company names match and `domain` when only the email domain does. Company matches come first.
## Get campaign logs
`GET /campaigns/:id/logs`
+22 -22
View File
@@ -17,7 +17,7 @@ Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
| --- | --- | --- | --- |
| `cursor` | query | string | Opaque pagination cursor from the previous page's `pagination.next_cursor`. It carries the exact position of the next page under the ordering it was issued for, so rows that share a sort value are never skipped or repeated. A malformed cursor, or one replayed with a different `sort_by` or `reverse` than it was issued under, is a `400`. |
| `limit` | query | string | Page size (numeric string). |
| `category` | query | string | Convenience filter for a single category ID. |
| `category` | query | string | Convenience filter for a single label ID. |
### Request body
@@ -30,7 +30,7 @@ Every field is optional; an empty body matches all contacts in the organization.
| `campaign_ids` | string[] | No | Contact must be in ALL of these campaigns. |
| `lead_status` | string | No | Filter to one derived lead status: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `paused`, `undeliverable`, or `unsubscribed`. Requires exactly one `campaign_ids` entry, otherwise the request is rejected with `lead_filter_requires_campaign`; an unknown value is rejected with `invalid_lead_status`. |
| `engagement` | string | No | Filter by engagement inside that campaign: `opened`, `not_opened`, `clicked`, `not_clicked`, `replied`, `not_replied`, or `bounced`. `opened` means a human open (machine opens never count); the `not_*` values match only leads sent at least one step. Combines with `lead_status` as AND. Requires exactly one `campaign_ids` entry (`lead_filter_requires_campaign`); an unknown value is rejected with `invalid_engagement`. |
| `category_ids` | string[] | No | Contact must have ALL of these categories. |
| `category_ids` | string[] | No | Label IDs. The contact must have ALL of these labels. |
| `segment_ids` | string[] | No | Contact must be a member of ALL of these segments (conditions plus manual overrides). An id that is not a valid UUID is rejected with `400`; an unknown segment matches nothing. |
| `verification_status` | string | No | Filter by verification verdict: `valid`, `risky`, `invalid`, or `unknown`. |
| `mail_hosts` | string[] | No | Contacts whose inbox is hosted by any of these `mail_host` values (see [Email provider](#email-provider)). `""` matches contacts with no known provider: not checked yet, or a domain with no mail server. An unknown value is rejected with `invalid_mail_host`. |
@@ -102,7 +102,7 @@ Every contact carries `mail_host`, who hosts the inbox the address belongs to, a
`mail_host` is one of `google_workspace`, `gmail`, `microsoft365`, `outlook`, `zoho`, `yahoo`, `aol`, `icloud`, `fastmail`, `godaddy`, `namecheap`, `ionos`, `hostinger`, `ovh`, `migadu`, `purelymail`, `rackspace`, `yandex`, `gmx`, `proton`, or `other` (the domain receives mail, on a host Warmbly does not name), and is empty until the check has run or when the domain has no mail server. `esp_provider` is `gmail` for either Google product, `outlook` for either Microsoft one, `other` for the rest, and empty with `mail_host`. Campaign [ESP matching](/guides/campaigns/) pairs senders and recipients by `esp_provider`. A domain whose provider could not be read is checked again a week later; one whose lookup failed is retried within the hour.
When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `machine_opened`, `clicked`, `replied`, `bounced`, `current_step`, `sender`, `last_activity_at`, `hold` when held, and `failure_reason` when failed). `sender` is the mailbox address the lead's whole sequence sends from, fixed when its first email went out and absent until then. `opened` counts steps opened by a person; steps fetched automatically by a mail client (Apple Mail Privacy Protection and similar) are in `machine_opened` instead, matching the machine opens the analytics summary reports. The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `paused` (the lead's flow is held, by an out-of-office auto-reply or by hand; the `hold` object carries `since`, `until`, `reason` and `source`), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets.
When the search filters by exactly one campaign, each contact additionally carries a `campaign_lead` object with its processing state inside that campaign (`status`, `sent`, `opened`, `machine_opened`, `clicked`, `replied`, `bounced`, `current_step`, `sender`, `last_activity_at`, `hold` when held, `cc` when the lead copies anyone, and `failure_reason` when failed). `cc` lists the contacts copied on every email to the lead, in the shape [get a lead's CC](/api/reference/campaigns/#get-a-leads-cc) returns. `sender` is the mailbox address the lead's whole sequence sends from, fixed when its first email went out and absent until then. `opened` counts steps opened by a person; steps fetched automatically by a mail client (Apple Mail Privacy Protection and similar) are in `machine_opened` instead, matching the machine opens the analytics summary reports. The `status` derivation, highest priority first, is `unsubscribed` (not subscribed), then `bounced`, `replied`, `failed` (a step could not be sent after every retry; `failure_reason` carries the sending worker's reason), `completed` (every email step sent, no reply), `paused` (the lead's flow is held, by an out-of-office auto-reply, by hand, or because the contact is copied on another lead's emails in the campaign; the `hold` object carries `since`, `until`, `reason` and `source`), `active` (some steps sent, more to send), `undeliverable` (pre-send verification refused the address, so the campaign skips the lead and never sends to it), and `pending` (queued, nothing sent). A step counts as sent only once the sending worker has delivered it to the mailbox provider; a send the worker could not complete is retried on the campaign's next pass and never shows as sent. The `lead_status` filter narrows to one of these buckets.
When the search filters by exactly one campaign, the first page (no `cursor`) also includes a `lead_counts` object: per-status lead totals for that campaign, independent of the `lead_status` and `engagement` filters so every scope's total is available at once. The status buckets include `paused`. Alongside them it carries engagement totals that match the `engagement` filter: `contacted` (leads sent at least one step), `opened` (a human open on any step), `clicked`, and `replied_any` (a reply on any step, whatever the derived status). `providers` splits the campaign's leads by [email provider](#email-provider) family, the grouping ESP matching uses: `gmail`, `outlook`, `other` (including checked domains with no known provider, which ESP matching treats the same way), and `undetected` for leads whose provider has not been read yet.
@@ -160,7 +160,7 @@ A JSON array of contact objects (at least one, up to the per-request maximum; an
| `company` | string | No | Company name. |
| `phone` | string | No | Phone number. |
| `campaigns` | string[] | No | Campaign IDs to add the contact to. |
| `categories` | string[] | No | Category IDs to assign. |
| `categories` | string[] | No | Label IDs to put on the contact. |
| `segments` | string[] | No | Segment IDs to pin the contact into, as a manual include override, so it belongs whether or not the conditions match it. An unknown id is rejected with `400` before any contact is written, and the override is written in the same transaction as the contact, so a success response always means the membership exists. |
| `custom_fields` | object | No | String key/value custom fields. Keys may use letters, numbers, underscores, spaces, and dashes. |
| `subscribed` | boolean | No | Marketing-consent flag. Omit it to let a new contact default to subscribed and an existing one keep whatever it already had. |
@@ -212,16 +212,16 @@ Returns the created contacts as a bare JSON array (same contact shape as search)
Every endpoint that acts on a set of contacts (`PATCH /contacts`, `DELETE /contacts`, `POST /contacts/verification`, `POST /contacts/research/batch`, `POST /segments/:id/members` and `POST /integrations/connections/:id/push`) names that set one of two ways.
**By id.** A `contacts` array of up to 1000 ids, the original shape. Nothing about it has changed.
**By id.** A `contacts` array of up to 10,000 ids, the original shape.
**By filter.** Set `all` to `true` and pass the same body `POST /contacts/search` takes as `filters`. The server resolves that search and applies the action to every contact it matches, so one call can cover far more than a page. `exclude` drops ids back out of the resolved set, which is how the dashboard handles rows unticked after a select-all.
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contacts` | string[] | Yes, unless `all` | Contact ids, up to 1000. |
| `contacts` | string[] | Yes, unless `all` | Contact ids, up to 10,000 (`too_many_contacts` past that). |
| `all` | boolean | No | Resolve the selection from `filters` instead of `contacts`. |
| `filters` | object | Yes when `all` | A [contact search](#search-contacts) body. The action applies to everything it matches. |
| `exclude` | string[] | No | Contact ids to drop from the resolved set, up to 50,000 (`too_many_contacts` past that). Ignored unless `all`. |
| `exclude` | string[] | No | Contact ids to drop from the resolved set, up to 250,000 (`too_many_contacts` past that). Ignored unless `all`. |
```json
{
@@ -231,13 +231,13 @@ Every endpoint that acts on a set of contacts (`PATCH /contacts`, `DELETE /conta
}
```
A filter selection that matches more than 50,000 contacts is refused with `selection_too_large` rather than truncated; narrow it and repeat. One that matches nothing is a `400`. `POST /integrations/connections/:id/push` accepts the same shape and answers the same way, but still caps the resolved set at 500, because it calls the CRM once per contact inside the request.
A filter selection that matches more than 250,000 contacts is refused with `selection_too_large` rather than truncated; narrow it and repeat. One that matches nothing is a `400`. `POST /integrations/connections/:id/push` and `POST /contacts/research/batch` accept the same shape and answer the same way, but still cap the resolved set at 500: a push calls the CRM once per contact inside the request, and each research run spends AI credits.
## Bulk update contacts
`PATCH /contacts`
Applies one set of edits across a [selection of contacts](#selecting-contacts-for-a-bulk-action): add/remove campaigns and categories, set custom-field operations, and toggle subscription.
Applies one set of edits across a [selection of contacts](#selecting-contacts-for-a-bulk-action): add/remove campaigns and labels (`add_categories`, `remove_categories`), set custom-field operations, and toggle subscription.
Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts`
@@ -245,12 +245,12 @@ Auth: **Scope** `BULK_CONTACTS` · **Org permission** `manage_contacts`
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contacts` | string[] | Yes, unless `all` | Contact IDs to edit (1 to 1000). |
| `contacts` | string[] | Yes, unless `all` | Contact IDs to edit (1 to 10,000). |
| `all`, `filters`, `exclude` | — | No | Select by filter instead; see [selecting contacts](#selecting-contacts-for-a-bulk-action). |
| `add_campaigns` | string[] | No | Campaign IDs to add. |
| `remove_campaigns` | string[] | No | Campaign IDs to remove. |
| `add_categories` | string[] | No | Category IDs to add. |
| `remove_categories` | string[] | No | Category IDs to remove. |
| `add_categories` | string[] | No | Label IDs to add. |
| `remove_categories` | string[] | No | Label IDs to remove. |
| `fields` | array | No | Custom-field operations: `{ "type", "key", "value" }` where `type` is `ADD`, `EDIT`, `DELETE`, or `RENAME`. |
| `subscribe` | boolean | No | Set subscription status for all listed contacts. |
@@ -409,10 +409,10 @@ Send `multipart/form-data` with a `file` field and an `options` field containing
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `mapping` | array | Yes | Column mappings: `{ "index", "target", "custom_key", "verification_provider" }`. `target` is `ignore`, `email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `categories`, `verification_status`, or `custom` with the name in `custom_key`. `custom:<key>` is still accepted as the older spelling. Exactly one column must map to `email`. A `verification_status` column is read in the vocabulary named by `verification_provider` (see [Create contacts](#create-contacts)), or recognised value by value when it is omitted; a cell nobody recognises leaves that contact unverified rather than failing the row. The preview suggests this target itself when a column's header or values look like another service's results. |
| `mapping` | array | Yes | Column mappings: `{ "index", "target", "custom_key", "verification_provider" }`. `target` is `ignore`, `email`, `first_name`, `last_name`, `company`, `phone`, `subscribed`, `categories` (label names), `verification_status`, or `custom` with the name in `custom_key`. `custom:<key>` is still accepted as the older spelling. Exactly one column must map to `email`. A `verification_status` column is read in the vocabulary named by `verification_provider` (see [Create contacts](#create-contacts)), or recognised value by value when it is omitted; a cell nobody recognises leaves that contact unverified rather than failing the row. The preview suggests this target itself when a column's header or values look like another service's results. |
| `dedup` | string | Yes | `skip`, `update`, or `create_duplicate` for rows whose email matches an existing contact. |
| `has_header` | boolean | Yes | Whether the first row is a header. |
| `category_ids` | string[] | No | Categories to assign to imported contacts. |
| `category_ids` | string[] | No | Label IDs to put on imported contacts. |
| `campaign_ids` | string[] | No | Campaigns to add imported contacts to. |
| `segment_ids` | string[] | No | Segments to pin imported contacts into, as a manual include override. Applies to imported, updated, and skipped-but-linked contacts alike. An id that is not a valid UUID, or that names no segment in the organization, is rejected with `400` before any row is written. |
| `subscribed_default` | boolean | No | Subscription state for new contacts when no subscribed column is mapped. Defaults to true. |
@@ -523,7 +523,7 @@ Reads a draft under a mapping and writes nothing. `409` once the import has star
}
```
Every row lands in exactly one of `new`, `existing`, `duplicates_in_file`, `invalid`, or `conflicts` (an address the caller holds as a contact in another organization). `invalid_samples` lists up to 25 of the invalid and conflicting rows. `problem`, when present, is why starting would be refused as a whole: the plan's contact limit, or too many distinct categories.
Every row lands in exactly one of `new`, `existing`, `duplicates_in_file`, `invalid`, or `conflicts` (an address the caller holds as a contact in another organization). `invalid_samples` lists up to 25 of the invalid and conflicting rows. `problem`, when present, is why starting would be refused as a whole: the plan's contact limit, or too many distinct labels.
### Start an import
@@ -739,7 +739,7 @@ When the contact is suppressed, `suppression` is an object: `{ "id", "kind", "va
`PATCH /contacts/:id`
Partially updates a single contact. Only the fields present are changed. Campaign and category lists can be set wholesale or adjusted with diff-style add/remove.
Partially updates a single contact. Only the fields present are changed. Campaign and label lists (`categories`) can be set wholesale or adjusted with diff-style add/remove.
`email` replaces the contact's address. It is stored lowercased, a display name (`Dana Reyes <dana@acme.com>`) is reduced to the address inside it, and anything that is not an address answers `400`. The address has to be free: one another contact already holds answers `409` with `code` `contact_email_taken` rather than merging the two. A changed address drops the contact's verification verdict back to `unknown`, clears the delivery evidence behind it and forgets its `mail_host` and `esp_provider`, because all of them belonged to the old mailbox; the next verification pass checks the new address and the provider is read again from the new domain. Steps already sent went to the old address and keep their history.
@@ -761,7 +761,7 @@ Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`
| `custom_fields` | object | No | Replaces the custom-fields map. |
| `subscribed` | boolean | No | Subscription status. |
| `campaigns` | string[] | No | Set the full campaign membership (nil leaves as-is). |
| `categories` | string[] | No | Set the full category list (nil leaves as-is). |
| `categories` | string[] | No | Set the full list of label IDs (nil leaves as-is). |
| `add_categories` | string[] | No | Diff-style add (ignored when `categories` is set). |
| `remove_categories` | string[] | No | Diff-style remove (ignored when `categories` is set). |
@@ -865,7 +865,7 @@ Returns a `data` array plus a `pagination` envelope.
`GET /contacts/:id/timeline`
Returns the selected organization's merged activity feed for a contact: sends, opens, clicks (one per link, naming the link), replies, bounces, deliverability and suppression events, notes, meeting bookings, and lifecycle events (the contact's creation with its first-touch source, and every time it joined or left a campaign or a category). Every member with permission to view contacts receives the same timeline, regardless of who created the contact or its campaigns. A request with no selected organization returns `400`, and a contact outside the selected organization returns `404`.
Returns the selected organization's merged activity feed for a contact: sends, opens, clicks (one per link, naming the link), replies, bounces, deliverability and suppression events, notes, meeting bookings, and lifecycle events (the contact's creation with its first-touch source, and every time it joined or left a campaign or gained or lost a label). Every member with permission to view contacts receives the same timeline, regardless of who created the contact or its campaigns. A request with no selected organization returns `400`, and a contact outside the selected organization returns `404`.
Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
@@ -947,7 +947,7 @@ Returns a `data` array and the standard `pagination` envelope. Paginate by passi
A `form_submitted` event carries `form_id` and `form_name`. A `page_hit` event is a page view on your own site from a browser tied to the contact through an email-link ticket (see [Website tracking](/guides/website-tracking/)); `subject` is the page title, or its path when the page has none, and `page_hit` carries the full view: `url`, `path`, `title`, `referrer`, `referrer_domain`, `landing` (the first view of a session), the `utm_*` parameters, `device_type`, `os`, `browser`, `browser_version`, `device_brand`, `language`, `timezone`, `screen_width`, `screen_height`, and `country_code`, `region`, `city` when known.
Lifecycle events carry the name of what changed as it was at the time (`campaign_name`, or `category_id` plus `category_title`), so a later rename or deletion does not rewrite history. A `contact_created` event carries `source` (`manual`, `campaign`, `import`, `sheet_sync`, `api`, `form`, `automation`, `ai_assistant`, or `unknown` for contacts that predate attribution) and `source_detail` (the file, campaign, sheet, form, automation or API key name). The same values are on the contact itself as `source`, `source_detail` and `first_seen_at`, and never change after creation.
Lifecycle events carry the name of what changed as it was at the time (`campaign_name`, or, for a label, `category_id` plus `category_title`), so a later rename or deletion does not rewrite history. A `contact_created` event carries `source` (`manual`, `campaign`, `import`, `sheet_sync`, `api`, `form`, `automation`, `ai_assistant`, or `unknown` for contacts that predate attribution) and `source_detail` (the file, campaign, sheet, form, automation or API key name). The same values are on the contact itself as `source`, `source_detail` and `first_seen_at`, and never change after creation.
## Get a contact's campaign state
@@ -1008,7 +1008,7 @@ Auth: **Scope** `READ_CONTACTS` · **Org permission** `view_contacts`
`sender_id` and `sender_email` are the mailbox this lead's whole sequence sends from. Rotation picks it when the first email goes out and every follow-up keeps it, so the contact only ever hears from one address; both fields are absent until that first email. They change only when that mailbox can no longer send for the campaign.
`lead_status` uses the same values as the campaign Leads view: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `paused`, `unsubscribed`, or `undeliverable`. A held lead also carries a `hold` object (`since`, `until`, `reason`, `source`) and keeps its `next` action, with the hold as the reason it is waiting; see [pause a lead](/api/reference/campaigns/#pause-a-lead). Each step carries whichever of `sent_at`, `opened_at`, `clicked_at`, `replied_at`, `bounced_at` and `failed_at` apply, plus `attempts` and `in_flight` (reserved for a worker whose result has not come back). `opened_at` is a person's open, as it is in the Leads view: a step a mail client prefetched or a security gateway scanned carries no `opened_at`. While a branch condition is undecided, `next.step_id` is absent and `next.step_label` says the step depends on the contact's response.
`lead_status` uses the same values as the campaign Leads view: `pending`, `active`, `completed`, `replied`, `bounced`, `failed`, `paused`, `unsubscribed`, or `undeliverable`. A held lead also carries a `hold` object (`since`, `until`, `reason`, `source`) and keeps its `next` action, with the hold as the reason it is waiting; see [pause a lead](/api/reference/campaigns/#pause-a-lead). `cc` lists the contacts copied on every email to the contact in that campaign, empty when none; see [get a lead's CC](/api/reference/campaigns/#get-a-leads-cc). Each step carries whichever of `sent_at`, `opened_at`, `clicked_at`, `replied_at`, `bounced_at` and `failed_at` apply, plus `attempts` and `in_flight` (reserved for a worker whose result has not come back). `opened_at` is a person's open, as it is in the Leads view: a step a mail client prefetched or a security gateway scanned carries no `opened_at`. While a branch condition is undecided, `next.step_id` is absent and `next.step_label` says the step depends on the contact's response.
## List a contact's activities
@@ -1257,7 +1257,7 @@ Each condition names a `field`, an `operator`, and either a `value` (scalar oper
| bool | `subscribed`, `suppressed`, `is_catch_all` | `is_true`, `is_false` | none |
| date | `created_at`, `updated_at`, `last_sent_at`, `last_opened_at`, `last_clicked_at`, `last_replied_at` | `within_days`, `not_within_days` (`value` is a day count, 1 to 3650); `before`, `after` (`value` is `YYYY-MM-DD` or RFC 3339); `is_empty`, `is_not_empty` | see operators |
| number | `campaign_count`, `emails_sent`, `emails_opened`, `emails_clicked`, `emails_replied`, `emails_bounced` | `equals`, `not_equals`, `gt`, `gte`, `lt`, `lte` | `value`, a whole number |
| category | `category` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, category ids |
| category | `category` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, label ids |
| campaign | `campaign` | `in`, `not_in`, `is_empty`, `is_not_empty` | `values`, campaign ids |
| segment | `segment` | `in`, `not_in` | `values`, segment ids; at most five levels deep, no loops |
@@ -1287,7 +1287,7 @@ Counts the contacts an unsaved definition would match. Send `match` and `conditi
{ "contacts": ["…", "…"], "mode": "include" }
```
`mode` is `include` (pin in), `exclude` (pin out) or `auto` (clear the override). Up to 1,000 contact ids per call; ids outside the organization are ignored. Returns `{ "updated": n }`. The body also takes a [filter selection](#selecting-contacts-for-a-bulk-action) (`all`, `filters`, `exclude`) instead of `contacts`, for pinning everything a search matches.
`mode` is `include` (pin in), `exclude` (pin out) or `auto` (clear the override). Up to 10,000 contact ids per call, or a filter selection of up to 250,000; ids outside the organization are ignored. Returns `{ "updated": n }`. The body also takes a [filter selection](#selecting-contacts-for-a-bulk-action) (`all`, `filters`, `exclude`) instead of `contacts`, for pinning everything a search matches.
`POST /segments/:id/members/lookup` takes `{ "contacts": [...] }` and returns `{ "data": { "<contact id>": "include" | "exclude" } }` for the contacts that carry an override.
@@ -1123,7 +1123,7 @@ Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`.
| `column_mapping` | array | Yes | Column-to-target mappings ([same shape as contact import](/api/reference/contacts/)). Validated on save, not on the next sync: a mapping with no `email` column, or a custom field whose name Warmbly cannot use, is a `400` here. |
| `dedup` | string | No | Collision strategy: `skip`, `update`, or `create_duplicate`. |
| `target_campaign_id` | uuid | No | Enrol new/updated leads into this campaign on each sync. |
| `category_ids` | string[] | No | Categories to assign to synced leads. |
| `category_ids` | string[] | No | Label IDs to put on synced leads. |
| `segment_ids` | string[] | No | Segments every synced row is pinned into as a manual include override, on every run. Each id must name a segment in the organization or the save is a `400`; a segment deleted later is dropped from the run instead of failing it. |
| `subscribed_default` | boolean | No | Default subscription state for new contacts. |
| `label` | string | No | Friendly name. |
@@ -1215,7 +1215,7 @@ Auth: **Scope** `WRITE_CONTACTS` · **Org permission** `manage_contacts`.
| `dedup` | string | No | Collision strategy. |
| `target_campaign_id` | uuid | No | New target campaign. |
| `clear_campaign` | boolean | No | When `true`, unsets the target campaign. |
| `category_ids` | string[] | No | Replacement category set. |
| `category_ids` | string[] | No | Replacement set of label IDs. |
| `segment_ids` | string[] | No | Replacement segment target set. An unknown id is a `400`; an empty array clears the targets. |
| `subscribed_default` | boolean | No | Default subscription state. |
| `label` | string | No | New label. |
+235 -3
View File
@@ -1,13 +1,13 @@
---
title: Inbox placement tests
description: Start placement tests, read where each copy landed, manage the workspace's seed inboxes, and schedule a campaign's placement monitor.
description: Start placement tests and batches, read where each copy landed, manage the workspace's seed inboxes, and schedule a campaign's placement monitor.
---
An inbox placement test sends a template or a campaign step from one of the workspace's mailboxes to a panel of seed inboxes and reports where each copy landed: the primary inbox, a Gmail tab, spam, or nowhere. The [placement tests guide](/guides/placement-tests/) explains how copies are rendered, sent and found, and how to read a result.
Every route is scoped to the selected organization (API keys are always bound to one), and every id in a request is checked against it: a sending mailbox, campaign, step or contact from another workspace is a `404`. Errors follow the shared `{error, message, code, request_id}` envelope; the codes these routes add are listed under [placement test refusals](/api/error-codes/#placement-test-refusals).
Changes are pushed live as a `PLACEMENT_TEST_UPDATED` event on the organization's [realtime](/api/realtime/) channel when a test starts, on every new verdict, and when it finishes or is cancelled.
Changes are pushed live as a `PLACEMENT_TEST_UPDATED` event on the organization's [realtime](/api/realtime/) channel when a test starts, on every new verdict, and when it finishes or is cancelled. A batch sends the same event with `batch_id` instead of `test_id` when it starts, skips or defers a mailbox, and finishes.
## Get the overview
@@ -163,6 +163,8 @@ Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`
| `cursor` | query | string | Opaque cursor from a previous `pagination.next_cursor`. One this API did not issue is a `400` |
| `campaign_id` | query | uuid | Only tests of this campaign |
Tests a [batch](#start-a-batch) started are left out; read them through their batch.
### Response
```json
@@ -202,7 +204,8 @@ Each test carries the fields shown under [start a test](#start-a-test); the exam
| Field | Description |
|-------|-------------|
| `status` | `running`, `completed`, `cancelled` or `failed`. A test fails when no copy was delivered at all, with the reason in `error` |
| `origin` | `manual` (started by a person or a key), `monitor` (a campaign's placement monitor), `admin` (an operator) or `remote` |
| `origin` | `manual` (started by a person or a key), `monitor` (a campaign's placement monitor), `batch` (a [placement batch](#start-a-batch), named by `batch_id`), `admin` (an operator) or `remote` |
| `batch_id` | The batch that started the test, `null` otherwise |
| `pace` | `spaced` or `quick` |
| `credits_charged` | Credits the test cost past the month's free tests, `0` for a free test |
| `credits_refunded` | Credits a paid test that delivered no copy got back. Monthly credits whose allowance has reset since the charge are not returned, so this can be less than `credits_charged` |
@@ -272,6 +275,235 @@ Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`
`200 OK` with the test, in the list shape. A test that has already finished answers `409` with `placement_not_running`.
## Start a batch
`POST /placement/batches`
Runs the same test from many sending mailboxes. The request is validated as a whole (the copy, panel, tracking, allowance and credits agreed), the senders are resolved and written down, and the batch is answered `queued` right away. Nothing is sent by the request itself: the backend starts the senders a few at a time, each with its own test, checking each one's daily limit, connection and worker when its turn comes. See [testing a fleet with batches](/guides/placement-tests/#testing-a-fleet-with-batches). Supports `Idempotency-Key`.
Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`
### Request body
The copy fields are the ones [start a test](#start-a-test) takes: `campaign_id`, `sequence_id`, `contact_id`, `subject`, `body_html`, `body_plain`, `tracking`, `panel`, `families` and `seed_ids`. `pace` is always `spaced` for a batch; `quick` is a `400`. The copy is resolved once, when the batch is created, so every sender tests the same email. Instead of `sender_account_id` a batch takes exactly one of:
| Field | Type | Description |
|-------|------|-------------|
| `sender_account_ids` | uuid[] | These mailboxes. Duplicates are dropped; one that is not in the workspace, or is a seed inbox, is a `404`, and one an API key with a mailbox allowlist may not use is a `403`. A disconnected one is kept and deferred or skipped when its turn comes |
| `sender_scope` | object | Mailboxes resolved on the server, so a fleet of thousands needs no id list |
| `sender_scope.type` | string | `campaign` (every mailbox `campaign_id`'s scheduler sends from) or `workspace` (every mailbox of the workspace) |
| `sender_scope.campaign_id` | uuid | Required with `type` `campaign` |
| `sender_scope.providers` | string[] | Only mailboxes hosted by these families, such as `google_workspace`, `gmail`, `microsoft365`, `outlook` or `other` |
| `sender_scope.domains` | string[] | Only mailboxes sending from these domains |
| `sender_scope.tag_ids` | uuid[] | Only mailboxes carrying one of these tags |
| `sender_scope.include_inactive` | boolean | Keep disconnected mailboxes. Default `false` |
| `sender_scope.untested_days` | integer | Only mailboxes with no finished placement test in this many days, `1` to `365` |
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `sample` | object | no | Which of the resolved mailboxes to keep. Default `{"mode": "all"}` |
| `sample.mode` | string | no | `all`, `random` (`count` mailboxes), `percent` (`percent` of them, rounded up), `per_domain` or `per_provider` (up to `count` from each) |
| `sample.stratify` | string | no | With `random` or `percent` only: `provider` or `domain` splits the sample in proportion to each group's share |
| `on_unavailable` | string | no | `defer` (default: retry a mailbox that cannot send when its turn comes, for up to seven days) or `skip` |
| `max_credits` | integer | no | The most credits you agree to pay across the whole batch for tests past the month's free ones. Needed only when the batch needs more tests than are free: the request is refused with `placement_quota_exceeded`, naming the most it can cost, when it is lower. The batch never spends more |
```json
{
"sender_scope": { "type": "campaign", "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", "providers": ["google_workspace"] },
"sample": { "mode": "percent", "percent": 10, "stratify": "domain" },
"campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
"sequence_id": "c7d8e9f0-0000-0000-0000-000000000002",
"tracking": "compare"
}
```
### Response
`201 Created` with the batch.
```json
{
"data": {
"id": "a9b8c7d6-0000-0000-0000-000000000030",
"created_by": "9c2a0000-0000-0000-0000-000000000001",
"campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
"sequence_id": "c7d8e9f0-0000-0000-0000-000000000002",
"contact_id": "e5f6a7b8-0000-0000-0000-000000000004",
"subject": "Quick question about outbound at Acme",
"tracking": "compare",
"panel": "instance",
"pace": "spaced",
"families": [],
"seed_ids": [],
"on_unavailable": "defer",
"selection": {
"sender_scope": { "type": "campaign", "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001", "providers": ["google_workspace"] },
"sample": { "mode": "percent", "percent": 10, "stratify": "domain" },
"matched": 1126
},
"sender_count": 113,
"max_credits": 0,
"credits_spent": 0,
"status": "queued",
"retry_until": "2026-10-02T14:00:00Z",
"created_at": "2026-09-25T14:00:00Z",
"started_at": null,
"finished_at": null,
"progress": { "total": 113, "queued": 113, "deferred": 0, "running": 0, "completed": 0, "skipped": 0, "failed": 0, "cancelled": 0 },
"summary": { "total": 0, "pending": 0, "inbox": 0, "promotions": 0, "other": 0, "spam": 0, "missing": 0, "failed": 0, "cancelled": 0, "delivered": 0, "inbox_rate": null, "tabs_rate": null, "spam_rate": null, "missing_rate": null }
}
}
```
| Field | Description |
|-------|-------------|
| `status` | `queued` until the first mailbox starts, then `running`; at the end `completed`, `completed_with_warnings` (some mailboxes were skipped or failed), `failed` (none finished a test, with `error`) or `cancelled` |
| `selection` | How the mailboxes were chosen, and how many the scope `matched` before sampling. The mailboxes themselves are the snapshot under [list a batch's senders](#list-a-batchs-senders) |
| `progress` | The batch's mailboxes by status. `running` is a mailbox whose test has not finished yet |
| `summary` | Where the batch's copies landed so far, over the copy the campaign really sends: the tracked half of a tracking comparison |
| `retry_until` | Deferred mailboxes are retried until then, then skipped |
## Preview a batch
`POST /placement/batches/preview`
Takes the body of [start a batch](#start-a-batch) and returns what it would come to, without writing anything or sending anything. The copy fields may be left out to count the mailboxes alone; once any is given, the copy is checked as on start. A random sample is drawn again when the batch starts, so the counts are the same but the mailboxes can differ.
Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`
### Response
```json
{
"data": {
"matched": 1347, "selected": 135, "inactive": 2, "domains": 41,
"providers": [
{ "key": "google_workspace", "label": "Google Workspace", "senders": 80 },
{ "key": "other", "label": "Other provider", "senders": 40 },
{ "key": "microsoft365", "label": "Microsoft 365", "senders": 15 }
],
"variants": 2, "tests": 270, "seeds_per_test": 20, "max_sends": 5400,
"metered": true, "free_tests": 40, "paid_tests": 230, "credits": 5750,
"usage": { "used": 0, "limit": 40, "credits_per_test": 25, "credit_balance": 9000, "period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z" },
"senders_max": 10000, "concurrency": 20
}
}
```
| Field | Description |
|-------|-------------|
| `variants` | `2` for a tracking comparison, which doubles `tests` and `max_sends` |
| `max_sends` | The most copies the batch sends: `tests` times `seeds_per_test`. Each mailbox's daily limit can only make it fewer |
| `free_tests`, `paid_tests`, `credits` | How `tests` splits against the month's free tests, and the most the paid ones cost. All `0` on an unmetered panel |
| `concurrency` | How many of the workspace's batch mailboxes send at once |
## List batches
`GET /placement/batches`
Returns the workspace's batches, newest first, in the shape above. Takes `limit` (`1` to `100`, default `25`) and an opaque `cursor`, and returns `data` plus `pagination`.
Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`
## Get a batch
`GET /placement/batches/:id`
Returns one batch with its placement grouped across the fleet.
Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`
### Response
The batch's fields as above, plus:
| Field | Description |
|-------|-------------|
| `untracked` | On a tracking comparison only: the untracked half's counts. `summary` is the tracked half |
| `domains` | Per sending domain, worst inbox rate first: `key` (the domain), `senders` in the batch, `tested` (senders that finished a test) and `counts` |
| `providers` | The same per sending provider, `key` a host family and `label` its name |
| `recipients` | Counts per recipient provider family, like a test's `families` |
| `matrix` | Per sending domain, in the order of `domains`, its counts per recipient provider in the order of `recipients` |
| `content` | The content score of the batch's copy |
The groups read the same copy as `summary`. They are not broken down by worker: a worker signs in to the mailbox provider, which delivers from its own servers, so a recipient's filter never sees it.
## List a batch's senders
`GET /placement/batches/:id/senders`
Returns the batch's mailboxes with where each one's copies landed.
Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`
| Parameter | In | Type | Description |
|-----------|----|------|-------------|
| `limit` | query | integer | `1` to `100`, default `25` |
| `cursor` | query | string | Opaque cursor from a previous `pagination.next_cursor` |
| `sort` | query | string | `worst` (default: lowest inbox rate first, untested last), `best`, `email` or `status` |
| `status` | query | string | Only mailboxes in this status: `queued`, `deferred`, `running`, `completed`, `skipped`, `failed` or `cancelled` |
| `q` | query | string | Only addresses containing this text |
### Response
```json
{
"data": [
{
"id": "c0c1c2c3-0000-0000-0000-000000000040",
"batch_id": "a9b8c7d6-0000-0000-0000-000000000030",
"email_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
"sender_email": "john@domain2.com",
"sender_domain": "domain2.com",
"sender_family": "google_workspace",
"sender_family_label": "Google Workspace",
"status": "completed",
"attempts": 1,
"next_attempt_at": "2026-09-25T14:00:00Z",
"started_at": "2026-09-25T14:02:00Z",
"finished_at": "2026-09-25T16:40:00Z",
"summary": { "total": 20, "pending": 0, "inbox": 8, "promotions": 0, "other": 0, "spam": 12, "missing": 0, "failed": 0, "cancelled": 0, "delivered": 20, "inbox_rate": 0.4, "tabs_rate": 0, "spam_rate": 0.6, "missing_rate": 0 },
"test_ids": ["d1e2f3a4-0000-0000-0000-000000000011", "d1e2f3a4-0000-0000-0000-000000000012"]
}
],
"pagination": { "total": 113, "has_more": true, "next_cursor": "MjU" }
}
```
| Field | Description |
|-------|-------------|
| `status` | `queued`, `deferred` (waiting to retry at `next_attempt_at`), `running`, `completed`, `skipped`, `failed` or `cancelled` |
| `reason`, `detail` | Why a mailbox was deferred, skipped or failed: a code from [placement test refusals](/api/error-codes/#placement-test-refusals) and its sentence |
| `test_ids` | The mailbox's tests, readable with [get a test](#get-a-test): the untracked half first on a comparison |
## Cancel a batch
`POST /placement/batches/:id/cancel`
Stops the batch: no mailbox starts again, copies not sent yet are cancelled, and copies already sent keep being classified.
Auth: **Scope** `SEND_CAMPAIGNS` · **Org permission** `send_campaigns`
### Response
`200 OK` with the batch. A batch that has already finished answers `409` with `placement_batch_not_running`.
## Get fleet coverage
`GET /placement/coverage`
How many of the workspace's connected sending mailboxes finished a placement test recently.
Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`
### Response
```json
{
"data": { "mailboxes": 1347, "tested_7d": 312, "tested_30d": 947, "never_tested": 126 }
}
```
## List seed inboxes
`GET /placement/seeds`
+3 -3
View File
@@ -201,7 +201,7 @@ The response is a `data` plus `pagination` envelope. Each item is a full message
`GET /unibox/thread/labels`
Returns the conversation labels (the workspace's categories) attached to a thread. Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
Returns the labels attached to a thread, drawn from the same workspace list as contact labels (managed through `/categories`). Auth: **Scope** `READ_UNIBOX` · **Org permission** `access_unibox`.
| Parameter | In | Type | Description |
| --- | --- | --- | --- |
@@ -223,14 +223,14 @@ The response wraps the labels in a `data` array.
`PUT /unibox/thread/labels`
Replaces the full conversation-label set on a thread, for the whole workspace. The body's `category_ids` is the desired set, so the call is idempotent and retries are naturally safe. Only the workspace's own categories are attached; an id belonging to another organization is dropped. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
Replaces the full conversation-label set on a thread, for the whole workspace. The body's `category_ids` is the desired set, so the call is idempotent and retries are naturally safe. Only the workspace's own labels are attached; an id belonging to another organization is dropped. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
### Request body
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `thread_id` | string | Yes | The thread to label. |
| `category_ids` | string[] | No | The full desired set of category UUIDs. An empty array clears all labels. |
| `category_ids` | string[] | No | The full desired set of label UUIDs. An empty array clears all labels. |
```json
{
@@ -169,6 +169,8 @@ An empty `desired_version` means "no opinion" and must never be read as "downgra
The backend is deliberately excluded from this. It is what tells everyone else their version, and a self-update that goes wrong leaves nothing to recover with.
On an instance that encodes the bus with Avro, the fleet also waits for the backend. A booting backend registers every schema its release publishes, and the fleet only moves to a release once the backend runs it and the Schema Registry has accepted those schemas. A refusal holds the fleet on the release it has and shows in the Fleet section, instead of reaching a worker that would then fail every event it reports.
The release check runs once when the backend boots, and only with `RELEASES_ENABLED=true`: it is off by default, so a self-hosted fleet is never rolled onto a vendor image. An operator moves the fleet by hand from the admin panel's Fleet section or with `warmblyctl fleet version` and `warmblyctl fleet channel`, which set the channel or pin a tag directly. Configuration is env-driven (`RELEASES_ENABLED`, `RELEASES_GITHUB_REPO`, `RELEASES_WORKER_IMAGE_REPO`, `RELEASES_GITHUB_TOKEN`) so self-hosters can point at their own fork and registry.
See `internal/app/releases/service.go`.
@@ -312,7 +312,7 @@ A worker's command topic is named after the node id issued when it joined, so th
</Callout>
`json` needs nothing and is the default. `avro` resolves every event against `SCHEMA_REGISTRY_URL` and ships only in the `-kafka` images. Producers and consumers have to agree on one: there is no in-band marker, so a consumer on the other codec cannot read what is already on the bus. Change it by draining the bus, not in place. `PUBSUB_ENABLED` must agree across backend, consumer and realtime.
`json` needs nothing and is the default. `avro` resolves every event against `SCHEMA_REGISTRY_URL` and ships only in the `-kafka` images. Producers and consumers have to agree on one: there is no in-band marker, so a consumer on the other codec cannot read what is already on the bus. Change it by draining the bus, not in place. The envelope subjects need `BACKWARD` compatibility, because a new event type adds a union branch; a publisher that meets `FORWARD` or `FULL` sets its subject to `BACKWARD` itself. `PUBSUB_ENABLED` must agree across backend, consumer and realtime.
<Callout type="warn" title="The tracking topic is read by two languages">
`KAFKA_TRACKING_TOPIC` is read by the Rust publisher and the Go subscriber. Override it in one place only and opens and clicks stop being consumed, with no error anywhere.
@@ -797,6 +797,10 @@ These are the only settings a browser can change, and no environment variable ow
| `placement.seeds_per_test` | integer, 1 to 100 | `20` | The most seed inboxes one test sends to. Every copy is a send from the sending mailbox's daily limit, so this is also how much of that limit one test takes. A test is sized down to what the mailbox has left that day, and refused below five seeds |
| `placement.spacing_seconds` | integer, 5 to 600 | `60` | The average gap between two copies of a test from one mailbox, varied by up to a third either way. A test started as quick uses the smaller of this and 8 seconds |
| `placement.credits_per_test` | integer, 0 to 10000 | `25` | What a test on the instance panel costs in credits once a paid workspace has used its free tests for the month. The workspace agrees to the price before each paid test, and a test that delivers no copy is refunded. `0` turns paid tests off. Only an instance running with `DEPLOYMENT_MODE=cloud` meters, so a self-hosted instance never charges |
| `placement.batch_senders_max` | integer, 1 to 100000 | `10000` | The most sending mailboxes one [placement batch](/guides/placement-tests/#testing-a-fleet-with-batches) may hold. An infrastructure safeguard on batch size only; how many run at once is the next setting |
| `placement.batch_sender_concurrency` | integer, 1 to 500 | `20` | How many of one workspace's batch mailboxes may be sending their copies at the same time, across all its batches. The next mailbox starts when one has sent every copy; each mailbox keeps its own daily limit and minimum gap |
| `placement.batch_instance_concurrency` | integer, 1 to 5000 | `200` | The same limit across every workspace together. It bounds how much batch mail the instance seed panel takes in at once, so a seed never receives enough in an hour to trip the sync flood rule, which deactivates a mailbox |
| `placement.batch_starts_per_minute` | integer, 1 to 600 | `10` | How many mailboxes one batch starts per minute, so a large batch ramps up instead of starting its whole concurrency at once |
The four `sync.*` values are read by the backend when a mailbox is loaded onto a worker (on connect, on reassignment, and by the reconciler's periodic republish), so a change reaches every mailbox within 30 minutes without a restart (the republish gives each mailbox one turn per 30 minutes, spread out so no worker gets them all at once). The fixed pacing numbers around them (burst per five minutes, hourly, backfill pace, the flood threshold and the chronic-overage rule) are compiled constants listed under **Instance > Configuration > Effective limits**; see [Mailboxes](/guides/mailboxes/#what-gets-synced) for how the budgets behave. The four `sync.*` values also have a companion read view on **Operations > Sync**, which shows each mailbox's backfill progress and fair-use throttle against them and can clear a throttle or restart a backfill.
@@ -812,7 +816,7 @@ It is applied only while the settings row has never been written, so from the fi
The two `deliverability.*` values are read on every scheduling pass and every warmup send, so turning the gate off releases blocked mailboxes within the 30 second cache window. Turning it back on does not stop anything retroactively: a domain still has to spend its whole grace window failing first. Only a sustained failure gates, so a domain reading `unknown` (never checked, DNS could not answer, or a special-use domain that cannot resolve) always sends. Manual sends and unibox replies are never gated; see [domain authentication](/guides/deliverability/#domain-authentication).
The four `placement.*` values are read when a test starts, so a change applies to the next test and never to one already sending. A value of zero or below resolves to the default, and one above the range to its ceiling. Which mailboxes form the instance seed panel is not a setting: an administrator marks them from the admin panel.
The `placement.*` allowance, seed and spacing values are read when a test starts, so a change applies to the next test and never to one already sending. The four `placement.batch_*` values are read on every pass of the batch runner, about every 30 seconds, so a change reaches running batches within a minute; lowering the concurrency never stops a mailbox already sending, it only delays the next start. A value of zero or below resolves to the default, and one above the range to its ceiling. Which mailboxes form the instance seed panel is not a setting: an administrator marks them from the admin panel.
Changing them is audited, and every value is validated and clamped server side on write as well as on read, so a row written by an older version still resolves.
@@ -98,7 +98,7 @@ See [Automatic inbox tagging](/guides/inbox-tagging/), [Advisor](/guides/advisor
| `retention.form_event_days` | 180 | Form funnel events: views, starts, field-level drop-off |
| `retention.audit_log_days` | 90 | The audit trail: actor, IP address, user agent, change payload |
| `retention.warmup_mail_days` | 30 | Warmup mail in the mailboxes themselves, and the stored copy of each message's body. Deleted by the platform once older than this, wherever the mailbox files it; a mailbox can set its own window |
| `retention.warmup_event_days` | 365 | Per-message warmup records: tokens, receipts, tampering events, spam reports. The daily sent and received counts behind the analytics are kept |
| `retention.warmup_event_days` | 365 | Per-message warmup records: tokens, receipts, tampering events, spam moves and their attribution, spam reports. The daily sent and received counts behind the analytics are kept. When each mailbox's owner last acted in it (five-minute marks, no message content) is kept 30 days whatever this is set to |
The first three are between 1 and 3,650 days, and they are the settings a retention or privacy policy applies to, because each window is also how long the personal data in that log is held. The warmup mail window starts at 3 days and the warmup records window at 30, the least the engagement legs and the pool health bands need.
+9 -3
View File
@@ -20,13 +20,17 @@ Message encoding is orthogonal to transport, selected by `CODEC_PROVIDER`:
<Callout type="warn" title="Every derived field carries a default">
Schemas are derived from the Go structs (`internal/models/event_schema.go`), and each field is given the Avro default matching its zero value. This is what makes adding a field safe: a reader on the new schema can still decode data written under the schema registered before it, filling the field it did not carry from the default, so the registry accepts the new version. A newly added field with no default is rejected under `BACKWARD` compatibility, and because the publisher registers before it serializes, the rejection stops every publish on that topic rather than degrading one field.
Two things follow. The registered document is the marshalled schema, not `Schema.String()`, which omits defaults and would throw the guarantee away. And the registry stays on `BACKWARD` rather than `FORWARD`: adding a field is safe under both, but adding a new event type adds a union branch, which `BACKWARD` accepts and `FORWARD` refuses.
Two things follow. The registered document is the marshalled schema, not `Schema.String()`, which omits defaults and would throw the guarantee away. And the registry stays on `BACKWARD` rather than `FORWARD`: adding a field is safe under both, but adding a new event type adds a union branch, which `BACKWARD` accepts and `FORWARD` refuses. When a registration is refused and the subject's effective level is `FORWARD` or `FULL` (or their transitive forms), the publisher sets that subject to `BACKWARD` and registers again, so the registry key needs permission to change a subject's compatibility. Any other level, and any other refusal, is left as it is.
Nothing in this reaches production untested. `internal/app/eventschemas` lists every schema a release publishes and keeps the one the registry last accepted under `testdata/`. CI decodes the recorded schema with the new one, which is the registry's own `BACKWARD` check, and fails a change the registry would refuse. It also fails a compatible change that has not been recorded, so a schema change is always in the diff: run `make schemas` and commit the result. On release the backend registers every listed schema when it boots, and the fleet does not move to the release until that has succeeded.
The registry check covers the schema, not the rollout. Readers decode with the schema id each message carries, so a worker still on the previous release reads new fields fine, but a command type it has never heard of is logged and skipped. The backend rolls out before the fleet, so a new worker command type must be safe to lose while the fleet catches up: send it only to a worker whose reported version handles it, or have the backend retry it until one does.
</Callout>
The Rust tracking service follows both switches. It speaks Kafka only when compiled with its `kafka` cargo feature, which is what the `tracking:*-kafka` image is, and it encodes with `CODEC_PROVIDER` on either transport. So a Kafka deployment on `CODEC_PROVIDER=json` needs no Schema Registry at all, and `avro` there is refused at boot without `SCHEMA_REGISTRY_URL` rather than failing at the first published event.
<Callout type="warn" title="One codec covers both topics">
The consumer decodes `jobs.worker-events` and `tracking-events` with the same `CODEC_PROVIDER`, and worker envelopes cannot be Avro. That makes `json` the only value a working deployment uses, and it is why the tracking publisher has to read the setting rather than always writing Avro: a JSON consumer handed Avro drops every open and click with nothing but a deserialize warning.
The consumer decodes `jobs.worker-events` and `tracking-events` with the same `CODEC_PROVIDER`, which is why the tracking publisher has to read the setting rather than always writing Avro: a JSON consumer handed Avro drops every open and click with nothing but a deserialize warning.
</Callout>
## Topics
@@ -77,12 +81,14 @@ Workers publish the same envelope shape back on `jobs.worker-events`:
}
```
Types: `NEW_EMAIL`, `INBOUND_BOUNCE`, `REMOVE_EMAIL`, `FLAGS_ADD`, `FLAGS_REMOVE`, `UPDATE_EMAIL`, `UPDATE_MAILBOX`, `DELETE_MAILBOX`, `TOKEN_UPDATE`, `HISTORY_ID_UPDATE`, `GRAPH_DELTA_UPDATE`, `SYNC_STATE`, `EMAIL_SENT`, `EMAIL_FAILED`, `EMAIL_AUTH_ERROR`, `EMAIL_DISABLED`, `EMAIL_RATE_LIMITED`, `EMAIL_SERVER_ERROR`, `WORKER_HEALTH`.
Types: `NEW_EMAIL`, `INBOUND_BOUNCE`, `REMOVE_EMAIL`, `FLAGS_ADD`, `FLAGS_REMOVE`, `UPDATE_EMAIL`, `UPDATE_FOLDER`, `UPDATE_MAILBOX`, `DELETE_MAILBOX`, `TOKEN_UPDATE`, `HISTORY_ID_UPDATE`, `GRAPH_DELTA_UPDATE`, `SYNC_STATE`, `EMAIL_SENT`, `EMAIL_FAILED`, `EMAIL_AUTH_ERROR`, `EMAIL_DISABLED`, `EMAIL_RATE_LIMITED`, `EMAIL_SERVER_ERROR`, `WORKER_HEALTH`.
The consumer registers one handler per type (`internal/app/consumer/events.go`); unregistered types are logged and acknowledged rather than redelivered.
Every `SEND_EMAIL` is answered with exactly one per-task result: `EMAIL_SENT` (the provider accepted the message; the consumer records the wire Message-ID on the task) or `EMAIL_FAILED` (a `SendEmailResult` carrying the error code and message). The control plane stamps a campaign step sent when it hands the send to the worker, so `EMAIL_FAILED` is what walks that back: the consumer marks the task failed, clears the step's `sent_at` and counts the attempt on `campaign_contact_progress`, gives the send back to the campaign's daily counters, writes the failure to the campaign activity log, and reopens a campaign that completed while the send was in flight. After `CampaignSendMaxAttempts` (5) the lead is marked failed and routing drops it; a `RECIPIENT_REJECTED` result (refused at RCPT) skips the retries and is ingested as a bounce instead. Account-level conditions (`EMAIL_AUTH_ERROR`, `EMAIL_DISABLED`, `EMAIL_RATE_LIMITED`, `EMAIL_SERVER_ERROR`) are raised in addition to, never instead of, the per-task result; they carry an `EmailErrorEvent` and act on the mailbox. A worker that does not hold the mailbox yet leaves the send for a few redeliveries before reporting it failed, and the backend never publishes a send to a worker that is not heartbeating.
`UPDATE_FOLDER` reports the canonical folder a provider now has a message in, without the rest of the message. Gmail sends it when a history record adds or removes `INBOX`, `TRASH`, `SPAM`, `SENT` or `DRAFT`, with the folder recomputed from the message's labels, and from a reconciliation that runs every six hours and once per mailbox load: the worker lists the Gmail inbox and asks the backend (`GET /api/v1/internal/sync/provider-folder-messages`) which rows it believes are in inbox, archive, spam or trash, then reports the ones Gmail has elsewhere. The consumer resolves it like `UPDATE_EMAIL`: against the folder the provider last reported, so a message filed in Warmbly stays filed unless the provider itself moved it.
`SYNC_STATE` is the worker's relay of a mailbox's sync state (backfill progress and cursor, fair-use throttle, last-synced time). It carries the full state rather than a delta, so a lost event is repaired by the next one; the consumer writes it to `email_sync_state` and the backend hands it back inside `ADD_EMAIL` on the next load, which is what lets a replaced worker resume an import instead of restarting it.
## Tracking events
@@ -179,7 +179,7 @@ Baseline (always loads):
The dev user's org always loads as a mid-flight workspace, not an empty shell:
- 4 warmed mailboxes on the shared worker, all in the premium warmup pool
- Folders, tags, and categories with real bindings (mailbox tags, campaign folders, contact categories, inbox thread labels)
- Folders, tags, and labels with real bindings (mailbox tags, campaign folders, labels on contacts and inbox threads)
- ~30 contacts with titles and companies, a few unsubscribed or suppressed
- An active 3-step campaign with 24 leads spread across the funnel (sent, opened, replied, bounced, queued), plus a draft campaign
- 14 days of stats history (campaign sends, warmup volume, per-mailbox counts), always including sends today
+1 -1
View File
@@ -44,7 +44,7 @@ Two other entry points:
- a CRM pipeline with five stages, five deals across them, tasks, contact notes, and per-contact activity timelines
- the analytics tables behind the deliverability and reporting views: deliverability events (opens, clicks, replies, bounces, complaints, unsubscribes), reply-intent classifications, a suppression list, and resolved and open mailbox errors
- an org audit trail (campaign created and started, mailbox connected, member invited, deal created, key created) so the audit log has depth
- reply templates, notifications (some unread), and labels everywhere they appear: folders (Outbound, Nurture), mailbox tags (VIP, Cold, Agency), and categories (Lead, Customer, Churn risk) bound to the campaigns, senders, several contacts, and inbox threads
- reply templates, notifications (some unread), and labels everywhere they appear: folders (Outbound, Nurture), mailbox tags (VIP, Cold, Agency), and workspace labels (Lead, Customer, Churn risk) bound to the campaigns, senders, several contacts, and inbox threads
- working credentials for every `smtp_imap` account in the database, including the older `make seed` fixtures, sealed with `CREDENTIALS_ENCRYPTION_KEY` so the worker can decrypt and use them
- an [Advisor](/guides/advisor/) showcase (see below), and one evaluation run at the end of seeding so the recommendations exist before you log in
+1 -1
View File
@@ -32,7 +32,7 @@ The panel resizes by dragging its inner edge, which also takes the keyboard once
## What you can ask
- Find, read, create, and delete contacts, edit fields, tags, subscription, and notes, and bulk-edit
- Find, read, create, and delete contacts, edit fields, labels, subscription, and notes, and bulk-edit
- Read a contact's activity timeline and the emails you sent them
- List campaigns and stats, and list leads by status, which is how it finds leads that went cold
- Create, edit, or delete campaigns, manage sender mailboxes and tracking domains, read logs, and edit sequence steps
@@ -16,11 +16,11 @@ Every mode takes a plain-language **instruction**, templated so you can drop eve
| Classify | Picks exactly one of your labels | `ai_class` |
| Extract fields | Pulls the fields you name out of the event text | One variable per field |
**Agent** is the default. Check which reversible actions it may take: add or remove a tag, label the email, create a task, create a deal, move a deal stage, set variables, and unsubscribe. It decides which fit, can chain several, and writes the details itself (task title, deal name, variable value). It only ever takes the actions you enable, and **never sends email or replies**. Billed one credit per step it takes.
**Agent** is the default. Check which reversible actions it may take: add or remove a label, label the conversation, create a task, create a deal, move a deal stage, set variables, and unsubscribe. It decides which fit, can chain several, and writes the details itself (task title, deal name, variable value). It only ever takes the actions you enable, and **never sends email or replies**. Billed one credit per step it takes.
For tag and label actions, an optional **pool** limits which tags it may choose. An empty pool lets it use any of yours, and optionally create a new one when nothing fits.
For the label actions, an optional **pool** limits which labels it may choose: the labels it can add or remove on the contact, and the conversation labels it can apply. An empty pool lets it use any of yours, and optionally create a new one when nothing fits.
On a Reply received trigger, an instruction like "If they ask about pricing, tag them and open a follow-up task; if they ask to stop, unsubscribe them" with those three actions enabled handles all three outcomes in one step.
On a Reply received trigger, an instruction like "If they ask about pricing, label them and open a follow-up task; if they ask to stop, unsubscribe them" with those three actions enabled handles all three outcomes in one step.
**Classify** needs at least two labels and returns exactly one, stored in `ai_class`. A non-exact answer resolves to the closest match; if none is close, the raw answer is stored so you can see what happened in run history.
+6 -6
View File
@@ -3,7 +3,7 @@ title: Automations
description: "A visual flow builder: a trigger event connected to action steps across your integrations."
---
An automation is a flow on a canvas: one **trigger** (a reply arrives, a meeting is booked) runs one or more **actions** (post to Slack, push to your CRM, tag a contact), with **IF conditions** in between to branch. No code, though an advanced mode accepts a free-form condition.
An automation is a flow on a canvas: one **trigger** (a reply arrives, a meeting is booked) runs one or more **actions** (post to Slack, push to your CRM, label a contact), with **IF conditions** in between to branch. No code, though an advanced mode accepts a free-form condition.
<Mermaid
chart={`
@@ -97,12 +97,12 @@ Slack, Discord, and webhook actions take an optional message template. Slack and
| Built-in action | What it does |
| --- | --- |
| Create or update contact | Makes a contact from the event's fields, or enriches the one with that email, then tags it and enrols it in a campaign. See [Lead intake](#lead-intake) |
| Create or update contact | Makes a contact from the event's fields, or enriches the one with that email, then labels it and enrols it in a campaign. See [Lead intake](#lead-intake) |
| Add to campaign | Enrols the event's contact in a campaign; a finished campaign restarts through the usual launch checks (a refusal is noted in its activity log and the lead waits), one waiting for leads picks up straight away |
Saving an automation with either of these actions turns on the picked campaign's **Keep running for new leads** setting, so the campaign waits for the next run instead of finishing between them.
| Add / remove a tag | Adds or removes a contact category |
| Label the email | Applies inbox labels to the replied-on conversation |
| Add / remove a label | Adds or removes a label on the contact |
| Label the conversation | Applies labels to the conversation the contact replied on |
| Create a task | Assigned to the workspace owner |
| Create a deal | In a chosen pipeline and stage |
| Move the deal stage | Moves the contact's most recent open deal in that pipeline |
@@ -111,7 +111,7 @@ Saving an automation with either of these actions turns on the picked campaign's
| Fire event | Publishes a `CUSTOM_EVENT` to the realtime gateway |
| AI step / AI switch | An agent or single-shot AI node, and AI-decided routing. See [AI steps](/guides/ai-steps-in-automations/) |
Three constraints worth knowing: **Move the deal stage** does nothing if the contact has no open deal in that pipeline, **Unsubscribe** needs an event carrying a campaign, and **Label the email** works only on a Reply received automation, since it needs a thread to label.
Three constraints worth knowing: **Move the deal stage** does nothing if the contact has no open deal in that pipeline, **Unsubscribe** needs an event carrying a campaign, and **Label the conversation** works only on a Reply received automation, since it needs a thread to label.
**Add to campaign** and every other contact action need a contact to act on: the event must carry `contact_id` or `contact_email`, which every Warmbly trigger does. On an inbound webhook, put **Create or update contact** first and the contact it writes becomes the event's contact for the steps after it.
@@ -120,7 +120,7 @@ Three constraints worth knowing: **Move the deal stage** does nothing if the con
Any system that can send an HTTP request can create contacts in Warmbly without an API client: an **Inbound webhook** trigger followed by a **Create or update contact** action.
1. Pick the Inbound webhook trigger, save, and copy the URL.
2. Add **Create or update contact**. Each field is a template over the JSON the caller sends: an email of `{{.email}}`, a first name of `{{.first_name}}`, a custom field `team_size` set to `{{.answers.team_size}}`. Pick the tags and the campaign the lead lands in.
2. Add **Create or update contact**. Each field is a template over the JSON the caller sends: an email of `{{.email}}`, a first name of `{{.first_name}}`, a custom field `team_size` set to `{{.answers.team_size}}`. Pick the labels and the campaign the lead lands in.
3. Point the sender at the URL. Zapier's *Webhooks by Zapier*, Make's *HTTP* module, n8n's *HTTP Request* node, a Typeform or Tally webhook, or your own code.
The action matches an existing contact by email, so re-sending the same lead updates it instead of duplicating it. A blank rendered value never erases a field the contact already has. **If the contact already exists** decides whether an existing contact is enriched and enrolled (the default) or left alone. New contacts carry `automation` as their first-touch source, with the automation's name as the detail.
+25 -2
View File
@@ -142,7 +142,7 @@ Anything above `50`/day per cold mailbox needs positive reputation signals and a
### Adding leads
The **Leads** tab takes contacts four ways. **From contacts** opens a picker over the workspace contact list: search by name, email or company, filter by category, tick individual people or **Select loaded**, or **Select all matching** to add everyone the search returns, up to `50,000` at a time (narrow the search and repeat past that). Contacts already in the campaign are marked as leads and skipped. **Import** runs the file import wizard with this campaign preselected (its Options step can also pin the file into segments), **Sheet sync** attaches a Google Sheet, and **Add lead** creates a single new contact in the campaign. Any workspace member with contact access can add leads to any campaign in the workspace, whoever created it.
The **Leads** tab takes contacts four ways. **From contacts** opens a picker over the workspace contact list: search by name, email or company, filter by label, tick individual people or **Select loaded**, or **Select all matching** to add everyone the search returns, up to `250,000` at a time (narrow the search and repeat past that). Contacts already in the campaign are marked as leads and skipped. **Import** runs the file import wizard with this campaign preselected (its Options step can also pin the file into segments), **Sheet sync** attaches a Google Sheet, and **Add lead** creates a single new contact in the campaign. Any workspace member with contact access can add leads to any campaign in the workspace, whoever created it.
**Segments** on the same toolbar links [segments](/guides/segments/) to the campaign as a live audience, up to 20 per campaign. Linking enrols every current member immediately and turns on **Keep running for new leads**, and contacts who enter a linked segment later are enrolled on their own, within a couple of minutes. An active campaign wakes to send to them (a campaign waiting for leads picks up straight away), a finished one restarts through the usual launch checks, and a paused one accumulates them for later. A contact who leaves a linked segment keeps their lead row, but detaching the segment itself takes its leads back out, so swapping one segment for another leaves the campaign with the new list rather than both. Leads the campaign has already emailed stay, as do any you added by hand and anyone a segment that stayed linked still covers; the dialog asks before a save that costs leads. See [linking a segment to a campaign](/guides/segments/#linking-a-segment-to-a-campaign).
@@ -162,7 +162,7 @@ A lead can also be taken out again: the remove button on a lead's row, or **Remo
| Replied | Contact replied. With stop on reply on, the cold sequence stops (a reply branch still runs); with it off, the follow-ups continue |
| Bounced | A send hard-bounced |
| Failed | A step could not be sent after every retry; hover the status for the reason |
| Paused | The lead's flow is held, by an out-of-office auto-reply or by hand; hover the status for why and until when |
| Paused | The lead's flow is held: by an out-of-office auto-reply, by hand, or because the contact is [copied on another lead's emails](#copying-colleagues-on-one-lead). Hover the status for why and until when |
| Unsubscribed | Unsubscribed or suppressed |
A lead is **Processing** only while steps remain, so a finished campaign reads as done rather than stuck mid-flight.
@@ -183,6 +183,29 @@ An away message is recognised by the subject line the recipient's mail provider
A held lead reads **Paused** in the list and carries its reason on hover. Open the contact's **Activity** tab and the campaign panel shows the hold with **Resume now** next to it, and **Stop**, which converts a dated hold into one with no end. A lead with a step still to send gets **Pause lead** there instead; one whose flow has ended has neither, because a pause would change nothing. Resume drops the held time rather than carrying it, so the step goes back to the schedule it would have had if the hold had never happened: on the campaign's next pass when that moment has already passed, otherwise when the step's own wait elapses. It lifts the hold, it does not skip the sequence's pacing. Pausing and resuming are audited, and every teammate's list updates live.
### Copying colleagues on one lead
Reaching two people at one company as two separate leads gives each their own thread and their own follow-ups, neither aware of the other, which reads as double outreach. Copy the second person on the first lead's emails instead. Open the lead's contact, go to **Activity**, and use **CC a colleague** on that campaign's card. The picker offers the lead's likely colleagues first (contacts with the same company name, or the same email domain when it is a company's rather than a personal mail service like Gmail or GMX), and searches the whole contact list as you type. A copy is always a contact in the workspace, so suppression, bounces and verification apply to it exactly as to a lead.
Every email the campaign sends that lead carries the copies in **CC**, follow-ups included, so the whole conversation stays in one thread. It is per campaign on purpose: copying someone on a lead in one campaign does not copy them anywhere else. A lead can copy at most `2` contacts. Every copy is one more recipient who did not ask for the email, so this suits small, personal campaigns better than volume sending. It does not change how much the mailbox sends: the email still counts once against its daily cap and spacing, though Google and Microsoft count each copy toward the mailbox's own recipient limits. Leads with copies show a **CC** badge in the Leads list, with the addresses on hover.
What happens to the people on the thread:
| Event | What Warmbly does |
|-------|-------------------|
| A copy replies | It counts as the lead's reply, whether they answer your email or the lead's reply to it. A new email from them that answers nothing counts for no lead. With stop on reply on, the sequence stops for everyone on the thread. A reply that leaves your mailbox off (sent only to the lead) never reaches Warmbly |
| A copy is out of office | Nothing: the lead is not held for someone else's away message |
| A copy replies asking to stop | Only the copy is suppressed and left off later emails. The lead is not |
| Someone uses the unsubscribe link | The link cannot tell who clicked, so the lead and every copy on the thread are unsubscribed |
| A copy bounces | That copy is dropped from the lead's later emails and the lead keeps receiving them. The lead is never marked bounced for a copy. When the mail server refuses the copy outright, the email is retried without them and the refusal does not count against the lead's five attempts |
| A copy is unsubscribed, suppressed, bounced or fails verification | They are left off the next email and their chip is struck through in the drawer; the lead's email still goes out |
Opens and clicks are counted per email, not per person, so a copy opening the email or clicking a link counts toward the lead.
A contact copied on a lead is reached in that lead's thread, so if they are also a lead of the same campaign their own sequence is held with the reason **Copied on the emails to** the lead's address. That hold has no end and does not keep the campaign from finishing. Removing the copy, or removing the lead from the campaign, releases it and their own sequence picks up. **Send their own too** on that hold starts it anyway after asking, for when two threads are really what you want. For the same reason a lead that is copied on someone else cannot copy others, and a contact with copies of their own cannot be copied.
The campaign's own **CC** and **BCC** in its preferences go on every email to every lead. Both kinds of copy skip an address that is suppressed or is the lead's own, so a copy never reaches someone the lead's email could not have. A campaign-wide address that bounces on one of the campaign's emails is left off its later emails, so one bad address cannot fail every lead's send; fix or remove it in the preferences.
### Who opened, clicked and replied
Next to each lead's status, the Leads list shows three engagement columns: **Opened**, **Clicked** and **Replied**, each with the number of emails in the sequence the person engaged with. A dash means the lead was emailed and has not engaged; the cell is blank for a lead not emailed yet. An open counts when a person opened the email, or clicked a link in it. Mail clients that fetch every image automatically (Apple Mail Privacy Protection, for example) show as **auto** instead, the same opens the campaign overview reports as automatic, so they never pass for engagement. Clicks are held to the same standard: a link followed from a known mail security network, within the instance's automated-click window after the send (thirty seconds by default), or several links of one email followed within a few seconds of each other, is a security gateway scanning the message, not the recipient. Those clicks are kept on the contact's activity marked **auto**, counted apart on the campaign overview, and never make a lead **Clicked**, never fire a clicked branch or automation, and never send a webhook. See [Link tracking and UTM parameters](#link-tracking-and-utm-parameters).
+1 -1
View File
@@ -30,7 +30,7 @@ Indicators are workspace-scoped. Teammates see only your name, avatar, current p
- Contact imports, edits, and deletes refresh contacts views for the whole team.
- Mailbox health transitions (a warmup quarantine, say) flip status badges live.
- The CRM is live end to end: moving a deal, ticking a task, or editing pipeline stages shows up immediately, and a deal being dragged carries a colored ring so you know it is in motion.
- Mailbox tags, contact categories and campaign folders each have their own shared set per workspace, so a label anyone creates appears in everybody's pickers and chips as they make it.
- Mailbox tags, labels and campaign folders each have their own shared set per workspace, so a label anyone creates appears in everybody's pickers and chips as they make it.
- The audit log streams new entries, so the activity trail is itself a live feed.
**Events are permission-aware**: a member without inbox access never receives unibox events, and billing events reach only those who can manage billing. See [Team roles](/guides/team-roles/).
+20 -20
View File
@@ -1,6 +1,6 @@
---
title: "Contacts & CRM"
description: "Import contacts, custom fields, categories, deals, and pipelines."
description: "Import contacts, custom fields, labels, deals, and pipelines."
---
Contacts are the people you reach out to; the CRM tracks what happens after they reply.
@@ -13,7 +13,7 @@ Two routes share the same column-mapping screen: a file upload and an on-demand
1. **Upload.** The file goes up once and is read on the server, which measures every column over the whole file. Nothing is written to your contacts yet. **Download a sample file** gives you a CSV with every standard column if you are starting from scratch.
2. **Map columns.** Each column shows how full it is across the file, a few of its values, and where it will go. **Find a column** and **Unmapped only** help with wide CRM exports, **First row is header** moves the first row between the headers and the data, and **Preview contacts** shows the first rows as the contacts they will become, with any row missing a usable address marked.
3. **Review.** The whole file is checked before anything is written, and the result is one line: how many new contacts will be added, how many are already in your workspace, how many repeated rows are merged, and how many rows can't be imported (open it to see which and why). When some contacts already exist, choose **Leave as is** or **Update details** for them. **Add them to** puts everything from the file into segments, categories, and campaigns; the segment picker creates a segment on the spot, and offers one named after the file, so an import becomes an audience in one step. **More options** holds whether new contacts are subscribed. An import that would be refused as a whole, because it would put the workspace over its plan's contact limit or create more than 100 categories, says so here instead of after the upload, and the import button says exactly what will happen (`Import 1,102 new · update 118`).
3. **Review.** The whole file is checked before anything is written, and the result is one line: how many new contacts will be added, how many are already in your workspace, how many repeated rows are merged, and how many rows can't be imported (open it to see which and why). When some contacts already exist, choose **Leave as is** or **Update details** for them. **Add them to** puts everything from the file into segments, labels, and campaigns; the segment picker creates a segment on the spot, and offers one named after the file, so an import becomes an audience in one step. **More options** holds whether new contacts are subscribed. An import that would be refused as a whole, because it would put the workspace over its plan's contact limit or create more than 100 labels, says so here instead of after the upload, and the import button says exactly what will happen (`Import 1,102 new · update 118`).
4. **Import.** The import runs on the server in chunks, with live progress, its rate, and the time left. You can close the window: it keeps running, survives a refresh, and everyone in the workspace sees it finish. While it runs, an **Importing** chip on the Contacts toolbar shows how far it is and reopens it; **Stop** ends it early and keeps the rows already imported. The result reports imported, updated, skipped, and failed counts, lists the failed rows with their reasons, and **Download to fix** gives you every failed row as uploaded, under the file's own headers, with the reason in the last column, ready to correct and import again.
A file whose headers match one you imported before maps itself the way you confirmed last time, marked **Mapping remembered**. The Upload step lists the workspace's recent imports, finished or still running, and opening one shows its progress or result.
@@ -25,7 +25,7 @@ You must map at least one column to **Email**.
| Email | Required, and used to dedupe |
| First name, Last name, Company, Phone | Standard identity fields |
| Subscribed | `yes`, `true`, `1`, `subscribed` and their opposites (`no`, `false`, `0`, `unsubscribed`). Applies to new contacts and to existing ones you update; a value the importer cannot read fails only that row |
| Categories | Category names, separated by commas or semicolons. Names you do not have yet are created, up to 100 new names per import |
| Labels | Label names, separated by commas or semicolons. Names you do not have yet are created, up to 100 new names per import |
| Custom field | Anything else: one of your existing custom fields, or a new one you name |
### Mapping into your custom fields
@@ -34,7 +34,7 @@ The mapping menu lists the custom fields your workspace already has under **Your
A column whose header names an existing field is mapped to it before you open the menu. The match ignores case, spaces, underscores, dashes and dots, so a `company url` column lands on your `company_url` field in its stored spelling instead of starting a second field. Only one column is matched to each field, and standard fields (Email, Company, and so on) are matched first.
When the instance has TypeSafe configured, the columns no header matched are also placed by what their header means: `Job Title` onto your `Title` field, `Firmenname` onto Company, `Sector` onto `Industry`. Only a confident answer is applied, each field still goes to one column, and those rows are marked **Matched by meaning. Check it.** until you change them. It picks names, Company, Phone and your custom fields only: Subscribed and Categories change who gets mail and which categories exist, so they are never guessed. What is sent is the column headers, the kind of value each column holds and your field names, never a cell, and nothing at all unless the first row is clearly headers (no address, date or number in it, and a named Email column); see [data control](/development/data-control/#typesafe-judgments). A column of addresses under any header, or none, is mapped to Email either way.
When the instance has TypeSafe configured, the columns no header matched are also placed by what their header means: `Job Title` onto your `Title` field, `Firmenname` onto Company, `Sector` onto `Industry`. Only a confident answer is applied, each field still goes to one column, and those rows are marked **Matched by meaning. Check it.** until you change them. It picks names, Company, Phone and your custom fields only: Subscribed and Labels change who gets mail and which labels exist, so they are never guessed. What is sent is the column headers, the kind of value each column holds and your field names, never a cell, and nothing at all unless the first row is clearly headers (no address, date or number in it, and a named Email column); see [data control](/development/data-control/#typesafe-judgments). A column of addresses under any header, or none, is mapped to Email either way.
Each custom mapping says what it will do: **Fills your existing field**, **Creates a new field**, or, when the name you typed differs from an existing field only in case or separators, **You already have `Industry`**, with **Use it** to switch to that field. **Keep N more as custom fields** uses the same matching, so the columns it claims land on your existing fields where it can.
@@ -43,7 +43,7 @@ Two columns may fill the same field, which is how a `Phone` and a `Mobile` colum
<Callout type="info" title="Duplicates match on lowercased email, across the workspace">
`Dana@Acme.com` and `dana@acme.com` are the same contact, and a contact belongs to the workspace, so an address a teammate already added counts as one you have. Choose **Leave as is** (their details stay untouched) or **Update details** (empty details are filled in from the file). The API also accepts `create_duplicate`, which updates too, since one address is one contact per workspace.
Skipping still adds the contact to the campaigns, segments, and categories the import targets: it leaves their fields alone, it does not leave them out of the list. If the same address appears twice in one file it becomes one contact, and the extra rows count as skipped with the line they repeat.
Skipping still adds the contact to the campaigns, segments, and labels the import targets: it leaves their fields alone, it does not leave them out of the list. If the same address appears twice in one file it becomes one contact, and the extra rows count as skipped with the line they repeat.
</Callout>
An address you already hold as a contact in another workspace you belong to cannot be added to this one, and that row fails with a reason saying so; the other workspace's contact is never changed by an import here.
@@ -56,7 +56,7 @@ Problems with the mapping itself, an unnamed custom field or a name Warmbly cann
On the **Review** step, **Campaigns** enrols everyone in the file as leads of the campaigns you pick (started from a campaign's Leads tab, that campaign is shown as the fixed target), and **Segments** pins them into the [segments](/guides/segments/) you pick as manual includes, so they stay members whatever the segment's conditions say. A segment created from the picker has no conditions, so it holds exactly the contacts pinned into it. Started from a segment, that segment is always applied and shown as a fixed row; the picker below it adds more.
**From Google Sheets**, a sheet is a reusable **sync source** rather than a one-time upload. Connect it once (Warmbly reads only the tab you choose and never writes back), paste the spreadsheet ID from the URL between `/d/` and `/edit`, pick the tab, map columns, then set duplicate handling, a label, and optionally a campaign to enroll into, categories to apply and segments to pin into. **Nothing syncs automatically**: press **Sync now**, or save and sync immediately.
**From Google Sheets**, a sheet is a reusable **sync source** rather than a one-time upload. Connect it once (Warmbly reads only the tab you choose and never writes back), paste the spreadsheet ID from the URL between `/d/` and `/edit`, pick the tab, map columns, then set duplicate handling, a label, and optionally a campaign to enroll into, labels to apply and segments to pin into. **Nothing syncs automatically**: press **Sync now**, or save and sync immediately.
Segment targets apply on every run, so a sheet you keep adding rows to keeps feeding the same audience. Opening **Sync sources** from a segment's member list lists only the sources feeding it and pre-targets a new one to it. A segment deleted later is dropped from the run rather than stopping it.
@@ -100,7 +100,7 @@ See [Personalization & expressions](/guides/expressions/) for the full templatin
## Filtering the list
A filter bar sits above the contact list. **Category**, **Segment**, **Status** and **Campaign** are always there; open one, tick values, and the list updates immediately with the matching count next to the bar. **Add filter** adds a custom-field condition (field, contains/is/starts with/ends with, value), a date-added or last-updated range, a number-of-campaigns range, the address verification verdict, the [email provider](#email-provider) and, on a campaign's Leads tab, lead status and engagement. Each active filter is a pill you can reopen to change or remove with its cross; **Clear** drops them all, and **Save as segment** turns the current set into a [segment](/guides/segments/). Free-text search, sort and the column chooser stay in the toolbar.
A filter bar sits above the contact list. **Label**, **Segment**, **Status** and **Campaign** are always there; open one, tick values, and the list updates immediately with the matching count next to the bar. **Add filter** adds a custom-field condition (field, contains/is/starts with/ends with, value), a date-added or last-updated range, a number-of-campaigns range, the address verification verdict, the [email provider](#email-provider) and, on a campaign's Leads tab, lead status and engagement. Each active filter is a pill you can reopen to change or remove with its cross; **Clear** drops them all, and **Save as segment** turns the current set into a [segment](/guides/segments/). Free-text search, sort and the column chooser stay in the toolbar.
Free-text search matches first name, last name, email, company and phone. Every word you type has to match one of those, so `Test Demo` finds the contact whose first name is Test and last name is Demo, and `Demo Acme` finds everyone named Demo at Acme. Words can be in any order, and only the first six count.
@@ -122,7 +122,7 @@ Each contact's avatar carries a small badge with the logo of whoever hosts their
A contact on a company domain shows that company's logo in place of their initials, and the **Company** column shows it beside the company name; when no company is on file, the column shows the domain their address is on. Personal inboxes (Gmail, Outlook.com, Yahoo and the like) keep their initials. Logos are on for Warmbly Cloud and off on a self-hosted instance unless its operator turns them on, because the browser fetches each one from DuckDuckGo; see [data control](/development/data-control/#outbound-calls).
It shows on the contacts page and on a campaign's Leads tab alike. You can sort by it from **Sort**, filter with **Add filter** > **Email provider** (including **Unknown or not checked yet**), select every match and act on them in bulk (add them to a campaign, tag them, export them), or save the filter as a segment. Campaign [ESP matching](/guides/campaigns/) uses the same data to pair each lead with a same-provider mailbox.
It shows on the contacts page and on a campaign's Leads tab alike. You can sort by it from **Sort**, filter with **Add filter** > **Email provider** (including **Unknown or not checked yet**), select every match and act on them in bulk (add them to a campaign, label them, export them), or save the filter as a segment. Campaign [ESP matching](/guides/campaigns/) uses the same data to pair each lead with a same-provider mailbox.
## Selecting rows
@@ -130,35 +130,35 @@ Tick a row's checkbox to select it, or the one in the table header to select eve
When more contacts match than are loaded, a bar appears under the header: **Select all N matching**. Clicking it hands the whole filtered set to the next action, however many pages that is, and the selection bar counts the full number rather than the loaded rows. Unticking a row afterwards takes just that contact out and the count follows. **Clear selection** in the bar, or the header checkbox, drops back to nothing.
The set is the one the list is showing: search, filters, the subscription facet, and the campaign or segment the list is scoped to all narrow it. Changing any of them clears the selection, because it would no longer mean what it did when you made it. A selection past `50,000` contacts is refused rather than half-applied; add a filter and work through it in parts.
The set is the one the list is showing: search, filters, the subscription facet, and the campaign or segment the list is scoped to all narrow it. Changing any of them clears the selection, because it would no longer mean what it did when you made it. A selection past `250,000` contacts is refused rather than half-applied; add a filter and work through it in parts.
Every bulk action reads the selection: **Edit**, **Segment**, **Remove from segment**, **Remove from campaign**, **Research**, **Verify**, **Mark deliverable** and **Delete**. **Push to CRM** is the exception: it calls the CRM once per contact while you wait, so it stays capped at `500` at a time.
Every bulk action reads the selection: **Edit**, **Segment**, **Remove from segment**, **Remove from campaign**, **Research**, **Verify**, **Mark deliverable** and **Delete**. **Push to CRM** and **Research** are the exceptions: a push calls the CRM once per contact while you wait, and each research run spends AI credits, so both stay capped at `500` at a time.
**Select all matching** is in the **From contacts** picker too, on a campaign's Leads tab and a segment page, so a whole search can be added as leads or members in one step.
## Editing one contact
Clicking a row opens the contact's panel. Its **Details** tab edits the fields the contact is made of: name, email address, company, phone, subscription, campaigns, categories and custom fields. Nothing is sent until **Save**, **Discard** puts the panel back to the stored values, and closing it with unsaved edits asks first.
Clicking a row opens the contact's panel. Its **Details** tab edits the fields the contact is made of: name, email address, company, phone, subscription, campaigns, labels and custom fields. Nothing is sent until **Save**, **Discard** puts the panel back to the stored values, and closing it with unsaved edits asks first.
The email address can be changed here, which is the right move when someone's address was mistyped on import or they moved to a new domain. It is stored lowercased and stripped of any display name, and it has to be free: an address another contact already holds is refused, because merging two people's campaign history is not something the edit could undo. A new address also clears the contact's [verification](/guides/deliverability/#address-verification) verdict and everything the platform had observed about the old mailbox, so the background check starts the new address from scratch on its next pass, and the recipient provider Warmbly matches senders against is worked out again from the new domain. Emails already sent went to the old address and stay in the timeline as they happened.
## Editing many contacts at once
Tick rows in any contact list, a segment's members or a campaign's leads, and the selection bar's **Edit** opens a bulk panel. It can add or remove campaigns, add or remove categories, force a subscription state, and queue custom-field operations (add, edit, delete, rename) that run on every selected contact (the key box suggests your existing fields as you type), including a **Select all matching** selection that reaches past the loaded pages.
Tick rows in any contact list, a segment's members or a campaign's leads, and the selection bar's **Edit** opens a bulk panel. It can add or remove campaigns, add or remove labels, force a subscription state, and queue custom-field operations (add, edit, delete, rename) that run on every selected contact (the key box suggests your existing fields as you type), including a **Select all matching** selection that reaches past the loaded pages.
Nothing is applied while you build it up. The header names the list the selection came from, and a **Will apply** strip above the buttons lists every queued change as a chip you can take back one at a time before pressing Apply. A field operation missing its key, or its value where one is needed, is marked and skipped rather than sent half-finished.
## Categories
## Labels
Colored labels that group and filter contacts (`Warm lead`, `Conference 2026`, `Enterprise`), behaving like tags. The same picker appears in bulk edit, a contact's Details tab, the new-contact dialog, the filters, and the import and sync wizards.
Colored labels that group and filter contacts (`Warm lead`, `Conference 2026`, `Enterprise`). The same list labels [inbox conversations](/guides/unibox/#labels) and is what [forms](/guides/forms/) file their submissions under. The same picker appears in bulk edit, a contact's Details tab, the new-contact dialog, the filters, and the import and sync wizards.
It supports type-ahead search, and typing an unmatched name offers **Create** to add and select it in one step. Each category keeps its color everywhere its chip appears.
It supports type-ahead search, and typing an unmatched name offers **Create** to add and select it in one step. Each label keeps its color everywhere its chip appears.
Categories belong to the workspace, not to whoever made them: every teammate sees the same list and can file contacts under it. **Contacts > Categories** lists every category with a live contact count. From there you can create one, rename it, change its color, delete it (contacts are kept; the label is removed from them and from inbox threads), or click a row to open the contact list filtered to it.
Labels belong to the workspace, not to whoever made them: every teammate sees the same list and can label contacts with it. **Contacts > Labels** lists every label with a live contact count. From there you can create one, rename it, change its color, delete it (contacts are kept; the label is removed from them and from inbox threads), or click a row to open the contact list filtered to it.
Categories also drive automation: a sequence can run **Add tag** or **Remove tag** as a contact moves through a flow, so a label can be applied automatically on a positive reply. **Add to segment** and **Remove from segment** do the same for [segments](/guides/segments/).
Labels also drive automation: a sequence can run **Add label** or **Remove label** as a contact moves through a flow, so a label can be applied automatically on a positive reply. **Add to segment** and **Remove from segment** do the same for [segments](/guides/segments/).
To turn labels and activity into a reusable audience, build a [segment](/guides/segments/): a saved set of conditions over contacts (categories, fields, campaign activity, engagement) that you can browse and add to a campaign in one step.
To turn labels and activity into a reusable audience, build a [segment](/guides/segments/): a saved set of conditions over contacts (labels, fields, campaign activity, engagement) that you can browse and add to a campaign in one step.
## Where a contact came from
@@ -182,11 +182,11 @@ A new contact is also an event. `contact.created` goes to your [webhooks](/guide
## Activity timeline
The **Activity** tab of a contact is one workspace-wide feed, newest first, of everything Warmbly knows about them. Every member with permission to view contacts sees the same timeline, regardless of who created the contact or its campaigns. It includes every campaign email sent, opened, clicked, replied to or bounced (with the campaign, step, subject and sending mailbox), replies with their classified intent, deliverability and suppression events, notes, meetings, and the contact's lifecycle: when it was created and how, and each time it joined or left a campaign or a category.
The **Activity** tab of a contact is one workspace-wide feed, newest first, of everything Warmbly knows about them. Every member with permission to view contacts sees the same timeline, regardless of who created the contact or its campaigns. It includes every campaign email sent, opened, clicked, replied to or bounced (with the campaign, step, subject and sending mailbox), replies with their classified intent, deliverability and suppression events, notes, meetings, and the contact's lifecycle: when it was created and how, and each time it joined or left a campaign or gained or lost a label.
Opens appear once per event, not once per email: a second open from another device is its own row. Opens show the device and mail client they were read on, such as **iPhone · Apple Mail app** or **Gmail · device hidden**, and clicks the device and browser the link opened in, with the city and country when known (see [How an open was read](/guides/analytics/#how-an-open-was-read)); expanded, the operating system, browser and full location, and why a device is hidden when a mail provider's image proxy fetched the email. The Overview tab sums this up under **How they read**: each client and device the contact's opens came from, with the count and the latest. A click names the link: the row reads **Clicked Pricing** and, expanded, shows the link's text, its full URL, the UTM source, medium, campaign and content it carried, and the browser. Every link in an email is tracked on its own, so two links clicked are two rows. Opens and clicks that came from a machine rather than the person (a mail privacy proxy, a security gateway that follows every link at delivery) carry an **auto** badge, and the expanded row says which rule caught them; see [Link tracking and UTM parameters](/guides/campaigns/#link-tracking-and-utm-parameters).
Filter chips narrow the feed (**Emails**, **Replies**, **Deliv.**, **Notes**, **Meetings**, **Campaigns**, **Lifecycle**), the search box matches subjects, campaigns, steps, mailboxes, categories, reasons and, for clicks, the link's text, URL and UTM values, and the date picker bounds it. Each row stays to one line until you click it; expanded, it shows every detail the event carries. The feed updates live as teammates and the schedulers write to it.
Filter chips narrow the feed (**Emails**, **Replies**, **Deliv.**, **Notes**, **Meetings**, **Campaigns**, **Lifecycle**), the search box matches subjects, campaigns, steps, mailboxes, labels, reasons and, for clicks, the link's text, URL and UTM values, and the date picker bounds it. Each row stays to one line until you click it; expanded, it shows every detail the event carries. The feed updates live as teammates and the schedulers write to it.
At the top of the tab sits the campaign panel: for each campaign the contact is in, its flow with this contact's progress, the lead status, and what the scheduler will do next. See [Campaigns](/guides/campaigns/) for how the next action is worked out and what its states mean.
+1 -1
View File
@@ -42,7 +42,7 @@ Each mailbox sits in a band, shown as a colored chip. The band controls how Warm
| Watch | `>= 10%` | `>= 0.03%` | n/a | n/a |
| Throttled | `>= 20%` | n/a | n/a | n/a |
| Quarantine | n/a | `>= 0.10%` | `>= 5%` | Repeated tampering with received warmup mail |
| Blocked | n/a | `>= 0.30%` | `>= 10%` | Clear abuse signals (repeated spam flags on received warmup mail) |
| Blocked | n/a | `>= 0.30%` | `>= 10%` | Clear abuse signals (four or more deletions or spam moves of received warmup mail in 7 days) |
A mailbox enters a band by crossing any single threshold. What each band does:
+2 -2
View File
@@ -3,7 +3,7 @@ title: "Forms"
description: "Build a hosted lead-capture form, style it to match your site, embed it anywhere, and turn every submission into a contact."
---
Forms turn website visitors into contacts. You build a form in the dashboard, publish it to a hosted page, and either share the link or embed the form on any website. Every submission is stored, and when it carries an email address it creates or updates a contact, files it under the categories you chose, and can drop it straight into a campaign.
Forms turn website visitors into contacts. You build a form in the dashboard, publish it to a hosted page, and either share the link or embed the form on any website. Every submission is stored, and when it carries an email address it creates or updates a contact, applies the labels you chose, and can drop it straight into a campaign.
Open **Forms** in the sidebar. Viewing takes the same permission as viewing contacts; building and publishing take the manage-contacts permission.
@@ -58,7 +58,7 @@ The **Design** tab styles the form while the canvas updates live. The defaults a
The **Settings** tab controls what a submission does:
- **Success message** is shown after submitting, or set a **redirect URL** to send the visitor to your own thank-you page instead.
- **Add to categories** files every submitted contact under the categories you pick, for example "Website leads".
- **Add to labels** puts the labels you pick on every submitted contact, for example "Website leads".
- **Add to campaign** enrolls new contacts as leads in the campaign you pick. Sending still follows the campaign's own schedule, limits and windows; a form never causes immediate mail. Picking a campaign turns on its **Keep running for new leads** setting, so it waits between submissions instead of finishing; a campaign that had already finished restarts when a lead arrives, through the usual launch checks, and a refused restart is noted in its activity log while the lead waits.
- **Spam protection** and **allowed embed domains** are covered below.
+2 -2
View File
@@ -37,7 +37,7 @@ Triggers poll on your scenario's schedule. Make remembers what it has seen, so a
| **Campaigns** | Create, Update, Start, Stop, Delete |
| **Templates** | Create, Update, Delete, Render Reply Template |
| **Meetings** | Log Meeting, Delete Meeting |
| **Organization** | Create Contact Category, Mailbox Tag, or Campaign Folder |
| **Organization** | Create Contact Category (a contact label), Mailbox Tag, or Campaign Folder |
Campaign, mailbox, pipeline, stage, template, and contact fields use live dropdowns from your workspace, so you pick real records instead of pasting ids.
@@ -56,7 +56,7 @@ Pair a search with an action for idempotent flows: Find Contact, then Create or
Facebook and Instagram Lead Ads, LinkedIn Lead Gen Forms and TikTok Lead Generation all deliver new form submissions to Make in real time, which makes Make the shortest route from an ad to a follow-up sent from your own mailbox.
1. **Trigger:** *Facebook Lead Ads: Watch Leads* (or the LinkedIn or TikTok equivalent), picking the Page and form.
2. **Module:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the categories. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
2. **Module:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the labels. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
Prefer to keep the mapping inside Warmbly? Use the *HTTP: Make a request* module to POST the lead as JSON to an [inbound webhook automation](/guides/automations/#lead-intake) instead. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates, and the same flow can tag, notify Slack and open a task.
+1 -1
View File
@@ -22,7 +22,7 @@ Both deliver the same JSON body: a delivery id, the event type and the full even
Two routes, depending on where you want the field mapping to live.
**Mapping in n8n.** An HTTP Request node calling `POST /v1/contacts` with the contact's fields, `categories` and `campaigns`. The write is an upsert by email, so re-running a workflow updates the contact instead of duplicating it.
**Mapping in n8n.** An HTTP Request node calling `POST /v1/contacts` with the contact's fields, its labels (`categories`) and `campaigns`. The write is an upsert by email, so re-running a workflow updates the contact instead of duplicating it.
**Mapping in Warmbly.** Create an automation with the **Inbound webhook** trigger, copy its URL, and POST the raw payload to it from an HTTP Request node. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates (`{{.email}}`, `{{.answers.company}}`), tags the contact and enrols it in a campaign, and the same flow can notify Slack or open a task. See [Lead intake](/guides/automations/#lead-intake). This route needs no API key: the URL is the credential.
+83 -5
View File
@@ -1,6 +1,6 @@
---
title: "Inbox placement tests"
description: "Send a campaign step or a template from one of your mailboxes to seed inboxes at the major providers, and see where each copy landed."
description: "Send a campaign step or a template from one of your mailboxes, or from a whole fleet at once, to seed inboxes at the major providers, and see where each copy landed."
---
A placement test answers one question before your prospects do: when this mailbox sends this email, where does it land? Warmbly sends a copy from a real mailbox of yours to a panel of seed inboxes spread across providers, then reads each seed's mail to see whether the copy reached the primary inbox, a Gmail tab, spam, or nowhere at all.
@@ -168,6 +168,78 @@ A monitor only tests a campaign that is running. When a run cannot start (the ca
**When a monitor's test lands below its threshold**, everyone in the workspace who can view campaigns gets a **Placement monitor alert**, with the result and a link to the test. With **Pause on alert** on, the campaign is also paused automatically, with the reason in its activity log. Like every [auto-pause](/guides/campaigns/#auto-pause-guardrails), it does not resume on its own: find out what changed, then start the campaign again yourself.
## Testing a fleet with batches
A test answers where one mailbox lands. With hundreds or thousands of sending mailboxes the question is different: is the fleet healthy, and if not, is the problem one mailbox, one domain, or one provider? A **placement batch** runs the same test from many mailboxes and reads the results together. Start one with **New batch** on the placement tests page, with **Test sender pool** on a campaign's settings, or with **Test untested mailboxes** from the coverage line.
Each mailbox in a batch gets its own ordinary placement test with its own results, so everything above about how a test runs, what it costs and how to read it applies to every one of them. The batch adds shared settings, one progress view, results grouped across the fleet, and one cancel button.
### Choosing the senders
| Senders | What it selects |
|---------|-----------------|
| **Choose mailboxes** | The mailboxes you tick, by hand (`sender_account_ids` in the API) |
| **A campaign's senders** | Every mailbox the campaign sends from, the same set its scheduler uses (`sender_scope.type` `campaign`) |
| **All mailboxes** | Every mailbox in the workspace (`sender_scope.type` `workspace`) |
A campaign's senders and all mailboxes can be narrowed by provider (Google Workspace, Microsoft 365, SMTP and the rest), by sending domain, by mailbox tag, and to mailboxes with no finished placement test in the last so many days. Disconnected mailboxes are left out unless you include them; a mailbox you tick by hand is always kept, and is reported by name if it cannot send when its turn comes. Seed inboxes never send.
The senders are resolved on the server and written down when the batch starts. A mailbox tagged or added to the campaign afterwards does not join a batch that is already running, and one removed from it does not leave.
### Sampling
Testing every mailbox is thorough but spends a test and a day's sends from each. A sample tests part of the fleet instead:
| Sample | Result |
|--------|--------|
| **All** | Every selected mailbox |
| **Random** | That many mailboxes, picked at random |
| **Percent** | That share of the mailboxes, rounded up. **Spread across providers** splits the sample in proportion to each provider's share of the fleet, so 10% of 800 Google Workspace, 400 SMTP and 147 Microsoft 365 mailboxes is 80, 40 and 15, rather than 135 picked at random |
| **Per domain** | Up to that many mailboxes from every sending domain, so a large domain is not over-represented |
| **Per provider** | Up to that many mailboxes from every sending provider |
### How a batch runs
A batch can hold thousands of mailboxes, but only a few send at once. Warmbly starts the next mailbox when one has sent every copy of its test, so at most `20` of a workspace's batch mailboxes are sending at the same time, a batch starts at most `10` mailboxes a minute, and the instance as a whole runs at most `200` batch mailboxes at once, which keeps the shared seed panel from receiving a burst (the operator can change all three). Every batch test runs at the spaced pace; quick is for a single test. Consecutive starts rotate across sending providers and domains, so a batch never works through one provider's mailboxes back to back. The number of mailboxes in a batch has no effect on how many run together; the operator's ceiling on batch size, `10,000` by default, exists only to protect the instance.
Nothing is reserved when the batch starts. Each mailbox is checked when its turn comes, against its own daily limit, minimum gap, connection, worker and the workspace's standing, exactly as a single test is. So campaigns keep sending while a batch works through the fleet, and a batch never gets around a safeguard a single test would hit.
A mailbox that cannot run when its turn comes is handled by the batch's policy:
| Policy | A mailbox without room today, disconnected or offline |
|--------|------------------------------------------------------|
| **Retry until every sender has been tested** (default, `defer`) | Waits and is tried again: early the next UTC day when its daily limit ran out, an hour later when it was disconnected. The batch keeps going with the other mailboxes, and retries for up to seven days from when it started |
| **Skip senders that can't send right away** (`skip`) | Skipped, with the reason, and the batch moves on |
A mailbox still sending another test is always retried a quarter of an hour later. A mailbox with no seed it can reach (every seed is on its own domain) is skipped under either policy. One mailbox failing never fails the batch: every mailbox keeps its own result or its own reason, such as its daily limit, its connection or its worker.
A batch ends **completed** when every mailbox finished a test, **completed with warnings** when some were skipped or failed, **failed** when none finished, or **cancelled**. When the free tests or the credits you agreed to run out partway, or the campaign or step being tested is deleted, the mailboxes not yet started are skipped with that reason and the batch ends with the results it has.
Cancelling a batch stops it starting mailboxes, cancels the copies not sent yet, and leaves the copies already sent to be classified, so a cancelled batch keeps every result it had.
### Reading a batch
The batch page shows its progress and the overall placement, then four views:
- **Mailboxes**, worst inbox rate first, with the reason for any that was skipped, deferred or failed. Each opens its own test.
- **Domains**, each sending domain's placement across its mailboxes, worst first. One bad mailbox on an otherwise healthy domain is a mailbox problem; a whole domain low is that domain's reputation or authentication.
- **Providers**, the same by sending provider, which shows a problem with one kind of mailbox.
- **Recipient providers**, a table of sending domains against the providers that received the copies, which shows a domain that only one provider is filtering.
For a tracking comparison the overall figure is the tracked half, the copy the campaign really sends, with the untracked half beside it.
The batch does not group by worker. A worker signs in to your mailbox provider, and the provider delivers the mail from its own servers, so recipients' filters never see which worker handled a mailbox.
### What a batch costs
Before a batch starts, the dialog states the workload: how many tests, the most copies it can send (seeds per test times mailboxes, doubled for a tracking comparison), and how many mailboxes send at once. Each mailbox's daily limit can only make the real number smaller.
Every mailbox's test counts as one test against the monthly free tests (two for a comparison), exactly like a single test. When a batch needs more tests than are free, you agree up front to the most the rest can cost, which the API takes as `max_credits` for the whole batch; the batch never spends more than that, and a test that delivers nothing is refunded as usual. When the tests cannot be paid for (a trial, or a panel without paid tests), a batch larger than the free tests left is refused; take a smaller sample or test on your own seed inboxes, which are never counted.
### Fleet coverage
The placement tests page shows how much of your connected fleet finished a placement test in the last 30 days, and how many mailboxes never did. **Test untested mailboxes** starts a batch of exactly those.
## Reading the results honestly
A placement test is a signal, not a verdict. Read it with its limits in mind:
@@ -184,16 +256,22 @@ Tests also feed the seed placement figures on the [Deliverability](/guides/deliv
| Notification | Who gets it | Default |
|--------------|-------------|---------|
| Placement test finished | Whoever started the test by hand, once every copy has a verdict. A tracking comparison reports once, with both halves | On, in-app and push, no email |
| Placement test finished | Whoever started the test by hand, once every copy has a verdict. A tracking comparison reports once, with both halves. For a batch, whoever started it, once, when the whole batch ends | On, in-app and push, no email |
| Placement monitor alert | Everyone who can view campaigns, when a monitor's test lands below its threshold | On, including email |
Both open the test. Change them under **Settings > Notifications**; see [notifications](/guides/notifications/).
Both open the test, or the batch. Change them under **Settings > Notifications**; see [notifications](/guides/notifications/).
## Limits
| Limit | Value |
|-------|-------|
| Tests running at once per workspace | `3` (a comparison counts as two) |
| Tests running at once per workspace | `3` (a comparison counts as two). A batch's tests are paced by the batch instead |
| Mailboxes in one batch | up to `10,000` by default, at most `100,000` (operator setting) |
| Batch mailboxes sending at once per workspace | `20` by default, at most `500` (operator setting) |
| Batch mailboxes sending at once across the instance | `200` by default, at most `5,000` (operator setting) |
| Batch mailboxes started per minute | `10` per batch by default (operator setting) |
| Batches running at once per workspace | `5` |
| How long a batch retries a deferred mailbox | `7` days from when it started |
| Tests sending from one mailbox at once | `1` |
| Seeds per test | up to `20` by default, at most `100` (operator setting). Seeds you chose by hand all get a copy, up to `50` |
| Your own seed inboxes | `50` per workspace |
@@ -202,7 +280,7 @@ Both open the test. Change them under **Settings > Notifications**; see [notific
## Moving a workspace
Placement tests and their results travel in a [workspace export](/guides/workspace-export-import/) as a record, under Delivery events (without what a paid test cost, since credits do not travel), and campaign placement monitors travel with the campaigns. Which mailboxes are seed inboxes does not travel, so mark them again on the destination. A test that is still sending leaves its unsent copies behind.
Placement tests and their results travel in a [workspace export](/guides/workspace-export-import/) as a record, under Delivery events (without what a paid test cost, since credits do not travel), and so do placement batches with their mailboxes and results. A batch still running when it is exported arrives cancelled, so the destination never starts sending it. Campaign placement monitors travel with the campaigns. Which mailboxes are seed inboxes does not travel, so mark them again on the destination. A test that is still sending leaves its unsent copies behind.
## Related guides
+8 -8
View File
@@ -1,11 +1,11 @@
---
title: Segments
description: Save reusable audiences built from contact fields, categories, campaign activity and email engagement, then add them to campaigns in one step.
description: Save reusable audiences built from contact fields, labels, campaign activity and email engagement, then add them to campaigns in one step.
---
A segment is a saved audience: a set of conditions over your contacts plus any contacts you pin in or out by hand. A segment says which contacts belong together; a campaign says what happens to them. Membership is evaluated live, so a contact that starts matching (a new category, a reply, a bounce) is in the segment the next time anyone looks, with no rebuild step.
A segment is a saved audience: a set of conditions over your contacts plus any contacts you pin in or out by hand. A segment says which contacts belong together; a campaign says what happens to them. Membership is evaluated live, so a contact that starts matching (a new label, a reply, a bounce) is in the segment the next time anyone looks, with no rebuild step.
Segments live under **Contacts > Segments**, next to **All contacts** and **Categories**, and need the same permissions as contacts: **View contacts** to browse, **Manage contacts** to create, edit or delete.
Segments live under **Contacts > Segments**, next to **All contacts** and **Labels**, and need the same permissions as contacts: **View contacts** to browse, **Manage contacts** to create, edit or delete.
## Building a segment
@@ -19,7 +19,7 @@ Give the segment a name and a color, then add conditions. Each condition is a fi
| Contact | Subscribed, on the suppression list, catch-all domain | is yes, is no |
| Contact | Source, verification status, email provider, email provider family | is any of, is none of |
| Contact | Created, updated | in the last N days, not in the last N days, after, before |
| Contact | Category | has any of, has none of, has none, has any |
| Contact | Label | has any of, has none of, has none, has any |
| Company | Company name | the text operators above |
| Campaign activity | In campaign | is in any of, is in none of, is in no campaign, is in a campaign |
| Campaign activity | Number of campaigns | is, is not, more than, at least, less than, at most |
@@ -52,7 +52,7 @@ A segment with no conditions at all is a plain list: it holds exactly the contac
## Using a segment
- **Browse**: a segment page lists its current members with the same table, filters, detail drawer and bulk actions as the contacts page, including [**Select all matching**](/guides/contacts-crm/#selecting-rows) so an action covers every member and not only the rows loaded so far, for selections up to `50,000` contacts.
- **Browse**: a segment page lists its current members with the same table, filters, detail drawer and bulk actions as the contacts page, including [**Select all matching**](/guides/contacts-crm/#selecting-rows) so an action covers every member and not only the rows loaded so far, for selections up to `250,000` contacts.
- **Add to campaign**: enrols every current member as a lead of the campaign you pick, from the segment page or with **From segment** on a campaign's Leads tab. Contacts already in that campaign are skipped, and a running campaign wakes up to schedule the new leads. This is a snapshot: contacts who join the segment later are not added until you run it again. To keep a campaign fed automatically, [link the segment](#linking-a-segment-to-a-campaign) instead.
- **Link to campaign**: attaches the segment to a campaign as a live audience, so contacts who join the segment later become leads on their own. See [below](#linking-a-segment-to-a-campaign).
- **New campaign**: pick the segment as a lead list on the first step of **Campaigns** > **New campaign**, which shows how many leads it holds and how long the mailbox pool needs to reach them. See [create a campaign](/guides/campaigns/#create-a-campaign).
@@ -93,15 +93,15 @@ Everything above can be driven from the [API](/api/reference/contacts/#segments)
API calls address a segment by its ID. It is shown at the bottom of the segment page header (click it to copy), in the **Copy segment ID** entry of a segment's row menu on the Segments tab, and in every segment the API returns. Reads take the `READ_CONTACTS` key scope, writes `WRITE_CONTACTS`, and enrolling into a campaign `WRITE_CAMPAIGNS`.
<Callout type="info" title="Segments and categories">
Categories are labels you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a category to mark a fact about a contact, and a segment to describe an audience.
<Callout type="info" title="Segments and labels">
Labels are something you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a label to mark a fact about a contact, and a segment to describe an audience.
</Callout>
## Limits
- 200 segments per workspace
- 50 conditions per segment, 200 values per list condition
- 1,000 contacts per manual add or remove request
- 10,000 ticked contacts, or `250,000` through **Select all matching**, per manual add or remove
## Where to go next
+2 -2
View File
@@ -127,10 +127,10 @@ Upload attachments for a step below its composer by dragging and dropping files
### Action steps
A step can perform an action instead of sending: add or remove a tag, label email, create a task, create a deal or move its stage, unsubscribe the contact, notify (fires your webhooks and integrations), run an automation, or switch. They connect like email steps and work best at the end of a reply branch, for example creating a deal and notifying your team on a positive reply.
A step can perform an action instead of sending: add or remove a label, label the conversation, create a task, create a deal or move its stage, unsubscribe the contact, notify (fires your webhooks and integrations), run an automation, or switch. They connect like email steps and work best at the end of a reply branch, for example creating a deal and notifying your team on a positive reply.
<Callout type="info">
**Label email** is reply-only. It labels the conversation the contact replied on, so place it on a reply branch; anywhere else it is a no-op because there is no thread to label.
**Label conversation** is reply-only. It labels the conversation the contact replied on, so place it on a reply branch; anywhere else it is a no-op because there is no thread to label.
</Callout>
### Switch steps
+1 -1
View File
@@ -55,7 +55,7 @@ Roles are workspace data. Every workspace starts with three seeded roles that ar
| Area | Capability | Allows |
| --- | --- | --- |
| **Data** | View / manage campaigns | Read settings, sequences, analytics / create, edit, archive |
| | View / manage contacts | Read contacts, segments, tags / create, edit, delete |
| | View / manage contacts | Read contacts, segments, labels / create, edit, delete |
| | Manage sequences | Edit step content and spacing |
| | View analytics | Deliverability and engagement reports |
| | Use integrations | Push contacts and deals to connected tools |
+12 -9
View File
@@ -1,6 +1,6 @@
---
title: "Unibox"
description: "A unified inbox across all connected mailboxes, with categories and threading."
description: "A unified inbox across all connected mailboxes, with labels and threading."
---
One inbox for every connected mailbox. Read, sort, and reply from a single screen instead of logging into each account. Three columns: a **scope rail** for picking what to look at, a **conversation list**, and a **thread view** where you read and reply.
@@ -16,6 +16,7 @@ The columns are yours to size. Every choice here is remembered in the browser yo
| The divider between the list and the thread | Drag it to widen the conversation list so long subjects fit, or to give the space back to the thread. It also takes the keyboard once it has focus: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, `Enter` (or a double-click) puts it back to the default. |
| **Collapse** at the bottom of the left navigation, or `b` | Shrinks Warmbly's own navigation to an icon rail and gives the width to the page. Row labels become tooltips; unread mail keeps its count badge and an open [Advisor](/guides/advisor/) finding shows as a coloured dot on the icon. The same control expands it again. |
| The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away, and again to bring it back. Like the other layout choices, it is remembered in this browser. A folded section still shows the page you are on, and a coloured dot beside its header flags an open [Advisor](/guides/advisor/) finding on one of the pages it hides. The icon rail follows the same choice. |
| The section headers in the scope rail (Mail, Views, Mailboxes, Labels, Tags) | Click one to fold its section away, and again to bring it back. A folded section still shows the scope you are looking at, and a small blue dot beside its header means a row it folds away has a highlighted count (rows you hid never raise it). The **…** beside a header (or a right-click on it) folds every other section, unfolds them all, or moves the section up or down the rail. The pencil on **Mail** and **Views** puts the rows in edit mode: untick a row to hide it, drag it by its handle (or focus the handle and use the arrow keys) to reorder, and press **Done**, `Escape`, or click anywhere else. **Reset** puts that section back the way it shipped. Each row also has its own **…** menu and right-click menu to move it, hide it (with an undo), or mark a folder read, and `Alt` with an arrow key moves the focused row. A small "2 hidden" beside the pencil tells you rows are off the rail. Hiding a row only takes it off the rail, so its shortcuts and links keep working, and the scope you are in always stays visible. All of it is remembered in this browser. |
| The contact button in the thread header | Shows or hides the contact panel on the right. It starts closed when no preference is saved. Opening or closing it sticks across conversations, and existing preferences are preserved. |
How wide the list can get depends on the window: the thread always keeps enough room to read a message, and the contact panel counts toward that when it is open, so the widest setting is narrower on a laptop than on a large monitor.
@@ -58,7 +59,7 @@ The rail is two short groups and then your mailboxes. The first group is where y
Opening **Inbox** from the main navigation starts in the Inbox folder. Sent messages live in **Sent**; choose **All mail** when you want inbound and outbound messages together. An Inbox conversation still shows its complete history, including your replies, when you open it.
Each message starts in the folder the provider has it in: IMAP special-use folder attributes, Gmail labels, and Outlook well-known folders all map to the same six. Moves at the provider (junking a message, clearing it out of spam) follow on the next sync. Drafts are also reconciled: a draft the mailbox no longer holds is removed here too, so the fresh copy Gmail saves on every autosave does not leave the previous ones behind. You can also file a conversation yourself, which moves it here without moving it at the provider; see [filing a conversation](#filing-a-conversation). The active row is highlighted grey, unread counts sit on the right in blue, and hovering a folder reveals a three-dot menu with **Mark all as read**.
Each message starts in the folder the provider has it in: IMAP special-use folder attributes, Gmail labels, and Outlook well-known folders all map to the same six. Moves at the provider (archiving, deleting, junking a message, clearing it out of spam, moving it back to the inbox) follow on the next sync. In Gmail each of those is a label change, and Warmbly files the message by the labels it ends up with, so a conversation archived in Gmail leaves Inbox here too. Gmail mailboxes are also rechecked against Gmail every few hours, which catches a move the live sync missed. Drafts are also reconciled: a draft the mailbox no longer holds is removed here too, so the fresh copy Gmail saves on every autosave does not leave the previous ones behind. You can also file a conversation yourself, which moves it here without moving it at the provider; see [filing a conversation](#filing-a-conversation). The active row is highlighted grey, unread counts sit on the right in blue, and hovering a folder reveals a three-dot menu with **Mark all as read**.
Spam and Trash stay out of every other view, and so does Archive: filing a conversation is how you take it out of the way, so it leaves Unread, Awaiting reply, every mailbox, label and tag view, and the unread badge. **All mail** and the **Archive** folder are the two places it stays, which is where you go to find it again.
@@ -108,6 +109,8 @@ Each row is a conversation, not a message, with a count next to the sender when
The list loads more conversations as you reach the end of it, and it keeps its place: opening a conversation, or leaving the inbox and coming back, returns you to the row you were on rather than the top of the list.
Picking another folder or view closes the open conversation, since it may not be in the new list. To close it yourself, use the **X** at the right of the conversation header on a wide screen, the back link above it on a phone (it names the list you came from), or `Escape`.
Threading applies on both sides. A reply carries an `In-Reply-To` header naming the last message in the conversation, which is what nests it for the recipient. From a Gmail mailbox that holds the conversation it also carries Gmail's thread id, which nests it in your own mailbox. The recipient's mail client only ever sees the header: a thread id means nothing outside the mailbox that issued it, so a reply sent [from another mailbox](#choosing-the-sending-mailbox) goes without one.
<Callout type="info" title="Keyboard navigation">
@@ -130,14 +133,14 @@ Recognized quoted history collapses behind **Show quoted text**. Click it to rea
Every open message has a details toggle on its recipient line, the `to` under the sender's name, and the sender's name and address open the same panel. It shows the message envelope in place: every From, Reply-To, To, Cc and Bcc address, when it was sent and, if that differs, when the mailbox received it, both in your own time zone, the mailbox and folder it lives in, its size, and the Message-ID and In-Reply-To headers. Click any address or identifier to copy it. The info icon next to Reply and Forward opens the same panel, including on a message that is still collapsed.
## Categories and labels
## Labels
Categories are the workspace's conversation labels, shared with the rest of Warmbly, so `Interested` means the same thing on a contact as it does here. They are shared with the rest of the team too: a category anyone creates is available to everyone, and a conversation one member labels shows that label to the next. Label with the tag button in the conversation header or `c`: search existing categories, tick what applies, or type a name and **Create**. A conversation can carry several.
Labels are one workspace list, used on [contacts](/guides/contacts-crm/#labels), on conversations here and by [forms](/guides/forms/), so `Interested` means the same thing on a contact as it does on a conversation. The list is shared with the rest of the team too: a label anyone creates is available to everyone, and a conversation one member labels shows that label to the next. Label with the tag button in the conversation header or `c`: search existing labels, tick what applies, or type a name and **Create**. A conversation can carry several.
Labeled conversations show colored chips on the row and in the header, and each category appears in the rail with its own count for one-click filtering. Labels you apply are never touched by the system. [Automatic inbox tagging](/guides/inbox-tagging/) adds its own labels alongside them, additively, and never removes one a person applied.
Labeled conversations show colored chips on the row and in the header, and each label appears in the rail with its own count for one-click filtering. Labels you apply are never touched by the system. [Automatic inbox tagging](/guides/inbox-tagging/) adds its own labels alongside them, additively, and never removes one a person applied.
<Callout type="info" title="Categories vs tags">
Categories label conversations. Tags label the mailboxes themselves (grouping accounts by client or domain). Both filter from the rail, but they describe different things.
<Callout type="info" title="Labels vs mailbox tags">
Labels mark conversations and contacts. Mailbox tags mark the mailboxes themselves (grouping accounts by client or domain). Both filter from the rail, but they describe different things.
</Callout>
## Read state
@@ -173,7 +176,7 @@ Each row has a tick box in its left gutter, in place of the unread dot. Hover a
With anything ticked, a bar appears along the bottom of the screen with the count and the actions that apply to all of them: **Mark read**, **Mark unread**, **Snooze**, **Archive** (or **Move to inbox**) and **Delete**. Each one is a single request, so filing a screenful is as quick as filing one. **Clear** drops the selection, and so does `Escape`; changing view drops it too, since the rows it applied to are no longer the rows on screen.
<Callout type="info" title="Filing here does not move the message at the provider">
Unlike read state, Archive and Delete are Warmbly's own filing. The message keeps its place in Gmail, Outlook, or whatever mail client the mailbox belongs to, and deleting a conversation here never deletes mail there. The next sync will not undo your filing either: Warmbly records where the provider has each message separately from where you filed it, and follows the provider only when the provider itself moves the message. So junking a message in Gmail still reaches Warmbly, and an ordinary sync pass does not.
Unlike read state, Archive and Delete are Warmbly's own filing. The message keeps its place in Gmail, Outlook, or whatever mail client the mailbox belongs to, and deleting a conversation here never deletes mail there. The next sync will not undo your filing either: Warmbly records where the provider has each message separately from where you filed it, and follows the provider only when the provider itself moves the message. So archiving or junking a message in Gmail still reaches Warmbly, and an ordinary sync pass does not.
</Callout>
## Replying
@@ -194,7 +197,7 @@ The composer shows the message it will forward under your note; expand **Forward
### Pausing their follow-ups
The contact panel lists every campaign the sender is a lead of, with their status in it and what happens next: the next step and when, the hold and when it lifts, or why the flow ended. A campaign with a step still to send has **Pause**, which opens the same dialog as the campaign's Leads list (until a date, or until you resume them, with an optional note). A held lead has **Resume** instead. Neither appears on a lead whose flow has ended, and both need the **Manage campaigns** permission. See [Out of office and pausing one lead](/guides/campaigns/#out-of-office-and-pausing-one-lead).
The contact panel lists every campaign the sender is a lead of, with their status in it and what happens next: the next step and when, the hold and when it lifts, or why the flow ended. A campaign with a step still to send has **Pause**, which opens the same dialog as the campaign's Leads list (until a date, or until you resume them, with an optional note). A held lead has **Resume** instead; for a contact held because they are [copied on another lead's emails](/guides/campaigns/#copying-colleagues-on-one-lead) it asks first, since it starts a second thread to them. Neither appears on a lead whose flow has ended, and both need the **Manage campaigns** permission. See [Out of office and pausing one lead](/guides/campaigns/#out-of-office-and-pausing-one-lead).
The reply composer offers the same thing for the moment you answer. When the recipient still has a follow-up queued, **Pause follow-ups** next to **Schedule** holds them for 3 days, 1 week, 2 weeks, 30 days or until you resume them. When they are a lead of more than one such campaign, the menu lists each one, all ticked, and you untick the ones to leave running (at least one stays ticked). Nothing is paused until the reply is accepted, and a send that fails pauses nothing. A scheduled reply pauses the follow-ups straight away and counts the length from when the reply goes out, so a follow-up cannot overtake it. Undoing or cancelling the reply does not lift the pause, so use **Resume** in the contact panel if you change your mind. A lead already on hold, for example after an away message arrived while you were writing, keeps that hold.
+12 -2
View File
@@ -233,19 +233,29 @@ Partner selection favours the recipients where warmup earns something. A small-h
Quarantined and blocked mailboxes are selected as neither sender nor recipient. Mail arriving in a mailbox is never held against it: every warmup message carries a single-use token bound to its recipient, so a token cannot be replayed or redirected, and one that lands where it does not belong is simply filed as ordinary mail.
What a mailbox does to warmup mail it received is held against it, on a ladder rather than at once. Deleting a warmup email within a day of its arrival counts as one strike and marking one as spam counts as two, over the last seven days. One strike puts the mailbox on watch with the reason shown in its drawer, two pause it from the pool for seven days, and four block it for thirty. So deleting one fresh warmup message is a warning, not a ban, while flagging pool mail as spam twice is a block.
What a mailbox does to warmup mail it received is held against it, on a ladder rather than at once. Deleting a warmup email within a day of its arrival counts as one strike, and so does moving one to spam, over the last seven days. One strike puts the mailbox on watch with the reason shown in its drawer, two pause it from the pool for seven days, and four block it for thirty. So one deleted or junked warmup message is a warning, not a ban.
Only a fresh deletion counts, because that is the one that costs the pool something: the engagement a warmup message earns happens in its first hours, and removing it before then takes that signal away. A warmup message deleted later is housekeeping, whether by you, by Gmail emptying its Trash, by a retention rule on your mail server or by Warmbly's own [retention](#retention), and it is never held against the mailbox. On Gmail, pressing Delete is what is judged, not the purge from Trash weeks later. A mailbox's own filing is never counted either: Warmbly moving a warmup email into its folder, marking it read, rescuing it from spam or deleting it once its window has passed is the platform acting, not the owner.
Moving a warmup email is not deleting it. Outlook and Microsoft 365 report a message moved to another folder exactly as they report one deleted, and a mailbox's own rules, its provider's filter or a second Warmbly instance syncing the same mailbox can all move mail. So before a removal counts, Warmbly searches the whole mailbox for the message: found in any folder other than Trash (Deleted Items on Outlook), it was filed, and nothing is held against the mailbox. Only a message in Trash or gone for good is a strike. A search that cannot run, or cannot tell, counts as nothing. Deletion strikes recorded before this search existed are searched for in the same way. A strike whose message is still in the mailbox is withdrawn, and the pause or block it caused is decided again on the strikes that remain, so it is lifted or shortened only when those no longer earn it.
No provider says who moved a message into spam. Gmail, Microsoft 365 and IMAP report it the same way whether you pressed "Report spam", the provider re-filed it after delivery (Microsoft's zero-hour auto purge, Google Workspace's post-delivery scanning), a desktop mail client's junk filter moved it, or a security tool did. So Warmbly charges a move to spam only when the evidence points at a person, and waits 30 minutes after the move to see it:
- **Arrived in spam.** A warmup email that was in spam when it arrived is never a strike. The label on it is the provider's filter, and it counts as spam placement against the sender.
- **The provider at work.** When the same sender's warmup mail was moved to spam in another workspace within a day, the provider is re-judging that sender, and nobody is charged. If a mailbox was already charged for one of those, the strike is withdrawn and any pause it caused is decided again. A move within 15 minutes of arrival while nobody is using the mailbox is the filter catching up, and is not charged either.
- **You at the mailbox.** A move while someone was reading, marking unread or starring mail in the mailbox, within 30 minutes either side, is taken as yours. Only changes made at the provider count: reading a message in Warmbly's inbox is never mistaken for it.
- **A pattern.** In a mailbox someone has used in the last 14 days, unexplained moves of warmup mail from three or more different senders in a week, which no other workspace sees, are the mailbox's own filtering and are charged.
- **Anything else charges nobody**, including every move in a mailbox nobody has used for 14 days. The sender still has it counted as spam placement, which only slows sending down.
Only a move charged to you files a complaint against the sender. A move attributed to the provider, or to nobody, is spam placement for the sender instead. On Outlook, Microsoft 365 and IMAP mailboxes, a move to Junk is found by the search above and is not charged at all.
<Callout type="warn" title="Acting early protects everyone">
Warmbly intervenes well before providers would penalize a mailbox. Complaints, bounces and tampering take a mailbox out of the pool early. A mailbox landing in spam at the major providers is slowed down instead, so it keeps warming, which is how it recovers.
</Callout>
Getting back in requires requalifying, not just waiting: healthy authentication, no recent complaints or hard-bounce spikes, and spam placement back to a low level. Return is gradual, not a jump back to the old ceiling.
A quarantine or a block also holds for its full term. The signals behind it age out of their windows long before it ends, and that does not release it early; a more serious finding can still replace it. A throttle is different and lifts as soon as the mailbox recovers, as the table says.
A quarantine or a block also holds for its full term. The signals behind it age out of their windows long before it ends, and that does not release it early; a more serious finding can still replace it. A throttle is different and lifts as soon as the mailbox recovers, as the table says. When support lifts a pause or block, or approves an appeal against one, the strikes behind it are cleared with it, so the next evaluation does not reimpose it.
On a self-hosted instance linked to [Warmbly Cloud](/guides/warmbly-cloud/#safety-and-enforcement), a mailbox the cloud warms is judged there, and the instance applies the cloud's standing to its own campaigns with the same effects as this table.
@@ -18,15 +18,15 @@ The data is split into groups. Every export includes **Workspace**; the rest are
| Group | Contents |
|-------|----------|
| Workspace | The organization, members, roles, teams, mailboxes and their profile photos, mailbox tags, the column mappings saved by [mailbox imports](/guides/mailbox-import/), inbox vendor connections, [root redirects](/guides/sending-domains/#root-redirects), API keys, webhooks, and settings, including the website tracking site key. Always included |
| Contacts | Contacts, categories, the column mappings saved by [contact imports](/guides/contacts-crm/#importing), segments with their manual overrides, forms with their images, submissions, personalized link tickets and funnel events, notes, activities, and the suppression list |
| Campaigns | Campaigns, folders, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, each lead's step progress with its per-link clicks and per-event opens, [placement monitors](/guides/placement-tests/#campaign-placement-monitors), and the [unsubscribe links](/guides/unsubscribe/) already in recipients' inboxes |
| Contacts | Contacts, labels, the column mappings saved by [contact imports](/guides/contacts-crm/#importing), segments with their manual overrides, forms with their images, submissions, personalized link tickets and funnel events, notes, activities, and the suppression list |
| Campaigns | Campaigns, folders, sequences, senders, linked segments, attachments, the email image library, per-campaign settings, each lead's step progress with its per-link clicks and per-event opens, the colleagues [copied on each lead](/guides/campaigns/#copying-colleagues-on-one-lead), [placement monitors](/guides/placement-tests/#campaign-placement-monitors), and the [unsubscribe links](/guides/unsubscribe/) already in recipients' inboxes |
| CRM | Pipelines, deals, tasks, and meeting bookings |
| Automations | Automations, connected integrations, and lead sync sources |
| Assistant | Assistant sessions and messages, skills, MCP servers, and AI settings |
| Warmup | Warmup participation, routing rules, statistics and inbox placement history, appeals, and the standing of every penalised address, current or removed, so a move is not a way past a block |
| Inbox | Unified inbox threads, message bodies, conversation labels, mailbox sync state, and completed automatic-tagging verdicts with their raw probabilities |
| Send history | Queued and completed send tasks with their payloads |
| Delivery events | Bounces, complaints, opens, clicks, [placement tests](/guides/placement-tests/) with where each copy landed, and website page views with the browser records that tie them to contacts |
| Delivery events | Bounces, complaints, opens, clicks, [placement tests](/guides/placement-tests/) and batches with where each copy landed, and website page views with the browser records that tie them to contacts |
| Verification evidence | What real mail showed about each contact's address (deliveries, opens, replies, bounces), so verdicts and confidence survive the move |
| Logs | Audit log, campaign logs, and notifications |
| Billing history | Subscription, credit ledger, and referral records |
@@ -97,7 +97,7 @@ Some things belong to an instance rather than to a workspace, so they are not ap
| Cold rotation state | Whether a mailbox is resting or held in reserve is this instance's decision about sending it watched. Every mailbox arrives in normal rotation and earns its way out again |
| Cold sending ramps | How far a mailbox had eased into cold volume raises its cap, and the destination never watched it send. Mailboxes re-graduate from their warmup maturity, which costs a few days and errs toward sending less |
| Seed inboxes | Which mailboxes are placement seed inboxes is this instance's choice of test inboxes, and an archive must not add mailboxes to another instance's seed panel. Every mailbox arrives as an ordinary one; mark your own seed inboxes again on the destination |
| Placement test sends | Copies of a test that had not been sent yet stay behind, so the destination never sends to the source instance's seeds. So does the copy a running tracking comparison rendered for each seed. Tests and their results arrive as a record, without what a test cost in credits, since the credit ledger does not travel. A campaign's placement monitor keeps its settings and next run, but not the record of its last run |
| Placement test sends | Copies of a test that had not been sent yet stay behind, so the destination never sends to the source instance's seeds. So does the copy a running tracking comparison rendered for each seed. Tests and their results arrive as a record, without what a test cost in credits, since the credit ledger does not travel. A campaign's placement monitor keeps its settings and next run, but not the record of its last run. A placement batch that was still running arrives cancelled, with the results it had, so it never starts sending from the destination |
| Scheduled deletions | A pending deletion from the source must never follow the workspace to its new home |
| Failure and delivery counters | A webhook endpoint's failure streak and auto-disable state, and whether a notification's email already went out, describe what happened on the source. They start fresh, so an endpoint is not pre-disabled on the new instance and a notification is not re-sent |
| Sends still in flight | A campaign step handed to a worker on the source has no worker on the destination to report back, so it arrives queued and is sent there instead of waiting forever. Steps already sent keep their history |
+2 -2
View File
@@ -37,7 +37,7 @@ Triggers poll on Zapier's schedule (frequency depends on your plan). Zapier reme
| **Campaigns** | Create, Update, Start, Stop, Delete |
| **Templates** | Create, Update, Delete, Render Reply Template |
| **Meetings** | Log Meeting, Delete Meeting |
| **Organization** | Create Contact Category, Mailbox Tag, or Campaign Folder |
| **Organization** | Create Contact Category (a contact label), Mailbox Tag, or Campaign Folder |
Campaign, mailbox, pipeline, stage, and contact fields use live dropdowns from your workspace, so you pick real records instead of pasting ids.
@@ -56,7 +56,7 @@ Pair a search with an action for idempotent flows: Find Contact, then Create or
Facebook and Instagram Lead Ads, LinkedIn Lead Gen Forms and TikTok Lead Generation all deliver new form submissions to Zapier in real time, which makes Zapier the shortest route from an ad to a follow-up sent from your own mailbox.
1. **Trigger:** *Facebook Lead Ads: New Lead* (or the LinkedIn or TikTok equivalent), picking the Page and form.
2. **Action:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the categories. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
2. **Action:** Warmbly *Create or Update Contact*. Map the form's email and name fields, put every other question into a custom field, and pick the labels. Then *Add to Campaign* with the campaign that follows up. Re-running the same lead updates the contact rather than duplicating it.
Prefer to keep the mapping inside Warmbly? Use *Webhooks by Zapier: POST* to an [inbound webhook automation](/guides/automations/#lead-intake) instead, sending the lead as JSON. The automation's **Create or update contact** action maps the JSON keys onto contact fields with templates, and the same flow can tag, notify Slack and open a task.
+1892 -18
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// leadCCResponse is what the read and the write answer with.
type leadCCResponse struct {
CampaignID string `json:"campaign_id"`
ContactID string `json:"contact_id"`
CC []models.CampaignLeadCC `json:"cc"`
}
func leadCCOK(c *gin.Context, campaignID, contactID uuid.UUID, cc []models.CampaignLeadCC) {
if cc == nil {
cc = []models.CampaignLeadCC{}
}
c.JSON(http.StatusOK, leadCCResponse{CampaignID: campaignID.String(), ContactID: contactID.String(), CC: cc})
}
// GetCampaignLeadCC lists the contacts copied on every email to one lead.
//
// GET /campaigns/:id/leads/:contactId/cc
func (h *Handler) GetCampaignLeadCC(c *gin.Context) {
orgID, campaignID, contactID, ok := leadHoldParams(c)
if !ok {
return
}
cc, xerr := h.CampaignService.ListLeadCC(c.Request.Context(), orgID, campaignID, contactID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
leadCCOK(c, campaignID, contactID, cc)
}
// SetCampaignLeadCC replaces the contacts copied on one lead.
//
// No Idempotency-Key: the body is the whole list, so a retry lands on the
// same state.
//
// PUT /campaigns/:id/leads/:contactId/cc
func (h *Handler) SetCampaignLeadCC(c *gin.Context) {
orgID, campaignID, contactID, ok := leadHoldParams(c)
if !ok {
return
}
var req models.SetCampaignLeadCC
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.InvalidBody(err))
return
}
cc, xerr := h.CampaignService.SetLeadCC(c.Request.Context(), orgID, campaignID, contactID, req.ContactIDs)
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityCampaignLead, &contactID, nil, map[string]string{
"campaign_id": campaignID.String(),
"cc": strconv.Itoa(len(cc)),
})
leadCCOK(c, campaignID, contactID, cc)
}
// SuggestCampaignLeadCC offers the lead's likely colleagues to copy.
//
// GET /campaigns/:id/leads/:contactId/cc/suggestions
func (h *Handler) SuggestCampaignLeadCC(c *gin.Context) {
orgID, campaignID, contactID, ok := leadHoldParams(c)
if !ok {
return
}
out, xerr := h.CampaignService.SuggestLeadCC(c.Request.Context(), orgID, campaignID, contactID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
-2
View File
@@ -14,8 +14,6 @@ import (
"github.com/warmbly/warmbly/internal/models"
)
const maxBulkOperationSize = 1000
func (h *Handler) AddContacts(c *gin.Context) {
userIDStr := middleware.GetUserID(c)
+2 -2
View File
@@ -20,9 +20,9 @@ func (h *Handler) resolveContactSelection(c *gin.Context, orgID uuid.UUID, sel m
errx.Handle(c, errx.New(errx.BadRequest, "no contacts provided"))
return nil, false
}
if len(sel.Contacts) > maxBulkOperationSize {
if len(sel.Contacts) > models.MaxContactBatchIDs {
errx.Handle(c, errx.NewWithIdentifier(errx.BadRequest, "too_many_contacts",
fmt.Sprintf("too many contacts, maximum is %d per batch", maxBulkOperationSize)))
fmt.Sprintf("too many contacts, maximum is %d per batch", models.MaxContactBatchIDs)))
return nil, false
}
return sel.Contacts, true
+57
View File
@@ -7,6 +7,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/observability/errs"
"github.com/warmbly/warmbly/internal/repository"
)
@@ -86,3 +88,58 @@ func (h *Handler) InternalSyncFolderMessages(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"messages": messages})
}
// maxProviderFolderMessages bounds one provider-folder listing.
const maxProviderFolderMessages = 1000
// InternalSyncProviderFolderMessages answers the Gmail folder reconciliation:
// which rows does the platform still believe the provider has in these
// folders? The worker checks each against Gmail's labels and reports the ones
// that moved.
//
// GET /api/v1/internal/sync/provider-folder-messages?user_id=&email_id=&folders=inbox,archive&limit=
// -> 200 {"messages":[{"id":"...","provider_id":"...","provider_folder":"inbox","internal_date":"..."}]}
func (h *Handler) InternalSyncProviderFolderMessages(c *gin.Context) {
if h.EmailSyncState == nil {
c.JSON(http.StatusOK, gin.H{"messages": []repository.ProviderFolderMessage{}})
return
}
userID, err := uuid.Parse(c.Query("user_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user_id"})
return
}
emailID, err := uuid.Parse(c.Query("email_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email_id"})
return
}
var folders []string
for _, raw := range strings.Split(c.Query("folders"), ",") {
f := strings.TrimSpace(raw)
if f == "" {
continue
}
if !models.ValidFolder(f) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder"})
return
}
folders = append(folders, f)
}
if len(folders) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "folders required"})
return
}
limit, err := strconv.Atoi(c.Query("limit"))
if err != nil || limit < 1 || limit > maxProviderFolderMessages {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
return
}
messages, err := h.EmailSyncState.ListProviderFolderMessages(c.Request.Context(), userID, emailID, folders, limit)
if err != nil {
errs.CaptureException(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not list stored messages"})
return
}
c.JSON(http.StatusOK, gin.H{"messages": messages})
}
+262
View File
@@ -0,0 +1,262 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/placement"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type placementBatchRequest struct {
SenderAccountIDs []uuid.UUID `json:"sender_account_ids"`
SenderScope *models.PlacementSenderScope `json:"sender_scope"`
Sample models.PlacementSample `json:"sample"`
CampaignID string `json:"campaign_id"`
SequenceID string `json:"sequence_id"`
ContactID string `json:"contact_id"`
Subject string `json:"subject"`
BodyHTML string `json:"body_html"`
BodyPlain string `json:"body_plain"`
Tracking string `json:"tracking"`
Panel string `json:"panel"`
Pace string `json:"pace"`
Families []string `json:"families"`
SeedIDs []uuid.UUID `json:"seed_ids"`
OnUnavailable string `json:"on_unavailable"`
MaxCredits int `json:"max_credits"`
}
// placementBatchInput binds a batch request; false after answering the error.
func (h *Handler) placementBatchInput(c *gin.Context) (placement.BatchInput, bool) {
var in placement.BatchInput
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return in, false
}
if !h.placementReady(c) {
return in, false
}
var req placementBatchRequest
if err := c.ShouldBindJSON(&req); err != nil {
errx.JSON(c, errx.InvalidBody(err))
return in, false
}
campaignID, ok := optionalUUID(c, req.CampaignID, "campaign_id")
if !ok {
return in, false
}
sequenceID, ok := optionalUUID(c, req.SequenceID, "sequence_id")
if !ok {
return in, false
}
contactID, ok := optionalUUID(c, req.ContactID, "contact_id")
if !ok {
return in, false
}
in = placement.BatchInput{
OrgID: *orgID,
SenderAccountIDs: req.SenderAccountIDs,
Scope: req.SenderScope,
Sample: req.Sample,
CampaignID: campaignID,
SequenceID: sequenceID,
ContactID: contactID,
Subject: req.Subject,
BodyHTML: req.BodyHTML,
BodyPlain: req.BodyPlain,
Tracking: req.Tracking,
Panel: req.Panel,
Pace: req.Pace,
Families: req.Families,
SeedIDs: req.SeedIDs,
OnUnavailable: req.OnUnavailable,
MaxCredits: req.MaxCredits,
}
// A key limited to some mailboxes only ever tests from those.
if middleware.GetAuthType(c) == middleware.AuthTypeAPIKey {
if allowed := middleware.GetAPIKeyAllowedEmailAccounts(c); len(allowed) > 0 {
in.AllowedSenders = allowed
}
}
if id, err := middleware.GetUserUUID(c); err == nil && id != uuid.Nil {
in.UserID = &id
}
return in, true
}
// PreviewPlacementBatch reports how many senders, tests, sends and credits a
// batch would come to. It starts nothing.
func (h *Handler) PreviewPlacementBatch(c *gin.Context) {
in, ok := h.placementBatchInput(c)
if !ok {
return
}
preview, xerr := h.PlacementService.PreviewBatch(c.Request.Context(), in)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": preview})
}
// CreatePlacementBatch snapshots the senders and queues the batch; the
// backend starts its senders a few at a time.
func (h *Handler) CreatePlacementBatch(c *gin.Context) {
in, ok := h.placementBatchInput(c)
if !ok {
return
}
batch, xerr := h.PlacementService.CreateBatch(c.Request.Context(), in)
if xerr != nil {
errx.JSON(c, xerr)
return
}
id := batch.ID
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityPlacementBatch, &id, nil, map[string]string{
"senders": strconv.Itoa(batch.SenderCount),
"panel": batch.Panel,
})
c.JSON(http.StatusCreated, gin.H{"data": batch})
}
// ListPlacementBatches lists the workspace's batches, newest first.
func (h *Handler) ListPlacementBatches(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if !h.placementReady(c) {
return
}
offset, ok := decodeOffsetCursor(c.Query("cursor"))
if !ok {
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
return
}
limit, ok := placementLimit(c)
if !ok {
return
}
batches, total, xerr := h.PlacementService.ListBatches(c.Request.Context(), *orgID, limit, offset)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": batches, "pagination": pageMetaFor(offset, limit, len(batches), total)})
}
// GetPlacementBatch returns one batch with its placement overall and grouped
// by sending domain, sending provider and recipient provider.
func (h *Handler) GetPlacementBatch(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if !h.placementReady(c) {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
detail, xerr := h.PlacementService.GetBatch(c.Request.Context(), *orgID, id)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": detail})
}
// ListPlacementBatchSenders lists a batch's senders with where each one's
// copies landed, worst inbox rate first unless sorted otherwise.
func (h *Handler) ListPlacementBatchSenders(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if !h.placementReady(c) {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
offset, ok := decodeOffsetCursor(c.Query("cursor"))
if !ok {
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
return
}
limit, ok := placementLimit(c)
if !ok {
return
}
senders, total, xerr := h.PlacementService.ListBatchSenders(c.Request.Context(), *orgID, id, repository.PlacementBatchSenderFilter{
Status: c.Query("status"),
Search: c.Query("q"),
Sort: c.Query("sort"),
Limit: limit,
Offset: offset,
})
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": senders, "pagination": pageMetaFor(offset, limit, len(senders), total)})
}
// CancelPlacementBatch stops a batch. Copies already sent keep being
// classified.
func (h *Handler) CancelPlacementBatch(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if !h.placementReady(c) {
return
}
id, err := uuid.Parse(c.Param("id"))
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
return
}
view, xerr := h.PlacementService.CancelBatch(c.Request.Context(), *orgID, id)
if xerr != nil {
errx.JSON(c, xerr)
return
}
h.auditOrg(c, models.AuditActionStop, models.AuditEntityPlacementBatch, &id, nil, nil)
c.JSON(http.StatusOK, gin.H{"data": view})
}
// GetPlacementCoverage reports how much of the workspace's connected fleet
// delivered a placement test in the last 7 and 30 days.
func (h *Handler) GetPlacementCoverage(c *gin.Context) {
orgID := middleware.GetOrganizationID(c)
if orgID == nil {
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
return
}
if !h.placementReady(c) {
return
}
cov, xerr := h.PlacementService.Coverage(c.Request.Context(), *orgID)
if xerr != nil {
errx.JSON(c, xerr)
return
}
c.JSON(http.StatusOK, gin.H{"data": cov})
}
+20
View File
@@ -191,6 +191,10 @@ func Run(
// folder, so the worker can drop the rows the server no longer reports.
internal.GET("/sync/folder-messages", h.InternalSyncFolderMessages)
// Gmail folder reconciliation: the rows the platform believes Gmail
// has in a folder, so the worker can report the ones that moved.
internal.GET("/sync/provider-folder-messages", h.InternalSyncProviderFolderMessages)
// Worker bootstrap config + heartbeat. Workers POST their identity
// on boot (worker_id + bind_ip + tag) and pull their runtime config
// instead of carrying it all in the install-time env file.
@@ -704,6 +708,14 @@ func Run(
campaigns.POST("/:id/leads/:contactId/pause", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.PauseCampaignLead)
campaigns.POST("/:id/leads/:contactId/resume", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), h.ResumeCampaignLead)
// Contacts copied on every email to one lead, so colleagues
// share one thread. The answers carry contact details, hence
// the contacts gate too. PUT replaces the list, so retries are
// safe.
campaigns.GET("/:id/leads/:contactId/cc", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.GetCampaignLeadCC)
campaigns.PUT("/:id/leads/:contactId/cc", m.RequireOrganization(), m.RequireAccess(models.PermManageCampaigns, models.APIPermWriteCampaigns), m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.SetCampaignLeadCC)
campaigns.GET("/:id/leads/:contactId/cc/suggestions", m.RequireOrganization(), m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), m.RequireAccess(models.PermViewContacts, models.APIPermReadContacts), h.SuggestCampaignLeadCC)
sequences := campaigns.Group("/:id/steps")
{
sequences.GET("", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadCampaigns), h.GetSequences)
@@ -1011,6 +1023,14 @@ func Run(
placementTests.GET("/tests/:id", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetPlacementTest)
placementTests.POST("/tests", m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.CreatePlacementTest)
placementTests.POST("/tests/:id/cancel", m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.CancelPlacementTest)
// Batches: one test run from many senders, started a few at a time.
placementTests.GET("/batches", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.ListPlacementBatches)
placementTests.GET("/batches/:id", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetPlacementBatch)
placementTests.GET("/batches/:id/senders", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.ListPlacementBatchSenders)
placementTests.POST("/batches/preview", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.PreviewPlacementBatch)
placementTests.POST("/batches", m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.CreatePlacementBatch)
placementTests.POST("/batches/:id/cancel", m.RequireAccess(models.PermSendCampaigns, models.APIPermSendCampaigns), h.CancelPlacementBatch)
placementTests.GET("/coverage", m.RateLimitMiddleware(models.RateLimitAnalytics), m.RequireAccess(models.PermViewAnalytics, models.APIPermReadAnalytics), h.GetPlacementCoverage)
placementTests.GET("/seeds", m.RequireAccess(models.PermViewCampaigns, models.APIPermReadEmails), h.ListPlacementSeeds)
placementTests.PUT("/seeds/:id", m.RequireAccess(models.PermManageEmails, models.APIPermWriteEmails), middleware.RequireAPIKeyEmailAccountParam("id"), h.SetPlacementSeed)
}
+36 -2
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/mailhdr"
)
// RecordInboundBounce turns a permanent NDR the worker parsed into a bounce
@@ -60,9 +61,19 @@ func (s *service) RecordInboundBounce(ctx context.Context, emailAccountID uuid.U
if ct, cerr := s.taskRepo.GetCampaignTask(ctx, task.ID); cerr == nil && ct != nil {
req.CampaignID = ct.CampaignID
req.ContactID = ct.ContactID
if req.RecipientEmail == "" && ct.ContactID != nil {
if ct.ContactID != nil {
if contact, cerr := s.contactRepo.GetByID(ctx, *ct.ContactID); cerr == nil && contact != nil {
req.RecipientEmail = contact.Email
switch {
case req.RecipientEmail == "":
req.RecipientEmail = contact.Email
case ct.CampaignID != nil && !strings.EqualFold(mailhdr.Bare(req.RecipientEmail), strings.TrimSpace(contact.Email)):
if owner, isCopy := s.copyBounceOwner(ctx, *ct.CampaignID, *ct.ContactID, req.RecipientEmail); isCopy {
// A copy bounced, not the lead: the lead keeps its
// sequence, and the copy's own NDR is its own event.
req.ContactID = owner
req.IdempotencyKey += ":" + strings.ToLower(mailhdr.Bare(req.RecipientEmail))
}
}
}
}
}
@@ -144,3 +155,26 @@ func (s *service) RecordInboundComplaint(ctx context.Context, emailAccountID uui
return s.IngestDeliverabilityEvent(ctx, *account.OrganizationID, req)
}
// copyBounceOwner tells a bounced copy apart from the lead. A contact copied on
// the lead is marked bounced there and owns the event; an address from the
// campaign's own CC or BCC owns it with no contact. Any other address (a
// forward, an alias) stays the lead's, as before.
func (s *service) copyBounceOwner(ctx context.Context, campaignID, leadID uuid.UUID, address string) (*uuid.UUID, bool) {
bare := mailhdr.Bare(address)
if s.campaignProgressRepo != nil {
if id, err := s.campaignProgressRepo.MarkLeadCCBounced(ctx, campaignID, leadID, bare); err == nil && id != nil {
return id, true
}
}
if campaign, err := s.campaignRepo.GetByID(ctx, campaignID); err == nil && campaign != nil {
for _, list := range [][]string{campaign.CC, campaign.BCC} {
for _, a := range list {
if strings.EqualFold(mailhdr.Bare(a), bare) {
return nil, true
}
}
}
}
return &leadID, false
}
@@ -83,3 +83,49 @@ func TestRecordInboundBounceStillAttributesRealSends(t *testing.T) {
t.Fatal("a campaign NDR was dropped by the warmup gate")
}
}
type copyBounceProgress struct {
repository.CampaignProgressRepository
copies map[string]uuid.UUID
}
func (r copyBounceProgress) MarkLeadCCBounced(_ context.Context, _, _ uuid.UUID, address string) (*uuid.UUID, error) {
if id, ok := r.copies[address]; ok {
return &id, nil
}
return nil, nil
}
type copyBounceCampaigns struct {
repository.CampaignRepository
cc, bcc []string
}
func (r copyBounceCampaigns) GetByID(_ context.Context, id uuid.UUID) (*models.Campaign, error) {
return &models.Campaign{ID: id, CC: r.cc, BCC: r.bcc}, nil
}
// A DSN naming someone copied on the send bounces that copy, not the lead; an
// address nobody copied (a forward, an alias) stays the lead's as before.
func TestCopyBounceOwnerTellsACopyFromTheLead(t *testing.T) {
lead, copied := uuid.New(), uuid.New()
s := &service{
campaignProgressRepo: copyBounceProgress{copies: map[string]uuid.UUID{"jonas@acme.test": copied}},
campaignRepo: copyBounceCampaigns{cc: []string{"Boss <boss@acme.test>"}, bcc: []string{"crm@acme.test"}},
}
for _, tc := range []struct {
address string
owner *uuid.UUID
isCopy bool
}{
{"jonas@acme.test", &copied, true},
{"BOSS@acme.test", nil, true},
{"crm@acme.test", nil, true},
{"forwarded@elsewhere.test", &lead, false},
} {
owner, isCopy := s.copyBounceOwner(context.Background(), uuid.New(), lead, tc.address)
if isCopy != tc.isCopy || (owner == nil) != (tc.owner == nil) || (owner != nil && *owner != *tc.owner) {
t.Errorf("%s: owner %v copy %v, want %v %v", tc.address, owner, isCopy, tc.owner, tc.isCopy)
}
}
}
@@ -84,6 +84,17 @@ type incomingReplyProgressRepo struct {
receivingSent bool
completeErr error
advanced *incomingReplyAdvancedRepo
copies []models.CampaignLeadCC
copiedLead *repository.CopiedLeadRef
copiesErr error
}
func (r *incomingReplyProgressRepo) ListLeadCC(context.Context, uuid.UUID, uuid.UUID) ([]models.CampaignLeadCC, error) {
return r.copies, r.copiesErr
}
func (r *incomingReplyProgressRepo) LeadForCopiedReply(context.Context, uuid.UUID, uuid.UUID) (*repository.CopiedLeadRef, error) {
return r.copiedLead, nil
}
func (r *incomingReplyProgressRepo) IsInboundReplySource(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
@@ -0,0 +1,185 @@
package advanced
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type copyReplyAdvancedRepo struct {
*incomingReplyAdvancedRepo
suppressed []string
}
func (r *copyReplyAdvancedRepo) UpsertSuppressedRecipient(_ context.Context, s *models.SuppressedRecipient) error {
r.suppressed = append(r.suppressed, strings.ToLower(s.Email))
return nil
}
type copyReplyContactRepo struct {
incomingReplyContactRepo
}
func (copyReplyContactRepo) SetSubscribedByEmail(context.Context, uuid.UUID, string, bool) error {
return nil
}
type copyReplyProgressRepo struct {
*incomingReplyProgressRepo
heldEverywhere int
}
func (r *copyReplyProgressRepo) HoldLeadEverywhere(context.Context, uuid.UUID, *time.Time, string, string) ([]uuid.UUID, error) {
r.heldEverywhere++
return nil, nil
}
// newCopyReplyService is the incoming-reply harness with the lead copying
// jonas@acme.test, answering in the lead's thread.
func newCopyReplyService(t *testing.T) (*service, *copyReplyProgressRepo, *copyReplyAdvancedRepo, uuid.UUID) {
t.Helper()
orgID, accountID, leadID := uuid.New(), uuid.New(), uuid.New()
account := &models.Email{ID: accountID, OrganizationID: &orgID, Email: "sender@example.test"}
svc, progress := newIncomingReplyService(account, nil, leadID)
progress.copies = []models.CampaignLeadCC{{ContactID: uuid.New(), Email: "jonas@acme.test", Status: models.LeadCCStatusActive}}
adv := &copyReplyAdvancedRepo{incomingReplyAdvancedRepo: progress.advanced}
wrapped := &copyReplyProgressRepo{incomingReplyProgressRepo: progress}
svc.repo = adv
svc.campaignProgressRepo = wrapped
svc.contactRepo = copyReplyContactRepo{svc.contactRepo.(incomingReplyContactRepo)}
return svc, wrapped, adv, accountID
}
func copyReply(accountID uuid.UUID, subject, body string, inReplyTo []string) *models.EmailMessageStoreData {
return &models.EmailMessageStoreData{
ID: uuid.New(), EmailID: accountID, Folder: models.FolderInbox,
FromAddr: []string{"Jonas <jonas@acme.test>"},
ToAddr: []string{"sender@example.test"},
InReplyTo: inReplyTo,
Subject: subject,
Snippet: body,
BodyText: body,
}
}
// A copy asking to stop is about the copy: the lead they were copied on is
// not suppressed with them.
func TestCopyOptOutSuppressesOnlyTheCopy(t *testing.T) {
svc, _, adv, accountID := newCopyReplyService(t)
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Re: Hello", "Please remove me from your list.", []string{"<opener@example.test>"})); xerr != nil {
t.Fatal(xerr)
}
if len(adv.suppressed) != 1 || adv.suppressed[0] != "jonas@acme.test" {
t.Fatalf("suppressed %v, want only the copy", adv.suppressed)
}
}
// A copy's away message says nothing about the lead's desk.
func TestCopyOutOfOfficeDoesNotHoldTheLead(t *testing.T) {
svc, progress, _, accountID := newCopyReplyService(t)
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Automatic reply: Hello", "I am out of the office until Monday.", []string{"<opener@example.test>"})); xerr != nil {
t.Fatal(xerr)
}
if progress.heldEverywhere != 0 {
t.Fatalf("the lead was held %d times for a copy's away message", progress.heldEverywhere)
}
}
// A copy answering further down the thread names no message of ours; the
// mailbox that wrote to the lead ties it back, and it is the lead's reply.
func TestCopyReplyDownThreadCountsForTheLead(t *testing.T) {
svc, progress, _, accountID := newCopyReplyService(t)
copyContact := &models.Contact{ID: uuid.New(), Email: "jonas@acme.test"}
svc.taskRepo = incomingReplyTaskRepo{}
cr := svc.contactRepo.(copyReplyContactRepo)
cr.senderContact = copyContact
svc.contactRepo = cr
progress.copiedLead = &repository.CopiedLeadRef{CampaignID: uuid.New(), ContactID: uuid.New(), SequenceID: uuid.New()}
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Re: Hello", "Sounds good, let's talk Tuesday.", []string{"<leads-own-reply@acme.test>"})); xerr != nil {
t.Fatal(xerr)
}
if progress.replied != 1 {
t.Fatalf("RecordEmailReplied calls = %d, want the copy's reply counted for the lead", progress.replied)
}
}
// The link in a copied message cannot say who used it, so it opts out the
// lead and everyone copied; a sequence action is about the lead alone.
func TestUnsubscribeLinkOptsOutTheLeadsCopies(t *testing.T) {
for _, tc := range []struct {
name string
run func(s *service, org, campaign, lead uuid.UUID) *errx.Error
want []string
}{
{"link", func(s *service, org, campaign, lead uuid.UUID) *errx.Error {
return s.UnsubscribeFromLink(context.Background(), org, campaign, lead, "one_click")
}, []string{"task-contact@example.test", "jonas@acme.test"}},
{"sequence action", func(s *service, _, campaign, lead uuid.UUID) *errx.Error {
return s.Unsubscribe(context.Background(), campaign, lead)
}, []string{"task-contact@example.test"}},
} {
svc, _, adv, _ := newCopyReplyService(t)
campaign := svc.campaignRepo.(incomingReplyCampaignRepo).campaign
lead := svc.contactRepo.(copyReplyContactRepo).taskContact.ID
if xerr := tc.run(svc, *campaign.OrganizationID, campaign.ID, lead); xerr != nil {
t.Fatalf("%s: %v", tc.name, xerr)
}
if strings.Join(adv.suppressed, ",") != strings.Join(tc.want, ",") {
t.Fatalf("%s: suppressed %v, want %v", tc.name, adv.suppressed, tc.want)
}
}
}
// An unreadable copy list is an error, never "not a copy": guessing would
// suppress or hold the lead for what a copy did.
func TestCopyLookupFailureIsNotReadAsTheLead(t *testing.T) {
svc, progress, adv, accountID := newCopyReplyService(t)
progress.copiesErr = errors.New("database unavailable")
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Re: Hello", "Please remove me from your list.", []string{"<opener@example.test>"})); xerr == nil {
t.Fatal("a failed copy lookup was processed as if the sender were not a copy")
}
if len(adv.suppressed) != 0 {
t.Fatalf("suppressed %v on a failed lookup", adv.suppressed)
}
}
// A fresh message from a copy is not a reply to anything, so it credits no lead.
func TestCopyFreshMessageCreditsNoLead(t *testing.T) {
svc, progress, _, accountID := newCopyReplyService(t)
svc.taskRepo = incomingReplyTaskRepo{}
cr := svc.contactRepo.(copyReplyContactRepo)
cr.senderContact = &models.Contact{ID: uuid.New(), Email: "jonas@acme.test"}
svc.contactRepo = cr
progress.copiedLead = &repository.CopiedLeadRef{CampaignID: uuid.New(), ContactID: uuid.New(), SequenceID: uuid.New()}
if xerr := svc.ProcessIncomingReply(context.Background(), accountID,
copyReply(accountID, "Quick question", "Unrelated: are you at the fair next week?", nil)); xerr != nil {
t.Fatal(xerr)
}
if progress.replied != 0 {
t.Fatalf("RecordEmailReplied calls = %d, want none for a message that replies to nothing", progress.replied)
}
}
// The opt-out is not acknowledged while a copy is still sendable.
func TestUnsubscribeFailsWhenCopiesCannotBeRead(t *testing.T) {
svc, progress, _, _ := newCopyReplyService(t)
progress.copiesErr = errors.New("database unavailable")
campaign := svc.campaignRepo.(incomingReplyCampaignRepo).campaign
lead := svc.contactRepo.(copyReplyContactRepo).taskContact.ID
if xerr := svc.UnsubscribeFromLink(context.Background(), *campaign.OrganizationID, campaign.ID, lead, "link"); xerr == nil {
t.Fatal("the unsubscribe succeeded without reaching the lead's copies")
}
}
+103 -4
View File
@@ -568,7 +568,7 @@ func (s *service) ListCategories(ctx context.Context, orgID uuid.UUID) ([]models
// creator is nil: an automation has no human behind it.
func (s *service) CreateCategory(ctx context.Context, orgID uuid.UUID, title, color string) (models.MiniCategory, error) {
if s.categoryRepo == nil {
return models.MiniCategory{}, errx.New(errx.BadRequest, "categories are not available")
return models.MiniCategory{}, errx.New(errx.BadRequest, "labels are not available")
}
if strings.TrimSpace(color) == "" {
color = "#64748b"
@@ -641,6 +641,72 @@ func (s *service) unsubscribe(ctx context.Context, expectOrg *uuid.UUID, campaig
"contact_email": contact.Email,
"source": via,
})
// The link in a message is the same for everyone it copied and cannot say
// who used it, so it opts all of them out. A sequence action is about the
// lead alone.
if via != "action" {
return s.unsubscribeLeadCopies(ctx, *campaign.OrganizationID, campaignID, contactID, contact.Email, via, reason)
}
return nil
}
// isLeadCopy reports whether sender is one of the contacts copied on the
// lead's emails rather than the lead answering from another address. A failed
// read is an error, never "not a copy", which would charge the lead.
func (s *service) isLeadCopy(ctx context.Context, campaignID, contactID uuid.UUID, leadEmail, sender string) (bool, error) {
if s.campaignProgressRepo == nil || sender == "" || strings.EqualFold(leadEmail, sender) {
return false, nil
}
copies, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return false, err
}
for _, cp := range copies {
if strings.EqualFold(strings.TrimSpace(cp.Email), sender) {
return true, nil
}
}
return false, nil
}
// unsubscribeLeadCopies suppresses every contact copied on one lead's emails.
// A failure fails the request, so the opt-out is retried rather than
// acknowledged with a copy still sendable; the upserts are idempotent.
func (s *service) unsubscribeLeadCopies(ctx context.Context, orgID, campaignID, contactID uuid.UUID, leadEmail, via, reason string) *errx.Error {
if s.campaignProgressRepo == nil {
return nil
}
copies, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return toErrx(err)
}
for _, cp := range copies {
addr := strings.ToLower(strings.TrimSpace(cp.Email))
if addr == "" {
continue
}
if err := s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
OrganizationID: orgID,
Email: addr,
Kind: models.SuppressionKindEmail,
Reason: reason + " on an email copied to them",
Source: models.DeliverabilityEventUnsubscribe,
CampaignID: &campaignID,
Metadata: map[string]interface{}{"via": via, "copied_on": leadEmail},
}); err != nil {
return toErrx(err)
}
if err := s.contactRepo.SetSubscribedByEmail(ctx, orgID, addr, false); err != nil {
log.Warn().Err(err).Str("contact_id", cp.ContactID.String()).Msg("unsubscribe: could not clear a copied contact's subscription flag")
}
s.emit(ctx, orgID, models.WebhookEventCampaignUnsubscribed, map[string]any{
"campaign_id": campaignID.String(),
"contact_id": cp.ContactID.String(),
"contact_email": cp.Email,
"source": via,
})
}
return nil
}
@@ -1173,6 +1239,10 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
// contactEmail is the address we mailed, which is not always the one
// that answered; an opt-out has to reach both.
var contactEmail string
// senderIsCopy is a reply from a contact copied on the lead's emails. It
// counts as the lead's reply, but the copy's own away message or opt-out
// is about the copy, not the lead.
var senderIsCopy bool
// First, try exact message threading via In-Reply-To.
for _, mid := range msg.InReplyTo {
@@ -1223,6 +1293,11 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
campaignID = ct.CampaignID
contactID = ct.ContactID
sequenceID = ct.SequenceID
isCopy, cerr := s.isLeadCopy(ctx, *ct.CampaignID, *ct.ContactID, contactEmail, sender)
if cerr != nil {
return toErrx(cerr)
}
senderIsCopy = isCopy
break
}
@@ -1245,6 +1320,29 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
}
}
// A copied contact answering further down the thread (to the lead's own
// reply, say) names no message of ours; the mailbox that wrote to the
// lead is the evidence, and the reply is the lead's. A fresh message with
// no parent is not a reply to anything and credits nobody.
if campaignID == nil && contactID != nil && !referencesCampaignThread && len(msg.InReplyTo) > 0 {
ref, err := s.campaignProgressRepo.LeadForCopiedReply(ctx, *contactID, emailAccountID)
if err != nil {
return toErrx(err)
}
if ref != nil {
lead, lerr := s.contactRepo.GetByID(ctx, ref.ContactID)
if lerr != nil {
return lerr
}
if lead != nil {
campaignID, sequenceID = &ref.CampaignID, &ref.SequenceID
contactID = &ref.ContactID
contactEmail = strings.TrimSpace(lead.Email)
senderIsCopy = true
}
}
}
if campaignID != nil && contactID != nil && sequenceID != nil {
cID, ctID, sID := *campaignID, *contactID, *sequenceID
@@ -1416,7 +1514,7 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
}
var held *time.Time
if campaignID != nil && contactID != nil && verdict.Class == replyclassify.ClassOutOfOffice && settings.ReplyIntent.HoldOnOutOfOffice {
if campaignID != nil && contactID != nil && !senderIsCopy && verdict.Class == replyclassify.ClassOutOfOffice && settings.ReplyIntent.HoldOnOutOfOffice {
held = s.holdForOutOfOffice(ctx, *account.OrganizationID, *contactID, settings.ReplyIntent, msg)
}
@@ -1446,8 +1544,9 @@ func (s *service) ProcessIncomingReply(ctx context.Context, emailAccountID uuid.
if err := s.contactRepo.SetSubscribedByEmail(ctx, *account.OrganizationID, sender, false); err != nil {
log.Warn().Err(err).Msg("reply opt-out: could not clear the contact's subscription flag")
}
// Answered from another address: the one we mailed asked to stop too.
if contactEmail != "" && !strings.EqualFold(contactEmail, sender) {
// Answered from another address: the one we mailed asked to stop too,
// unless it was a copy asking for themselves.
if contactEmail != "" && !senderIsCopy && !strings.EqualFold(contactEmail, sender) {
_ = s.repo.UpsertSuppressedRecipient(ctx, &models.SuppressedRecipient{
OrganizationID: *account.OrganizationID,
Email: strings.ToLower(contactEmail),
+8 -2
View File
@@ -162,6 +162,10 @@ func WithCopyJudge(asker typesafe.Asker, cache repository.CopyJudgmentRepository
// least important cards their rewrite, and the next run picks them up.
const maxNarrationsPerRun = 12
// narrationBudget bounds the completions of one run so the summary and run
// record still land inside the caller's deadline.
const narrationBudget = 60 * time.Second
func (s *service) Evaluate(ctx context.Context, orgID uuid.UUID, trigger string) (*models.AdvisorSummary, error) {
settings, err := s.repo.GetSettings(ctx, orgID)
if err != nil {
@@ -219,7 +223,9 @@ func (s *service) Evaluate(ctx context.Context, orgID uuid.UUID, trigger string)
// rather than reporting problems that no longer exist.
fixed := s.autopilot(ctx, orgID, settings, stored)
narrated := s.narrateBatch(ctx, orgID, stored)
nctx, cancelNarrate := context.WithTimeout(ctx, narrationBudget)
narrated := s.narrateBatch(nctx, orgID, stored)
cancelNarrate()
summary, err := s.repo.Summary(ctx, orgID)
if err != nil {
@@ -303,7 +309,7 @@ func (s *service) narrateBatch(ctx context.Context, orgID uuid.UUID, findings []
// Detect, so the cap spends the budget on what matters.
count := 0
for _, f := range findings {
if count >= maxNarrationsPerRun {
if count >= maxNarrationsPerRun || ctx.Err() != nil {
break
}
if f.Narrated {
+7 -7
View File
@@ -25,7 +25,7 @@ func (d Deps) registerContactTools(r *Registry) {
r.Register(Tool{
Name: "get_contact",
Description: "Get one contact by id, including custom fields, categories, subscription state, and engagement summary.",
Description: "Get one contact by id, including custom fields, labels (categories), subscription state, and engagement summary.",
InputSchema: objectSchema(map[string]any{
"contact_id": strProp("The contact's UUID."),
}, "contact_id"),
@@ -55,10 +55,10 @@ func (d Deps) registerContactTools(r *Registry) {
r.Register(Tool{
Name: "add_tag",
Description: "Add a category (tag) to a contact. The category_id comes from a contact's categories in get_contact/search results.",
Description: "Add a label to a contact. Labels are called categories in the API; the category_id comes from a contact's categories in get_contact/search results.",
InputSchema: objectSchema(map[string]any{
"contact_id": strProp("The contact's UUID."),
"category_id": strProp("The category (tag) UUID to add."),
"category_id": strProp("The label (category) UUID to add."),
}, "contact_id", "category_id"),
Risk: generation.RiskWrite,
RequiredOrgPerm: models.PermManageContacts,
@@ -68,10 +68,10 @@ func (d Deps) registerContactTools(r *Registry) {
r.Register(Tool{
Name: "remove_tag",
Description: "Remove a category (tag) from a contact.",
Description: "Remove a label (category) from a contact.",
InputSchema: objectSchema(map[string]any{
"contact_id": strProp("The contact's UUID."),
"category_id": strProp("The category (tag) UUID to remove."),
"category_id": strProp("The label (category) UUID to remove."),
}, "contact_id", "category_id"),
Risk: generation.RiskWrite,
RequiredOrgPerm: models.PermManageContacts,
@@ -114,8 +114,8 @@ func (d Deps) registerContactTools(r *Registry) {
Description: "Apply the same change (add/remove tags, set subscription) to many contacts at once.",
InputSchema: objectSchema(map[string]any{
"contact_ids": arrProp("Contact UUIDs to edit (required).", strProp("Contact UUID.")),
"add_categories": arrProp("Category (tag) UUIDs to add to each contact.", strProp("Category UUID.")),
"remove_categories": arrProp("Category (tag) UUIDs to remove from each contact.", strProp("Category UUID.")),
"add_categories": arrProp("Label (category) UUIDs to add to each contact.", strProp("Label UUID.")),
"remove_categories": arrProp("Label (category) UUIDs to remove from each contact.", strProp("Label UUID.")),
"subscribe": boolProp("Set subscription state on each contact."),
}, "contact_ids"),
Risk: generation.RiskWrite,
+1 -1
View File
@@ -30,7 +30,7 @@ func (d Deps) registerInboxActionTools(r *Registry) {
r.Register(Tool{
Name: "set_thread_labels",
Description: "Replace a conversation thread's label (category) set. Pass the full desired set; an empty list clears labels.",
Description: "Replace a conversation thread's label set (labels are called categories in the API). Pass the full desired set; an empty list clears labels.",
InputSchema: objectSchema(map[string]any{
"thread_id": strProp("The thread id."),
"category_ids": arrProp("Category (label) UUIDs to apply.", strProp("Category UUID.")),
+194
View File
@@ -3,6 +3,8 @@ package aitools
import (
"context"
"encoding/json"
"errors"
"strconv"
"github.com/google/uuid"
@@ -18,6 +20,9 @@ type PlacementTests interface {
CreateTests(ctx context.Context, in placement.CreateInput) ([]placement.TestView, *errx.Error)
ListTests(ctx context.Context, orgID *uuid.UUID, campaignID *uuid.UUID, limit, offset int) ([]placement.TestView, int, *errx.Error)
GetTest(ctx context.Context, orgID *uuid.UUID, id uuid.UUID) (*placement.TestDetail, *errx.Error)
CreateBatch(ctx context.Context, in placement.BatchInput) (*placement.BatchView, *errx.Error)
ListBatches(ctx context.Context, orgID uuid.UUID, limit, offset int) ([]placement.BatchView, int, *errx.Error)
GetBatch(ctx context.Context, orgID, id uuid.UUID) (*placement.BatchDetail, *errx.Error)
}
type placementTools struct {
@@ -78,6 +83,195 @@ func RegisterPlacementTools(r *Registry, svc PlacementTests, auditSvc audit.Audi
RequiredAPIPerm: models.APIPermSendCampaigns,
Handler: p.run,
})
r.Register(Tool{
Name: "list_placement_batches",
Description: "List the workspace's placement batches (one placement test run from many sending mailboxes), newest first, with each batch's progress and overall inbox placement.",
InputSchema: objectSchema(map[string]any{
"limit": intProp("Maximum batches to return (default 10, max 50)."),
}),
Risk: generation.RiskRead,
RequiredOrgPerm: models.PermViewAnalytics,
RequiredAPIPerm: models.APIPermReadAnalytics,
Handler: p.listBatches,
})
r.Register(Tool{
Name: "get_placement_batch",
Description: "Read one placement batch: progress, overall placement, and placement by sending domain, by sending provider and by recipient provider, worst first. " +
"Use it to tell one bad mailbox from a whole domain or provider losing reputation.",
InputSchema: objectSchema(map[string]any{
"batch_id": strProp("The placement batch's UUID."),
}, "batch_id"),
Risk: generation.RiskRead,
RequiredOrgPerm: models.PermViewAnalytics,
RequiredAPIPerm: models.APIPermReadAnalytics,
Handler: p.getBatch,
})
r.Register(Tool{
Name: "run_placement_batch",
Description: "Start a placement batch: run the same placement test from many of the workspace's mailboxes (a campaign's senders or the whole workspace, optionally sampled). " +
"Every copy is a real send counted against its mailbox's daily limit, and the backend starts a few senders at a time, so a large batch takes hours. Only run it when the user asked for a fleet-wide placement test.",
InputSchema: objectSchema(map[string]any{
"scope": enumProp("Whose mailboxes to test: a campaign's senders or every mailbox in the workspace.", "campaign", "workspace"),
"campaign_id": strProp("The campaign whose senders to test and, with sequence_id, whose copy to send (UUID)."),
"sequence_id": strProp("The campaign step to test (UUID); requires campaign_id."),
"subject": strProp("Subject of an ad-hoc template, when not testing a campaign step."),
"body_plain": strProp("Plain-text body of an ad-hoc template."),
"sample": enumProp("all (default), random (sample_count mailboxes), percent (sample_percent of them, spread across providers), per_domain or per_provider (sample_count each).", "all", "random", "percent", "per_domain", "per_provider"),
"sample_count": intProp("Mailboxes for a random sample, or per group for per_domain and per_provider."),
"sample_percent": intProp("Share of mailboxes for a percent sample, 1 to 100."),
"panel": enumProp("Which seed inboxes to test on (default instance).", "instance", "workspace", "cloud"),
"tracking": enumProp("Open and click tracking on the copies (default campaign).", "campaign", "on", "off", "compare"),
}, "scope"),
Risk: generation.RiskSend,
RequiredOrgPerm: models.PermSendCampaigns,
RequiredAPIPerm: models.APIPermSendCampaigns,
Handler: p.runBatch,
})
}
func batchSummary(v placement.BatchView) map[string]any {
out := map[string]any{
"batch_id": v.ID.String(),
"status": v.Status,
"subject": v.Subject,
"panel": v.Panel,
"tracking": v.Tracking,
"senders": v.SenderCount,
"progress": v.Progress,
"summary": v.Summary,
"created_at": v.CreatedAt,
}
if v.CampaignID != nil {
out["campaign_id"] = v.CampaignID.String()
}
if v.Error != "" {
out["error"] = v.Error
}
return out
}
func (p placementTools) listBatches(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
Limit int `json:"limit"`
}](args)
if err != nil {
return "", err
}
limit := in.Limit
if limit <= 0 {
limit = 10
}
batches, total, xerr := p.svc.ListBatches(ctx, inv.OrgID, min(limit, 50), 0)
if xerr != nil {
return "", fromErrx(xerr)
}
out := make([]map[string]any, 0, len(batches))
for _, b := range batches {
out = append(out, batchSummary(b))
}
return jsonResult(map[string]any{"batches": out, "total": total})
}
func (p placementTools) getBatch(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
in, err := decodeArgs[struct {
BatchID string `json:"batch_id"`
}](args)
if err != nil {
return "", err
}
id, err := parseUUIDArg(in.BatchID)
if err != nil {
return "", err
}
d, xerr := p.svc.GetBatch(ctx, inv.OrgID, id)
if xerr != nil {
return "", fromErrx(xerr)
}
out := batchSummary(d.BatchView)
if d.Untracked != nil {
out["untracked_summary"] = d.Untracked
}
// The worst groups carry the answer; the rest are on the batch page.
out["by_sending_domain"] = d.Domains[:min(len(d.Domains), 25)]
out["by_sending_provider"] = d.Providers
out["by_recipient_provider"] = d.Recipients
out["content_score"] = d.Content.Score
return jsonResult(out)
}
func (p placementTools) runBatch(ctx context.Context, inv Invocation, args json.RawMessage) (string, error) {
// An API key may be limited to some mailboxes, which this path cannot
// see; the REST endpoint applies that limit.
if inv.IsAPIKey {
return "", errors.New("start a placement batch with POST /placement/batches, which applies this key's mailbox limits")
}
in, err := decodeArgs[struct {
Scope string `json:"scope"`
CampaignID string `json:"campaign_id"`
SequenceID string `json:"sequence_id"`
Subject string `json:"subject"`
BodyPlain string `json:"body_plain"`
Sample string `json:"sample"`
SampleCount int `json:"sample_count"`
SamplePercent int `json:"sample_percent"`
Panel string `json:"panel"`
Tracking string `json:"tracking"`
}](args)
if err != nil {
return "", err
}
optional := func(s string) (*uuid.UUID, error) {
if s == "" {
return nil, nil
}
id, err := parseUUIDArg(s)
return &id, err
}
campaignID, err := optional(in.CampaignID)
if err != nil {
return "", err
}
sequenceID, err := optional(in.SequenceID)
if err != nil {
return "", err
}
scope := &models.PlacementSenderScope{Type: in.Scope, CampaignID: campaignID}
sample := models.PlacementSample{Mode: in.Sample, Count: in.SampleCount, Percent: in.SamplePercent}
if sample.Mode == models.PlacementSamplePercent {
sample.Stratify = "provider"
}
var userID *uuid.UUID
if inv.UserID != uuid.Nil {
u := inv.UserID
userID = &u
}
batch, xerr := p.svc.CreateBatch(ctx, placement.BatchInput{
OrgID: inv.OrgID,
UserID: userID,
Scope: scope,
Sample: sample,
CampaignID: campaignID,
SequenceID: sequenceID,
Subject: in.Subject,
BodyPlain: in.BodyPlain,
Panel: in.Panel,
Tracking: in.Tracking,
})
if xerr != nil {
return "", fromErrx(xerr)
}
id := batch.ID
if p.audit != nil {
p.audit.LogAction(ctx, inv.OrgID, inv.UserID, models.AuditActionCreate, models.AuditEntityPlacementBatch, &id,
inv.IP, inv.UserAgent, nil, map[string]string{"senders": strconv.Itoa(batch.SenderCount), "panel": batch.Panel})
}
return jsonResult(map[string]any{
"batch": batchSummary(*batch),
"note": "Senders start a few at a time; read progress with get_placement_batch.",
})
}
func placementSummary(v placement.TestView) map[string]any {
+111
View File
@@ -0,0 +1,111 @@
package campaign
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// leadCCSuggestionLimit bounds the colleagues offered when picking copies.
const leadCCSuggestionLimit = 8
func (s *campaignService) ListLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCC, *errx.Error) {
if s.campaignProgressRepo == nil {
return nil, errx.InternalError()
}
if xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
return nil, xerr
}
if _, err := s.campaignProgressRepo.GetLeadHold(ctx, campaignID, contactID); err != nil {
if errors.Is(err, repository.ErrLeadNotInCampaign) {
return nil, errx.New(errx.NotFound, "contact is not a lead of this campaign")
}
return nil, errx.InternalError()
}
cc, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return nil, errx.InternalError()
}
return cc, nil
}
func (s *campaignService) SetLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID, contactIDs []string) ([]models.CampaignLeadCC, *errx.Error) {
if s.campaignProgressRepo == nil {
return nil, errx.InternalError()
}
if xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
return nil, xerr
}
ids := make([]uuid.UUID, 0, len(contactIDs))
seen := map[uuid.UUID]bool{}
for _, raw := range contactIDs {
id, err := uuid.Parse(raw)
if err != nil {
return nil, errx.New(errx.BadRequest, "contact_ids must be contact ids")
}
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
if len(ids) > config.CampaignLeadMaxCC {
return nil, errx.NewWithIdentifier(errx.BadRequest, "lead_cc_limit",
fmt.Sprintf("A lead can have at most %d contacts copied on their emails", config.CampaignLeadMaxCC))
}
before, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return nil, errx.InternalError()
}
switch err := s.campaignProgressRepo.SetLeadCC(ctx, orgID, campaignID, contactID, ids); {
case err == nil:
case errors.Is(err, repository.ErrLeadNotInCampaign):
return nil, errx.New(errx.NotFound, "contact is not a lead of this campaign")
case errors.Is(err, repository.ErrLeadCCSelf):
return nil, errx.NewWithIdentifier(errx.BadRequest, "lead_cc_self", "A lead cannot be copied on their own emails")
case errors.Is(err, repository.ErrLeadCCContactNotFound):
return nil, errx.NewWithIdentifier(errx.NotFound, "lead_cc_contact_not_found", "A contact to copy was not found in this workspace")
case errors.Is(err, repository.ErrLeadCCLeadIsCopied):
return nil, errx.NewWithIdentifier(errx.Conflict, "lead_cc_lead_is_copied",
"This lead is copied on another lead's emails in this campaign, so it sends none of its own to copy anyone on")
case errors.Is(err, repository.ErrLeadCCHasCopies):
return nil, errx.NewWithIdentifier(errx.Conflict, "lead_cc_has_copies",
"A contact to copy has contacts copied on their own emails in this campaign; remove those first")
default:
return nil, errx.InternalError()
}
// A removed copy's own lead is released and may be due now.
for _, b := range before {
if !seen[b.ContactID] {
s.WakeCampaigns(ctx, orgID, []string{campaignID.String()})
break
}
}
cc, err := s.campaignProgressRepo.ListLeadCC(ctx, campaignID, contactID)
if err != nil {
return nil, errx.InternalError()
}
return cc, nil
}
func (s *campaignService) SuggestLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCCSuggestion, *errx.Error) {
if s.campaignProgressRepo == nil {
return nil, errx.InternalError()
}
if xerr := s.ownedCampaign(ctx, orgID, campaignID); xerr != nil {
return nil, xerr
}
out, err := s.campaignProgressRepo.SuggestLeadCC(ctx, orgID, campaignID, contactID, leadCCSuggestionLimit)
if err != nil {
return nil, errx.InternalError()
}
return out, nil
}
+8
View File
@@ -89,6 +89,14 @@ type CampaignService interface {
ResumeLead(ctx context.Context, orgID, campaignID, contactID uuid.UUID) *errx.Error
// GetLeadHold reads the live hold on one lead (nil when it is not held).
GetLeadHold(ctx context.Context, orgID, campaignID, contactID uuid.UUID) (*models.LeadHold, *errx.Error)
// ListLeadCC reads the contacts copied on every email to one lead.
ListLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCC, *errx.Error)
// SetLeadCC replaces the contacts copied on one lead and returns the new
// list. A copied contact's own lead in the campaign is held meanwhile.
SetLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID, contactIDs []string) ([]models.CampaignLeadCC, *errx.Error)
// SuggestLeadCC offers the lead's likely colleagues to copy.
SuggestLeadCC(ctx context.Context, orgID, campaignID, contactID uuid.UUID) ([]models.CampaignLeadCCSuggestion, *errx.Error)
}
// Bounds on a manual lead hold. A hold in the past would lift the moment it
+35 -8
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/models"
@@ -16,19 +17,22 @@ import (
func (s *JobsService) HandleFlagsAdd(ctx context.Context, e *models.JobEventFlags) error {
// Tampering check first: verified warmup mail is NOT in the unibox, so we
// detect it via the warmup_received record. If the recipient marked a
// warmup email as spam, that harms the pool — penalise the sender for the
// spam signal AND ban the harmer (they can appeal). Warmup mail isn't
// tracked in the unibox, so there's nothing else to do for it.
// detect it via the warmup_received record. A spam label names no actor on
// any provider: on mail that arrived in spam it is the filter's own, and
// any other move is held and attributed on the evidence around it
// (attributeSpamMove) before anyone is charged.
if s.WarmupRepo != nil {
if rec, _ := s.WarmupRepo.GetWarmupReceived(ctx, e.EmailID, e.ID); rec != nil {
switch {
case s.WarmupService == nil:
case containsSpamFlag(e.Flags) && (rec.LandedSpam || rec.MessageID == ""):
case containsSpamFlag(e.Flags):
hSender, _ := s.WarmupService.ApplySpamReport(ctx, e.EmailID, rec.SenderAccountID, rec.MessageID, "user_complaint")
s.markRiskBandFromWarmupHealth(ctx, rec.SenderAccountID, hSender)
hHarmer, _ := s.WarmupService.RecordTampering(ctx, e.EmailID, rec.MessageID, "spam_flag")
s.markRiskBandFromWarmupHealth(ctx, e.EmailID, hHarmer)
if _, err := s.WarmupRepo.RecordWarmupSpamMove(ctx, repository.WarmupSpamMove{
EmailAccountID: e.EmailID, MessageID: rec.MessageID,
SenderAccountID: rec.SenderAccountID, ReceivedAt: rec.CreatedAt,
}); err != nil {
return fmt.Errorf("hold warmup spam move: %w", err)
}
case containsTrashFlag(e.Flags) && warmupDeletionCounts(rec, time.Now()):
// Gmail reports Delete as gaining the TRASH label and only
// reports the message gone when Trash is emptied, weeks later.
@@ -98,6 +102,9 @@ func (s *JobsService) HandleFlagsAdd(ctx context.Context, e *models.JobEventFlag
if !slices.Contains(email.Flags, e.Flags[i]) {
email.Flags = append(email.Flags, e.Flags[i])
updated = true
if e.Flags[i] == models.FlagFlagged {
s.noteOwnerActivity(ctx, e.EmailID, email.InternalDate)
}
}
}
@@ -111,6 +118,7 @@ func (s *JobsService) HandleFlagsAdd(ctx context.Context, e *models.JobEventFlag
update.Seen = &seen
email.Seen = true
updated = true
s.noteOwnerActivity(ctx, e.EmailID, email.InternalDate)
}
if !updated {
@@ -175,6 +183,9 @@ func (s *JobsService) HandleFlagsRemove(ctx context.Context, e *models.JobEventF
// Losing \Seen is the provider reporting the message back to unread, and
// that is the column the inbox reads, not the flag array.
unread := models.SeenFromFlags(e.Flags) && email.Seen
if unread || (slices.Contains(e.Flags, models.FlagFlagged) && slices.Contains(email.Flags, models.FlagFlagged)) {
s.noteOwnerActivity(ctx, e.EmailID, email.InternalDate)
}
if len(email.Flags) == 0 && !unread {
return nil
@@ -228,6 +239,22 @@ func (s *JobsService) HandleFlagsRemove(ctx context.Context, e *models.JobEventF
return nil
}
// ownerActivityArrivalGrace is how long after arrival a filter may still be labelling a message.
const ownerActivityArrivalGrace = 2 * time.Minute
// noteOwnerActivity records the owner acting on their own mail at the
// provider. Callers pass only changes our store did not already hold, so a
// change made in Warmbly and echoed back by the sync never counts, and a
// change to mail that only just arrived may be a filter finishing delivery.
func (s *JobsService) noteOwnerActivity(ctx context.Context, accountID uuid.UUID, arrived time.Time) {
if s.WarmupRepo == nil || arrived.IsZero() || time.Since(arrived) < ownerActivityArrivalGrace {
return
}
if err := s.WarmupRepo.RecordOwnerActivity(ctx, accountID, time.Now()); err != nil {
log.Warn().Err(err).Str("email_id", accountID.String()).Msg("owner activity not recorded")
}
}
// containsTrashFlag reports the transition Gmail emits for Delete: the TRASH
// label, passed through untranslated by the worker.
func containsTrashFlag(flags []string) bool {
+4 -2
View File
@@ -45,6 +45,7 @@ func (s *JobsService) ingestNewEmail(ctx context.Context, e *models.JobEventNewE
log.Warn().Msg("NEW_EMAIL event without a message body, dropping")
return nil
}
e.Message.ValidText()
warmupToken := warmupTokenFromMessage(e.Message)
if warmupToken != "" {
handled, err := s.handleWarmupEmail(ctx, e, warmupToken)
@@ -446,18 +447,19 @@ func firstSenderAddress(from []string) string {
func (s *JobsService) acceptWarmupEmail(ctx context.Context, e *models.JobEventNewEmail, token *models.WarmupToken) {
s.WarmupRepo.ConsumeWarmupToken(ctx, token.Token)
landed := models.ClassifyWarmupLanding(e.Message.Folder, e.Message.Flags)
// Record the receipt so a later deletion or spam-flag of THIS message can be
// attributed back to warmup and to the sender. Verified warmup mail is not
// stored in the unibox, so this is the only record that the message was a
// warmup email.
if e.Message != nil {
if err := s.WarmupRepo.RecordWarmupReceived(ctx, e.Message.EmailID, e.Message.ID, e.Message.MessageID, token.SenderAccountID); err != nil {
if err := s.WarmupRepo.RecordWarmupReceived(ctx, e.Message.EmailID, e.Message.ID, e.Message.MessageID, token.SenderAccountID, landed == models.WarmupLandedSpam); err != nil {
log.Warn().Err(err).Str("email_id", e.Message.EmailID.String()).Msg("Failed to record warmup receipt")
}
}
recipient := s.recipientAccount(ctx, e.Message.EmailID)
landed := models.ClassifyWarmupLanding(e.Message.Folder, e.Message.Flags)
// If the warmup mail arrived in a Junk/Spam state, record a
// spam_placement event against the sender. This is distinct from a
+65 -5
View File
@@ -15,6 +15,7 @@ import (
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/pkg/mailhdr"
"github.com/warmbly/warmbly/internal/repository"
)
@@ -167,7 +168,7 @@ func (s *JobsService) HandleEmailFailed(ctx context.Context, result models.SendE
switch task.TaskType {
case "campaign":
return s.failCampaignSend(ctx, task, reason, code, nil)
return s.failCampaignSend(ctx, task, reason, code, refusedRecipient(result), nil)
case "email":
s.notifyUserSendFailed(ctx, task, reason)
case "placement":
@@ -194,7 +195,7 @@ func (s *JobsService) failWarmupSend(ctx context.Context, task *repository.Task,
// day the send was counted against, for giving the daily counters back; nil
// reads it off the task, which is right for a worker result but not for the
// reclaimer, whose sends can be counted on an earlier day.
func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Task, reason, code string, countedOn *time.Time) error {
func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Task, reason, code, refused string, countedOn *time.Time) error {
ct, err := s.TaskRepo.GetCampaignTask(ctx, task.ID)
if err != nil {
return err
@@ -216,6 +217,11 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
}
}
// A copy the server refused is not the lead's bounce: it is dropped from
// later emails and the lead's step is retried without it.
copyRefused := code == string(errx.MailErrorCodeRecipientRejected) && refused != "" &&
recipient != "" && !strings.EqualFold(mailhdr.Bare(refused), recipient)
// A refusal on the SENDING DOMAIN's authentication is not about this lead:
// the recipient received nothing, and every retry from that domain fails
// identically until its DNS is fixed. Give the reservation back without
@@ -234,14 +240,22 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
}
// Only permanent recipient refusals are bounce evidence; deferrals may use the same wording.
if s.Evidence != nil && ct.ContactID != nil && ct.SequenceID != nil &&
if s.Evidence != nil && ct.ContactID != nil && ct.SequenceID != nil && !copyRefused &&
code != string(errx.MailErrorCodeServerUnreachable) && emailverify.NamesRecipient(reason) {
s.Evidence.RecordEvidence(ctx, *ct.ContactID, models.Step(&campaignID, ct.SequenceID), "bounced_recipient", "send:"+ct.SequenceID.String(), reason)
}
// A refused copy that the retry will leave off costs the lead no attempt;
// one that could not be recorded is counted, so it cannot loop forever.
copyExcluded := copyRefused && s.recordRefusedCopy(ctx, task, ct, campaign, refused, reason)
attempts, exhausted, rolledBack := 0, false, false
if ct.ContactID != nil && ct.SequenceID != nil && s.CampaignProgressRepo != nil {
attempts, exhausted, rolledBack, err = s.CampaignProgressRepo.RecordSendFailure(ctx, campaignID, *ct.ContactID, *ct.SequenceID, reason)
if copyExcluded {
attempts, exhausted, rolledBack, err = s.CampaignProgressRepo.WalkBackSend(ctx, campaignID, *ct.ContactID, *ct.SequenceID, reason, false)
} else {
attempts, exhausted, rolledBack, err = s.CampaignProgressRepo.RecordSendFailure(ctx, campaignID, *ct.ContactID, *ct.SequenceID, reason)
}
if err != nil {
return err
}
@@ -271,7 +285,7 @@ func (s *JobsService) failCampaignSend(ctx context.Context, task *repository.Tas
// reputation, so it goes through the bounce pipeline (progress, optional
// suppression, guardrails, warmup health, webhooks) and the lead is
// dropped as bounced instead of being offered again.
if rolledBack && code == string(errx.MailErrorCodeRecipientRejected) {
if rolledBack && !copyRefused && code == string(errx.MailErrorCodeRecipientRejected) {
if s.recordSynchronousBounce(ctx, task, ct, campaign, recipient, reason) {
s.logCampaignSendFailure(ctx, campaignID, ct, recipient, reason, code, attempts, false, false, false)
s.publishCampaignUpdated(ctx, campaign, campaignID, "")
@@ -335,6 +349,52 @@ func (s *JobsService) recordSynchronousBounce(ctx context.Context, task *reposit
return true
}
// recordRefusedCopy feeds a copied address the server refused at RCPT into
// the bounce pipeline under its own name, and reports whether the next send
// is sure to leave it off: a lead's copy through its bounced mark, a
// campaign-wide one through the recorded bounce the send path reads.
func (s *JobsService) recordRefusedCopy(ctx context.Context, task *repository.Task, ct *repository.CampaignTask, campaign *models.Campaign, refused, reason string) bool {
if campaign == nil || campaign.OrganizationID == nil || ct.CampaignID == nil || ct.ContactID == nil {
return false
}
address := strings.ToLower(mailhdr.Bare(refused))
var owner *uuid.UUID
if s.CampaignProgressRepo != nil {
id, err := s.CampaignProgressRepo.MarkLeadCCBounced(ctx, *ct.CampaignID, *ct.ContactID, address)
if err != nil {
log.Warn().Err(err).Str("task_id", task.ID.String()).Msg("could not mark a refused copy bounced")
}
owner = id
}
if s.AdvancedService == nil {
return owner != nil
}
taskID := task.ID
req := &models.IngestDeliverabilityEventRequest{
EventType: models.DeliverabilityEventBounce,
Provider: "smtp_reject",
TaskID: &taskID,
CampaignID: ct.CampaignID,
ContactID: owner,
RecipientEmail: address,
Reason: reason,
IdempotencyKey: "reject:" + taskID.String() + ":" + address,
}
if xerr := s.AdvancedService.IngestDeliverabilityEvent(ctx, *campaign.OrganizationID, req); xerr != nil {
log.Warn().Str("task_id", taskID.String()).Str("error", xerr.Message).Msg("could not record a refused copy as a bounce")
return owner != nil
}
return true
}
// refusedRecipient is the address the server refused, when the worker knew.
func refusedRecipient(result models.SendEmailResult) string {
if result.Error == nil {
return ""
}
return result.Error.Recipient
}
// publishCampaignUpdated pulses the campaign for every teammate (status "" keeps
// the dashboard's status as is).
func (s *JobsService) publishCampaignUpdated(ctx context.Context, campaign *models.Campaign, campaignID uuid.UUID, status string) {
@@ -45,6 +45,7 @@ func (s *JobsService) HandleUpdateEmail(ctx context.Context, e *models.JobEventE
// provider: mail read in the customer's own client is read here too.
if seen := models.SeenFromFlags(e.Flags); seen != email.Seen {
updateData.Seen = &seen
s.noteOwnerActivity(ctx, e.EmailID, email.InternalDate)
}
if email.UID != e.UID {
updateData.UID = &e.UID
@@ -87,6 +88,43 @@ func (s *JobsService) HandleUpdateEmail(ctx context.Context, e *models.JobEventE
return nil
}
// HandleFolderUpdate files a message where the provider moved it. Like a full
// rescan, it only moves the stored folder when the provider's own placement
// changed, so a message filed in Warmbly stays filed.
func (s *JobsService) HandleFolderUpdate(ctx context.Context, e *models.JobEventFolderUpdate) error {
if !models.ValidFolder(e.Folder) {
return nil
}
email, err := s.emailForSyncUpdate(ctx, e.UserID, e.ID, func(message *models.EmailMessageStoreData) {
// A pending row is not visible yet, so nothing has filed it locally.
message.Folder = e.Folder
})
if err != nil {
CaptureError(e.UserID, e.EmailID, fmt.Errorf("Email (%s): %w", e.ID.String(), err))
return err
}
if email == nil {
return nil
}
folder, provider, providerMoved := models.ResolveFolderSync(email.Folder, email.ProviderFolder, e.Folder)
if !providerMoved {
return nil
}
update := repository.UpdateUniboxEntry{ProviderFolder: &provider}
if folder != email.Folder {
update.Folder = &folder
}
if err := s.UniboxRepository.UpdateEntry(ctx, e.UserID, e.EmailID, e.ID, &update); err != nil {
return err
}
email.Folder = folder
email.ProviderFolder = provider
s.publishEmailUpdated(ctx, e.UserID, email)
return nil
}
// emailForSyncUpdate rechecks visible mail if verification won the pending-row lock.
func (s *JobsService) emailForSyncUpdate(ctx context.Context, userID, id uuid.UUID, updatePending func(*models.EmailMessageStoreData)) (*models.EmailMessageStoreData, error) {
message, err := s.UniboxRepository.GetByID(ctx, userID, id)
+1
View File
@@ -33,6 +33,7 @@ func (w *JobsService) InitEvents() {
Register(w, models.JobEventTypeRemoveEmail, w.HandleRemoveEmail)
Register(w, models.JobEventTypeFlagsAdd, w.HandleFlagsAdd)
Register(w, models.JobEventTypeFlagsRemove, w.HandleFlagsRemove)
Register(w, models.JobEventTypeFolderUpdate, w.HandleFolderUpdate)
Register(w, models.JobEventTypeMailboxUpdate, w.HandleMailboxUpdate)
Register(w, models.JobEventTypeMailboxDelete, w.HandleMailboxDelete)
Register(w, models.JobEventTypeMailboxRename, w.HandleMailboxRename)
+138
View File
@@ -0,0 +1,138 @@
package jobs
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/app/advanced"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type copyContactRepo struct {
repository.ContactRepository
email string
}
func (r copyContactRepo) GetByID(_ context.Context, id uuid.UUID) (*models.Contact, *errx.Error) {
return &models.Contact{ID: id, Email: r.email}, nil
}
type copyCampaignRepo struct {
repository.CampaignRepository
org uuid.UUID
}
func (r copyCampaignRepo) GetByID(_ context.Context, id uuid.UUID) (*models.Campaign, error) {
return &models.Campaign{ID: id, OrganizationID: &r.org, Status: "active"}, nil
}
func (copyCampaignRepo) DecrementCampaignDailySend(context.Context, uuid.UUID, time.Time, bool) error {
return nil
}
type copyProgressRepo struct {
repository.CampaignProgressRepository
copyID uuid.UUID
bounced []string
counted []bool
}
func (r *copyProgressRepo) RecordSendFailure(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, string) (int, bool, bool, error) {
r.counted = append(r.counted, true)
return 1, false, true, nil
}
func (r *copyProgressRepo) WalkBackSend(_ context.Context, _, _, _ uuid.UUID, _ string, count bool) (int, bool, bool, error) {
r.counted = append(r.counted, count)
return 0, false, true, nil
}
func (*copyProgressRepo) HasSentSteps(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
return false, nil
}
func (r *copyProgressRepo) MarkLeadCCBounced(_ context.Context, _, _ uuid.UUID, address string) (*uuid.UUID, error) {
r.bounced = append(r.bounced, address)
return &r.copyID, nil
}
type copyAdvanced struct {
advanced.Service
events []*models.IngestDeliverabilityEventRequest
}
func (a *copyAdvanced) IngestDeliverabilityEvent(_ context.Context, _ uuid.UUID, req *models.IngestDeliverabilityEventRequest) *errx.Error {
a.events = append(a.events, req)
return nil
}
// A copy the server refused at RCPT bounces the copy, never the lead: no
// address evidence against the lead, and the bounce event names the copy.
func TestRefusedCopyIsNotTheLeadsBounce(t *testing.T) {
for _, tc := range []struct {
name string
refused string
wantCopy bool
}{
{"a copy", "Jonas <jonas@acme.test>", true},
{"the lead", "ana@acme.test", false},
} {
campaign, lead, step, taskID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
progress := &copyProgressRepo{copyID: uuid.New()}
adv := &copyAdvanced{}
ev := &recordingEvidence{}
s := &JobsService{
TaskRepo: &evidenceTaskRepo{
task: &repository.Task{ID: taskID, TaskType: "campaign", EmailAccountID: uuid.New(), Status: "completed"},
ct: &repository.CampaignTask{TaskID: taskID, CampaignID: &campaign, ContactID: &lead, SequenceID: &step},
},
CampaignRepo: copyCampaignRepo{org: uuid.New()},
CampaignProgressRepo: progress,
ContactRepo: copyContactRepo{email: "ana@acme.test"},
AdvancedService: adv,
Evidence: ev,
}
err := s.HandleEmailFailed(context.Background(), models.SendEmailResult{
TaskID: taskID,
Error: &models.EmailSendError{
Code: string(errx.MailErrorCodeRecipientRejected),
Message: `The mail server rejected the recipient: 550 "5.1.1 no such user"`,
Recipient: tc.refused,
},
})
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if len(adv.events) != 1 {
t.Fatalf("%s: %d bounce events, want 1", tc.name, len(adv.events))
}
got := adv.events[0]
if tc.wantCopy {
if len(ev.kinds) != 0 {
t.Fatalf("%s: evidence %v recorded against the lead", tc.name, ev.kinds)
}
if got.RecipientEmail != "jonas@acme.test" || got.ContactID == nil || *got.ContactID != progress.copyID {
t.Fatalf("%s: bounce event %+v, want it on the copy", tc.name, got)
}
if len(progress.bounced) != 1 {
t.Fatalf("%s: copy marked bounced %v times, want once", tc.name, progress.bounced)
}
// The retry leaves the copy off, so the lead is not charged an attempt.
if len(progress.counted) != 1 || progress.counted[0] {
t.Fatalf("%s: walk-backs %v, want one that counts no attempt", tc.name, progress.counted)
}
continue
}
if len(progress.counted) != 1 || !progress.counted[0] {
t.Fatalf("%s: walk-backs %v, want the lead's attempt counted", tc.name, progress.counted)
}
if got.RecipientEmail != "ana@acme.test" || got.ContactID == nil || *got.ContactID != lead || len(progress.bounced) != 0 {
t.Fatalf("%s: bounce event %+v (copies marked %v), want the lead's own bounce", tc.name, got, progress.bounced)
}
}
}
@@ -109,7 +109,7 @@ func (s *JobsService) reclaimStuckSend(ctx context.Context, d repository.StuckDi
return "", err
}
dispatchedAt := d.DispatchedAt
if err := s.failCampaignSend(ctx, task, reason, "SEND_OUTCOME_LOST", &dispatchedAt); err != nil {
if err := s.failCampaignSend(ctx, task, reason, "SEND_OUTCOME_LOST", "", &dispatchedAt); err != nil {
return "", err
}
return "reclaimed", nil
@@ -0,0 +1,53 @@
package jobs
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
// A folder the provider reports is resolved against where the provider last
// had the message, so Gmail's own moves land and Warmbly's filing survives.
func TestFolderUpdateFollowsOnlyProviderMoves(t *testing.T) {
for _, tc := range []struct {
name string
folder string
providerFolder string
reported string
wantFolder string // "" means nothing written
}{
{"archived in Gmail", models.FolderInbox, models.FolderInbox, models.FolderArchive, models.FolderArchive},
{"trashed in Gmail", models.FolderInbox, models.FolderInbox, models.FolderTrash, models.FolderTrash},
{"moved back to the inbox in Gmail", models.FolderArchive, models.FolderArchive, models.FolderInbox, models.FolderInbox},
{"filed in Warmbly, still in the Gmail inbox", models.FolderArchive, models.FolderInbox, models.FolderInbox, ""},
{"unknown folder", models.FolderInbox, models.FolderInbox, "elsewhere", ""},
} {
t.Run(tc.name, func(t *testing.T) {
s, repo := seenSyncService(&models.EmailMessageStoreData{Folder: tc.folder, ProviderFolder: tc.providerFolder})
if err := s.HandleFolderUpdate(context.Background(), &models.JobEventFolderUpdate{
UserID: uuid.New(), EmailID: uuid.New(), ID: uuid.New(),
Folder: tc.reported,
}); err != nil {
t.Fatal(err)
}
if tc.wantFolder == "" {
if len(repo.updates) != 0 {
t.Fatalf("wrote %+v, want nothing", repo.updates)
}
return
}
if len(repo.updates) != 1 {
t.Fatalf("wrote %d updates, want 1", len(repo.updates))
}
u := repo.updates[0]
if u.ProviderFolder == nil || *u.ProviderFolder != tc.reported {
t.Errorf("provider_folder = %v, want %q", u.ProviderFolder, tc.reported)
}
if u.Folder == nil || *u.Folder != tc.wantFolder {
t.Errorf("folder = %v, want %q", u.Folder, tc.wantFolder)
}
})
}
}
+23 -8
View File
@@ -19,17 +19,24 @@ import (
// handlers make.
type retentionWarmupRepo struct {
repository.WarmupRepository
rec *repository.WarmupReceived
rec *repository.WarmupReceived
held *[]repository.WarmupSpamMove
}
func (r retentionWarmupRepo) GetWarmupReceived(context.Context, uuid.UUID, uuid.UUID) (*repository.WarmupReceived, error) {
return r.rec, nil
}
func (r retentionWarmupRepo) RecordWarmupSpamMove(_ context.Context, m repository.WarmupSpamMove) (bool, error) {
*r.held = append(*r.held, m)
return true, nil
}
// retentionWarmupService records which strikes the handlers asked for.
type retentionWarmupService struct {
warmupapp.Service
strikes []string
held []repository.WarmupSpamMove
fail bool
}
@@ -54,7 +61,7 @@ func (s *retentionWarmupService) ApplySpamReport(context.Context, uuid.UUID, uui
func retentionService(rec *repository.WarmupReceived) (*JobsService, *retentionWarmupService) {
svc := &retentionWarmupService{}
return &JobsService{
WarmupRepo: retentionWarmupRepo{rec: rec},
WarmupRepo: retentionWarmupRepo{rec: rec, held: &svc.held},
WarmupService: svc,
EmailRepository: warmupInboxEmailRepo{},
}, svc
@@ -275,19 +282,24 @@ func TestRecheckTamperingSearchesEachOldStrike(t *testing.T) {
}
// Gmail reports Delete as gaining the TRASH label. That is the owner's act
// and is judged on the same freshness rule; a spam flag is still the graver
// strike and is never subject to the window.
// and is judged on the same freshness rule. A spam label charges nobody on
// sight: a move after arrival is held for attribution, and the label on mail
// that arrived in spam is the filter's own and is not even held.
func TestFlagsAddJudgesGmailTrashOnFreshness(t *testing.T) {
landedSpam := receivedAgo(time.Second)
landedSpam.LandedSpam = true
cases := []struct {
name string
rec *repository.WarmupReceived
flags []string
want []string
held int
}{
{"trashed an hour after arrival", receivedAgo(time.Hour), []string{"TRASH"}, []string{"deletion"}},
{"trashed a month after arrival", receivedAgo(30 * 24 * time.Hour), []string{"TRASH"}, nil},
{"flagged as spam a month after arrival", receivedAgo(30 * 24 * time.Hour), []string{"SPAM"}, []string{"spam_report", "spam_flag"}},
{"read is not a strike", receivedAgo(time.Hour), []string{models.FlagSeen}, nil},
{"trashed an hour after arrival", receivedAgo(time.Hour), []string{"TRASH"}, []string{"deletion"}, 0},
{"trashed a month after arrival", receivedAgo(30 * 24 * time.Hour), []string{"TRASH"}, nil, 0},
{"moved to spam a month after arrival is held", receivedAgo(30 * 24 * time.Hour), []string{"SPAM"}, nil, 1},
{"spam label on mail that arrived in spam", landedSpam, []string{"SPAM"}, nil, 0},
{"read is not a strike", receivedAgo(time.Hour), []string{models.FlagSeen}, nil, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -300,6 +312,9 @@ func TestFlagsAddJudgesGmailTrashOnFreshness(t *testing.T) {
if len(svc.strikes) != len(tc.want) {
t.Fatalf("strikes = %v, want %v", svc.strikes, tc.want)
}
if len(svc.held) != tc.held {
t.Fatalf("held %d moves, want %d", len(svc.held), tc.held)
}
for i := range tc.want {
if svc.strikes[i] != tc.want[i] {
t.Fatalf("strikes = %v, want %v", svc.strikes, tc.want)
@@ -0,0 +1,167 @@
package jobs
import (
"context"
"fmt"
"slices"
"time"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/jobrun"
"github.com/warmbly/warmbly/internal/repository"
)
const (
spamMoveAttributionInterval = 5 * time.Minute
spamMoveAttributionBatch = 200
// spamMoveClaimLease outlives one move's work; a consumer that dies holding it frees it on expiry.
spamMoveClaimLease = 5 * time.Minute
)
// Evidence names stored with each verdict, so an operator can read why.
const (
spamMoveCorrelated = "correlated"
spamMoveOnArrival = "on_arrival"
spamMoveOwnerActive = "owner_active"
spamMoveRepeated = "repeated"
spamMoveDormant = "dormant"
spamMoveNoOwnerTrace = "no_owner_activity"
)
// attributeSpamMove decides who moved a received warmup email into spam. No
// provider says, so the verdict rests on what the pool and the mailbox show:
// the provider when other workspaces saw the same sender junked or the move
// came straight after arrival with nobody there, the owner when they were
// active in the mailbox around it or keep junking pool mail nobody else does,
// and nobody otherwise. Only the owner is charged.
func attributeSpamMove(m repository.WarmupSpamMove, ev repository.WarmupSpamMoveEvidence) (string, []string) {
quick := m.ObservedAt.Sub(m.ReceivedAt) < time.Duration(config.WarmupSpamMoveQuickMinutes)*time.Minute
switch {
case ev.CorrelatedElsewhere > 0:
return repository.SpamMoveProvider, []string{spamMoveCorrelated}
case quick && !ev.OwnerActiveNear:
return repository.SpamMoveProvider, []string{spamMoveOnArrival}
case ev.OwnerActiveNear:
return repository.SpamMoveOwner, []string{spamMoveOwnerActive}
case !ev.OwnerActiveRecently:
return repository.SpamMoveUnattributed, []string{spamMoveDormant}
case ev.PatternSenders >= config.WarmupSpamMovePatternSenders:
return repository.SpamMoveOwner, []string{spamMoveRepeated}
}
return repository.SpamMoveUnattributed, []string{spamMoveNoOwnerTrace}
}
// StartWarmupSpamMoveAttribution decides each held spam move once it has settled.
func (s *JobsService) StartWarmupSpamMoveAttribution(ctx context.Context) {
if s.WarmupRepo == nil || s.WarmupService == nil {
return
}
jobrun.Loop(ctx, "warmup_spam_move_attribution", spamMoveAttributionInterval, true, func(ctx context.Context) error {
batchCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
return s.attributeSpamMoves(batchCtx, time.Now())
})
}
func (s *JobsService) attributeSpamMoves(ctx context.Context, now time.Time) error {
settled := now.Add(-time.Duration(config.WarmupSpamMoveSettleMinutes) * time.Minute)
moves, err := s.WarmupRepo.ListSettledWarmupSpamMoves(ctx, settled, spamMoveAttributionBatch)
if err != nil {
return fmt.Errorf("list warmup spam moves: %w", err)
}
for _, m := range moves {
if err := s.attributeOneSpamMove(ctx, m); err != nil {
return err
}
}
return nil
}
// attributeOneSpamMove claims the move, fixes its verdict once and applies it.
// Effects are idempotent and the move is completed only after all of them, so
// a failure part way re-applies the same verdict on a later pass.
func (s *JobsService) attributeOneSpamMove(ctx context.Context, m repository.WarmupSpamMove) error {
claimed, err := s.WarmupRepo.ClaimWarmupSpamMove(ctx, m.EmailAccountID, m.MessageID, spamMoveClaimLease)
if err != nil {
return fmt.Errorf("claim warmup spam move: %w", err)
}
if !claimed {
return nil
}
verdict, signals := m.Verdict, m.Signals
if verdict == repository.SpamMovePending {
ev, err := s.WarmupRepo.WarmupSpamMoveEvidence(ctx, m)
if err != nil {
return fmt.Errorf("warmup spam move evidence: %w", err)
}
verdict, signals = attributeSpamMove(m, ev)
fixed, err := s.WarmupRepo.FixWarmupSpamMoveVerdict(ctx, m.EmailAccountID, m.MessageID, verdict, signals)
if err != nil {
return fmt.Errorf("fix warmup spam move verdict: %w", err)
}
if !fixed {
return nil
}
}
if verdict == repository.SpamMoveOwner {
hSender, xerr := s.WarmupService.ApplySpamReport(ctx, m.EmailAccountID, m.SenderAccountID, m.MessageID, "user_complaint")
if xerr != nil {
return fmt.Errorf("record warmup spam complaint: %w", xerr)
}
s.markRiskBandFromWarmupHealth(ctx, m.SenderAccountID, hSender)
hOwner, xerr := s.WarmupService.RecordTampering(ctx, m.EmailAccountID, m.MessageID, "spam_flag")
if xerr != nil {
return fmt.Errorf("record warmup spam strike: %w", xerr)
}
s.markRiskBandFromWarmupHealth(ctx, m.EmailAccountID, hOwner)
} else {
// The provider junked the sender's mail, or may have: a placement
// reading against the sender, which only ever slows it down.
provider, domain := recipientProviderDomain(s.recipientAccount(ctx, m.EmailAccountID))
hSender, xerr := s.WarmupService.RecordSpamPlacement(ctx, m.EmailAccountID, m.SenderAccountID, m.MessageID, "", provider, domain)
if xerr != nil {
return fmt.Errorf("record warmup spam placement: %w", xerr)
}
s.markRiskBandFromWarmupHealth(ctx, m.SenderAccountID, hSender)
}
if slices.Contains(signals, spamMoveCorrelated) {
if err := s.withdrawCorrelatedOwnerMoves(ctx, m); err != nil {
return err
}
}
if err := s.WarmupRepo.CompleteWarmupSpamMove(ctx, m.EmailAccountID, m.MessageID); err != nil {
return fmt.Errorf("complete warmup spam move: %w", err)
}
log.Info().Str("email_id", m.EmailAccountID.String()).Str("verdict", verdict).Strs("signals", signals).
Msg("warmup spam move attributed")
return nil
}
// withdrawCorrelatedOwnerMoves takes back the strikes charged for the same
// sender's mail in other workspaces before this move showed the provider at work.
func (s *JobsService) withdrawCorrelatedOwnerMoves(ctx context.Context, m repository.WarmupSpamMove) error {
owners, err := s.WarmupRepo.CorrelatedOwnerSpamMoves(ctx, m.SenderAccountID, m.EmailAccountID, m.ObservedAt)
if err != nil {
return fmt.Errorf("correlated spam moves: %w", err)
}
for _, o := range owners {
health, xerr := s.WarmupService.WithdrawTampering(ctx, o.EmailAccountID, o.MessageID, "spam_flag")
if xerr != nil {
return fmt.Errorf("withdraw correlated spam strike: %w", xerr)
}
s.markRiskBandFromWarmupHealth(ctx, o.EmailAccountID, health)
}
if len(owners) == 0 {
return nil
}
if err := s.WarmupRepo.ReattributeOwnerSpamMoves(ctx, m.SenderAccountID, m.EmailAccountID, m.ObservedAt); err != nil {
return fmt.Errorf("reattribute correlated spam moves: %w", err)
}
return nil
}
@@ -0,0 +1,238 @@
package jobs
import (
"context"
"slices"
"testing"
"time"
"github.com/google/uuid"
warmupapp "github.com/warmbly/warmbly/internal/app/warmup"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
func spamMoveAfter(sinceArrival time.Duration) repository.WarmupSpamMove {
observed := time.Date(2026, 9, 28, 14, 18, 36, 0, time.UTC)
return repository.WarmupSpamMove{
EmailAccountID: uuid.New(), MessageID: "<warmup@example.test>", SenderAccountID: uuid.New(),
ReceivedAt: observed.Add(-sinceArrival), ObservedAt: observed, Verdict: repository.SpamMovePending,
}
}
func TestAttributeSpamMove(t *testing.T) {
quick := time.Duration(config.WarmupSpamMoveQuickMinutes)*time.Minute - time.Second
later := 6 * time.Hour
pattern := config.WarmupSpamMovePatternSenders
cases := []struct {
name string
since time.Duration
ev repository.WarmupSpamMoveEvidence
want string
}{
{"the filter catching up with nobody there", quick, repository.WarmupSpamMoveEvidence{OwnerActiveRecently: true}, repository.SpamMoveProvider},
{"the owner at the mailbox right after arrival", quick, repository.WarmupSpamMoveEvidence{OwnerActiveNear: true, OwnerActiveRecently: true}, repository.SpamMoveOwner},
{"the owner at the mailbox later", later, repository.WarmupSpamMoveEvidence{OwnerActiveNear: true, OwnerActiveRecently: true}, repository.SpamMoveOwner},
{"other workspaces junked the same sender", later, repository.WarmupSpamMoveEvidence{OwnerActiveNear: true, OwnerActiveRecently: true, CorrelatedElsewhere: 1}, repository.SpamMoveProvider},
{"a mailbox nobody uses", later, repository.WarmupSpamMoveEvidence{PatternSenders: pattern + 5}, repository.SpamMoveUnattributed},
{"one unexplained move in a used mailbox", later, repository.WarmupSpamMoveEvidence{OwnerActiveRecently: true, PatternSenders: 1}, repository.SpamMoveUnattributed},
{"a used mailbox junking many senders nobody else does", later, repository.WarmupSpamMoveEvidence{OwnerActiveRecently: true, PatternSenders: pattern}, repository.SpamMoveOwner},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, signals := attributeSpamMove(spamMoveAfter(tc.since), tc.ev)
if got != tc.want {
t.Fatalf("verdict = %s (%v), want %s", got, signals, tc.want)
}
if len(signals) == 0 {
t.Fatal("a verdict carries no evidence for an operator to read")
}
})
}
}
// attributionRepo serves settled moves and their evidence, and records what was decided.
type attributionRepo struct {
repository.WarmupRepository
moves []repository.WarmupSpamMove
evidence repository.WarmupSpamMoveEvidence
correlated []repository.WarmupSpamMove
claimLost bool
log *[]string
}
func (r attributionRepo) ClaimWarmupSpamMove(context.Context, uuid.UUID, string, time.Duration) (bool, error) {
return !r.claimLost, nil
}
func (r attributionRepo) FixWarmupSpamMoveVerdict(_ context.Context, _ uuid.UUID, _, verdict string, _ []string) (bool, error) {
*r.log = append(*r.log, "fix:"+verdict)
return true, nil
}
func (r attributionRepo) CompleteWarmupSpamMove(context.Context, uuid.UUID, string) error {
*r.log = append(*r.log, "complete")
return nil
}
func (r attributionRepo) ListSettledWarmupSpamMoves(context.Context, time.Time, int) ([]repository.WarmupSpamMove, error) {
return r.moves, nil
}
func (r attributionRepo) WarmupSpamMoveEvidence(context.Context, repository.WarmupSpamMove) (repository.WarmupSpamMoveEvidence, error) {
return r.evidence, nil
}
func (r attributionRepo) CorrelatedOwnerSpamMoves(context.Context, uuid.UUID, uuid.UUID, time.Time) ([]repository.WarmupSpamMove, error) {
return r.correlated, nil
}
func (r attributionRepo) ReattributeOwnerSpamMoves(context.Context, uuid.UUID, uuid.UUID, time.Time) error {
*r.log = append(*r.log, "reattribute")
return nil
}
// attributionService records the effects a verdict has, in order.
type attributionService struct {
warmupapp.Service
senderFails bool
log *[]string
}
func (s attributionService) ApplySpamReport(_ context.Context, _, _ uuid.UUID, _, reportType string) (*models.WarmupParticipantHealth, *errx.Error) {
if s.senderFails {
return nil, errx.InternalError()
}
*s.log = append(*s.log, "sender:"+reportType)
return nil, nil
}
func (s attributionService) RecordSpamPlacement(context.Context, uuid.UUID, uuid.UUID, string, string, string, string) (*models.WarmupParticipantHealth, *errx.Error) {
if s.senderFails {
return nil, errx.InternalError()
}
*s.log = append(*s.log, "sender:spam_placement")
return nil, nil
}
func (s attributionService) RecordTampering(_ context.Context, _ uuid.UUID, _, kind string) (*models.WarmupParticipantHealth, *errx.Error) {
*s.log = append(*s.log, "strike:"+kind)
return nil, nil
}
func (s attributionService) WithdrawTampering(_ context.Context, _ uuid.UUID, _, kind string) (*models.WarmupParticipantHealth, *errx.Error) {
*s.log = append(*s.log, "withdraw:"+kind)
return nil, nil
}
// Only an owner verdict charges the recipient; the rest read as placement
// against the sender. The verdict is fixed before any effect and the move is
// completed after all of them, so a failure part way re-applies the same
// verdict, and a correlation takes back the owner verdicts it explains first.
func TestAttributeSpamMovesAppliesTheVerdict(t *testing.T) {
owner := repository.WarmupSpamMoveEvidence{OwnerActiveNear: true, OwnerActiveRecently: true}
fixedProvider := spamMoveAfter(6 * time.Hour)
fixedProvider.Verdict, fixedProvider.Signals = repository.SpamMoveProvider, []string{spamMoveOnArrival}
cases := []struct {
name string
move repository.WarmupSpamMove
ev repository.WarmupSpamMoveEvidence
correlated int
claimLost bool
senderFails bool
wantErr bool
want []string
}{
{name: "owner", move: spamMoveAfter(6 * time.Hour), ev: owner,
want: []string{"fix:owner", "sender:user_complaint", "strike:spam_flag", "complete"}},
{name: "provider on arrival", move: spamMoveAfter(time.Minute),
want: []string{"fix:provider", "sender:spam_placement", "complete"}},
{name: "unattributed", move: spamMoveAfter(6 * time.Hour), ev: repository.WarmupSpamMoveEvidence{OwnerActiveRecently: true, PatternSenders: 1},
want: []string{"fix:unattributed", "sender:spam_placement", "complete"}},
{name: "correlated withdraws earlier owner verdicts", move: spamMoveAfter(6 * time.Hour), ev: repository.WarmupSpamMoveEvidence{CorrelatedElsewhere: 2}, correlated: 2,
want: []string{"fix:provider", "sender:spam_placement", "withdraw:spam_flag", "withdraw:spam_flag", "reattribute", "complete"}},
{name: "a move another consumer holds is left alone", move: spamMoveAfter(6 * time.Hour), ev: owner, claimLost: true,
want: nil},
{name: "a fixed verdict is re-applied, not decided again", move: fixedProvider, ev: owner,
want: []string{"sender:spam_placement", "complete"}},
{name: "a failed sender write leaves the move to retry", move: spamMoveAfter(6 * time.Hour), ev: owner, senderFails: true, wantErr: true,
want: []string{"fix:owner"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var log []string
repo := attributionRepo{moves: []repository.WarmupSpamMove{tc.move}, evidence: tc.ev, claimLost: tc.claimLost, log: &log}
for range tc.correlated {
repo.correlated = append(repo.correlated, spamMoveAfter(6*time.Hour))
}
s := &JobsService{WarmupRepo: repo, WarmupService: attributionService{log: &log, senderFails: tc.senderFails}, EmailRepository: warmupInboxEmailRepo{}}
err := s.attributeSpamMoves(context.Background(), time.Now())
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, want error %v", err, tc.wantErr)
}
if !slices.Equal(log, tc.want) {
t.Fatalf("effects = %v, want %v", log, tc.want)
}
})
}
}
// activityRepo is a mailbox with no warmup receipt that records owner activity.
type activityRepo struct {
repository.WarmupRepository
noted *int
}
func (activityRepo) GetWarmupReceived(context.Context, uuid.UUID, uuid.UUID) (*repository.WarmupReceived, error) {
return nil, nil
}
func (r activityRepo) RecordOwnerActivity(context.Context, uuid.UUID, time.Time) error {
*r.noted++
return nil
}
// Owner activity is a change the provider reports that our store did not
// already hold: a read made in Warmbly and echoed back is not the owner at the
// mailbox, and a label on mail that only just arrived may be a filter.
func TestOwnerActivityIsOnlyTheProvidersOwnChange(t *testing.T) {
old := time.Now().Add(-time.Hour)
cases := []struct {
name string
stored models.EmailMessageStoreData
add bool
flags []string
want int
}{
{"read at the provider", models.EmailMessageStoreData{Flags: []string{}, InternalDate: old}, true, []string{models.FlagSeen}, 1},
{"a read made in Warmbly echoed back", models.EmailMessageStoreData{Flags: []string{models.FlagSeen}, Seen: true, InternalDate: old}, true, []string{models.FlagSeen}, 0},
{"read by a filter on arrival", models.EmailMessageStoreData{Flags: []string{}, InternalDate: time.Now()}, true, []string{models.FlagSeen}, 0},
{"starred at the provider", models.EmailMessageStoreData{Flags: []string{}, Seen: true, InternalDate: old}, true, []string{models.FlagFlagged}, 1},
{"marked unread at the provider", models.EmailMessageStoreData{Flags: []string{models.FlagSeen}, Seen: true, InternalDate: old}, false, []string{models.FlagSeen}, 1},
{"a label no person sets", models.EmailMessageStoreData{Flags: []string{}, Seen: true, InternalDate: old}, true, []string{`\Important`}, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stored := tc.stored
s, _ := seenSyncService(&stored)
noted := 0
s.WarmupRepo = activityRepo{noted: &noted}
ev := &models.JobEventFlags{UserID: uuid.New(), EmailID: uuid.New(), ID: uuid.New(), Flags: tc.flags}
var err error
if tc.add {
err = s.HandleFlagsAdd(context.Background(), ev)
} else {
err = s.HandleFlagsRemove(context.Background(), ev)
}
if err != nil {
t.Fatal(err)
}
if noted != tc.want {
t.Fatalf("noted %d owner activities, want %d", noted, tc.want)
}
})
}
}
+8 -1
View File
@@ -114,8 +114,15 @@ func holdCopy(hold *models.LeadHold) string {
return "Paused for this contact"
}
what := "Paused for this contact"
if hold.Source == models.LeadHoldSourceOutOfOffice {
switch hold.Source {
case models.LeadHoldSourceOutOfOffice:
what = "Out of office"
case models.LeadHoldSourceCC:
// The reason is the lead they are copied on; no end is expected.
if r := strings.TrimSpace(hold.Reason); r != "" {
return "Copied on the emails to " + r + ", so none of their own are sent"
}
return "Copied on another lead's emails, so none of their own are sent"
}
if r := strings.TrimSpace(hold.Reason); r != "" {
what += ": " + r
+1 -1
View File
@@ -194,7 +194,7 @@ func fieldHeader(f string) string {
case models.ContactExportFieldSubscribed:
return "Subscribed"
case models.ContactExportFieldCategories:
return "Categories"
return "Labels"
case models.ContactExportFieldCampaigns:
return "Campaigns"
case models.ContactExportFieldCreatedAt:
+67
View File
@@ -0,0 +1,67 @@
// Package eventschemas registers a release's bus schemas from the control
// plane, which rolls out first, and holds the fleet off a release the schema
// registry refused, so a refusal never reaches a worker mid-send.
package eventschemas
import (
"context"
"fmt"
"strings"
"github.com/hamba/avro/v2"
"github.com/warmbly/warmbly/internal/events"
"github.com/warmbly/warmbly/internal/infrastructure/codec"
"github.com/warmbly/warmbly/internal/infrastructure/kafka"
"github.com/warmbly/warmbly/internal/models"
)
// WorkerCommands names every per-node command topic (w.<node-id>).
const WorkerCommands = "w.*"
// Published is every schema this build publishes, by topic.
func Published() map[string]avro.Schema {
return map[string]avro.Schema{
kafka.TopicWorkerEvents: models.JobEvent{}.Schema(),
WorkerCommands: models.WorkerEvent{}.Schema(),
events.TopicEmailEvents: events.EmailSentEvent{}.Schema(),
events.TopicWarmupEvents: events.WarmupEmailSentEvent{}.Schema(),
}
}
// Register registers every published schema when c resolves against a
// registry. A codec without one has nothing to refuse.
func Register(ctx context.Context, c codec.Codec) error {
r, ok := c.(codec.SchemaRegistrar)
if !ok {
return nil
}
return r.RegisterSchemas(ctx, Published())
}
// Gate answers whether the fleet may move to tag. On a registry, the control
// plane has to be running that release and its schemas have to register;
// running is this binary's stamped version, and a dev build is not held.
func Gate(c codec.Codec, running string) func(ctx context.Context, tag string) error {
return func(ctx context.Context, tag string) error {
if _, ok := c.(codec.SchemaRegistrar); !ok {
return nil
}
if release(running) && release(tag) && base(running) != base(tag) {
return fmt.Errorf("the control plane runs %s, so the fleet waits for it to run %s and register its schemas", running, tag)
}
if err := Register(ctx, c); err != nil {
return fmt.Errorf("schema registry refused this release's event schemas: %w", err)
}
return nil
}
}
// release is a tagged version rather than a dev or unstamped build.
func release(v string) bool {
return len(v) > 1 && v[0] == 'v' && v[1] >= '0' && v[1] <= '9'
}
// base drops the image variant, so v1.2.3-kafka and v1.2.3 are one release.
func base(v string) string {
return strings.TrimSuffix(v, "-kafka")
}
@@ -0,0 +1,121 @@
package eventschemas
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"os"
"path/filepath"
"strings"
"testing"
"github.com/hamba/avro/v2"
"github.com/warmbly/warmbly/internal/infrastructure/codec"
"github.com/warmbly/warmbly/internal/infrastructure/kafka"
"github.com/warmbly/warmbly/internal/models"
)
var update = flag.Bool("update", false, "record the current schemas as the snapshot (make schemas)")
// snapshotPath is the recorded schema for topic, as the registry last accepted it.
func snapshotPath(topic string) string {
name := topic
if topic == WorkerCommands {
name = "worker-commands"
}
return filepath.Join("testdata", name+".avsc")
}
func document(t *testing.T, s avro.Schema) []byte {
t.Helper()
doc, err := models.SchemaDocument(s)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := json.Indent(&out, doc, "", " "); err != nil {
t.Fatal(err)
}
return append(out.Bytes(), '\n')
}
// TestPublishedSchemasStayCompatible is the registry's check, run before merge:
// a reader on the new schema has to decode what the recorded one wrote.
func TestPublishedSchemasStayCompatible(t *testing.T) {
for topic, schema := range Published() {
t.Run(topic, func(t *testing.T) {
path := snapshotPath(topic)
current := document(t, schema)
recorded, err := os.ReadFile(path)
if err == nil {
old, perr := avro.Parse(string(recorded))
if perr != nil {
t.Fatalf("%s does not parse: %v", path, perr)
}
if cerr := avro.NewSchemaCompatibility().Compatible(schema, old); cerr != nil {
t.Fatalf("the %s schema is not BACKWARD compatible with %s, so the registry would refuse it and every publish on the topic would stop: %v", topic, path, cerr)
}
} else if !os.IsNotExist(err) || !*update {
t.Fatalf("no recorded schema for %s; run make schemas: %v", topic, err)
}
if *update {
if err := os.WriteFile(path, current, 0o644); err != nil {
t.Fatal(err)
}
return
}
if !bytes.Equal(current, recorded) {
t.Fatalf("the %s schema changed compatibly; run make schemas to record it in %s", topic, path)
}
})
}
}
type registry struct {
codec.Codec
err error
registered map[string]avro.Schema
}
func (r *registry) RegisterSchemas(_ context.Context, s map[string]avro.Schema) error {
r.registered = s
return r.err
}
func TestGate(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name, running, tag string
err error
held bool
}{
{"the control plane runs the release", "v1.2.3", "v1.2.3", nil, false},
{"the kafka image is the same release", "v1.2.3-kafka", "v1.2.3", nil, false},
{"the control plane is behind", "v1.2.3", "v1.2.4", nil, true},
{"a dev build still registers", "dev-abc", "v1.2.4", nil, false},
{"the registry refuses", "v1.2.3", "v1.2.3", errors.New("409"), true},
} {
t.Run(tc.name, func(t *testing.T) {
r := &registry{err: tc.err}
err := Gate(r, tc.running)(ctx, tc.tag)
if (err != nil) != tc.held {
t.Fatalf("held = %v, want %v", err, tc.held)
}
if !tc.held && len(r.registered) != len(Published()) {
t.Fatalf("registered %d schemas, want %d", len(r.registered), len(Published()))
}
})
}
if err := Gate(codec.NewJSON(), "v1.2.3")(ctx, "v9.9.9"); err != nil {
t.Fatalf("a codec with no registry held the fleet: %v", err)
}
}
func TestWorkerCommandsNamesThePerNodeTopics(t *testing.T) {
if !strings.HasPrefix(kafka.GetWorkerTopic("node"), strings.TrimSuffix(WorkerCommands, "*")) {
t.Fatalf("WorkerCommands %q does not match w.<node-id>", WorkerCommands)
}
}
+59
View File
@@ -0,0 +1,59 @@
{
"fields": [
{
"default": "",
"name": "event_type",
"type": "string"
},
{
"default": "",
"name": "task_id",
"type": "string"
},
{
"default": "",
"name": "account_id",
"type": "string"
},
{
"default": "",
"name": "campaign_id",
"type": "string"
},
{
"default": "",
"name": "contact_id",
"type": "string"
},
{
"default": "",
"name": "sequence_id",
"type": "string"
},
{
"default": "",
"name": "message_id",
"type": "string"
},
{
"default": "",
"name": "recipient",
"type": "string"
},
{
"default": "",
"name": "subject",
"type": "string"
},
{
"default": 0,
"name": "sent_at",
"type": {
"logicalType": "timestamp-millis",
"type": "long"
}
}
],
"name": "warmbly.events.EmailSentEvent",
"type": "record"
}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"fields": [
{
"default": "",
"name": "event_type",
"type": "string"
},
{
"default": "",
"name": "task_id",
"type": "string"
},
{
"default": "",
"name": "sender_account_id",
"type": "string"
},
{
"default": "",
"name": "target_account_id",
"type": "string"
},
{
"default": "",
"name": "message_id",
"type": "string"
},
{
"default": false,
"name": "is_reply",
"type": "boolean"
},
{
"default": 0,
"name": "sent_at",
"type": {
"logicalType": "timestamp-millis",
"type": "long"
}
}
],
"name": "warmbly.events.WarmupEmailSentEvent",
"type": "record"
}
+839
View File
@@ -0,0 +1,839 @@
{
"fields": [
{
"name": "type",
"type": "string"
},
{
"name": "body",
"type": [
{
"fields": [
{
"default": "",
"name": "id",
"type": "string"
},
{
"default": "",
"name": "user_id",
"type": "string"
},
{
"default": null,
"name": "organization_id",
"type": [
"null",
"string"
]
},
{
"default": false,
"name": "imap_sync",
"type": "boolean"
},
{
"default": null,
"name": "save_to_sent",
"type": [
"null",
"boolean"
]
},
{
"default": "",
"name": "email",
"type": "string"
},
{
"default": "",
"name": "first_name",
"type": "string"
},
{
"default": "",
"name": "last_name",
"type": "string"
},
{
"default": "",
"name": "type",
"type": "string"
},
{
"default": null,
"name": "google",
"type": [
"null",
{
"fields": [
{
"default": "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"name": "last_history_id",
"type": {
"name": "warmbly.events.uint64",
"size": 8,
"type": "fixed"
}
},
{
"default": null,
"name": "token",
"type": [
"null",
{
"fields": [
{
"default": "",
"name": "AccessToken",
"type": "string"
},
{
"default": "",
"name": "TokenType",
"type": "string"
},
{
"default": "",
"name": "RefreshToken",
"type": "string"
},
{
"default": 0,
"name": "Expiry",
"type": {
"logicalType": "timestamp-millis",
"type": "long"
}
},
{
"default": 0,
"name": "ExpiresIn",
"type": "long"
}
],
"name": "warmbly.events.Token",
"type": "record"
}
]
}
],
"name": "warmbly.events.AddWorkerEmailGoogleData",
"type": "record"
}
]
},
{
"default": null,
"name": "smtp_imap",
"type": [
"null",
{
"fields": [
{
"default": [],
"name": "mailboxes",
"type": {
"items": {
"fields": [
{
"default": "",
"name": "name",
"type": "string"
},
{
"default": [],
"name": "attributes",
"type": {
"items": "string",
"type": "array"
}
},
{
"default": 0,
"name": "uid_validity",
"type": "long"
},
{
"default": "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000",
"name": "highestmodseq",
"type": "warmbly.events.uint64"
},
{
"default": 0,
"name": "uid_next",
"type": "long"
},
{
"default": "",
"name": "delim",
"type": "string"
},
{
"default": 0,
"name": "messages",
"type": "long"
},
{
"default": 0,
"name": "updated_at",
"type": {
"logicalType": "timestamp-millis",
"type": "long"
}
}
],
"name": "warmbly.events.Mailbox",
"type": "record"
},
"type": "array"
}
},
{
"default": null,
"name": "token",
"type": [
"null",
"warmbly.events.Token"
]
},
{
"default": null,
"name": "credentials",
"type": [
"null",
{
"fields": [
{
"default": null,
"name": "smtp",
"type": [
"null",
{
"fields": [
{
"default": "",
"name": "username",
"type": "string"
},
{
"default": "",
"name": "password",
"type": "string"
},
{
"default": "",
"name": "host",
"type": "string"
},
{
"default": 0,
"name": "port",
"type": "int"
},
{
"default": "",
"name": "security",
"type": "string"
}
],
"name": "warmbly.events.Service",
"type": "record"
}
]
},
{
"default": null,
"name": "imap",
"type": [
"null",
"warmbly.events.Service"
]
}
],
"name": "warmbly.events.SmtpImap",
"type": "record"
}
]
}
],
"name": "warmbly.events.AddWorkerEmailSmtpImapData",
"type": "record"
}
]
},
{
"default": null,
"name": "graph",
"type": [
"null",
{
"fields": [
{
"default": null,
"name": "token",
"type": [
"null",
"warmbly.events.Token"
]
},
{
"default": {},
"name": "delta_links",
"type": {
"type": "map",
"values": "string"
}
},
{
"default": "",
"name": "user",
"type": "string"
}
],
"name": "warmbly.events.AddWorkerEmailGraphData",
"type": "record"
}
]
},
{
"default": null,
"name": "sync",
"type": [
"null",
{
"fields": [
{
"default": {
"backfill_days": 0,
"backfill_messages": 0,
"daily_messages": 0,
"org_daily_messages": 0,
"skip_folders": []
},
"name": "policy",
"type": {
"fields": [
{
"default": 0,
"name": "backfill_days",
"type": "int"
},
{
"default": 0,
"name": "backfill_messages",
"type": "int"
},
{
"default": 0,
"name": "daily_messages",
"type": "int"
},
{
"default": 0,
"name": "org_daily_messages",
"type": "int"
},
{
"default": [],
"name": "skip_folders",
"type": {
"items": "string",
"type": "array"
}
}
],
"name": "warmbly.events.SyncPolicy",
"type": "record"
}
},
{
"default": null,
"name": "state",
"type": [
"null",
{
"fields": [
{
"default": "",
"name": "backfill_status",
"type": "string"
},
{
"default": {
"folders": {},
"page_token": ""
},
"name": "backfill_cursor",
"type": {
"fields": [
{
"default": "",
"name": "page_token",
"type": "string"
},
{
"default": {},
"name": "folders",
"type": {
"type": "map",
"values": {
"fields": [
{
"default": "",
"name": "next",
"type": "string"
},
{
"default": 0,
"name": "uid",
"type": "long"
},
{
"default": false,
"name": "done",
"type": "boolean"
}
],
"name": "warmbly.events.SyncFolderCursor",
"type": "record"
}
}
}
],
"name": "warmbly.events.SyncCursor",
"type": "record"
}
},
{
"default": 0,
"name": "backfill_synced",
"type": "int"
},
{
"default": null,
"name": "backfill_since",
"type": [
"null",
{
"logicalType": "timestamp-millis",
"type": "long"
}
]
},
{
"default": null,
"name": "backfill_started_at",
"type": [
"null",
{
"logicalType": "timestamp-millis",
"type": "long"
}
]
},
{
"default": null,
"name": "backfill_completed_at",
"type": [
"null",
{
"logicalType": "timestamp-millis",
"type": "long"
}
]
},
{
"default": null,
"name": "throttled_until",
"type": [
"null",
{
"logicalType": "timestamp-millis",
"type": "long"
}
]
},
{
"default": "",
"name": "throttle_reason",
"type": "string"
},
{
"default": 0,
"name": "deferred",
"type": "int"
},
{
"default": 0,
"name": "folders_skipped_cap",
"type": "int"
},
{
"default": 0,
"name": "folders_skipped_conflict",
"type": "int"
},
{
"default": null,
"name": "last_synced_at",
"type": [
"null",
{
"logicalType": "timestamp-millis",
"type": "long"
}
]
}
],
"name": "warmbly.events.SyncState",
"type": "record"
}
]
}
],
"name": "warmbly.events.AddWorkerEmailSyncData",
"type": "record"
}
]
},
{
"default": false,
"name": "brokered",
"type": "boolean"
}
],
"name": "warmbly.events.AddWorkerEmail",
"type": "record"
},
{
"fields": [
{
"default": "",
"name": "org_id",
"type": "string"
},
{
"default": "",
"name": "process_id",
"type": "string"
},
{
"default": null,
"name": "credentials",
"type": [
"null",
"warmbly.events.SmtpImap"
]
}
],
"name": "warmbly.events.EventWorkerEmailValidation",
"type": "record"
},
{
"fields": [
{
"default": "",
"name": "email_id",
"type": "string"
},
{
"default": "",
"name": "process_id",
"type": "string"
},
{
"default": false,
"name": "want_signature",
"type": "boolean"
},
{
"default": "",
"name": "signature_for",
"type": "string"
}
],
"name": "warmbly.events.EventWorkerMailboxIdentity",
"type": "record"
},
{
"fields": [
{
"default": "",
"name": "email_id",
"type": "string"
},
{
"default": false,
"name": "seen",
"type": "boolean"
},
{
"default": [],
"name": "messages",
"type": {
"items": {
"fields": [
{
"default": "",
"name": "provider_id",
"type": "string"
},
{
"default": 0,
"name": "uid",
"type": "long"
},
{
"default": "",
"name": "folder",
"type": "string"
},
{
"default": "",
"name": "rfc_message_id",
"type": "string"
}
],
"name": "warmbly.events.MessageSeenRef",
"type": "record"
},
"type": "array"
}
}
],
"name": "warmbly.events.MessageSeenAction",
"type": "record"
},
{
"fields": [
{
"default": "",
"name": "user_id",
"type": "string"
},
{
"default": "",
"name": "email_id",
"type": "string"
}
],
"name": "warmbly.events.RemoveWorkerEmail",
"type": "record"
},
{
"fields": [
{
"default": "",
"name": "task_id",
"type": "string"
},
{
"default": "",
"name": "email_id",
"type": "string"
},
{
"default": "",
"name": "org_id",
"type": "string"
},
{
"default": [],
"name": "to",
"type": {
"items": "string",
"type": "array"
}
},
{
"default": [],
"name": "cc",
"type": {
"items": "string",
"type": "array"
}
},
{
"default": [],
"name": "bcc",
"type": {
"items": "string",
"type": "array"
}
},
{
"default": "",
"name": "subject",
"type": "string"
},
{
"default": "",
"name": "body_s3_key",
"type": "string"
},
{
"default": "",
"name": "message_id",
"type": "string"
},
{
"default": "",
"name": "in_reply_to",
"type": "string"
},
{
"default": null,
"name": "parent",
"type": [
"null",
{
"fields": [
{
"default": "",
"name": "id",
"type": "string"
},
{
"default": "",
"name": "message_id",
"type": "string"
},
{
"default": "",
"name": "thread_id",
"type": "string"
}
],
"name": "warmbly.events.EmailParent",
"type": "record"
}
]
},
{
"default": false,
"name": "is_warmup",
"type": "boolean"
},
{
"default": null,
"name": "tracking_info",
"type": [
"null",
{
"fields": [
{
"default": false,
"name": "open_tracking",
"type": "boolean"
},
{
"default": false,
"name": "link_tracking",
"type": "boolean"
},
{
"default": "",
"name": "tracking_domain",
"type": "string"
}
],
"name": "warmbly.events.TrackingInfo",
"type": "record"
}
]
},
{
"default": "",
"name": "warmup_token",
"type": "string"
},
{
"default": "",
"name": "unsubscribe_url",
"type": "string"
}
],
"name": "warmbly.events.SendEmail",
"type": "record"
},
{
"fields": [
{
"default": "",
"name": "user_id",
"type": "string"
},
{
"default": "",
"name": "email_id",
"type": "string"
},
{
"default": "",
"name": "gmail_id",
"type": "string"
},
{
"default": 0,
"name": "uid",
"type": "long"
},
{
"default": 0,
"name": "mailbox_uid_validity",
"type": "long"
},
{
"default": "",
"name": "mailbox_folder",
"type": "string"
},
{
"default": "",
"name": "rfc_message_id",
"type": "string"
},
{
"default": [],
"name": "actions",
"type": {
"items": "string",
"type": "array"
}
},
{
"default": "",
"name": "placement",
"type": "string"
},
{
"default": "",
"name": "target_folder",
"type": "string"
},
{
"default": "",
"name": "internal_id",
"type": "string"
},
{
"default": false,
"name": "recheck",
"type": "boolean"
},
{
"default": 0,
"name": "delay_seconds",
"type": "int"
}
],
"name": "warmbly.events.WarmupEmailAction",
"type": "record"
}
]
}
],
"name": "warmbly.events.WorkerEvent",
"type": "record"
}
+43
View File
@@ -202,6 +202,17 @@ type Placement struct {
// CreditsPerTest is what a test past the monthly allowance costs in
// credits. Zero turns paid tests off; nil is the compiled default.
CreditsPerTest *int `json:"credits_per_test"`
// BatchSendersMax is the most senders one batch may hold, an
// infrastructure safeguard separate from how many run at once.
BatchSendersMax int `json:"batch_senders_max"`
// BatchSenderConcurrency is how many of one workspace's batch senders
// may be sending probes at the same time.
BatchSenderConcurrency int `json:"batch_sender_concurrency"`
// BatchInstanceConcurrency caps batch senders sending at once across
// every workspace, which bounds what the shared seed panel receives.
BatchInstanceConcurrency int `json:"batch_instance_concurrency"`
// BatchStartsPerMinute paces how fast one batch starts senders.
BatchStartsPerMinute int `json:"batch_starts_per_minute"`
}
// CreditPrice is the resolved price of a paid test, zero when off.
@@ -221,6 +232,11 @@ func DefaultPlacement() Placement {
SeedsPerTest: config.PlacementSeedsPerTestDefault,
SpacingSeconds: config.PlacementSpacingSecondsDefault,
CreditsPerTest: &price,
BatchSendersMax: config.PlacementBatchSendersMaxDefault,
BatchSenderConcurrency: config.PlacementBatchSenderConcurrencyDefault,
BatchInstanceConcurrency: config.PlacementBatchInstanceConcurrencyDefault,
BatchStartsPerMinute: config.PlacementBatchStartsPerMinuteDefault,
}
}
@@ -248,6 +264,16 @@ func (p *Placement) Normalize() {
v = max(0, min(*p.CreditsPerTest, config.PlacementCreditsPerTestMax))
}
p.CreditsPerTest = &v
clamp := func(v, def, ceiling int) int {
if v <= 0 {
return def
}
return min(v, ceiling)
}
p.BatchSendersMax = clamp(p.BatchSendersMax, config.PlacementBatchSendersMaxDefault, config.PlacementBatchSendersMaxCeiling)
p.BatchSenderConcurrency = clamp(p.BatchSenderConcurrency, config.PlacementBatchSenderConcurrencyDefault, config.PlacementBatchSenderConcurrencyMax)
p.BatchInstanceConcurrency = clamp(p.BatchInstanceConcurrency, config.PlacementBatchInstanceConcurrencyDefault, config.PlacementBatchInstanceConcurrencyMax)
p.BatchStartsPerMinute = clamp(p.BatchStartsPerMinute, config.PlacementBatchStartsPerMinuteDefault, config.PlacementBatchStartsPerMinuteMax)
}
// QuickSpacing is the gap between two probes of a quick test.
@@ -453,6 +479,11 @@ type Patch struct {
SeedsPerTest *int `json:"seeds_per_test"`
SpacingSeconds *int `json:"spacing_seconds"`
CreditsPerTest *int `json:"credits_per_test"`
BatchSendersMax *int `json:"batch_senders_max"`
BatchSenderConcurrency *int `json:"batch_sender_concurrency"`
BatchInstanceConcurrency *int `json:"batch_instance_concurrency"`
BatchStartsPerMinute *int `json:"batch_starts_per_minute"`
} `json:"placement"`
// Channels replaces the whole list when present. A channel that comes back
// with a masked target or secret keeps the stored value, so the admin panel
@@ -552,6 +583,18 @@ func (p Patch) Apply(doc Document) Document {
v := *p.Placement.CreditsPerTest
doc.Placement.CreditsPerTest = &v
}
if p.Placement.BatchSendersMax != nil {
doc.Placement.BatchSendersMax = *p.Placement.BatchSendersMax
}
if p.Placement.BatchSenderConcurrency != nil {
doc.Placement.BatchSenderConcurrency = *p.Placement.BatchSenderConcurrency
}
if p.Placement.BatchInstanceConcurrency != nil {
doc.Placement.BatchInstanceConcurrency = *p.Placement.BatchInstanceConcurrency
}
if p.Placement.BatchStartsPerMinute != nil {
doc.Placement.BatchStartsPerMinute = *p.Placement.BatchStartsPerMinute
}
}
if p.Notifications != nil && p.Notifications.Channels != nil {
doc.Notifications.Channels = mergeChannels(doc.Notifications.Channels, *p.Notifications.Channels)
+20 -1
View File
@@ -286,7 +286,7 @@ var Tables = []Table{
{
Name: "categories", Group: models.OrgDataGroupContacts,
Scope: scopeOrg,
Note: "The whole category registry travels, including ones no contact or conversation carries yet.",
Note: "The whole label registry travels, including ones no contact or conversation carries yet.",
},
{
Name: "contacts", Group: models.OrgDataGroupContacts,
@@ -462,6 +462,12 @@ var Tables = []Table{
Name: "campaign_segments", Group: models.OrgDataGroupCampaigns,
Scope: `campaign_id IN ` + orgCampaigns,
},
{
// Both contacts are in the contacts group, which campaigns require.
Name: "campaign_lead_cc", Group: models.OrgDataGroupCampaigns,
Scope: `campaign_id IN ` + orgCampaigns,
Note: "Must travel with the leads, or a copied contact held on their own lead is released into a second sequence.",
},
{
Name: "campaign_lead_removals", Group: models.OrgDataGroupCampaigns,
Scope: `campaign_id IN ` + orgCampaigns,
@@ -767,6 +773,17 @@ var Tables = []Table{
Name: "deliverability_events", Group: models.OrgDataGroupEvents,
Scope: scopeOrg,
},
{
// A batch travels as a record of its senders and results. It lands
// inactive, so the destination never resumes sending it.
Name: "placement_batches", Group: models.OrgDataGroupEvents,
Scope: scopeOrg,
ResetOnImport: []string{"active", "lease_until", "last_tick_at"},
},
{
Name: "placement_batch_senders", Group: models.OrgDataGroupEvents,
Scope: `batch_id IN (SELECT id FROM placement_batches WHERE organization_id = $1)`,
},
{
// The results travel as a record. The link to a cloud-run test and the
// seeds on the source instance's panel do not, and neither does a
@@ -888,6 +905,8 @@ var ExcludedTables = map[string]string{
"oauth_authorization_codes": "Single-use authorization codes, valid for seconds.",
"scheduled_deletions": "Instance lifecycle state. Importing a pending deletion would schedule the destination workspace for destruction.",
"dedicated_worker_assignments": "Worker topology, which is a property of the instance rather than the workspace.",
"warmup_spam_moves": "Per-message attribution evidence for warmup mail this instance synced, kept only to decide recent tampering; the destination judges its own.",
"mailbox_owner_activity": "Five-minute buckets of sync-observed owner activity on this instance, read only to attribute recent spam moves.",
"warmup_pools": "Instance-global pool definitions shared by every workspace on the instance.",
"pool_link_codes": "In-flight link handshakes between a self-hosted instance and this cloud, valid for minutes.",
"cli_auth_codes": "In-flight `warmbly auth login` handshakes, valid for minutes. The API key an approval mints does travel, with the api_keys rows.",
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
package placement
import (
"context"
"fmt"
"math/rand/v2"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/observability/errs"
)
// senderOutcome is what one attempt to start a batch sender came to.
type senderOutcome int
const (
outcomeStarted senderOutcome = iota
// outcomeNotStarted: skipped or deferred, the batch goes on.
outcomeNotStarted
// outcomeStopBatch: no other sender could start either (the allowance or
// the credits ran out, the campaign went away).
outcomeStopBatch
)
// runBatches advances every active batch: resolve senders whose tests
// finished, start the next due ones within the workspace's concurrency and the
// start rate, and close a batch with nothing left to do.
func (s *service) runBatches(ctx context.Context) {
if s.Batches == nil {
return
}
if err := s.Batches.CloseInactiveBatches(ctx); err != nil {
errs.CaptureException(err)
}
s.settleCancelledBatches(ctx)
now := s.now()
batches, err := s.Batches.ClaimBatches(ctx, now, 2*time.Minute, config.PlacementBatchRunnerBatchesPerTick)
if err != nil {
errs.CaptureException(err)
return
}
pol := s.policy(ctx)
instance, err := s.Batches.CountSendingBatchSenders(ctx, nil)
if err != nil {
errs.CaptureException(err)
return
}
pace := batchPace{
orgLimit: pol.BatchSenderConcurrency, instanceLimit: pol.BatchInstanceConcurrency,
perMinute: pol.BatchStartsPerMinute, instance: instance, sending: map[uuid.UUID]int{},
}
for i := range batches {
b := &batches[i]
s.advanceBatch(ctx, b, &pace, now)
if err := s.Batches.ReleaseBatch(ctx, b.ID); err != nil {
errs.CaptureException(err)
}
}
}
// settleCancelledBatches finishes what a cancel leaves behind: a test a sender
// started while the batch was being cancelled is cancelled too, and senders
// whose tests have since finished are resolved.
func (s *service) settleCancelledBatches(ctx context.Context) {
late, err := s.Batches.RunningTestsOfCancelledBatches(ctx, 100)
if err != nil {
errs.CaptureException(err)
}
for _, t := range late {
if _, err := s.Repo.CancelTest(ctx, t.OrganizationID, t.TestID); err != nil {
errs.CaptureException(err)
}
}
if err := s.Batches.SyncClosedBatchSenders(ctx); err != nil {
errs.CaptureException(err)
}
}
// batchPace is one runner pass's view of how many batch senders are sending,
// per workspace and across the instance, against their limits.
type batchPace struct {
orgLimit, instanceLimit, perMinute int
instance int
sending map[uuid.UUID]int
}
func (s *service) advanceBatch(ctx context.Context, b *models.PlacementBatch, pace *batchPace, now time.Time) {
stale := now.Add(-time.Duration(config.PlacementBatchSenderStaleMinutes) * time.Minute)
if err := s.Batches.SyncBatchSenders(ctx, b.ID, stale); err != nil {
errs.CaptureException(err)
return
}
if now.After(b.RetryUntil) {
if err := s.Batches.CloseOpenBatchSenders(ctx, b.ID, []string{models.PlacementSenderDeferred}, models.PlacementSenderSkipped,
"placement_batch_retry_expired", "Still could not run when the batch's retry window closed."); err != nil {
errs.CaptureException(err)
}
}
if _, ok := pace.sending[b.OrganizationID]; !ok {
orgID := b.OrganizationID
n, err := s.Batches.CountSendingBatchSenders(ctx, &orgID)
if err != nil {
errs.CaptureException(err)
return
}
pace.sending[b.OrganizationID] = n
}
slots := min(pace.orgLimit-pace.sending[b.OrganizationID], pace.instanceLimit-pace.instance,
startAllowance(pace.perMinute, b.LastTickAt, now))
moved := false
if slots > 0 {
due, err := s.Batches.DueBatchSenders(ctx, b.ID, now, slots*4+10)
if err != nil {
errs.CaptureException(err)
return
}
started := 0
for _, snd := range due {
if started >= slots {
break
}
outcome, reason, detail := s.startBatchSender(ctx, b, snd, now)
moved = true
if outcome == outcomeStarted {
started++
pace.sending[b.OrganizationID]++
pace.instance++
continue
}
if outcome == outcomeStopBatch {
if err := s.Batches.CloseOpenBatchSenders(ctx, b.ID,
[]string{models.PlacementSenderQueued, models.PlacementSenderDeferred},
models.PlacementSenderSkipped, reason, detail); err != nil {
errs.CaptureException(err)
}
break
}
}
if started > 0 && b.Status == models.PlacementBatchQueued {
if err := s.Batches.MarkBatchStarted(ctx, b.ID); err != nil {
errs.CaptureException(err)
}
b.Status = models.PlacementBatchRunning
}
}
progress, err := s.Batches.BatchProgress(ctx, []uuid.UUID{b.ID})
if err != nil {
errs.CaptureException(err)
return
}
p := progress[b.ID]
if p.Open() > 0 {
if moved {
s.publishBatch(ctx, b)
}
return
}
s.finishBatch(ctx, b, p)
}
// startAllowance is how many senders a batch may start this pass: its rate
// over the time since it was last advanced, at most a minute's worth, and at
// least one so a slow rate still moves.
func startAllowance(perMinute int, last *time.Time, now time.Time) int {
if perMinute <= 0 {
return 0
}
elapsed := time.Minute
if last != nil {
elapsed = min(max(now.Sub(*last), 0), time.Minute)
}
return max(1, int(float64(perMinute)*elapsed.Seconds()/60))
}
// startBatchSender starts one sender's test, or records why it did not start.
// The sender is claimed first, so a restart between the claim and the test
// can never start it twice; SyncBatchSenders returns a claim with no test.
func (s *service) startBatchSender(ctx context.Context, b *models.PlacementBatch, snd models.PlacementBatchSender, now time.Time) (senderOutcome, string, string) {
record := func(status, reason, detail string, next time.Time) (senderOutcome, string, string) {
if err := s.Batches.SetBatchSenderOutcome(ctx, snd.ID, status, reason, detail, next); err != nil {
errs.CaptureException(err)
}
return outcomeNotStarted, reason, detail
}
if snd.EmailAccountID == nil {
return record(models.PlacementSenderSkipped, "placement_sender_deleted", "The mailbox was removed from the workspace.", now)
}
// The mailbox is checked here so a not-found from the test below means
// the batch's campaign or step went away, which stops every sender.
acct, xerr := s.Emails.GetByID(ctx, *snd.EmailAccountID)
if xerr != nil || acct == nil || acct.OrganizationID == nil || *acct.OrganizationID != b.OrganizationID {
return record(models.PlacementSenderSkipped, "placement_sender_deleted", "The mailbox was removed from the workspace.", now)
}
ok, err := s.Batches.ClaimBatchSender(ctx, snd.ID)
if err != nil {
errs.CaptureException(err)
return outcomeNotStarted, "", ""
}
if !ok {
return outcomeNotStarted, "", ""
}
batchID, senderID := b.ID, snd.ID
views, xerr := s.CreateTests(ctx, CreateInput{
OrgID: b.OrganizationID,
UserID: b.CreatedBy,
SenderAccountID: *snd.EmailAccountID,
CampaignID: b.CampaignID,
SequenceID: b.SequenceID,
ContactID: b.ContactID,
Subject: b.Subject,
BodyHTML: b.BodyHTML,
BodyPlain: b.BodyPlain,
Tracking: b.Tracking,
Panel: b.Panel,
SeedIDs: b.SeedIDs,
Families: b.Families,
Pace: models.PlacementPaceSpaced,
MaxCredits: max(0, b.MaxCredits-b.CreditsSpent),
Origin: models.PlacementOriginBatch,
BatchID: &batchID,
BatchSenderID: &senderID,
})
if xerr == nil {
charged := 0
for _, v := range views {
charged += v.CreditsCharged
}
if charged > 0 {
b.CreditsSpent += charged
if err := s.Batches.AddBatchCredits(ctx, b.ID, charged); err != nil {
errs.CaptureException(err)
}
}
return outcomeStarted, "", ""
}
retry := b.OnUnavailable == models.PlacementUnavailableDefer
unavailable := func(next time.Time) (senderOutcome, string, string) {
if retry {
return record(models.PlacementSenderDeferred, xerr.Identifier, xerr.Message, next)
}
return record(models.PlacementSenderSkipped, xerr.Identifier, xerr.Message, now)
}
switch xerr.Identifier {
case "placement_sender_busy":
// Another test is still sending from it; that is minutes, not a day.
return record(models.PlacementSenderDeferred, xerr.Identifier, xerr.Message, now.Add(15*time.Minute))
case "placement_daily_budget":
return unavailable(nextSendingDay(now))
case "placement_sender_unavailable", "placement_invalid_seeds":
return unavailable(now.Add(time.Hour))
case "placement_no_seeds":
// Every seed is on this sender's own domain; no retry changes that.
return record(models.PlacementSenderSkipped, xerr.Identifier, xerr.Message, now)
case "placement_quota_exceeded", "insufficient_credits", "usage_cap_exceeded", "placement_not_entitled",
"placement_panel_unavailable", "placement_invalid_tracking":
record(models.PlacementSenderSkipped, xerr.Identifier, xerr.Message, now)
return outcomeStopBatch, xerr.Identifier, xerr.Message
}
switch xerr.Code {
case errx.NotFound, errx.BadRequest:
record(models.PlacementSenderSkipped, "placement_batch_copy_unavailable", xerr.Message, now)
return outcomeStopBatch, "placement_batch_copy_unavailable", "The batch's campaign or email is no longer available: " + xerr.Message
}
// Anything else is unexpected: try again later, then give up on the sender.
if snd.Attempts+1 >= config.PlacementBatchSenderErrorAttemptsMax {
if err := s.Batches.SetBatchSenderOutcome(ctx, snd.ID, models.PlacementSenderFailed, "placement_batch_start_failed",
"The test could not be started.", now); err != nil {
errs.CaptureException(err)
}
return outcomeNotStarted, "", ""
}
return record(models.PlacementSenderDeferred, "placement_batch_start_failed", "The test could not be started; it is retried shortly.",
now.Add(time.Duration(snd.Attempts+1)*10*time.Minute))
}
// nextSendingDay is a moment early in the next UTC day, when a mailbox's daily
// count starts over, spread over two hours so a deferred fleet does not all
// start at midnight.
func nextSendingDay(now time.Time) time.Time {
day := now.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour)
return day.Add(time.Duration(rand.Int64N(int64(2 * time.Hour))))
}
// finishBatch closes a batch with nothing left to run and tells its creator.
func (s *service) finishBatch(ctx context.Context, b *models.PlacementBatch, p models.PlacementBatchProgress) {
status, msg := models.PlacementBatchCompleted, ""
switch {
case p.Completed == 0:
status, msg = models.PlacementBatchFailed, "No sender finished a test."
case p.Skipped+p.Failed+p.Cancelled > 0:
status = models.PlacementBatchCompletedWithWarnings
}
ok, err := s.Batches.FinishBatch(ctx, b.ID, status, msg)
if err != nil {
errs.CaptureException(err)
return
}
if !ok {
return
}
b.Status = status
s.publishBatch(ctx, b)
if b.CreatedBy == nil || s.Notifier == nil {
return
}
body := fmt.Sprintf("%d of %d mailboxes finished a test.", p.Completed, p.Total)
if sums, err := s.Batches.BatchSummaries(ctx, []uuid.UUID{b.ID}); err == nil {
c := sums[b.ID]
c.Finish()
body += " " + summaryLine(c)
}
orgID := b.OrganizationID
s.Notifier.Notify(ctx, *b.CreatedBy, &orgID, models.NotifPlacementFinished, "Placement batch finished", body,
"/app/placement/batches/"+b.ID.String(), map[string]any{"placement_batch_id": b.ID.String()})
}
+548
View File
@@ -0,0 +1,548 @@
package placement
import (
"context"
"fmt"
"math/rand/v2"
"slices"
"sort"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
type fakeBatches struct {
repository.PlacementBatchRepository
cands []repository.PlacementBatchCandidate
candOrg map[uuid.UUID]uuid.UUID
batches map[uuid.UUID]*models.PlacementBatch
senders []*models.PlacementBatchSender
open int
credits int
finished []string
}
func newFakeBatches() *fakeBatches {
return &fakeBatches{candOrg: map[uuid.UUID]uuid.UUID{}, batches: map[uuid.UUID]*models.PlacementBatch{}}
}
func (f *fakeBatches) ListBatchCandidates(_ context.Context, orgID uuid.UUID, fl repository.PlacementCandidateFilter) ([]repository.PlacementBatchCandidate, error) {
var out []repository.PlacementBatchCandidate
for _, c := range f.cands {
if f.candOrg[c.ID] != orgID {
continue
}
if fl.IDs != nil && !slices.Contains(fl.IDs, c.ID) {
continue
}
if !fl.IncludeInactive && c.Status != "active" {
continue
}
out = append(out, c)
}
return out, nil
}
func (f *fakeBatches) CountOpenBatches(context.Context, uuid.UUID) (int, error) { return f.open, nil }
func (f *fakeBatches) CreateBatch(_ context.Context, b *models.PlacementBatch, senders []models.PlacementBatchSender) error {
b.Active = true
cp := *b
f.batches[b.ID] = &cp
for i := range senders {
s := senders[i]
s.BatchID = b.ID
f.senders = append(f.senders, &s)
}
return nil
}
func (f *fakeBatches) CloseInactiveBatches(context.Context) error { return nil }
func (f *fakeBatches) SyncClosedBatchSenders(context.Context) error { return nil }
func (f *fakeBatches) RunningTestsOfCancelledBatches(context.Context, int) ([]repository.PlacementOrgTest, error) {
return nil, nil
}
func (f *fakeBatches) ClaimBatches(context.Context, time.Time, time.Duration, int) ([]models.PlacementBatch, error) {
var out []models.PlacementBatch
for _, b := range f.batches {
if b.Active {
out = append(out, *b)
}
}
return out, nil
}
func (f *fakeBatches) ReleaseBatch(context.Context, uuid.UUID) error { return nil }
func (f *fakeBatches) SyncBatchSenders(context.Context, uuid.UUID, time.Time) error { return nil }
func (f *fakeBatches) CountSendingBatchSenders(context.Context, *uuid.UUID) (int, error) {
n := 0
for _, s := range f.senders {
if s.Status == models.PlacementSenderRunning {
n++
}
}
return n, nil
}
func (f *fakeBatches) DueBatchSenders(_ context.Context, batchID uuid.UUID, now time.Time, limit int) ([]models.PlacementBatchSender, error) {
var out []models.PlacementBatchSender
for _, s := range f.senders {
if s.BatchID == batchID && (s.Status == models.PlacementSenderQueued || s.Status == models.PlacementSenderDeferred) && !s.NextAttemptAt.After(now) {
out = append(out, *s)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Position < out[j].Position })
return out[:min(limit, len(out))], nil
}
func (f *fakeBatches) sender(id uuid.UUID) *models.PlacementBatchSender {
for _, s := range f.senders {
if s.ID == id {
return s
}
}
return nil
}
func (f *fakeBatches) ClaimBatchSender(_ context.Context, id uuid.UUID) (bool, error) {
s := f.sender(id)
if s == nil || (s.Status != models.PlacementSenderQueued && s.Status != models.PlacementSenderDeferred) {
return false, nil
}
s.Status, s.Attempts = models.PlacementSenderRunning, s.Attempts+1
return true, nil
}
func (f *fakeBatches) SetBatchSenderOutcome(_ context.Context, id uuid.UUID, status, reason, detail string, next time.Time) error {
s := f.sender(id)
s.Status, s.Reason, s.Detail, s.NextAttemptAt = status, reason, detail, next
return nil
}
func (f *fakeBatches) CloseOpenBatchSenders(_ context.Context, batchID uuid.UUID, from []string, status, reason, detail string) error {
for _, s := range f.senders {
if s.BatchID == batchID && slices.Contains(from, s.Status) {
s.Status, s.Reason, s.Detail = status, reason, detail
}
}
return nil
}
func (f *fakeBatches) AddBatchCredits(_ context.Context, _ uuid.UUID, n int) error {
f.credits += n
return nil
}
func (f *fakeBatches) MarkBatchStarted(_ context.Context, id uuid.UUID) error {
f.batches[id].Status = models.PlacementBatchRunning
return nil
}
func (f *fakeBatches) BatchProgress(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]models.PlacementBatchProgress, error) {
out := map[uuid.UUID]models.PlacementBatchProgress{}
for _, s := range f.senders {
if slices.Contains(ids, s.BatchID) {
p := out[s.BatchID]
p.Add(s.Status, 1)
out[s.BatchID] = p
}
}
return out, nil
}
func (f *fakeBatches) BatchSummaries(context.Context, []uuid.UUID) (map[uuid.UUID]models.PlacementCounts, error) {
return map[uuid.UUID]models.PlacementCounts{}, nil
}
func (f *fakeBatches) FinishBatch(_ context.Context, id uuid.UUID, status, _ string) (bool, error) {
b := f.batches[id]
if !b.Active {
return false, nil
}
b.Active, b.Status = false, status
f.finished = append(f.finished, status)
return true, nil
}
// fleetHarness is a harness whose workspace has n extra mailboxes spread
// over domains and providers, all on the batch store as candidates.
func fleetHarness(t *testing.T, n int) (*harness, *fakeBatches) {
t.Helper()
h := newHarness(t)
fb := newFakeBatches()
h.svc.Batches = fb
emails := h.svc.Emails.(*fakeEmails)
worker := uuid.New()
hosts := []string{"google_workspace", "google_workspace", "microsoft365", "other"}
for i := range n {
id := uuid.New()
host := hosts[i%len(hosts)]
addr := fmt.Sprintf("rep%d@domain%d.test", i, i%7)
emails.accounts[id] = &models.Email{ID: id, OrganizationID: &h.org, Email: addr, Status: "active", WorkerID: &worker, CampaignLimit: 50}
fb.cands = append(fb.cands, repository.PlacementBatchCandidate{ID: id, Email: addr, Provider: "smtp_imap", MailHost: host, Status: "active", WorkerID: &worker})
fb.candOrg[id] = h.org
}
return h, fb
}
func (h *harness) batchInput() BatchInput {
return BatchInput{OrgID: h.org, Scope: &models.PlacementSenderScope{Type: models.PlacementScopeWorkspace}, Subject: "Quick question", BodyPlain: "Hi there"}
}
func TestAllocateSplitsBySizeByLargestRemainder(t *testing.T) {
got := allocate(135, []int{800, 400, 147})
if !slices.Equal(got, []int{80, 40, 15}) {
t.Fatalf("allocate(135, 800/400/147) = %v; want 80/40/15", got)
}
got = allocate(3, []int{100, 1, 1})
if got[0]+got[1]+got[2] != 3 || got[0] > 100 || got[1] > 1 || got[2] > 1 {
t.Fatalf("allocate(3, 100/1/1) = %v; want 3 in total within each size", got)
}
if got := allocate(10, []int{2, 3}); !slices.Equal(got, []int{2, 3}) {
t.Fatalf("allocate beyond the total = %v; want every member", got)
}
}
func TestSampleSendersPercentIsStratifiedByProvider(t *testing.T) {
var cands []batchCandidate
add := func(n int, family string) {
for i := range n {
cands = append(cands, batchCandidate{family: family, domain: fmt.Sprintf("%s%d.test", family, i%9)})
}
}
add(800, "google_workspace")
add(400, "other")
add(147, "microsoft365")
rng := rand.New(rand.NewPCG(1, 2))
got := sampleSenders(cands, models.PlacementSample{Mode: models.PlacementSamplePercent, Percent: 10, Stratify: "provider"}, rng)
per := map[string]int{}
for _, c := range got {
per[c.family]++
}
if len(got) != 135 || per["google_workspace"] != 80 || per["other"] != 40 || per["microsoft365"] != 15 {
t.Fatalf("10%% stratified sample = %d (%v); want 135 split 80/40/15", len(got), per)
}
if got := sampleSenders(cands, models.PlacementSample{Mode: models.PlacementSampleRandom, Count: 100}, rng); len(got) != 100 {
t.Fatalf("random 100 kept %d", len(got))
}
if got := sampleSenders(cands, models.PlacementSample{Mode: models.PlacementSampleAll}, rng); len(got) != len(cands) {
t.Fatalf("all kept %d of %d", len(got), len(cands))
}
}
func TestSampleSendersPerDomainCapsEveryDomainAlike(t *testing.T) {
var cands []batchCandidate
for i := range 500 {
cands = append(cands, batchCandidate{family: "gmail", domain: "big.test", PlacementBatchCandidate: repository.PlacementBatchCandidate{Email: fmt.Sprint(i)}})
}
for i := range 3 {
cands = append(cands, batchCandidate{family: "gmail", domain: "small.test", PlacementBatchCandidate: repository.PlacementBatchCandidate{Email: fmt.Sprint(i)}})
}
got := sampleSenders(cands, models.PlacementSample{Mode: models.PlacementSamplePerDomain, Count: 5}, rand.New(rand.NewPCG(3, 4)))
per := map[string]int{}
for _, c := range got {
per[c.domain]++
}
if per["big.test"] != 5 || per["small.test"] != 3 {
t.Fatalf("5 per domain = %v; want 5 from the large domain and all 3 of the small one", per)
}
}
func TestStaggerSendersRotatesProviders(t *testing.T) {
var cands []batchCandidate
for i := range 30 {
cands = append(cands, batchCandidate{family: "google_workspace", domain: fmt.Sprintf("g%d.test", i%3)})
}
for i := range 3 {
cands = append(cands, batchCandidate{family: "microsoft365", domain: fmt.Sprintf("m%d.test", i)})
}
got := staggerSenders(cands, rand.New(rand.NewPCG(5, 6)))
if len(got) != len(cands) {
t.Fatalf("stagger kept %d of %d", len(got), len(cands))
}
for i := range 6 {
want := "google_workspace"
if i%2 == 1 {
want = "microsoft365"
}
if got[i].family != want {
t.Fatalf("position %d is %s; want providers to alternate while both have senders", i, got[i].family)
}
}
if got[0].domain == got[2].domain {
t.Fatalf("consecutive google senders share %s; want domains to rotate", got[0].domain)
}
}
func TestStartAllowance(t *testing.T) {
now := time.Now()
half := now.Add(-30 * time.Second)
if got := startAllowance(10, &half, now); got != 5 {
t.Fatalf("10/min over 30s = %d; want 5", got)
}
if got := startAllowance(1, &half, now); got != 1 {
t.Fatalf("a slow rate gave %d; want at least one", got)
}
long := now.Add(-time.Hour)
if got := startAllowance(10, &long, now); got != 10 {
t.Fatalf("an idle hour gave %d; want at most a minute's worth", got)
}
}
func TestCreateBatchHoldsMoreSendersThanRunAtOnce(t *testing.T) {
h, fb := fleetHarness(t, 1300)
view, xerr := h.svc.CreateBatch(context.Background(), h.batchInput())
if xerr != nil {
t.Fatalf("CreateBatch: %v", xerr)
}
if view.SenderCount != 1300 || len(fb.senders) != 1300 || view.Status != models.PlacementBatchQueued {
t.Fatalf("batch = %d senders (%d rows), %s; want 1300 queued", view.SenderCount, len(fb.senders), view.Status)
}
if len(h.repo.created) != 0 {
t.Fatalf("creating the batch started %d tests; want none until the runner", len(h.repo.created))
}
pol := h.svc.policy(context.Background())
pol.BatchSenderConcurrency, pol.BatchStartsPerMinute = 20, 600
h.svc.Policy = fakePolicy{pol}
h.svc.runBatches(context.Background())
if len(h.repo.created) != 20 {
t.Fatalf("one pass started %d tests; want the concurrency of 20", len(h.repo.created))
}
seen := map[uuid.UUID]bool{}
for _, test := range h.repo.created {
if test.BatchID == nil || *test.BatchID != view.ID || test.BatchSenderID == nil || test.Origin != models.PlacementOriginBatch {
t.Fatalf("test %+v is not tied to the batch", test)
}
if seen[*test.SenderAccountID] {
t.Fatalf("sender %s started twice", test.SenderAccountID)
}
seen[*test.SenderAccountID] = true
}
// The same 20 are still sending: nothing more starts.
h.svc.runBatches(context.Background())
if len(h.repo.created) != 20 {
t.Fatalf("a second pass started %d tests while 20 were sending", len(h.repo.created)-20)
}
}
func TestCreateBatchRefusals(t *testing.T) {
cases := []struct {
name string
setup func(*harness, *fakeBatches, *BatchInput)
wantID string
}{
{"too many senders for the instance", func(h *harness, _ *fakeBatches, in *BatchInput) {
pol := h.svc.policy(context.Background())
pol.BatchSendersMax = 10
h.svc.Policy = fakePolicy{pol}
}, "placement_batch_too_large"},
{"too many open batches", func(_ *harness, fb *fakeBatches, _ *BatchInput) { fb.open = 5 }, "placement_too_many_batches"},
{"nothing matches", func(_ *harness, _ *fakeBatches, in *BatchInput) { in.Scope.Domains = []string{"nowhere.test"} }, "placement_batch_empty"},
{"both ids and a scope", func(h *harness, _ *fakeBatches, in *BatchInput) { in.SenderAccountIDs = []uuid.UUID{h.sender} }, ""},
{"another workspace's mailbox", func(_ *harness, _ *fakeBatches, in *BatchInput) {
in.Scope, in.SenderAccountIDs = nil, []uuid.UUID{uuid.New()}
}, ""},
{"a stratified per-domain sample", func(_ *harness, _ *fakeBatches, in *BatchInput) {
in.Sample = models.PlacementSample{Mode: models.PlacementSamplePerDomain, Count: 2, Stratify: "provider"}
}, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h, fb := fleetHarness(t, 40)
in := h.batchInput()
c.setup(h, fb, &in)
_, xerr := h.svc.CreateBatch(context.Background(), in)
if xerr == nil {
t.Fatalf("CreateBatch succeeded; want a refusal")
}
if c.wantID != "" && xerr.Identifier != c.wantID {
t.Fatalf("refused with %q (%s); want %q", xerr.Identifier, xerr.Message, c.wantID)
}
if len(fb.batches) != 0 {
t.Fatalf("a refused batch was written")
}
})
}
}
func TestCreateBatchDedupesChosenSendersAndHonoursAKeysMailboxes(t *testing.T) {
h, fb := fleetHarness(t, 5)
ids := []uuid.UUID{fb.cands[0].ID, fb.cands[1].ID, fb.cands[0].ID}
in := h.batchInput()
in.Scope, in.SenderAccountIDs = nil, ids
view, xerr := h.svc.CreateBatch(context.Background(), in)
if xerr != nil {
t.Fatalf("CreateBatch: %v", xerr)
}
if view.SenderCount != 2 {
t.Fatalf("batch has %d senders; want the duplicate dropped", view.SenderCount)
}
in = h.batchInput()
in.AllowedSenders = []uuid.UUID{fb.cands[2].ID}
view, xerr = h.svc.CreateBatch(context.Background(), in)
if xerr != nil {
t.Fatalf("CreateBatch: %v", xerr)
}
if view.SenderCount != 1 {
t.Fatalf("a key limited to one mailbox resolved %d senders", view.SenderCount)
}
in = h.batchInput()
in.Scope, in.SenderAccountIDs, in.AllowedSenders = nil, []uuid.UUID{fb.cands[3].ID}, []uuid.UUID{fb.cands[2].ID}
if _, xerr := h.svc.CreateBatch(context.Background(), in); xerr == nil {
t.Fatalf("a key named a mailbox it may not send from and the batch was created")
}
}
func TestBatchSenderWithoutDailyHeadroomIsDeferredOrSkipped(t *testing.T) {
for _, policy := range []string{models.PlacementUnavailableDefer, models.PlacementUnavailableSkip} {
t.Run(policy, func(t *testing.T) {
h, fb := fleetHarness(t, 3)
h.tasks.sentToday = 49
in := h.batchInput()
in.OnUnavailable = policy
view, xerr := h.svc.CreateBatch(context.Background(), in)
if xerr != nil {
t.Fatalf("CreateBatch: %v", xerr)
}
h.svc.runBatches(context.Background())
for _, s := range fb.senders {
if s.Reason != "placement_daily_budget" {
t.Fatalf("sender %s: reason %q; want the daily budget", s.SenderEmail, s.Reason)
}
switch policy {
case models.PlacementUnavailableDefer:
if s.Status != models.PlacementSenderDeferred || !s.NextAttemptAt.After(time.Now().Add(time.Minute)) {
t.Fatalf("sender %s is %s until %v; want deferred to the next sending day", s.SenderEmail, s.Status, s.NextAttemptAt)
}
default:
if s.Status != models.PlacementSenderSkipped {
t.Fatalf("sender %s is %s; want skipped", s.SenderEmail, s.Status)
}
}
}
b := fb.batches[view.ID]
if policy == models.PlacementUnavailableSkip && (b.Active || b.Status != models.PlacementBatchFailed) {
t.Fatalf("an all-skipped batch is %s (active %v); want failed", b.Status, b.Active)
}
if policy == models.PlacementUnavailableDefer && !b.Active {
t.Fatalf("a deferred batch closed; want it to wait for tomorrow")
}
})
}
}
func TestBatchStopsWhenTheAllowanceRunsOut(t *testing.T) {
h, fb := fleetHarness(t, 4)
in := h.batchInput()
if _, xerr := h.svc.CreateBatch(context.Background(), in); xerr != nil {
t.Fatalf("CreateBatch: %v", xerr)
}
// Between creation and the run, another test used the allowance up.
t.Setenv("DEPLOYMENT_MODE", "cloud")
h.repo.metered = 1000
h.svc.runBatches(context.Background())
for _, s := range fb.senders {
if s.Status != models.PlacementSenderSkipped || s.Reason != "placement_quota_exceeded" {
t.Fatalf("sender %s is %s (%s); want every sender skipped for the allowance", s.SenderEmail, s.Status, s.Reason)
}
}
if len(h.repo.created) != 0 {
t.Fatalf("%d tests started past the allowance", len(h.repo.created))
}
}
func TestFinishedBatchStatus(t *testing.T) {
for _, c := range []struct {
name string
statuses []string
want string
}{
{"all completed", []string{"completed", "completed"}, models.PlacementBatchCompleted},
{"some skipped", []string{"completed", "skipped"}, models.PlacementBatchCompletedWithWarnings},
{"none completed", []string{"failed", "skipped"}, models.PlacementBatchFailed},
} {
t.Run(c.name, func(t *testing.T) {
h, fb := fleetHarness(t, len(c.statuses))
view, xerr := h.svc.CreateBatch(context.Background(), h.batchInput())
if xerr != nil {
t.Fatalf("CreateBatch: %v", xerr)
}
for i, s := range fb.senders {
s.Status = c.statuses[i]
}
h.svc.runBatches(context.Background())
if got := fb.batches[view.ID].Status; got != c.want {
t.Fatalf("batch finished %s; want %s", got, c.want)
}
})
}
}
func TestBuildBatchDetailGroupsWorstFirst(t *testing.T) {
rows := []repository.PlacementBreakdownRow{
{Set: "overall", Tracked: true, Folder: "inbox", Count: 6},
{Set: "overall", Tracked: true, Folder: "spam", Count: 4},
{Set: "overall", Tracked: false, Folder: "inbox", Count: 9},
{Set: "domain", SenderDomain: "good.test", Folder: "inbox", Count: 5},
{Set: "domain", SenderDomain: "bad.test", Folder: "inbox", Count: 1},
{Set: "domain", SenderDomain: "bad.test", Folder: "spam", Count: 4},
{Set: "provider", SenderFamily: "google_workspace", Folder: "inbox", Count: 6},
{Set: "recipient", RecipientFamily: "gmail", Folder: "inbox", Count: 6},
{Set: "matrix", SenderDomain: "bad.test", RecipientFamily: "gmail", Folder: "spam", Count: 4},
}
sizes := []repository.PlacementBatchGroupSize{
{ByDomain: true, Domain: "good.test", Senders: 2, Completed: 2},
{ByDomain: true, Domain: "bad.test", Senders: 3, Completed: 3},
{Family: "google_workspace", Senders: 5, Completed: 5},
}
v := BatchView{PlacementBatch: models.PlacementBatch{Tracking: models.PlacementTrackingCompare}}
d := buildBatchDetail(v, rows, sizes)
if d.Summary.Inbox != 6 || d.Summary.Spam != 4 || d.Untracked == nil || d.Untracked.Inbox != 9 {
t.Fatalf("summary %+v untracked %+v; want the tracked half as headline", d.Summary, d.Untracked)
}
if len(d.Domains) != 2 || d.Domains[0].Key != "bad.test" || d.Domains[0].Senders != 3 {
t.Fatalf("domains = %+v; want bad.test first with its 3 senders", d.Domains)
}
if len(d.Matrix) != 1 || d.Matrix[0].Domain != "bad.test" || d.Matrix[0].Recipients[0].Counts.Spam != 4 {
t.Fatalf("matrix = %+v", d.Matrix)
}
if len(d.Providers) != 1 || d.Providers[0].Senders != 5 {
t.Fatalf("providers = %+v", d.Providers)
}
}
func TestBatchRespectsTheInstanceWideLimit(t *testing.T) {
h, _ := fleetHarness(t, 50)
if _, xerr := h.svc.CreateBatch(context.Background(), h.batchInput()); xerr != nil {
t.Fatalf("CreateBatch: %v", xerr)
}
pol := h.svc.policy(context.Background())
pol.BatchSenderConcurrency, pol.BatchInstanceConcurrency, pol.BatchStartsPerMinute = 20, 5, 600
h.svc.Policy = fakePolicy{pol}
h.svc.runBatches(context.Background())
if len(h.repo.created) != 5 {
t.Fatalf("started %d tests; want the instance-wide limit of 5 below the workspace's 20", len(h.repo.created))
}
for _, test := range h.repo.created {
if test.Pace != models.PlacementPaceSpaced {
t.Fatalf("a batch test runs %s; want spaced", test.Pace)
}
}
}
func TestBatchRefusesQuickPace(t *testing.T) {
h, _ := fleetHarness(t, 3)
in := h.batchInput()
in.Pace = models.PlacementPaceQuick
if _, xerr := h.svc.CreateBatch(context.Background(), in); xerr == nil {
t.Fatalf("a quick batch was created")
}
}
func TestPreviewCountsSendersBeforeTheCopyIsChosen(t *testing.T) {
h, _ := fleetHarness(t, 12)
in := h.batchInput()
in.Subject, in.BodyPlain, in.Tracking = "", "", models.PlacementTrackingCompare
p, xerr := h.svc.PreviewBatch(context.Background(), in)
if xerr != nil {
t.Fatalf("PreviewBatch without copy: %v", xerr)
}
if p.Selected != 12 || p.Tests != 24 {
t.Fatalf("preview = %d senders, %d tests; want 12 and 24 for a comparison", p.Selected, p.Tests)
}
if _, xerr := h.svc.CreateBatch(context.Background(), in); xerr == nil {
t.Fatalf("a batch without copy was created")
}
}
+129 -80
View File
@@ -51,6 +51,7 @@ type Entitlements interface {
// satisfies it.
type Publisher interface {
PublishPlacementTest(ctx context.Context, orgID, testID uuid.UUID, campaignID *uuid.UUID, status string)
PublishPlacementBatch(ctx context.Context, orgID, batchID uuid.UUID, status string)
}
// Notifier reaches people about a finished test or a monitor alert.
@@ -99,6 +100,8 @@ type Deps struct {
// Credits pays for tests past the monthly free allowance; nil turns
// paid tests off.
Credits Credits
// Batches stores placement batches; nil turns batches off.
Batches repository.PlacementBatchRepository
}
// Credits is the slice of the credit ledger a paid test uses.
@@ -133,6 +136,14 @@ type Service interface {
RemoteSends(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID, sends []models.PlacementCloudSend) *errx.Error
RemoteGet(ctx context.Context, inst *models.PoolLinkInstance, testID uuid.UUID) (*models.PlacementCloudTest, *errx.Error)
PreviewBatch(ctx context.Context, in BatchInput) (*BatchPreview, *errx.Error)
CreateBatch(ctx context.Context, in BatchInput) (*BatchView, *errx.Error)
ListBatches(ctx context.Context, orgID uuid.UUID, limit, offset int) ([]BatchView, int, *errx.Error)
GetBatch(ctx context.Context, orgID, id uuid.UUID) (*BatchDetail, *errx.Error)
ListBatchSenders(ctx context.Context, orgID, id uuid.UUID, f repository.PlacementBatchSenderFilter) ([]BatchSenderView, int, *errx.Error)
CancelBatch(ctx context.Context, orgID, id uuid.UUID) (*BatchView, *errx.Error)
Coverage(ctx context.Context, orgID uuid.UUID) (*repository.PlacementCoverage, *errx.Error)
// Tick classifies delivered probes, closes finished tests, syncs cloud
// tests and runs due monitors. The poller calls it.
Tick(ctx context.Context) error
@@ -172,6 +183,9 @@ type CreateInput struct {
MaxCredits int
Origin string
MonitorID *uuid.UUID
// BatchID and BatchSenderID tie a batch's test to its sender row.
BatchID *uuid.UUID
BatchSenderID *uuid.UUID
}
func (s *service) policy(ctx context.Context) instancesettings.Placement {
@@ -245,86 +259,17 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView,
return nil, placementErr(errx.Conflict, "placement_sender_unavailable", "A seed mailbox receives tests; it cannot send one.")
}
// The copy: a campaign step (snapshotted now, so an edit mid-test does not
// change what the later seeds get) or an ad-hoc template.
var campaign *models.Campaign
if in.CampaignID != nil {
c, err := s.Campaigns.GetByID(ctx, *in.CampaignID)
if err != nil || c == nil || c.OrganizationID == nil || *c.OrganizationID != in.OrgID {
return nil, errx.New(errx.NotFound, "campaign not found")
}
campaign = c
if in.SequenceID != nil {
seq := s.campaignStep(ctx, c.ID, *in.SequenceID)
if seq == nil {
return nil, errx.New(errx.NotFound, "campaign step not found")
}
if strings.TrimSpace(in.Subject) == "" && in.BodyHTML == "" && in.BodyPlain == "" {
in.Subject, in.BodyHTML, in.BodyPlain = seq.Subject, seq.BodyHTML, seq.BodyPlain
}
if strings.TrimSpace(in.Subject) == "" {
in.Subject = s.threadSubject(ctx, c.ID, seq)
}
}
} else if in.SequenceID != nil {
return nil, errx.New(errx.BadRequest, "sequence_id needs campaign_id")
spec := copySpec{
CampaignID: in.CampaignID, SequenceID: in.SequenceID, ContactID: in.ContactID,
Subject: in.Subject, BodyHTML: in.BodyHTML, BodyPlain: in.BodyPlain, Panel: in.Panel,
}
// A campaign test renders for the campaign's first lead unless told
// otherwise, so merge fields and AI blocks read as a lead would get them.
// Not on the cloud panel: those copies land in another operator's inboxes,
// so a real lead's details go there only when someone chose that lead.
if in.ContactID == nil && campaign != nil && in.Panel != models.PlacementPanelCloud {
if lead, err := s.Repo.SampleLead(ctx, campaign.ID); err == nil {
in.ContactID = lead
}
if xerr := s.resolveCopy(ctx, in.OrgID, &spec); xerr != nil {
return nil, xerr
}
if in.ContactID != nil {
found, xerr := s.contactsInOrg(ctx, in.OrgID, *in.ContactID)
if xerr != nil {
return nil, xerr
}
if !found {
return nil, errx.New(errx.NotFound, "contact not found")
}
}
in.Subject = strings.TrimSpace(in.Subject)
if in.Subject == "" {
return nil, errx.New(errx.BadRequest, "subject is required")
}
if !mailhtml.HasContent(in.BodyHTML) && strings.TrimSpace(in.BodyPlain) == "" {
return nil, errx.New(errx.BadRequest, "a plain-text or HTML body is required")
}
if len(in.Subject) > config.SequenceSubjectLimit*4 || len(in.BodyHTML)+len(in.BodyPlain) > config.SequenceBodyLimit*4 {
return nil, errx.New(errx.BadRequest, "the template is too long")
}
// Tracking, resolved to what each test's copies carry.
textOnly := campaign != nil && campaign.TextOnly
campOpen, campLink := false, false
if campaign != nil {
campOpen, campLink = campaign.OpenTracking, campaign.LinkTracking
}
type variant struct{ open, link bool }
var variants []variant
switch in.Tracking {
case models.PlacementTrackingCampaign:
variants = []variant{{campOpen, campLink}}
case models.PlacementTrackingOn:
variants = []variant{{true, true}}
case models.PlacementTrackingOff:
variants = []variant{{false, false}}
case models.PlacementTrackingCompare:
tracked := variant{campOpen, campLink}
if !tracked.open && !tracked.link {
tracked = variant{true, true}
}
variants = []variant{{false, false}, tracked}
}
if textOnly && (in.Tracking == models.PlacementTrackingOn || in.Tracking == models.PlacementTrackingCompare) {
return nil, placementErr(errx.BadRequest, "placement_invalid_tracking", "This campaign sends plain text, which carries no tracking to compare.")
}
if textOnly {
variants = []variant{{false, false}}
in.ContactID, in.Subject, in.BodyHTML, in.BodyPlain = spec.ContactID, spec.Subject, spec.BodyHTML, spec.BodyPlain
variants, xerr := trackingVariants(in.Tracking, spec.campaign)
if xerr != nil {
return nil, xerr
}
// Limits: tests in flight, the sender's own queue, the monthly allowance.
@@ -357,7 +302,7 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView,
}
if free := usage.Remaining(); free >= 0 && free < len(variants) {
paid, price = len(variants)-free, usage.CreditsPerTest
if price == 0 || in.Origin != models.PlacementOriginManual {
if price == 0 || (in.Origin != models.PlacementOriginManual && in.Origin != models.PlacementOriginBatch) {
return nil, placementErr(errx.PaymentRequired, "placement_quota_exceeded",
"This workspace has used its free placement tests for the month.")
}
@@ -505,6 +450,8 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView,
SequenceID: in.SequenceID,
ContactID: in.ContactID,
MonitorID: in.MonitorID,
BatchID: in.BatchID,
BatchSenderID: in.BatchSenderID,
Subject: in.Subject,
BodyHTML: in.BodyHTML,
BodyPlain: in.BodyPlain,
@@ -586,6 +533,106 @@ func (s *service) CreateTests(ctx context.Context, in CreateInput) ([]TestView,
return views, nil
}
// copySpec is the email a test sends and where it comes from.
type copySpec struct {
CampaignID, SequenceID, ContactID *uuid.UUID
Subject, BodyHTML, BodyPlain string
Panel string
campaign *models.Campaign
}
// resolveCopy checks that a test's copy belongs to the workspace and fills it
// in from the campaign step.
func (s *service) resolveCopy(ctx context.Context, orgID uuid.UUID, c *copySpec) *errx.Error {
// The copy: a campaign step (snapshotted now, so an edit mid-test does not
// change what the later seeds get) or an ad-hoc template.
if c.CampaignID != nil {
campaign, err := s.Campaigns.GetByID(ctx, *c.CampaignID)
if err != nil || campaign == nil || campaign.OrganizationID == nil || *campaign.OrganizationID != orgID {
return errx.New(errx.NotFound, "campaign not found")
}
c.campaign = campaign
if c.SequenceID != nil {
seq := s.campaignStep(ctx, campaign.ID, *c.SequenceID)
if seq == nil {
return errx.New(errx.NotFound, "campaign step not found")
}
if strings.TrimSpace(c.Subject) == "" && c.BodyHTML == "" && c.BodyPlain == "" {
c.Subject, c.BodyHTML, c.BodyPlain = seq.Subject, seq.BodyHTML, seq.BodyPlain
}
if strings.TrimSpace(c.Subject) == "" {
c.Subject = s.threadSubject(ctx, campaign.ID, seq)
}
}
} else if c.SequenceID != nil {
return errx.New(errx.BadRequest, "sequence_id needs campaign_id")
}
// A campaign test renders for the campaign's first lead unless told
// otherwise, so merge fields and AI blocks read as a lead would get them.
// Not on the cloud panel: those copies land in another operator's inboxes,
// so a real lead's details go there only when someone chose that lead.
if c.ContactID == nil && c.campaign != nil && c.Panel != models.PlacementPanelCloud {
if lead, err := s.Repo.SampleLead(ctx, c.campaign.ID); err == nil {
c.ContactID = lead
}
}
if c.ContactID != nil {
found, xerr := s.contactsInOrg(ctx, orgID, *c.ContactID)
if xerr != nil {
return xerr
}
if !found {
return errx.New(errx.NotFound, "contact not found")
}
}
c.Subject = strings.TrimSpace(c.Subject)
if c.Subject == "" {
return errx.New(errx.BadRequest, "subject is required")
}
if !mailhtml.HasContent(c.BodyHTML) && strings.TrimSpace(c.BodyPlain) == "" {
return errx.New(errx.BadRequest, "a plain-text or HTML body is required")
}
if len(c.Subject) > config.SequenceSubjectLimit*4 || len(c.BodyHTML)+len(c.BodyPlain) > config.SequenceBodyLimit*4 {
return errx.New(errx.BadRequest, "the template is too long")
}
return nil
}
// variant is the tracking one test's copies carry.
type variant struct{ open, link bool }
// trackingVariants resolves a tracking choice to the tests it runs: one, or
// two for a comparison.
func trackingVariants(tracking string, campaign *models.Campaign) ([]variant, *errx.Error) {
textOnly := campaign != nil && campaign.TextOnly
campOpen, campLink := false, false
if campaign != nil {
campOpen, campLink = campaign.OpenTracking, campaign.LinkTracking
}
var variants []variant
switch tracking {
case models.PlacementTrackingCampaign:
variants = []variant{{campOpen, campLink}}
case models.PlacementTrackingOn:
variants = []variant{{true, true}}
case models.PlacementTrackingOff:
variants = []variant{{false, false}}
case models.PlacementTrackingCompare:
tracked := variant{campOpen, campLink}
if !tracked.open && !tracked.link {
tracked = variant{true, true}
}
variants = []variant{{false, false}, tracked}
}
if textOnly && (tracking == models.PlacementTrackingOn || tracking == models.PlacementTrackingCompare) {
return nil, placementErr(errx.BadRequest, "placement_invalid_tracking", "This campaign sends plain text, which carries no tracking to compare.")
}
if textOnly {
variants = []variant{{false, false}}
}
return variants, nil
}
// remainingSends is what is left of the sender's daily campaign limit,
// counting campaign sends and probes already queued today.
func (s *service) remainingSends(ctx context.Context, sender *models.Email) (int, *errx.Error) {
@@ -908,8 +955,10 @@ func (s *service) CancelTest(ctx context.Context, orgID, id uuid.UUID) (*TestVie
// ListTests lists tests newest first; a nil orgID lists every workspace's.
func (s *service) ListTests(ctx context.Context, orgID *uuid.UUID, campaignID *uuid.UUID, limit, offset int) ([]TestView, int, *errx.Error) {
// The operator's list shows every test; a workspace's leaves batch tests
// to their batch.
tests, total, err := s.Repo.ListTests(ctx, repository.PlacementTestFilter{
OrganizationID: orgID, CampaignID: campaignID, Limit: limit, Offset: offset,
OrganizationID: orgID, CampaignID: campaignID, IncludeBatch: orgID == nil, Limit: limit, Offset: offset,
})
if err != nil {
errs.CaptureException(err)
+3 -1
View File
@@ -16,7 +16,8 @@ import (
)
// Tick is one pass of the poller: read verdicts, expire what never arrived,
// sync the cloud's panel, close finished tests and start due monitors.
// sync the cloud's panel, close finished tests, start due monitors and
// advance batches.
func (s *service) Tick(ctx context.Context) error {
now := s.now()
touched := map[uuid.UUID]bool{}
@@ -75,6 +76,7 @@ func (s *service) Tick(ctx context.Context) error {
}
s.runMonitors(ctx)
s.runBatches(ctx)
return nil
}
+10
View File
@@ -33,6 +33,8 @@ type Config struct {
WorkerImageRepo string // "ghcr.io/warmbly/warmbly/worker"
GithubToken string // optional, raises API rate limit
HTTPClient *http.Client
// SchemaGate refuses a tag whose bus schemas are not registered; nil allows any.
SchemaGate func(ctx context.Context, tag string) error
}
type Service struct {
@@ -139,6 +141,14 @@ func (s *Service) CheckGitHub(ctx context.Context) (*models.FleetReleaseState, e
return current, nil
}
if s.cfg.SchemaGate != nil {
if err := s.cfg.SchemaGate(ctx, head.TagName); err != nil {
s.recordError(err.Error())
log.Printf("releases: fleet held at %s: %v", current.Tag, err)
return current, nil
}
}
next := &models.FleetReleaseState{
Channel: current.Channel,
Tag: head.TagName,
+60
View File
@@ -0,0 +1,60 @@
package releases
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/warmbly/warmbly/internal/models"
)
type memSettings struct{ release *models.FleetReleaseState }
func (m *memSettings) GetRelease(context.Context) (*models.FleetReleaseState, error) {
return m.release, nil
}
func (m *memSettings) SetRelease(_ context.Context, s *models.FleetReleaseState) error {
m.release = s
return nil
}
func (m *memSettings) GetJoinTokenHash(context.Context) (string, error) { return "", nil }
func (m *memSettings) SetJoinTokenHash(context.Context, string) error { return nil }
type githubStub struct{}
func (githubStub) RoundTrip(*http.Request) (*http.Response, error) {
body := `[{"tag_name":"v2.0.0","published_at":"2026-09-29T12:00:00Z"}]`
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}}, nil
}
func TestCheckGitHubHoldsTheFleetWhenTheSchemaGateRefuses(t *testing.T) {
for _, tc := range []struct {
name string
gate func(context.Context, string) error
want string
}{
{"refused", func(context.Context, string) error { return errors.New("incompatible") }, "v1.0.0"},
{"accepted", func(context.Context, string) error { return nil }, "v2.0.0"},
} {
t.Run(tc.name, func(t *testing.T) {
settings := &memSettings{release: &models.FleetReleaseState{Channel: models.FleetChannelStable, Tag: "v1.0.0"}}
svc := New(Config{
Enabled: true,
GithubRepo: "warmbly/warmbly",
HTTPClient: &http.Client{Transport: githubStub{}},
SchemaGate: tc.gate,
}, settings)
if _, err := svc.CheckGitHub(context.Background()); err != nil {
t.Fatal(err)
}
if settings.release.Tag != tc.want {
t.Fatalf("fleet target %s, want %s", settings.release.Tag, tc.want)
}
})
}
}
+8 -5
View File
@@ -283,12 +283,13 @@ func (s *service) CountAudience(ctx context.Context, orgID uuid.UUID, segmentIDs
return s.repo.CountAudience(ctx, orgID, ids, campaignID)
}
func parseContactIDs(raw []string) ([]uuid.UUID, *errx.Error) {
func parseContactIDs(raw []string, max int) ([]uuid.UUID, *errx.Error) {
if len(raw) == 0 {
return nil, errx.New(errx.BadRequest, "no contacts provided")
}
if len(raw) > 1000 {
return nil, errx.New(errx.BadRequest, "at most 1000 contacts per request")
if len(raw) > max {
return nil, errx.NewWithIdentifier(errx.BadRequest, "too_many_contacts",
fmt.Sprintf("too many contacts, maximum is %d per request", max))
}
out := make([]uuid.UUID, 0, len(raw))
for _, r := range raw {
@@ -307,7 +308,9 @@ func (s *service) SetMembers(ctx context.Context, orgID, id uuid.UUID, in *model
default:
return 0, errx.New(errx.BadRequest, "mode must be include, exclude or auto")
}
ids, xerr := parseContactIDs(in.Contacts)
// The handler already bounded the selection by its shape; this is the
// ceiling for callers that pass ids straight in.
ids, xerr := parseContactIDs(in.Contacts, models.MaxContactBulkSelection)
if xerr != nil {
return 0, xerr
}
@@ -326,7 +329,7 @@ func (s *service) SetMembers(ctx context.Context, orgID, id uuid.UUID, in *model
}
func (s *service) MemberModes(ctx context.Context, orgID, id uuid.UUID, contactIDs []string) (map[uuid.UUID]models.SegmentMemberMode, *errx.Error) {
ids, xerr := parseContactIDs(contactIDs)
ids, xerr := parseContactIDs(contactIDs, models.MaxContactBatchIDs)
if xerr != nil {
return nil, xerr
}
+9 -9
View File
@@ -59,10 +59,10 @@ const (
minComplaintSample = 100
// Tampering: harm done to warmup mail the mailbox received, as weighted
// strikes over the seven-day window (a deletion is one, a spam flag two).
// One deletion is housekeeping until proven otherwise, so it only warns;
// the ladder climbs from there and every step lapses on its own.
// Tampering: harm done to warmup mail the mailbox received, one strike per
// deletion or spam move over the seven-day window. One is housekeeping or a
// provider's filter until proven otherwise, so it only warns; the ladder
// climbs from there and every step lapses on its own.
tamperingWatchStrikes = 1
tamperingQuarantineStrikes = 2
tamperingBlockStrikes = 4
@@ -446,7 +446,7 @@ func tamperingVerb(kind string) string {
case "deletion":
return "deleted"
case "spam_flag":
return "marked as spam"
return "moved to spam"
default:
return "tampered with"
}
@@ -464,7 +464,7 @@ func tamperingKind(m *models.WarmupHealthMetrics) string {
func tamperingSummary(m *models.WarmupHealthMetrics) string {
parts := []string{}
if m.SpamFlagsLast7d > 0 {
parts = append(parts, fmt.Sprintf("%d warmup %s marked as spam", m.SpamFlagsLast7d, plural(m.SpamFlagsLast7d, "email", "emails")))
parts = append(parts, fmt.Sprintf("%d warmup %s moved to spam", m.SpamFlagsLast7d, plural(m.SpamFlagsLast7d, "email", "emails")))
}
if m.DeletionsLast7d > 0 {
parts = append(parts, fmt.Sprintf("%d warmup %s deleted", m.DeletionsLast7d, plural(m.DeletionsLast7d, "email", "emails")))
@@ -769,9 +769,9 @@ func moreSevere(a, b evaluationDecision) evaluationDecision {
return a
}
// evaluateTampering needs no sample: each strike is one deliberate act on mail
// the mailbox verifiably received. A single deletion only warns, because the
// most likely cause is someone tidying the folder by hand. A deletion is only
// evaluateTampering needs no sample: each strike is one act on mail the mailbox
// verifiably received. A single one only warns, because the likeliest cause is
// someone tidying the folder or a provider filing it as spam. A deletion is only
// recorded inside config.WarmupDeletionStrikeHours of arrival, and only once a
// search of the mailbox found the message in the trash or gone.
func evaluateTampering(metrics *models.WarmupHealthMetrics, now time.Time) evaluationDecision {
+5 -4
View File
@@ -187,10 +187,11 @@ func TestEvaluateMetricsTamperingLadder(t *testing.T) {
{"nothing", 0, 0, models.WarmupHealthHealthy, 0},
{"one deletion warns", 1, 0, models.WarmupHealthWatch, 0},
{"two deletions pause", 2, 0, models.WarmupHealthQuarantined, warmupQuarantineDuration},
{"one spam flag pauses", 0, 1, models.WarmupHealthQuarantined, warmupQuarantineDuration},
{"one spam flag warns", 0, 1, models.WarmupHealthWatch, 0},
{"two spam flags pause", 0, 2, models.WarmupHealthQuarantined, warmupQuarantineDuration},
{"four deletions block", 4, 0, models.WarmupHealthBlocked, warmupBlockDuration},
{"two spam flags block", 0, 2, models.WarmupHealthBlocked, warmupBlockDuration},
{"a flag and two deletions block", 2, 1, models.WarmupHealthBlocked, warmupBlockDuration},
{"four spam flags block", 0, 4, models.WarmupHealthBlocked, warmupBlockDuration},
{"a flag and three deletions block", 3, 1, models.WarmupHealthBlocked, warmupBlockDuration},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -238,7 +239,7 @@ func TestEvaluateMetricsTamperingCombinesWithRates(t *testing.T) {
{"one deletion does not mask a placement throttle", models.WarmupHealthMetrics{DeletionsLast7d: 1, PlacementSample: 20, SpamPlacementRate: 50}, models.WarmupHealthThrottled, func() *time.Time { u := now.Add(warmupThrottleDuration); return &u }()},
{"a placement watch does not mask a tampering quarantine", models.WarmupHealthMetrics{DeletionsLast7d: 2, PlacementSample: 20, SpamPlacementRate: 10}, models.WarmupHealthQuarantined, &quarantine},
{"a complaint-rate quarantine does not mask a tampering block", models.WarmupHealthMetrics{DeletionsLast7d: 4, DeliveredLast30d: 100, ComplaintRate: complaintRateQuarantinePct}, models.WarmupHealthBlocked, &block},
{"a bounce-rate quarantine does not mask a tampering block", models.WarmupHealthMetrics{SpamFlagsLast7d: 2, DeliveredLast30d: 100, BounceRate: bounceRateQuarantinePct}, models.WarmupHealthBlocked, &block},
{"a bounce-rate quarantine does not mask a tampering block", models.WarmupHealthMetrics{SpamFlagsLast7d: 4, DeliveredLast30d: 100, BounceRate: bounceRateQuarantinePct}, models.WarmupHealthBlocked, &block},
{"a warmup-complaint quarantine does not mask a tampering block", models.WarmupHealthMetrics{DeletionsLast7d: 4, SentLast7d: 20, WarmupComplaintRate: warmupComplaintQuarantinePct}, models.WarmupHealthBlocked, &block},
{"a placement throttle does not mask a tampering quarantine", models.WarmupHealthMetrics{DeletionsLast7d: 2, PlacementSample: 20, SpamPlacementRate: spamPlacementThrottlePct}, models.WarmupHealthQuarantined, &quarantine},
{"heavy placement does not soften a tampering block", models.WarmupHealthMetrics{DeletionsLast7d: 4, PlacementSample: 20, SpamPlacementRate: 90}, models.WarmupHealthBlocked, &block},
+71 -6
View File
@@ -2,8 +2,13 @@ package wmail
import (
"context"
"errors"
"slices"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/client/goog"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
@@ -62,15 +67,15 @@ func translateGmailLabels(labelIDs []string, added bool) (addFlags, removeFlags
return addFlags, removeFlags
}
func (w *WMail) onGoogleMessageLabelsAdded(ctx context.Context, messageID string, labelIDs []string) error {
return w.emitGoogleFlagEvents(ctx, messageID, labelIDs, true)
func (w *WMail) onGoogleMessageLabelsAdded(ctx context.Context, messageID string, changed, current []string) error {
return w.emitGoogleLabelEvents(ctx, messageID, changed, current, true)
}
func (w *WMail) onGoogleMessageLabelsRemoved(ctx context.Context, messageID string, labelIDs []string) error {
return w.emitGoogleFlagEvents(ctx, messageID, labelIDs, false)
func (w *WMail) onGoogleMessageLabelsRemoved(ctx context.Context, messageID string, changed, current []string) error {
return w.emitGoogleLabelEvents(ctx, messageID, changed, current, false)
}
func (w *WMail) emitGoogleFlagEvents(ctx context.Context, messageID string, labelIDs []string, added bool) error {
func (w *WMail) emitGoogleLabelEvents(ctx context.Context, messageID string, changed, current []string, added bool) error {
internalMessage, err := w.EmailMessageMapRepository.Get(ctx, w.UserID, w.ID, messageID)
if err != nil {
return err
@@ -85,7 +90,7 @@ func (w *WMail) emitGoogleFlagEvents(ctx context.Context, messageID string, labe
return err
}
addFlags, removeFlags := translateGmailLabels(labelIDs, added)
addFlags, removeFlags := translateGmailLabels(changed, added)
if len(addFlags) > 0 {
if err := w.onEvent(models.JobEventTypeFlagsAdd, &models.JobEventFlags{
@@ -108,5 +113,65 @@ func (w *WMail) emitGoogleFlagEvents(ctx context.Context, messageID string, labe
}
}
if !slices.ContainsFunc(changed, goog.IsFolderLabel) {
return nil
}
// Archive, Delete, Report spam and Move to inbox are label changes in
// Gmail, so the folder is recomputed from the labels the message now has.
// Labels that contradict the change are not trusted and are looked up.
labels := current
if labels == nil || !labelsReflect(changed, labels, added) {
found := false
labels, found, err = w.GoogleData.Client.MessageLabels(ctx, messageID)
if err != nil {
// Only a failure the whole walk should retry holds the checkpoint;
// anything else is left to the folder reconciliation.
if gmailRetryable(err) {
return err
}
log.Debug().Err(err).Str("email_id", w.ID.String()).Msg("gmail labels lookup failed; folder left to reconciliation")
return nil
}
if !found {
return nil
}
}
folder := goog.Folder(labels)
if w.googleFolders[messageID] == folder {
return nil
}
if err := w.onEvent(models.JobEventTypeFolderUpdate, &models.JobEventFolderUpdate{
UserID: w.UserID,
EmailID: w.ID,
ID: internalID,
Folder: folder,
}); err != nil {
return err
}
if w.googleFolders == nil {
w.googleFolders = make(map[string]string)
}
w.googleFolders[messageID] = folder
return nil
}
// gmailRetryable reports a Gmail failure that is about the mailbox or the
// connection (auth, throttling, transport) rather than one message.
func gmailRetryable(err error) bool {
var merr *errx.MailError
if !errors.As(err, &merr) {
return true
}
return merr.Type == errx.MailErrorCritical || merr.Code == errx.MailErrorCodeSendingTooFast || isTransportError(merr)
}
// labelsReflect reports whether labels already show the folder labels in
// changed as added (or removed).
func labelsReflect(changed, labels []string, added bool) bool {
for _, l := range changed {
if goog.IsFolderLabel(l) && slices.Contains(labels, l) != added {
return false
}
}
return true
}
@@ -0,0 +1,283 @@
package wmail
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// labelGmail is the Gmail API as the folder paths see it: an inbox listing
// and a minimal messages.get that answers with labels, or 404 when a message
// is not in labels at all.
type labelGmail struct {
inbox []string
labels map[string][]string
refuse map[string]int // messages.get answers this status instead
queries []url.Values
gets []string
}
func (g *labelGmail) serve(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if id, isGet := strings.CutPrefix(r.URL.Path, "/gmail/v1/users/me/messages/"); isGet {
g.gets = append(g.gets, id)
if code, ok := g.refuse[id]; ok {
w.WriteHeader(code)
_, _ = fmt.Fprintf(w, `{"error":{"code":%d,"message":"refused"}}`, code)
return
}
labels, ok := g.labels[id]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":404,"message":"Requested entity was not found."}}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "labelIds": labels})
return
}
g.queries = append(g.queries, r.URL.Query())
msgs := make([]map[string]string, 0, len(g.inbox))
for _, id := range g.inbox {
msgs = append(msgs, map[string]string{"id": id, "threadId": "t-" + id})
}
_ = json.NewEncoder(w).Encode(map[string]any{"messages": msgs})
}))
t.Cleanup(srv.Close)
return srv
}
func folderUpdates(events []captured) map[uuid.UUID]string {
out := map[uuid.UUID]string{}
for _, e := range events {
if e.eventType != models.JobEventTypeFolderUpdate {
continue
}
u := e.body.(*models.JobEventFolderUpdate)
out[u.ID] = u.Folder
}
return out
}
// Archiving in Gmail only removes the INBOX label, and that has to reach the
// platform as a move to archive, not only as a flag nobody files by.
func TestGmailLabelChangeReportsTheFolder(t *testing.T) {
g := &labelGmail{
labels: map[string][]string{
"gone-to-trash": {"TRASH", "UNREAD"},
"stale-record": {"CATEGORY_UPDATES"},
},
refuse: map[string]int{"refused": http.StatusBadRequest},
}
var events []captured
w := newGoogleTestMail(t, g.serve(t), &events)
rowID := uuid.New()
w.EmailMessageMapRepository = knownMessageMap{id: rowID.String()}
cases := []struct {
name string
id string
changed []string
current []string
added bool
want string
}{
{"archive", "m1", []string{"INBOX"}, []string{"CATEGORY_UPDATES"}, false, models.FolderArchive},
{"move to inbox", "m2", []string{"INBOX"}, []string{"INBOX", "UNREAD"}, true, models.FolderInbox},
{"report spam", "m3", []string{"SPAM"}, []string{"SPAM"}, true, models.FolderSpam},
{"labels looked up when the record has none", "gone-to-trash", []string{"TRASH"}, nil, true, models.FolderTrash},
{"labels that contradict the change are looked up", "stale-record", []string{"INBOX"}, []string{"INBOX"}, false, models.FolderArchive},
{"a star moves nothing", "m4", []string{"STARRED"}, []string{"INBOX", "STARRED"}, true, ""},
{"a lookup Gmail refuses leaves the folder to reconciliation", "refused", []string{"INBOX"}, nil, false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
events = nil
if err := w.emitGoogleLabelEvents(t.Context(), tc.id, tc.changed, tc.current, tc.added); err != nil {
t.Fatalf("emit: %v", err)
}
got, moved := folderUpdates(events)[rowID]
if tc.want == "" {
if moved {
t.Fatalf("reported a move to %q for a label that is not a folder", got)
}
return
}
if got != tc.want {
t.Fatalf("folder = %q, want %q", got, tc.want)
}
})
}
if strings.Join(g.gets, ",") != "gone-to-trash,stale-record,refused" {
t.Errorf("looked up %v, want only the records whose labels could not be trusted", g.gets)
}
// Delete in Gmail adds TRASH and removes INBOX: one move, one report.
events = nil
current := []string{"TRASH"}
if err := w.emitGoogleLabelEvents(t.Context(), "m5", []string{"TRASH"}, current, true); err != nil {
t.Fatal(err)
}
if err := w.emitGoogleLabelEvents(t.Context(), "m5", []string{"INBOX"}, current, false); err != nil {
t.Fatal(err)
}
n := 0
for _, e := range events {
if e.eventType == models.JobEventTypeFolderUpdate {
n++
}
}
if n != 1 {
t.Errorf("reported the move %d times, want once", n)
}
}
// providerRows stands in for the control plane's provider-folder listing.
type providerRows struct {
fakeSyncContext
rows []repository.ProviderFolderMessage
calls int
}
func (p *providerRows) ListProviderFolderMessages(_ context.Context, _, _ uuid.UUID, folders []string, _ int) ([]repository.ProviderFolderMessage, error) {
p.calls++
var out []repository.ProviderFolderMessage
for _, r := range p.rows {
if slices.Contains(folders, r.ProviderFolder) {
out = append(out, r)
}
}
return out, nil
}
func removedIDs(events []captured) []uuid.UUID {
var out []uuid.UUID
for _, e := range events {
if e.eventType == models.JobEventTypeRemoveEmail {
out = append(out, e.body.(*models.JobEventRemoveEmail).ID)
}
}
return out
}
// Mail archived in Gmail before labels were followed stays in the unibox
// inbox until the reconciliation finds it.
func TestGmailReconcileMovesWhatGmailMoved(t *testing.T) {
now := time.Now()
row := func(providerID, folder string, age time.Duration) repository.ProviderFolderMessage {
return repository.ProviderFolderMessage{ID: uuid.New(), ProviderID: providerID, ProviderFolder: folder, InternalDate: now.Add(-age)}
}
stillInbox := row("still-inbox", models.FolderInbox, time.Hour)
archived := row("archived", models.FolderInbox, 2*time.Hour)
trashed := row("trashed", models.FolderInbox, 3*time.Hour)
refused := row("refused", models.FolderInbox, 3*time.Hour+30*time.Minute)
deleted := row("deleted", models.FolderInbox, 4*time.Hour)
backToInbox := row("back", models.FolderArchive, 5*time.Hour)
stillArchived := row("old-archive", models.FolderArchive, 6*time.Hour)
g := &labelGmail{
inbox: []string{"still-inbox", "back"},
labels: map[string][]string{
"archived": {"CATEGORY_PERSONAL"},
"trashed": {"TRASH"},
},
refuse: map[string]int{"refused": http.StatusBadRequest},
}
var events []captured
w := newGoogleTestMail(t, g.serve(t), &events)
sc := &providerRows{rows: []repository.ProviderFolderMessage{stillInbox, archived, trashed, refused, deleted, backToInbox, stillArchived}}
w.SyncContext = sc
if merr := w.googleReconcileFolders(t.Context(), now, &tickStats{}); merr != nil {
t.Fatalf("reconcile: %v", merr.Message)
}
want := map[uuid.UUID]string{
archived.ID: models.FolderArchive,
trashed.ID: models.FolderTrash,
backToInbox.ID: models.FolderInbox,
}
got := folderUpdates(events)
if len(got) != len(want) {
t.Fatalf("reported %d moves, want %d: %v", len(got), len(want), got)
}
for id, folder := range want {
if got[id] != folder {
t.Errorf("row %s moved to %q, want %q", id, got[id], folder)
}
}
// Gone from Gmail is removed, as the live feed's delete would.
if got := removedIDs(events); len(got) != 1 || got[0] != deleted.ID {
t.Errorf("removed %v, want only the deleted row", got)
}
// Only inbox rows missing from the listing cost a lookup, and one Gmail
// refuses does not stop the rows after it.
if strings.Join(g.gets, ",") != "archived,trashed,refused,deleted" {
t.Errorf("looked up %v, want archived, trashed, refused, deleted", g.gets)
}
// The bound reaches the oldest row of either set, here an archived one
// older than every inbox row, or its move back to the inbox is never seen.
wantQ := fmt.Sprintf("after:%d", stillArchived.InternalDate.Add(-24*time.Hour).Unix())
if len(g.queries) != 1 || g.queries[0].Get("labelIds") != "INBOX" || g.queries[0].Get("q") != wantQ {
t.Errorf("inbox listing queries = %v, want labelIds=INBOX and q=%s", g.queries, wantQ)
}
// Inside the interval nothing runs; after it, a message already found
// gone is not looked up again.
events, g.gets = nil, nil
if merr := w.googleReconcileFolders(t.Context(), now.Add(time.Minute), &tickStats{}); merr != nil {
t.Fatalf("second pass: %v", merr.Message)
}
if sc.calls != 2 {
t.Errorf("listed stored rows %d times inside the interval, want the first pass's 2", sc.calls)
}
sc.rows = []repository.ProviderFolderMessage{stillInbox, deleted}
if merr := w.googleReconcileFolders(t.Context(), now.Add(7*time.Hour), &tickStats{}); merr != nil {
t.Fatalf("third pass: %v", merr.Message)
}
if len(g.gets) != 0 {
t.Errorf("looked up %v again, want nothing", g.gets)
}
if len(events) != 0 {
t.Errorf("reported %d events for rows already where Gmail has them", len(events))
}
// A day on, the same message is worth one more look.
if merr := w.googleReconcileFolders(t.Context(), now.Add(25*time.Hour), &tickStats{}); merr != nil {
t.Fatalf("fourth pass: %v", merr.Message)
}
if strings.Join(g.gets, ",") != "deleted" {
t.Errorf("looked up %v a day later, want deleted", g.gets)
}
}
// A pass Gmail throttles is tried again soon, not after the full interval.
func TestGmailReconcileRetriesAFailedPassSoon(t *testing.T) {
now := time.Now()
g := &labelGmail{refuse: map[string]int{"throttled": http.StatusTooManyRequests}}
var events []captured
w := newGoogleTestMail(t, g.serve(t), &events)
w.SyncContext = &providerRows{rows: []repository.ProviderFolderMessage{
{ID: uuid.New(), ProviderID: "throttled", ProviderFolder: models.FolderInbox, InternalDate: now},
}}
if merr := w.googleReconcileFolders(t.Context(), now, &tickStats{}); merr == nil {
t.Fatal("a throttled lookup was swallowed")
}
g.gets = nil
if merr := w.googleReconcileFolders(t.Context(), now.Add(20*time.Minute), &tickStats{}); merr == nil || len(g.gets) != 1 {
t.Errorf("20 minutes on the pass did not run again (looked up %v)", g.gets)
}
}
+1
View File
@@ -563,5 +563,6 @@ func MailErrorToSendError(err *errx.MailError) *models.EmailSendError {
UserTitle: userInfo.Title,
UserMessage: userInfo.Message,
ActionRequired: userInfo.ActionRequired,
Recipient: err.Recipient,
}
}
+156
View File
@@ -6,9 +6,13 @@ import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/client/goog"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/repository"
)
// googleBackfillPage is how many ids one messages.list call returns. Small
@@ -22,6 +26,7 @@ func (w *WMail) SyncGoogle(ctx context.Context) *errx.MailError {
w.beginTick()
stats := &tickStats{}
w.googleTick = stats
w.googleFolders = nil
if !w.retryUnmap(ctx) {
return nil
}
@@ -50,6 +55,13 @@ func (w *WMail) SyncGoogle(ctx context.Context) *errx.MailError {
return merr
}
}
if !stats.aborted {
// The pass's own work is done; only a refusal the owner has to act
// on is worth failing it for.
if merr := w.googleReconcileFolders(ctx, time.Now(), stats); merr != nil && merr.Type == errx.MailErrorCritical {
return merr
}
}
w.endTick(stats)
return nil
}
@@ -227,6 +239,150 @@ func (w *WMail) googleBackfill(ctx context.Context, stats *tickStats) *errx.Mail
return nil
}
// reconcileElsewhere is every folder a stored Gmail message can be moved back
// to the inbox from.
var reconcileElsewhere = []string{models.FolderArchive, models.FolderSpam, models.FolderTrash}
// googleReconcileFolders repairs stored mail the history feed did not move:
// changes from before the sync followed labels, and any a checkpoint Gmail
// expired skipped over. Inbox rows missing from Gmail's inbox are looked up;
// every other row only moves when Gmail lists it in the inbox. A pass that
// fails is tried again after GmailFolderReconcileRetry.
func (w *WMail) googleReconcileFolders(ctx context.Context, now time.Time, stats *tickStats) *errx.MailError {
if w.SyncContext == nil || now.Sub(w.googleReconciledAt) < config.GmailFolderReconcileInterval {
return nil
}
w.googleReconciledAt = now.Add(config.GmailFolderReconcileRetry - config.GmailFolderReconcileInterval)
inboxRows, err := w.SyncContext.ListProviderFolderMessages(ctx, w.UserID, w.ID, []string{models.FolderInbox}, config.GmailFolderReconcileMessages)
if err != nil {
return w.controlPlaneError(err, stats)
}
otherRows, err := w.SyncContext.ListProviderFolderMessages(ctx, w.UserID, w.ID, reconcileElsewhere, config.GmailFolderReconcileMessages)
if err != nil {
return w.controlPlaneError(err, stats)
}
if len(inboxRows) == 0 && len(otherRows) == 0 {
w.googleReconciledAt = now
return nil
}
// Each set comes newest first, and the listing has to reach the oldest row
// of either; a day of slack covers Gmail reading after: against its own
// calendar.
var oldest time.Time
for _, rows := range [][]repository.ProviderFolderMessage{inboxRows, otherRows} {
if len(rows) > 0 && (oldest.IsZero() || rows[len(rows)-1].InternalDate.Before(oldest)) {
oldest = rows[len(rows)-1].InternalDate
}
}
q := ""
if after := oldest.Add(-24 * time.Hour); after.Unix() > 0 {
q = fmt.Sprintf("after:%d", after.Unix())
}
inInbox := make(map[string]struct{})
token := ""
for page := 0; page < config.GmailFolderReconcilePages; page++ {
ids, next, err := w.GoogleData.Client.ListLabelMessages(ctx, goog.Inbox, q, token, 500)
if err != nil {
return w.googleReconcileError(err)
}
for _, id := range ids {
inInbox[id] = struct{}{}
}
if next == "" {
break
}
token = next
}
for _, m := range otherRows {
if _, ok := inInbox[m.ProviderID]; !ok {
continue
}
if err := w.emitFolder(m.ID, models.FolderInbox); err != nil {
return w.controlPlaneError(err, stats)
}
}
if w.googleInboxChecked == nil {
w.googleInboxChecked = make(map[string]time.Time)
}
for id, at := range w.googleInboxChecked {
if now.Sub(at) >= config.GmailFolderReconcileRecheck {
delete(w.googleInboxChecked, id)
}
}
lookups := 0
for _, m := range inboxRows {
if _, ok := inInbox[m.ProviderID]; ok {
continue
}
if _, checked := w.googleInboxChecked[m.ProviderID]; checked {
continue
}
if lookups >= config.GmailFolderReconcileLookups {
break
}
lookups++
labels, found, err := w.GoogleData.Client.MessageLabels(ctx, m.ProviderID)
if err != nil {
if gmailRetryable(err) {
return w.googleReconcileError(err)
}
// One message Gmail refuses must not hold up every row after it.
log.Debug().Err(err).Str("email_id", w.ID.String()).Str("gmail_id", m.ProviderID).Msg("gmail folder reconciliation: lookup refused")
w.googleInboxChecked[m.ProviderID] = now
continue
}
if !found {
// Gone from Gmail, which is what the live feed's delete reports.
if err := w.onEvent(models.JobEventTypeRemoveEmail, &models.JobEventRemoveEmail{
UserID: w.UserID,
EmailID: w.ID,
ID: m.ID,
}); err != nil {
return w.controlPlaneError(err, stats)
}
w.googleInboxChecked[m.ProviderID] = now
continue
}
folder := goog.Folder(labels)
if folder == models.FolderInbox {
w.googleInboxChecked[m.ProviderID] = now
continue
}
if err := w.emitFolder(m.ID, folder); err != nil {
return w.controlPlaneError(err, stats)
}
}
w.googleReconciledAt = now
return nil
}
func (w *WMail) emitFolder(id uuid.UUID, folder string) error {
return w.onEvent(models.JobEventTypeFolderUpdate, &models.JobEventFolderUpdate{
UserID: w.UserID,
EmailID: w.ID,
ID: id,
Folder: folder,
})
}
// googleReconcileError returns a Gmail failure to the caller, which fails the
// tick only when it is critical; the rest are captured unless transient.
func (w *WMail) googleReconcileError(err error) *errx.MailError {
var errMail *errx.MailError
if !errors.As(err, &errMail) {
w.CaptureError(err)
return nil
}
if errMail.Type != errx.MailErrorCritical && !gmailRetryable(err) {
w.CaptureError(err)
}
return errMail
}
// NewHistoryID persists the mailbox's Gmail history checkpoint. UserID and
// EmailID address the row: email_history_ids is keyed (user_id, email_id) with
// a foreign key to users, so omitting them sends a zero UUID and the write is
@@ -649,6 +649,10 @@ func (f *fakeSyncContext) ListFolderMessages(_ context.Context, _, _ uuid.UUID,
return f.stored[folderPath], nil
}
func (fakeSyncContext) ListProviderFolderMessages(context.Context, uuid.UUID, uuid.UUID, []string, int) ([]repository.ProviderFolderMessage, error) {
return nil, nil
}
func removeIDs(events []captured) []uuid.UUID {
var out []uuid.UUID
for _, e := range events {

Some files were not shown because too many files have changed in this diff Show More