diff --git a/site/src/pages/sending.astro b/site/src/pages/sending.astro index 1b150413..b79eb62b 100644 --- a/site/src/pages/sending.astro +++ b/site/src/pages/sending.astro @@ -1,189 +1,451 @@ --- import Layout from '../layouts/Layout.astro'; -import Cloud from '../components/Cloud.astro'; +import HeroAtmosphere from '../components/HeroAtmosphere.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; + +// Sourced from CLAUDE.md + cmd/worker/main.go + internal/scheduler/email_scheduler.go. + +const limits = [ + { k: 'Per-mailbox cold cap', v: '100 / day', why: 'Hard cap before provider anomaly heuristics start scoring the mailbox.' }, + { k: 'Per-mailbox gap', v: '600 sec', why: 'Minimum spacing between sends from the same mailbox. Matches human cadence.' }, + { k: 'Worker concentration', v: '~10 mailboxes', why: 'Suggested ceiling for a single worker. Past this we surface a warning and add workers.' }, + { k: 'Worker daily budget', v: 'Σ mailbox caps', why: 'No flat per-worker ceiling. Volume scales by adding mailboxes, not by cranking one.' }, + { k: 'Send window', v: 'Recipient TZ', why: 'Business hours in the recipient time zone, not the sender\'s.' }, + { k: 'Worker dependencies', v: 'No Postgres', why: 'Workers only open Kafka, Redis, KMS, DynamoDB and S3 clients. SQL stays in the control plane.' }, +]; + +// Topology mock for the floating panel below the hero +const workers = [ + { id: 'w-01', region: 'us-east-1a', ip: '192.0.2.14', mailboxes: 8, status: 'healthy' }, + { id: 'w-02', region: 'us-east-1b', ip: '192.0.2.42', mailboxes: 10, status: 'healthy' }, + { id: 'w-03', region: 'us-east-1c', ip: '192.0.2.71', mailboxes: 11, status: 'watch' }, + { id: 'w-04', region: 'eu-west-1a', ip: '198.51.100.6', mailboxes: 9, status: 'healthy' }, + { id: 'w-05', region: 'eu-west-1b', ip: '198.51.100.18', mailboxes: 6, status: 'healthy' }, +]; + +const tones = (s: string) => { + if (s === 'healthy') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; + if (s === 'watch') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; + return { dot: 'bg-rose-500', chip: 'bg-rose-50 text-rose-700' }; +}; + +const providers = [ + { name: 'Google Workspace', tag: 'Gmail · OAuth 2.0', body: 'Gmail API for send. Threaded replies preserved through In-Reply-To and References headers. Postmaster Tools reputation surfaced where the domain is enrolled.' }, + { name: 'Microsoft 365', tag: 'Outlook · OAuth', body: 'Graph API for send with shared-mailbox support. IMAP / EWS for sync. Tenant-scoped admin consent flow.' }, + { name: 'Custom SMTP / IMAP', tag: 'BYOS · TLS', body: 'Bring your own server. TLS, STARTTLS, app passwords, and OAuth where the provider supports it. iCloud and Zoho work first-class.' }, +]; + +const faq = [ + ['How does worker assignment happen?', 'Mailboxes are pinned to a worker on first connect and stay there for stability. The control plane can rebalance live by moving the mailbox to a different worker without dropping campaigns or warmup.'], + ['What happens if a worker crashes?', 'Worker state is disposable. A replacement worker reads pending commands from the same Kafka topic on boot and resumes. No mailbox is stuck waiting on a single host.'], + ['Can a single mailbox use multiple IPs?', 'No. A mailbox lives on one worker with one IP at a time, so receiver reputation can stabilise. Migration changes the IP intentionally and is logged.'], + ['How are bursts prevented?', 'The per-mailbox 600-second gap is enforced server-side. Even if a campaign queues 100 sends at once, dispatch is rate-limited to the gap, not the queue size.'], +]; --- - -
-
- -
+ +
+ -
-
-
Sending engine
-

- Many mailboxes.
Many workers.
Many IPs. -

-

- Warmbly is not a glorified SMTP relay. The control plane decides what to send. The execution plane spreads it across distributed workers so no mailbox, machine or IP becomes a concentration point. -

+
+
+ + Sending engine + + Distributed across many machines, many IPs
- -
- - - - - - - - - - - CONTROL PLANE - Backend · Kafka - - - {[60, 130, 200, 270].map((y, i) => ( - - - worker-{i + 1} - IP · {(192 + i)}.0.2.{14 + i * 3} - - ))} - - - {[60, 130, 200, 270].map((y) => ( - - {Array.from({ length: 3 }).map((_, i) => ( - - ))} - - ))} - - - {[60, 130, 200, 270].map((y, i) => ( - - - - - - - - ))} - -
-
-
- - -
-
-
Architecture
-

- Control plane decides. Execution plane sends. -

-

- Backend orchestrates campaign math, mailbox assignment, sequencing. Workers execute send, sync and track. Kafka carries every command and result. +

+ Many mailboxes.
Many workers.
Many IPs. +

+

+ The control plane decides what to send. The execution plane spreads it across distributed workers so no mailbox, machine, or IP becomes a concentration point.

-
- {[ - { icon: 'cpu', title: 'One worker per machine', body: 'Each worker has its own machine-level network identity. No NAT-induced IP concentration.' }, - { icon: 'workflow', title: 'Per-worker Kafka topics', body: 'Commands and results route through worker-scoped topics. Migrations are transparent.' }, - { icon: 'database', title: 'Stateless workers', body: 'Workers boot Kafka, Redis, KMS, DynamoDB, S3. They do not open a Postgres connection.' }, - { icon: 'layers', title: 'Per-mailbox budgets', body: 'Default 50 / day cold cap. 10-minute minimum gap. Override per mailbox, per campaign, per TZ.' }, - { icon: 'shield', title: 'Worker concentration cap', body: 'A shared worker should hold ~10 actively-sending cold mailboxes at defaults. Concentration warnings fire early.' }, - { icon: 'clock', title: 'Business hours respect', body: 'Sends honour the recipient time zone, weekends, holidays. Set the window once.' }, - ].map((s) => ( -
-
- -
-

{s.title}

-

{s.body}

-
- ))} +
- -
-
-
+ +
+
+
+ +
+
+
Worker fleet · live
+ + + 5 active + +
+
+ 44 mailboxes + + 2 regions + + shared paid pool +
+
+ + +
+ +
+ + + + + + + + + + + CONTROL PLANE + Backend · Kafka + + + {workers.map((w, i) => { + const y = 30 + i * 60; + const ts = tones(w.status); + return ( + + + + {w.id} · {w.region} + {w.ip} · {w.mailboxes} mailboxes + + + + + + ); + })} + +
+ + +
+
+
Worker
+
IP · Region
+
Mboxes
+
Status
+
+ {workers.map((w) => { + const ts = tones(w.status); + return ( +
+
{w.id}
+
+
{w.ip}
+
{w.region}
+
+
{w.mailboxes}
+
+ + + {w.status} + +
+
+ ); + })} +
+
+ +
+ w-03 at 11 mailboxes → concentration warning fires above 10 + Migrate live · no campaign drops +
+
+
+
+ + +
+
+
+
Two planes
+

+ Control plane decides. Execution plane sends. +

+

+ The two planes never share a database connection. The control plane owns relational state and business workflows. Workers execute side effects and stay disposable. +

+
+ +
+ +
+
+
+
+
Control plane
+
cmd/backend · cmd/consumer
+
+
decisions + persisted state
+
    +
  • Owns Postgres. Stores accounts, campaigns, contacts, suppression, ban history.
  • +
  • Decides which mailbox sits on which worker and when to rebalance.
  • +
  • Publishes commands to per-worker Kafka topics.
  • +
  • Consumes worker-emitted events back into campaign state.
  • +
+
+
+ + +
+
+
+
+
Execution plane
+
cmd/worker
+
+
disposable · cattle, not pets
+
    +
  • No Postgres connection. Boots Kafka, Redis, KMS, DynamoDB, S3.
  • +
  • One worker per machine = one IP = one independent reputation surface.
  • +
  • State is replayable from Kafka. Killing a worker loses nothing.
  • +
  • Per-mailbox cap and gap enforced server-side, not by the campaign.
  • +
+
+
+
+
+
+ + +
+
+
Per-mailbox math

- A worker's safe volume is the sum of its mailbox budgets. + Worker capacity is the sum of its mailboxes.

-

- We don't reward forcing one inbox to do the work of ten. Volume scales by adding mailboxes, not by cranking one. Concentration warnings surface before reputation feedback would. +

+ No flat per-worker ceiling. Volume scales by adding mailboxes, not by raising per-mailbox caps. The scheduler refuses to set a worker cap higher than the sum of its mailbox budgets.

-
    - {[ - 'Default 50 / day per mailbox · default 600s gap', - 'Worker cap = Σ mailbox budgets · no flat ceiling', - 'Concentration warning at ~10 active cold mailboxes / worker', - 'Live rebalance and migration between workers', - ].map((b) => ( -
  • - - {b} -
  • - ))} +
      +
    • Default 100/day per mailbox · 600s minimum gap
    • +
    • Concentration warning fires above 10 active cold mailboxes per worker
    • +
    • Health band of any mailbox feeds back into worker pool selection
- -
+
- worker-budget.ts + worker_scheduler.go
-
const mailboxes = worker.mailboxes
-  .filter(m => m.status === 'active' && m.campaignsAttached)
+        
// Active mailboxes attached to outbound campaigns
+mailboxes := worker.Mailboxes.Filter(func(m Mailbox) bool {
+    return m.Status == "active" && m.HasOutbound()
+})
 
-const dailyBudget = mailboxes
-  .reduce((sum, m) => sum + m.coldCapPerDay, 0)
-
-// Concentration check
-if (mailboxes.length > CONCENTRATION_THRESHOLD) {
-  warnings.push({
-    code: 'WORKER_CONCENTRATION',
-    activeMailboxes: mailboxes.length,
-    suggestedAction: 'distribute',
-  })
+// Daily budget = sum of mailbox caps. No flat per-worker number.
+dailyBudget := 0
+for _, m := range mailboxes {
+    dailyBudget += m.ColdCapPerDay
 }
 
-// dailyBudget caps outbound — no flat per-worker number
-scheduler.setWorkerCap(worker.id, dailyBudget)
+// Concentration check (default threshold = 10) +if len(mailboxes) > ConcentrationThreshold { + metrics.EmitWarning(WorkerConcentration{ + WorkerID: worker.ID, + ActiveMailboxes: len(mailboxes), + Suggestion: "distribute", + }) +} + +scheduler.SetWorkerCap(worker.ID, dailyBudget)
- -
-
-
Providers
-

- Native Gmail. Native Outlook. SMTP / IMAP for the rest. -

+ +
+
+
+
Message lifecycle
+

+ From queue to delivered, one send. +

+

+ Every send moves through six handoffs between the control plane and the execution plane, each with a defined input, output, and Kafka topic. +

+
-
+
    {[ - { tag: 'Google Workspace · Gmail', title: 'OAuth 2.0 with refresh tokens.', body: 'Gmail API for send. FBL signals where available. Threaded replies preserved.' }, - { tag: 'Microsoft 365 · Outlook', title: 'OAuth with shared mailbox support.', body: 'Graph API for send. IMAP / EWS for sync. Tenant-scoped consent.' }, - { tag: 'Custom SMTP / IMAP', title: 'Bring your own server.', body: 'TLS, STARTTLS, app passwords, and OAuth where the provider supports it.' }, - ].map((p) => ( -
    -
    {p.tag}
    -
    {p.title}
    -

    {p.body}

    + { n: '01', t: 'Scheduled', k: 'scheduler.enqueue(mailbox_id, message_id)', b: 'The campaign scheduler picks the next eligible recipient, checks the per-mailbox cap and gap, and enqueues a send command.' }, + { n: '02', t: 'Dispatched', k: 'kafka.publish(worker.commands.{worker_id})', b: 'The command is published to the worker-scoped Kafka topic. Only the assigned worker consumes it.' }, + { n: '03', t: 'Sent', k: 'provider.send(msg) → 250 + queue_id', b: 'The worker calls the mailbox provider (Gmail API, Graph API, or SMTP). Provider returns the queue ID, which is recorded for delivery correlation.' }, + { n: '04', t: 'Acknowledged', k: 'kafka.publish(worker.results.{worker_id})', b: 'The worker emits a send-result event back to the control plane. Cap usage is incremented atomically.' }, + { n: '05', t: 'Tracked', k: 'tracking.record(message_id, signals)', b: 'Open and click tracking, when enabled, flow through the tracking edge with dedupe at IP + task granularity.' }, + { n: '06', t: 'Reconciled', k: 'consumer.fold(events, campaign_state)', b: 'Bounces, complaints, replies, and OOOs are folded back into per-mailbox health and contact suppression. The next send is scheduled.' }, + ].map((s) => ( +
  1. + {s.n} +
    +
    {s.t}
    +
    {s.k}
    +

    {s.b}

    +
    +
  2. + ))} +
+
+
+ + +
+
+
+
Providers
+

+ Native Gmail. Native Outlook. SMTP for the rest. +

+

+ OAuth-first integrations with the two big providers, plus SMTP / IMAP for everything else. No third-party relay in the middle. +

+
+ +
+ {providers.map((p, i) => ( +
+
+
+
{p.tag}
+
{p.name}
+

{p.body}

+
))}
- + +
+
+
+
Limits
+

+ What the engine enforces. +

+

+ Hard limits in the codebase. Override the per-mailbox cap if you have a real reason, but the gap and concentration ceiling are not negotiable. +

+
+ +
+ {limits.map((d, i) => ( +
+
+
+ {String(i + 1).padStart(2, '0')} · {d.k} +
+

{d.why}

+
+
+
{d.v}
+
+
+ ))} +
+ +
+ Source: cmd/worker/main.go · internal/scheduler/email_scheduler.go +
+
+
+ + +
+
+
+
FAQ
+

+ Four engine questions. +

+
+
+ {faq.map(([q, a]) => ( +
+ +
+
+

{a}

+
+
+
+ ))} +
+ + +
+
+ +