22 KiB
Warmbly Agent Notes
Purpose
Warmbly is an email warmup and cold outreach platform.
At a product level, the app does four main things:
- manages sender accounts and their assignment to workers
- sends campaign and warmup mail through distributed workers
- syncs mailbox state back into the platform
- tracks opens, clicks, replies, suppression, and deliverability signals
The backend API is the control plane. Workers are the execution plane.
System Shape
cmd/backend: API and business orchestrationcmd/consumer: consumes Kafka events and updates platform statecmd/worker: execution worker for send/sync operationstracking/: open and click tracking servicerealtime/: websocket fanout serviceweb/: frontend
Worker Topology
Workers are intended to run distributed across many machines, with one worker process per machine.
That layout matters because it lets the system spread sending activity across different machine-level network identities and IP addresses instead of concentrating traffic through a single sender runtime.
In production, workers are treated as individually addressable executors:
- each worker has its own
worker_id - email accounts are assigned to a specific worker
- worker events are delivered through worker-specific Kafka topics
- the platform can rebalance or migrate accounts between workers
This repo already models three worker modes:
- shared free-tier workers
- shared premium workers
- dedicated workers assigned to a single paying organization
The relevant code paths are in:
internal/app/worker/assignment.gointernal/repository/pg_worker.gointernal/infrastructure/db/migrations/000015_worker_tiers.up.sql
Warmup Pool Model
Warmup traffic is also separated by pool:
freepremium
This is modeled in:
internal/infrastructure/db/migrations/000010_warmup_pools.up.sqlinternal/repository/pg_warmup.gointernal/tasks/email_task.go
Keep this separation intact. Free-tier accounts should not silently mix into premium warmup traffic, and dedicated-worker accounts should still follow the intended warmup pool policy explicitly rather than by accident.
Worker Networking Rules
The worker should stay operationally lightweight because there can be a lot of them.
Design intent:
- workers should not depend on PostgreSQL or other direct SQL access
- workers should receive commands from Kafka
- workers should publish results back through Kafka
- workers may talk to infrastructure-style services that scale independently, such as S3, KMS, DynamoDB, and cache layers
- worker-local state should be minimal and disposable
Current code matches that intent in cmd/worker/main.go: the worker boots Kafka, Redis cache, KMS, DynamoDB, and S3 clients, but does not open a PostgreSQL connection.
When changing worker behavior, preserve that boundary unless there is a very strong reason not to.
Encryption Model
Warmbly uses envelope encryption for application secrets and sensitive payloads.
High-level flow:
- AWS KMS is the root of trust
- each user gets a data encryption key (DEK)
- the plaintext DEK is used for application-layer encryption and decryption
- the encrypted DEK is stored, not the plaintext DEK
- decrypted DEKs are cached for reuse
Current implementation:
- KMS generates a 32-byte DEK for AES-256
- the encrypted DEK blob is base64-encoded and stored in DynamoDB
- the plaintext DEK is cached in Redis with a TTL
- encrypted fields are sealed with AES-GCM and then base64-encoded
Main code paths:
internal/app/cipher/cipher.gointernal/app/cipher/encrypt.gointernal/app/cipher/decrypt.gointernal/app/cipher/cache.gointernal/infrastructure/kms/encryption.gointernal/infrastructure/kms/decryption.gointernal/repository/dynamo_user_encrypted_keys.go
Operational guidance:
- do not introduce plaintext storage of secrets or message content where the current design expects encrypted values
- do not move DEK storage into Postgres; keep it in the KMS + DynamoDB envelope-encryption model unless there is a migration plan
- if workers need access to encrypted payloads, prefer passing encrypted material plus access to KMS-backed decryption primitives rather than introducing direct SQL dependencies
- be explicit about which fields are encrypted at rest in app code versus stored in infrastructure services like S3
Sending Safety Policy
Cold email safety should be mailbox-first, not worker-first.
Do not think of a worker as having one flat global send limit. A worker's safe outbound volume should be the sum of the budgets of the mailboxes assigned to it, with volume spread across many mailboxes and many worker IPs instead of concentrated through one runtime.
Hard product defaults in this repo
These are the current built-in defaults and guardrails:
- default cold campaign cap per mailbox:
50emails/day - default minimum gap per mailbox:
600seconds between sends - default warmup start per mailbox:
10emails/day - default warmup ceiling per mailbox:
40emails/day - default warmup ramp:
+1email/day campaign_limitupdates are validated up to100max
Relevant code:
internal/config/constants.gointernal/models/email.gointernal/repository/pg_email.gointernal/scheduler/campaign_scheduler.gointernal/scheduler/email_scheduler.gointernal/scheduler/warmup_scheduler.go
Recommended operational posture
Treat these as operational heuristics, not protocol guarantees:
- for a fresh or recently connected mailbox, start cold outreach around
10-20/day - ramp slowly until the mailbox proves stable
- use
30-50/dayas the normal safe band for most cold outreach mailboxes - do not raise a mailbox above the default
50/daycasually - anything above
50/dayper cold mailbox should require positive reputation signals, low complaint rates, and explicit review - never jump a new mailbox directly to high volume
- preserve spacing between emails; avoid bursty send patterns from the same mailbox
Warmup posture:
- start around the repo default of
10/day - ramp gradually instead of doubling volume abruptly
- keep warmup and cold outreach budgets separate in reasoning
Worker-level distribution rule
For shared workers, distribute volume by mailbox budget and IP spread:
- no shared worker should become a concentration point for a large fraction of total cold-email traffic
- prefer adding more workers and spreading accounts rather than increasing per-worker density
- if one worker holds many active cold mailboxes, keep the total planned worker volume equal to the sum of those mailbox caps, not an independent higher target
- as a conservative planning heuristic, shared workers should usually stay near the equivalent of about
10actively sending cold mailboxes at default settings, or roughly500cold campaign emails/day, unless there is explicit evidence that the worker/IP pool can safely sustain more
Dedicated workers may carry higher organization-specific volume, but those increases should come from more healthy mailboxes, not from forcing a small number of inboxes to send too much.
Internet research constraints
Current external guidance reinforces conservative limits:
- Google bulk sender guidance requires SPF, DKIM, DMARC alignment, one-click unsubscribe for marketing/subscribed mail, and says to keep spam rate below
0.10%and avoid reaching0.30% - Microsoft Exchange Online documents platform send limits, but also explicitly says customers sending legitimate bulk commercial email should use specialized third-party providers rather than treating Exchange Online as bulk-mail infrastructure
That means Warmbly should stay conservative by default:
- low complaint rate matters more than chasing maximum throughput
- low spam rate matters more than open-rate screenshots
- gradual warmup and distributed sending matter more than maximizing one mailbox or one worker
Warmup Process
Warmup exists to build and maintain sender reputation by sending low-risk traffic gradually, spacing it out over time, and generating normal mailbox activity patterns instead of sudden bulk spikes.
Current product behavior
Warmup is currently a paid-only feature at the product layer.
This is enforced in:
internal/app/feature/gate.gointernal/tasks/email_task.go
Important nuance:
- the database and repository model still support
freeandpremiumwarmup pools - the task flow currently blocks non-paid organizations from using warmup
- so the architecture supports pool separation, but product access is effectively premium-only right now
Treat that as the current truth unless product requirements change.
How warmup works in this codebase
The warmup task flow currently does the following:
- checks that the mailbox's organization is allowed to use warmup
- chooses a partner mailbox from the configured warmup pool
- avoids selecting the same partner too frequently
- sometimes replies to an existing warmup thread based on
warmup_reply_rate - otherwise sends a new plaintext warmup message
- creates a warmup verification token
- sends the email through the assigned worker
- increments daily warmup stats
- schedules the next warmup task using gradual volume progression
Relevant code:
internal/tasks/email_task.gointernal/scheduler/warmup_scheduler.gointernal/repository/pg_warmup.gointernal/infrastructure/db/migrations/000010_warmup_pools.up.sql
Pool behavior
Warmup pools are mailbox pools, not campaign lists.
The intent is:
- only other participating mailboxes are used as warmup recipients
- recipients can be blocked from the pool if their spam score or invalid-token behavior looks bad
- repeated pairings should be reduced
- warmup should look like low-volume natural traffic, not repetitive synthetic blasting
Pool safety signals in code include:
- recent-partner avoidance
- warmup token validation
- invalid-token attempt counting
- spam-score tracking
- auto-blocking from pools
Recommended paid-pool policy
For paid warmup pools:
- only use warmed, valid, monitored mailboxes as participants
- do not mix in trial, temporary, or low-quality inboxes just to inflate pool size
- keep volume gradual and spaced
- maintain conversational behavior, including some replies, instead of only one-way sends
- keep warmup running even after campaigns begin, rather than stopping immediately once a mailbox is "ready"
Internet research summary
Current provider guidance and deliverability references support the same shape:
- warmup means gradual volume growth over days or weeks, not instant scale
- start with low volume, then increase only while performance stays healthy
- use authenticated domains and keep complaint/spam signals low
- shared pools can help smaller senders, while higher sustained volume may justify dedicated IPs or dedicated pools
Concrete external guidance:
- Postmark describes domain warmup as slowly and steadily increasing volume over a period of weeks, often reaching stable behavior in
3-6 weeks - Mailgun describes IP warmup as gradually increasing email volume from an IP to let mailbox providers observe behavior and build reputation
- Mailgun also notes that shared IPs do not need dedicated IP warmup in the same way, while dedicated IPs do
Practical interpretation for Warmbly
For Warmbly, the safest interpretation is:
- warmup should be gradual per mailbox
- pool quality matters more than pool size
- paid warmup pools should remain isolated from lower-trust traffic
- dedicated-worker customers may still participate in premium warmup logic, but their sending reputation should be evaluated mailbox-by-mailbox, not assumed safe just because they have isolated infrastructure
Fraud And Abuse Detection
Warmbly does not currently appear to rely on one centralized ML fraud engine.
Instead, the codebase uses layered abuse controls and trust signals across auth, API usage, warmup behavior, tracking, and mailbox sync.
Main anti-abuse layers
- CAPTCHA on auth-sensitive entry points
- per-user API rate limiting
- WebSocket rate limiting
- warmup-token verification and invalid-attempt tracking
- warmup spam-score tracking and auto-blocking from pools
- tracking-event deduplication and replay resistance
- deliverability-event idempotency and suppression lists
- worker-side sync abuse detection
- admin ban and manual override controls
Auth and signup protection
Authentication flows use Cloudflare Turnstile:
- login
- registration
- password reset
- confirmation flows
The Turnstile verifier also checks:
- remote IP format
- optional expected hostname
- challenge freshness to reduce replay risk
Relevant code:
internal/pkg/captcha/turnstile.gointernal/app/auth/login.gointernal/app/auth/registration.gointernal/app/auth/reset_password.go
API and realtime throttling
The backend applies user-level rate limiting by category, backed by Redis and plan/user limits.
The realtime service separately rate-limits:
- websocket joins
- websocket messages
- websocket events
This is not just performance protection; it is also an anti-abuse boundary against automated flooding and noisy clients.
Relevant code:
internal/api/middleware/ratelimit.gointernal/app/ratelimit/service.gorealtime/lib/realtime/rate_limiter.ex
Warmup fraud detection
Warmup has the clearest explicit abuse-detection path in the repo.
Signals used:
- every warmup email carries a verification token
- invalid token format is recorded
- missing, expired, or mismatched tokens are treated as suspicious
- invalid token attempts are counted over time
- spam score is accumulated for abusive or suspicious behavior
- accounts can be auto-blocked from warmup pools
Current auto-block thresholds in code:
>= 3invalid warmup-token attempts in24h- spam score
> 50
Relevant code:
internal/app/consumer/event_new_email.gointernal/repository/pg_warmup.gointernal/infrastructure/db/migrations/000010_warmup_pools.up.sql
Paid pool protection policy
Protecting shared paid-pool reputation is more important than maximizing access for one risky mailbox.
Do not wait for an inbox to reach an extreme failure state before acting.
Important:
80%spam placement is not a sensible block threshold- if a mailbox is landing in spam
80%of the time, it has already become dangerous to the shared pool - action should happen much earlier
Use separate metrics for separate failure modes:
- user complaint rate: recipients explicitly mark mail as spam
- spam-folder placement rate: warmup or seed observations indicate messages are landing in junk/spam
- bounce rate: especially hard bounces
- suspicious warmup-token behavior
- mailbox-sync abuse and provider throttling
Recommended internal policy for shared paid pools:
- start evaluating after a minimum sample size
- suggested sample floor for spam placement: at least
20warmup deliveries in the last7 days - suggested sample floor for complaints: at least
100delivered emails in the last30 days
Suggested automatic actions:
-
warning band: spam-folder placement
>= 10%over the last20+warmup deliveries or complaint rate>= 0.03%Action: lower warmup volume, increase spacing, increase monitoring -
quarantine band: spam-folder placement
>= 20%or complaint rate>= 0.10%or bounce rate>= 5%or repeated suspicious warmup-token failures Action: immediately remove mailbox from the shared paid warmup pool for7 days -
hard block band: spam-folder placement
>= 40%or complaint rate>= 0.30%or bounce rate>= 10%or clear abuse indicators such as token forgery patterns or repeated spam flags Action: block mailbox from shared paid pool for30 daysand require review before re-entry -
catastrophic band: spam-folder placement
>= 80%Action: immediate long-duration block and full reputation reset workflow; do not allow the mailbox back into the shared paid pool automatically
These thresholds are intentionally stricter than the point where large providers start penalizing senders, because shared warmup pools should act before provider-level enforcement hits the IP reputation.
What should happen when a paid-pool mailbox is quarantined
When a mailbox breaches the quarantine or hard-block band:
- it should not be selected as a warmup sender
- it should not be selected as a warmup recipient
- it should not continue using the shared paid warmup pool
- campaign sending should be throttled or paused if the same mailbox is also used for cold outreach
Best option:
- move it to a separate recovery state or recovery pool that is isolated from the main paid pool
If a recovery pool does not exist yet:
- block warmup access entirely until the cooldown expires and the mailbox requalifies
Re-entry requirements
Do not automatically restore a blocked mailbox just because time elapsed.
Require the mailbox to pass re-entry checks such as:
- authentication still healthy: SPF, DKIM, DMARC, PTR where relevant
- no recent provider complaints or hard-bounce spikes
- no recent invalid warmup-token attempts
- spam-folder placement back below
10%on a fresh probation sample - gradual re-entry with low volume, for example
5-10/daywarmup at first
External guidance behind these thresholds
As of April 3, 2026, the strongest official guidance I found supports acting early:
- Google says senders should keep user-reported spam rate below
0.1%and avoid ever reaching0.3% - Amazon SES says for best results keep complaint rate below
0.1%; at0.1%SES automatically places the account under review, and at0.5%SES may pause sending - Amazon SES also says to keep bounce rate below
5%; at5%the account can be placed under review, and at10%sending may be paused
That means a shared paid warmup pool should be stricter than mailbox-provider enforcement, not looser.
Recommended implementation shape
For this repo, the most practical implementation is:
- compute rolling mailbox health daily and on every relevant event
- maintain a mailbox health state such as:
healthy,watch,throttled,quarantined,blocked - store
blocked_until,health_reason,last_health_score, andlast_health_evaluated_at - feed the score from: warmup spam flags deliverability complaints bounce events invalid warmup-token attempts provider rate-limit or abuse signals
- make pool selection exclude any mailbox not in
healthy - keep positive engagement as a weak positive signal only; it should not instantly offset complaints or spam placement
Best product decision
If the main goal is protecting your IPs, the best default is:
- shared paid pool: strict automatic quarantine
- dedicated infrastructure: allow separate recovery handling if you want, but not on the shared paid pool
- never let a risky mailbox continue warming in the same reputation surface that healthy paying customers depend on
Worker-side abuse detection
Workers also defend against mailbox-sync abuse.
Current sync protections in code:
- burst sync limit:
100new emails per5 minutes - hourly sync limit:
500new emails per hour - if exceeded, the account is rate-limited, a warning/error event is emitted, and the mailbox can be terminated or set inactive upstream
Relevant code:
internal/app/worker/wmail/ratelimit.gointernal/app/consumer/event_email_error.go
Event replay and duplicate protection
Several parts of the system defend against replayed or duplicated events:
- tracking service keeps an in-memory dedupe cache keyed by task/IP or task/URL/IP
- tracking consumer keeps a persistent dedupe table as a second line of defense
- deliverability events use an idempotency key
- Stripe webhooks also use idempotency logging
Relevant code:
tracking/src/handlers.rsinternal/repository/pg_tracking_dedupe.gointernal/app/consumer/event_tracking.gointernal/app/advanced/service.gointernal/repository/pg_advanced_outreach.gointernal/repository/pg_subscription.go
Suppression as abuse containment
Some fraud and abuse prevention is expressed as containment rather than user banning.
Examples:
- recipients are suppressed after bounce, complaint, or unsubscribe signals
- suspicious warmup participants are blocked from pools
- rate-limited mailboxes can be disabled
- campaigns skip suppressed recipients automatically
This is operationally important because the safest response is often to stop further traffic rather than keep sending and collect more negative signals.
Admin enforcement
The admin surface supports manual enforcement and overrides:
- ban user
- unban user
- inspect ban history
- inspect and override rate limits
Relevant code:
internal/api/routes.gointernal/api/handler/admin.gointernal/app/admin/service.gointernal/infrastructure/db/migrations/000016_admin_system.up.sql
Practical interpretation
When extending anti-fraud logic in this repo:
- prefer layered controls over one brittle gate
- prefer idempotency and dedupe wherever external events arrive
- prefer blocking or suppressing risky traffic early
- keep worker-side abuse checks lightweight and infrastructure-backed
- record enough structured evidence for admin review when a user or mailbox is blocked
Control Plane vs Execution Plane
Prefer this split:
- backend/consumer own relational state and business workflows
- workers execute side effects: send, sync, validate, heartbeat
- assignment and migration decisions belong in the control plane
- workers should remain replaceable and horizontally scalable
If a new feature requires heavy joins, admin queries, billing checks, or complex campaign state transitions, it probably belongs in backend or consumer, not in the worker.
Practical Guidance
- do not add direct Postgres usage to
cmd/workerorinternal/app/workerunless explicitly required - preserve worker-specific Kafka topic routing
- preserve separation between free, premium, and dedicated worker capacity
- preserve separation between free and premium warmup pools
- optimize for many-worker deployments, not a single giant worker
- document any change that alters worker assignment, pool membership, or network boundaries
Source Anchors
These files are the fastest way to rebuild context:
README.mdresources/architecture.mdcmd/worker/main.gointernal/app/worker/assignment.gointernal/tasks/email_task.gointernal/repository/pg_worker.gointernal/repository/pg_warmup.go