Commit Graph
1681 Commits
Author SHA1 Message Date
Matthew Meszaros c886b919ce ci(admin): regenerate pnpm lockfile to match workspace overrides
admin/pnpm-workspace.yaml carries an 'overrides: picomatch: ^4.0.4'
block (added to mirror the dashboard) that was never reflected in
pnpm-lock.yaml — the lockfile was generated before the overrides were
introduced. Admin CI's pnpm install --frozen-lockfile rejected the
mismatch:

  ERR_PNPM_LOCKFILE_CONFIG_MISMATCH  Cannot proceed with the frozen
  installation. The current 'overrides' configuration doesn't match
  the value found in the lockfile.

Ran pnpm install --no-frozen-lockfile to regenerate the lockfile with
the override applied; --frozen-lockfile now passes locally.
2026-05-27 16:34:09 +00:00
Matthew Meszaros 8347237547 merge: resolve main into feature/workers-support
Brings in PR #15 (email warmup process 4) plus its preceding commits:
customer-defined warmup routing on premium pool, free-trial warmup +
1 inbox for 14 days, customer webhook subscriptions with HMAC signing
+ retry, bumped default API rate limits to 100 req/s with flat per-
user/per-plan caps, plus dev-fixture additions.

One real conflict: internal/client/smtpimap/imap/client.go added
distinct imports on each side (this branch added 'net' for the
*net.TCPAddr BindIP field; main added 'sync' for a Mutex). Kept both.

Everything else auto-merged additively:
  cmd/backend/main.go     - imports + handler fields + DI lines
  internal/api/handler/handler.go - new fields next to existing ones
  internal/api/routes.go  - new route group next to existing ones

Full build + test suite pass (no regressions).
2026-05-27 16:29:18 +00:00
Matthew Meszaros ff04f4dd37 ci: cover admin/ and site/ alongside web/
Existing CI only ran the changes filter on web/; admin/ (Vite admin app)
and site/ (Astro marketing site) had no coverage.

Adds:
  changes.outputs.admin / changes.outputs.site path filters
  admin-ci  - pnpm install + lint + typecheck + build
  site-ci   - pnpm install + build (Astro doesn't have a separate
              lint/typecheck script today; build catches type errors,
              missing imports, broken assets)
  ci-status now depends on admin-ci + site-ci so a failure blocks the
  branch protection check.

Both new jobs use the same pnpm 10 + node 20 setup as web-ci, with the
per-app pnpm-lock.yaml as the cache-dependency-path. Smoke-tested both
locally before pushing.
2026-05-27 16:22:21 +00:00
Matthew Meszaros 3e9123caf5 Merge pull request #15 from warmbly/feature/email-warmup-process-4
feat: harden warmup safety, diversity, and customer routing
2026-05-27 18:19:59 +02:00
Matthew Meszaros d5147659a7 style: gofmt struct alignment after burst_multiplier removal
removing the BurstMultiplier field changed column widths in several
struct literals; let gofmt realign them. no semantic change.
2026-05-27 16:17:53 +00:00
Matthew Meszaros 13c4ebb7a6 rename web-admin -> admin
Shorter, cleaner path. The 'web-' prefix was redundant given the dir
sits at the repo root next to web/ and is unambiguously the admin web
app. Git tracked the rename so blame + history follow through to the
new location.

Updated README.md and docs/VENDOR_LOCKIN.md references plus the package
README header. No code changes.
2026-05-27 16:17:47 +00:00
Matthew Meszaros e790818d35 fleet: wire health collection so the autonomous loops actually have data
Three small follow-ups that turn the fleet management system from 'all
the pieces ship green' into 'actually produces telemetry':

cmd/worker/main.go: go workerService.RunHealth(ctx, 30s) alongside
Heartbeat. The sampler snapshots rolling 1m counters into a WorkerHealth
event via the existing event bus + codec path.

cmd/backend/main.go: background goroutine refreshes
worker_capacity_view every minute via REFRESH MATERIALIZED VIEW
CONCURRENTLY. The assignment loop, Rebalancer, Scaler, and
QuarantineEvaluator all read from the view, so it's the freshness gate
for the whole system.

internal/app/worker/event_send_email.go + health_record.go: classify
every wmail.SendResult into the right counter (auth / rate-limit /
bounce-hard / bounce-soft / success) and record SMTP latency. Falls
back to free-text message classification when the error code is
generic, so signal stays useful as new error paths are added.

End-to-end: a worker that bounces 10% of sends now lands in the
'quarantined' band within 5min of the QuarantineEvaluator tick,
auto-drains via Rebalancer, and triggers a Scaler alert if its
removal drops fleet capacity below the warning threshold.
2026-05-27 16:03:35 +00:00
Matthew Meszaros c20971790c web-admin: Cloud Providers, Provisioning Templates, Provision modal, Jobs page
Three new admin surfaces on the existing Vite/React app, fully wired
against /admin/cloud-credentials, /admin/provisioning-templates,
/admin/provisioning-jobs.

Settings -> Cloud Providers: paste Hetzner API token, masked display,
Test connection button + green/red status banner with account email and
quota when healthy.

Settings -> Provisioning Templates: CRUD with every Hetzner option
exposed (provider, location, datacenter, server_type with cores/RAM/
price, server count, IPv4/IPv6 per server, worker tier, profile,
egress kind, image, placement group, private network, firewall, key=
value labels). Tier-exclusive 'auto-provision' checkbox enforced
client-side. Live cost preview card.

Workers page: amber 'Provision new' button opens two-tab modal (From
template / Custom). After submit, swaps to live state-machine progress
panel with checkmarks per state, polling every 2s via TanStack Query's
refetchInterval (stops on terminal state).

/workers/provisioning-jobs page: in-flight jobs at top (5s refresh),
30d history below with state + provider filters. Row click opens
timeline detail with retry button on failed jobs.

Fallback catalog for Hetzner locations + server types so the template
form stays usable even when the backend catalog endpoints are down
(small amber 'using built-in fallback list' note).

pnpm install + typecheck + build all clean. ~570KB production bundle.
2026-05-27 15:57:21 +00:00
Matthew Meszaros 4f088e04b5 cmd(backend): wire provisioning repos + fleet loops at boot
Constructs CloudCredentialRepository, ProvisioningTemplateRepository,
ProvisioningJobRepository, ProvisioningPolicyRepository alongside the
existing StorageBackendRepository and threads them onto Handler.

Spawns three goroutines on the root context for the fleet loops
(Rebalancer, Scaler, QuarantineEvaluator) so the autonomous management
starts immediately on backend boot. All three cancel cleanly on
shutdown via the root context.
2026-05-27 15:56:58 +00:00
Matthew Meszaros 1540ba39be admin: cloud credentials, templates, jobs, policy endpoints
/admin/cloud-credentials              CRUD + /:id/test (Hetzner Verify)
/admin/cloud-providers/:p/locations
/admin/cloud-providers/:p/server-types  catalog for admin form dropdowns
/admin/cloud-providers/:p/images
/admin/provisioning-templates         CRUD
/admin/provisioning-jobs              list, detail, create (from
                                       template or inline custom config)
/admin/provisioning-policy            list, update (per-provider budget
                                       caps + AUTO_PROVISION toggle)

All gated by AdminPermManageSettings (jobs use AdminPermManageWorkers).

Creating a job snapshots the template into the row's config jsonb so
mutating the template later doesn't retroactively change in-flight or
historical jobs.

The catalog endpoints proxy directly to the Hetzner API client,
returning whatever Hetzner exposes — admin UI doesn't need to know
the provider-specific shape, dropdowns just render Locations/
ServerTypes/Images verbatim.
2026-05-27 15:56:46 +00:00
Matthew Meszaros faaa3cded5 fleet: autonomous rebalance + scale + quarantine loops
Three closed-loop background goroutines on the backend that manage the
worker fleet without operator intervention.

Rebalancer (default 5min): for each tier, drain hot workers (>80%
utilization) onto cold workers (<50%, healthy). Safety rails: per-
mailbox 24h cooldown to prevent thrashing, max 200 in-flight migrations,
destination must be healthy or watch.

Scaler (default 1h): compute fleet utilization per tier. At >=70%
sustained, emit warning. At >=85% sustained, emit critical alert. If
AUTO_PROVISION is allowed by provisioning_policy, snapshot the active
auto-template for the tier into a new provisioning_jobs row — the state
machine picks it up and provisions the box without admin click.

QuarantineEvaluator (default 5min): inspect rolling 1h bounce/complaint
rates, transition workers between health bands (healthy / watch /
throttled / quarantined / blocked) using CLAUDE.md thresholds.
Quarantined and blocked workers are auto-drained by the Rebalancer
because ListCapacityCandidates excludes them.

Every action is written to decision_log so the admin Decisions page can
answer 'why did the system do X'.
2026-05-27 15:56:33 +00:00
Matthew Meszaros 0c306ceb73 worker: auto-register on first heartbeat
POST /api/v1/internal/worker/heartbeat now inserts a row into the
workers table the first time an unknown worker_id checks in. Tier and
egress_kind come from the heartbeat body; subsequent heartbeats just
keep ip_addr fresh.

Means provisioned workers self-register without admin clicks — the
state machine waits for all 16 expected UUIDv5(IP) workers to ping in
during the verify step, then marks the job completed.

tierToColumns collapses the higher-level (shared_free / shared_premium /
dedicated) name down into the existing (worker_type, free_tier) columns
so the rest of the assignment logic keeps working unchanged.
2026-05-27 15:56:15 +00:00
Matthew Meszaros 73cabf6f5e worker: WorkerHealth event, capacity view, smarter SelectSharedWorker
Workers emit a WorkerHealth event every 30s with assigned mailbox count,
IMAP IDLE connections, memory, goroutines, and rolling 1h send/bounce/
complaint/auth-error/rate-limit counters. Consumer writes them to
worker_health_samples.

Schema additions on workers: egress_kind (cold_smtp / oauth_api /
warmup_only), health_state (healthy / watch / throttled / quarantined /
blocked), load_score (weighted utilization).

worker_capacity_view aggregates the latest hour of samples into a
per-worker capacity row used by the assignment loop. Effective capacity
= base_ceiling(egress_kind) × health_multiplier × age_ramp_multiplier
so a fresh worker earns its way up to base capacity over 72h, and a
worker with rising bounces or complaints automatically gets less load.

MailboxWeight returns 1.0 for cold_smtp, 0.05 for Gmail/Graph API
(worker IP doesn't matter), 0.4 for warmup-only. AssignWorkerToEmail
resolves the mailbox's weight and SelectSharedWorker filters by
headroom + sorts by utilization, so a 200-mailbox OAuth worker and a
16-mailbox cold worker balance fairly.

UnassignWorkerFromEmail refunds the load_score symmetrically.
2026-05-27 15:56:01 +00:00
Matthew Meszaros f4487c3a09 app(provisioning): idempotent state machine driving jobs to terminal state
provisioning.Service.Run drives a provisioning_jobs row through the
full lifecycle (create_server, create_ips, assign_ips, set_rdns,
install, verify). Each step records progress to the DB before the
next transition so a backend crash mid-provision is resumable from
the current state.

JobConfig is the in-row snapshot of the template (or inline custom
config) at the time of submission — mutating the template later
doesn't retroactively change in-flight or historical jobs.

WorkerIDForIP is the canonical UUIDv5-from-IP helper, kept in sync
with cmd/worker and scripts/install-worker.sh so the state machine
can compute the expected worker_ids before they heartbeat.

Installer is a tiny SSH-driven interface (Install only) with a
StubInstaller for tests. The real impl plugs in worker_orchestrator
or any other transport (cloud-init, Ansible) without touching the
state machine.

On any step failure, rollback unassigns + deletes provider-created
IPs and deletes the server, leaving no orphaned resources at the
provider before marking the job failed.
2026-05-27 15:55:12 +00:00
Matthew Meszaros f22346af9a repository: provisioning + decision_log repos
pg_provisioning.go covers cloud_credentials, provisioning_templates,
provisioning_jobs, and provisioning_policy. Job repo exposes the
state machine helpers the orchestrator needs (UpdateState,
RecordServer, AppendIPs, AppendWorkerIDs, MarkFailed, MarkCompleted)
so the package doesn't reach through *db.DB itself.

pg_decision_log.go is a thin insert + recent-history reader for the
audit trail powering the admin Decisions page.
2026-05-27 15:54:56 +00:00
Matthew Meszaros a829b41c88 infra(cloudprovider): pluggable cloud-VPS abstraction + Hetzner Cloud impl
cloudprovider.Provider interface (Locations, ServerTypes, Images,
Verify, CreateServer/DeleteServer, CreatePrimaryIP/AssignPrimaryIP/
UnassignPrimaryIP/DeletePrimaryIP/SetReverseDNS). One impl today
(Hetzner Cloud); adding OVH or Vultr later means implementing the same
six surfaces.

hetzner.Client is a minimal idiomatic Go REST client over
https://api.hetzner.cloud/v1. Bearer-token auth. Returns provider-
native IDs as strings so the orchestration layer can persist them for
rollback.

9 tests against httptest.Server covering token transmission, error
surfacing, parsing, request-body shape, and interface conformance.
2026-05-27 15:54:36 +00:00
Matthew Meszaros fdf79a07a9 schema: cloud_credentials, worker_profiles, provisioning_templates, provisioning_jobs, provisioning_policy, decision_log
Foundation for autonomous fleet management.

cloud_credentials stores encrypted API tokens per cloud provider.
worker_profiles bundles the env vars that get rendered into
/etc/warmbly/worker.env at install time.
provisioning_templates is a customizable saved config — every Hetzner
option the admin form exposes lives here, so the cheapest-US-single-IP
setup is a one-click pick once you've saved it.
provisioning_jobs is the state machine (pending -> creating_server ->
creating_ips -> assigning_ips -> setting_rdns -> installing ->
verifying -> completed | failed -> rolling_back).
provisioning_policy is per-provider budget caps + the auto_provision
toggle the scale loop checks.
decision_log records every automated action so admins can audit what
the system did and why.
2026-05-27 15:54:21 +00:00
Matthew Meszaros 53c5371bf6 docs: SaaS-style README, internal-auth doc, vendor lock-in audit
README is now a landing-page-style entry point: centered hero banner,
badges, tagline, feature grid, architecture diagram, self-hosting
table, stack rationale, quick start, project layout, testing summary,
docs index. Image placeholders reference docs/assets/{banner,
dashboard-preview,admin-preview}.png with sizing guidance in
docs/assets/README.md.

docs/INTERNAL_API_AUTH.md walks through the bearer-token model the
worker uses to call /api/v1/internal/* endpoints: where the token
lives on both sides, the constant-time compare, fail-closed semantics
when INTERNAL_API_TOKEN is unset, and the planned per-worker JWT
upgrade path.

docs/VENDOR_LOCKIN.md is the honest scorecard: each external
dependency (AWS KMS, DynamoDB, S3, Cloud Tasks, Stripe, Turnstile,
Sentry) gets a row covering self-host status, alternatives, and what
'free of this dependency' actually looks like. Includes the minimum-
viable self-host env-var set.
2026-05-27 14:44:23 +00:00
Matthew Meszaros 1b316b9169 web-admin: Vite+React+Tailwind admin app with distinct amber theme
Separate from the user dashboard at web/ so admin and end-user surfaces
never get confused. Same stack (React 19 + Vite + Tailwind v4 + shadcn)
for consistency; differentiated visually via:

  - 3px amber top stripe on every authenticated page
  - persistent ADMIN badge in the sidebar header and login card
  - amber-tinted sidebar with diagonal pattern
  - amber accent on active nav items and primary buttons
  - env pill (red/amber/emerald for prod/staging/dev) in the topbar
  - 'Admin · Warmbly' title and amber-stroked shield favicon

Pages with real data wiring:
  Overview, Workers (list+detail with SSH actions), Egresses,
  Audit Log, Settings (Encryption / Storage / Messaging / Cache /
  Transports) backed by /admin/settings/backends

Stub pages with placeholder bodies (nav exists, no 404):
  Mailboxes, Users, Organizations, Plans, Warmup, Campaigns,
  Analytics

Auth reuses backend session cookies. Dev server runs on port 5174 to
coexist with the dashboard on 5173.

  pnpm install && pnpm build → clean
  pnpm typecheck && pnpm lint → clean (2 pre-existing fast-refresh
  warnings in shadcn primitives kept verbatim from the dashboard)
2026-05-27 14:44:08 +00:00
Matthew Meszaros 51506d98f4 cmd: wire pluggable infra (KMS, encryptedkeys, eventbus, codec, settings)
cmd/backend/main.go:
  - kms.FromEnv replaces kms.New (defaults to AWS, accepts local)
  - encryptedkeys.FromEnv with Deps{DB, Dynamo}; default postgres
  - codec.NewAvroFromClient wraps the existing Schema Registry client
  - eventbus.FromEnv with kafka default; KafkaBus.Producer().WithAvrov2
    preserves the existing Avro wire format on Kafka
  - events.NewPublisher takes (bus, codec) instead of (producer, avrov2)
  - settings.Registrar reflects KMS / EncryptedKeys / Blob / EventBus
    choices into storage_backends on boot
  - Handler gains EncryptedKeys + StorageBackendRepo for the new admin
    and internal endpoints

cmd/consumer/main.go: same eventbus + codec + encryptedkeys swap; the
legacy kafkaProducer is kept around for the consumer's tracking pipeline
which still uses *kafka.Consumer directly (follow-up refactor).
2026-05-27 14:43:51 +00:00
Matthew Meszaros e0e4a010a0 admin: storage_backends registry + settings/dek/worker-config endpoints
New storage_backends table is the runtime inventory of pluggable
infrastructure choices (KMS, encrypted_keys, blob, eventbus, cache).
Each kind has exactly one active row, enforced via a partial unique
index. Read-only rows are env-var driven; UI-mutable rows can be
flipped via SetActive.

settings.Registrar reflects boot-time backend choices into the table
so the admin UI sees what's actually running.

New admin endpoints under /admin/settings/backends:
  GET    /settings/backends?kind=...
  GET    /settings/backends/active/:kind
  POST   /settings/backends/:id/activate

New internal endpoints under /api/v1/internal:
  GET  /worker/config       - workers fetch runtime config on boot
  POST /worker/heartbeat    - liveness ping
(DEK endpoints added in the encryptedkeys commit.)

handler.Handler grows EncryptedKeys + StorageBackendRepo fields.

5 registrar tests cover create / update-and-activate / skip-when-active /
lookup-error propagation / RegisterAll stop-on-first-error using a
mock repository.
2026-05-27 14:43:36 +00:00
Matthew Meszaros 5463e3986e worker: multi-IP support (one systemd unit per Primary IP)
Hetzner CX32 + 16 Primary IPs becomes 16 sending identities with one
install command, without expanding ops complexity.

cmd/worker/main.go: WORKER_ID now resolves via 4-tier precedence:
  1. WORKER_ID env (explicit UUID)
  2. WORKER_BIND_IP env (derive UUIDv5 from the bound IP)
  3. hostname-as-UUID (legacy single-IP VPS)
  4. generated UUID (local dev fallback)

Boot also constructs the chosen Codec + EventBus + EncryptedKeyStore
via the FromEnv factories from earlier commits, so a worker process is
fully configured by its envelope env file plus the runtime config it
pulls from the backend on first boot.

scripts/install-worker.sh gains --ips <ipv4,ipv4,...> which:
  - writes a warmbly-worker@.service systemd template
  - drops a per-instance env file at /etc/warmbly/instances/<dashed-ip>.env
    with WORKER_BIND_IP and WORKER_ID
  - shares one /etc/warmbly/worker.env for the common config
  - --status, --update, --uninstall now multi-IP aware
  - single-IP mode preserved when --ips is absent

5 worker tests pin the UUIDv5 derivation against the installer's
uuidgen --sha1 output so the two never drift.

docs/MULTI_IP_WORKERS.md is the operator runbook with the Hetzner
recipe, OS-level IP attachment, rDNS automation, day-2 ops, and the
25%-of-fleet blast-radius rule.
2026-05-27 14:43:20 +00:00
Matthew Meszaros 35fc12b624 worker+events: route through EventBus + Codec, drop direct Kafka coupling
WorkerService now holds eventbus.EventBus + codec.Codec instead of
*kafka.Producer / *kafka.Consumer. Receive() satisfies the
eventbus.Handler signature; Produce() goes through Codec.Serialize +
Bus.Publish.

events.Publisher likewise switches to (bus, codec) and stops
serializing via *kafka.Avrov2 directly.

The kafka package and its Avrov2/Producer/Consumer types remain for
the few non-worker call sites (tracking consumer, validate_credentials)
that haven't been migrated yet; bundled with KafkaBus.Producer() so
existing Avro framing on Kafka is preserved.

Worker boot wiring is split into cmd/* entry-point commits.
2026-05-27 14:43:03 +00:00
Matthew Meszaros 4080258606 infra(codec): transport-agnostic Codec (Avro + JSON)
Codec interface (Serialize / Deserialize / Name) lets payload encoding
decouple from the transport choice. Two implementations:

  AvroCodec - wraps the existing kafka.Avrov2 Schema Registry client
              via NewAvroFromClient. Preserves identical wire format
              for production deployments already on Kafka + SR.
  JSONCodec - encoding/json based. No external dependency, suitable
              for self-hosters who don't want a Schema Registry.

Factory FromEnv selects via CODEC_PROVIDER (default avro).

Once codec.Codec is in the worker boot and publisher, self-hosters can
pick EVENTBUS_PROVIDER=nats CODEC_PROVIDER=json for a Schema-Registry-
free deployment.

11 tests cover JSON round-trip, nil guards, factory paths, and Avro
interface conformance.
2026-05-27 14:42:47 +00:00
Matthew Meszaros a552e69070 infra(eventbus): EventBus interface + NATS JetStream impl
Transport-agnostic EventBus (Publish / Subscribe / Close / Name) with
two implementations:

  KafkaBus  - wraps the existing internal/infrastructure/kafka producer
              and consumer. Existing call sites that use the kafka
              package directly keep working.
  NATSBus   - JetStream-backed. File storage, 7d max age, durable
              consumers per group, manual ack with redelivery on
              handler error.

Factory FromEnv selects via EVENTBUS_PROVIDER (default kafka). NATS
needs NATS_URL; Kafka reuses the existing bootstrap+SASL config.

Subject naming converts Kafka colons to NATS dots so the existing
w:<uuid> topic naming works on both backends.

16 tests including round-trip ack-redelivery against an embedded
nats-server.
2026-05-27 14:42:33 +00:00
Matthew Meszaros 99226338c9 infra(encryptedkeys): pluggable DEK store with HTTP proxy for workers
New encryptedkeys.Store interface with three impls:

  postgres  - backend default, durable via PG
  dynamodb  - existing AWS path, also covers Scylla Alternator via
              AWS_ENDPOINT_URL_DYNAMODB
  http      - worker-side adapter that talks to the backend's new
              /api/v1/internal/dek/:userID endpoint, so workers never
              connect directly to Postgres

The HTTP endpoint sits behind a new InternalAuthMiddleware that does
constant-time bearer-token compare against INTERNAL_API_TOKEN. Fail-
closed if the env var is unset.

cipher.Service now takes an encryptedkeys.Store instead of a Dynamo
repository. The old internal/repository/dynamo_user_encrypted_keys.go
is deleted (the file also had a pre-existing copy-paste bug using
EmailMessageMapTable in Get/Del that's gone with it).

New migration 38 adds user_encrypted_keys (user_id PK, encrypted_data_key,
created_at, updated_at).

20 tests cover HTTP round-trip, conflict semantics, factory selection,
middleware auth (fail-closed / wrong-scheme / timing-safe / happy path),
and DEK handler responses through gin's test harness.
2026-05-27 14:42:11 +00:00
Matthew Meszaros 59b515cad4 callers: migrate blob access from raw *s3 inputs to storage.Store
Seven call sites stop reaching through the embedded *s3.Client and
instead use the high-level storage.Store methods. Same runtime behavior
on the AWS path; opens the door to the Filesystem backend for
self-hosters.

avatar.go retains an S3-specific path for public-ACL + cache-control
on uploaded avatars and falls back to ServiceUnavailable on non-S3
backends. A future PublicStore interface could clean that up.

unibox/storage.go GetBody now propagates the emsg.DecodeBinary error
that the original code dropped on the floor.
2026-05-27 14:41:53 +00:00
Matthew Meszaros 368c1f7886 infra(storage): Store interface + Filesystem impl + S3-compatible factory
One Store interface (Get/Put/Delete/Has/PresignedGetURL/Name) covers
both the AWS S3 client (now exposes high-level methods alongside the
legacy embedded *s3.Client) and a new FilesystemStore for self-hosters.

FilesystemStore writes atomically via temp-file + rename, rejects '..'
in keys before path normalization, returns ErrUnsupported from
PresignedGetURL.

The S3 impl works against AWS S3, MinIO, Cloudflare R2, Backblaze B2,
and Hetzner Object Storage via standard AWS endpoint-URL config.

Factory NewFromEnv selects via BLOB_PROVIDER (defaults to s3).

14 tests cover round-trip, ErrNotFound, atomic-write cleanup, traversal
rejection, factory env-selection paths.
2026-05-27 14:41:40 +00:00
Matthew Meszaros 82050d12d1 client(smtpimap): use netbind for outbound source IP selection
SMTP and IMAP clients now expose a BindIP *net.TCPAddr field. When set,
outbound TCP binds to that IP; otherwise the netbind helper falls back to
WORKER_BIND_IP env var, then default route.

IMAP switched from raw tls.Dial to netbind.TLSDialer so the source IP is
configurable; behavior identical when BindIP is nil.

No call-site changes required: backwards compatible.
2026-05-27 14:41:24 +00:00
Matthew Meszaros b71deb37a6 infra(netbind): per-egress outbound source-IP helper
Tiny helper that builds a *net.Dialer (and tls.Dialer wrapper) with an
optional LocalAddr. Used by the SMTP and IMAP clients so a worker on a
multi-IP box can bind outbound TCP to a specific source IP.

Bind IP comes from an explicit *net.TCPAddr on the client, or falls back
to the WORKER_BIND_IP env var, or finally to the OS default route. The
env-var path is cached once via sync.Once.

4 tests cover explicit bind, nil-passthrough, tls.Dialer composition,
and timeout sanity.
2026-05-27 14:41:12 +00:00
Matthew Meszaros a8fa4386d5 infra(kms): pluggable Provider interface + local AES-256-GCM impl
Define kms.Provider so the cipher service can swap between AWS KMS and
a self-hostable local-key path. Local impl uses AES-256-GCM with the
master key sourced from KMS_LOCAL_MASTER_KEY (base64) or KMS_LOCAL_MASTER_KEY_FILE.
Factory FromEnv selects at boot via KMS_PROVIDER; defaults to aws for
backwards compatibility.

Ciphertext blob format is opaque: switching providers requires a DEK
migration because each provider can only decrypt its own blobs.

10 tests cover round-trip, tamper detection, nil-key rejection, and
factory env-selection paths.
2026-05-27 14:40:56 +00:00
Matthew Meszaros 79b87b9b19 refactor: drop burst_multiplier in favor of fixed per-user/per-plan limits
the multiplier was a no-op since the default already sat at 1.0, and
plan tiers / admin overrides express the actual ceiling more clearly
as a fixed limit_*_pm value. enterprise customers who need more
throughput now get a direct bump on user_rate_limits.limit_*_pm
instead of an indirect multiplier.

migration 43 drops the column from both tables. service.go uses the
base limit as the ceiling. UpdateUserRateLimits no longer accepts a
burst_multiplier field.
2026-05-27 12:06:18 +00:00
Matthew Meszaros adafe02c57 Merge pull request #14 from warmbly/feature/marketing-website
feat: launch warmbly marketing site
2026-05-27 08:13:57 +02:00
Matthew Meszaros 04fcd00ad2 site: home warmup - drop activity feed, KPIs become a clean 4-up row
Activity feed and its 'streaming' header were taking too much vertical
space. Removed both and let the four KPIs (Peers, Replies, Inbox,
Spam) sit on a single full-width row below the sky-tinted divider.
Big tabular numbers, eyebrow + sub-line each, no boxes. Pool
composition strip stays at the bottom.

Section is now: title bar, six mailbox rings, divider, KPI row,
pool strip. Roughly 35% shorter overall.
2026-05-27 06:11:18 +00:00
Matthew Meszaros a6be9ceac5 site: home warmup section - rings + activity feed, no chart, no white borders
User said the big SVG chart was too much and white borders looked odd
on the sky theme. Restructured the warmup mock entirely:

- Dropped the big SVG ramp chart.
- Dropped every white-tinted border / divider / ring. No more
  border-white/X, divide-white/X, ring-white/X anywhere in this
  section.
- Top row is a quiet status header: 'Mailboxes in warmup .
  acme.co' eyebrow on the left, 'pool active' streaming pill on
  the right.
- Six small circular progress rings, one per mailbox (founder,
  hello, team, intro, partnerships, support). Each ring is a
  bare SVG with a sky-tinted background track and a sky gradient
  stroke that fills proportionally to day / 30. Day number sits
  inside the ring, full mailbox under it, mono 'day X / 30' below.
  Group hover bumps the scale 5%.
- Section divider is a thin centered gradient line in sky-200,
  not a white rule.
- Live pool activity stream takes the focal-element role. A
  vertical-scrolling feed (warmup-feed-track, 45s loop, masked
  fades top + bottom) with timestamp + dot + email + action +
  uppercase kind tag (reply / send / rescue / star). Sits on
  the sky bg with no panel chrome.
- KPI column to the right of the feed: Peers 142, Replies 11,
  Inbox 96.4%, Spam 1.8% in a 2x2 grid, big tabular numbers, no
  surrounding box.
- Pool composition strip remains, also box-free.
2026-05-27 06:10:02 +00:00
Matthew Meszaros 40f75fdd53 site: rebuild home warmup section as a real product detail screen, trial=2 mailboxes
Warmup section: kept the dark sky radial gradient and drifting clouds,
swapped the flat 6-row mailbox table for a single-mailbox product
detail mock that actually looks like the warmup screen.

- App-shell top bar with traffic-light dots, Warmup / founder@acme.co
  breadcrumb, healthy pill.
- Body splits into a chart column (1.55fr) and a KPI rail (1fr).
- Chart column: real SVG ramp from day 1 to day 30 (10/day -> 40/day,
  +1/day), area gradient sky-300 -> transparent under a sky-300 ->
  sky-500 stroke, dashed y-gridlines, mono d1/d8/d15/d22/d30 x-ticks,
  vertical dashed guide at today (day 22) with a glowing dot and a
  'today' badge. To the right of the chart header, today's value
  '32 / 40' is shown big and right-aligned.
- KPI rail: Pool peers 142, Replies in 11 (rate 34%), Inbox 96.4%,
  Spam 1.8%, each with a mono eyebrow and a sub-line.
- Recent pool activity feed below: an 8-row list duplicated and
  wrapped in a .warmup-feed-track that vertical-scrolls forever
  (45s loop, respects prefers-reduced-motion). Each row is timestamp
  + colored dot + email + action + uppercase kind tag (reply / send /
  rescue / star) so it reads as a real event log.
- Pool composition footer retained (Gmail 46% / Outlook 24% / Yahoo
  12% / iCloud 9% / Self-IMAP 9%).

Trial scope: the hero caveat that the trial is '1 mailbox' was wrong;
it is now '2 mailboxes' as the user clarified. Same line, same place.
2026-05-27 06:05:27 +00:00
Matthew Meszaros 0a1f153cb1 site: home pricing - sky hero section, cards float into white
User asked for creative, on-theme, professional, not AI-generated. The
home pricing now uses the same sky-atmosphere hero pattern the rest of
the product pages use, with the card grid floating over the sky-to-white
boundary the way /pricing handles its hero.

- Section split in two: a relative-isolate sky hero with HeroAtmosphere,
  then a -mt-32/-mt-40 white section pulled up under it that holds the
  card grid and the see-comparison link.
- Hero content: small white sky-style mono 'Pricing' eyebrow, one big
  white headline 'Pay for what you send.', a short white/80 subhead
  naming what's bundled, and the toggle on a translucent backdrop-blur
  pill. Save 20% pill also translucent-on-sky.
- Toggle thumb flipped to white on the sky bg (matches /pricing's
  approach). Toggle JS updated to swap the active button color to
  sky-7 dark and the inactive to white/70 so the labels stay legible
  on both states of the new chrome.
- Card grid below unchanged; it now floats over the sky boundary like
  the cards on every other product page do, which makes the home
  pricing section visually consistent with the rest of the site.
2026-05-27 05:57:03 +00:00
Matthew Meszaros 616f18c0a2 site: home pricing - bundled-features panel as the section header
User asked for something clean, useful, visually appealing. Header above
the cards is now a real value-proposition surface instead of a marketing
block or a chart.

Layout:
- Top line keeps the tiny 'Pricing' sky-6 eyebrow on the left, with the
  toggle and Save 20% pill pulled right.
- A single white panel sits below with its own mono SHOUT eyebrow
  'Bundled on every paid plan' and a quiet subline 'same product .
  different sizes'.
- Six clickable feature tiles inside the panel in a 6-column row
  (2 / 3 / 6 across breakpoints), each with a sky-1 chip icon, a
  feature name, and a one-line description: Warmup, Sending,
  Unified inbox, Analytics, CRM, API & webhooks. Each tile links to
  its product page so the panel is functional, not decorative.
- One short centered line under the panel anchors the pricing thesis
  (you pay for daily send volume; everything in the panel ships on
  every plan).

Clean (no headline shouting, no chart, no side panel), useful (every
tile is a real surface and a real link), visually appealing (the
6-tile grid has its own composition that earns the space).
2026-05-27 05:54:09 +00:00
Matthew Meszaros 3e80c18bc4 site: home pricing - replace marketing header with a sends/day volume axis
User wanted something entirely different and creative. The header now
*is* a chart. Plan names sit above the sends/day tick that unlocks
them, on a single horizontal axis with a sky-color gradient implying
ascending scale.

- Top row: 'Pricing · sends per day' eyebrow on the left, toggle and
  Save 20% pill pulled to the right (no centered marketing block).
- Volume axis: 4 evenly spaced ticks, name above the dot, sends/day
  number below. Starter 150, Grow 3,000, Business 15,000, Enterprise
  infinity / custom.
- Business is visually emphasized: brand sky-6 fill + sky-2 ring +
  outer rgba shadow ring, bigger number, brand-color name with a
  trailing dot. The 'highlighted' card below is the same plan, so the
  chart and the cards reinforce each other.
- Below the axis: one short line stating the actual product positioning
  (the lever that changes between plans is sends/day; everything else
  is included). Replaces the marketing headline and subhead entirely.
- Cards below unchanged.
2026-05-27 05:47:35 +00:00
Matthew Meszaros 57d469c8fe site: home pricing - minimal centered header per the user's choice
Replaces the asymmetric editorial 2-column layout with a minimal
centered header: small sky-6 'Pricing' eyebrow, one big 'Same product.
Four sizes.' headline, the toggle directly below with the Save 20%
pill. No side control panel, no trust strip, no trial scope paragraph,
no subhead. The card grid does the rest of the work.

Selected by the user from a four-way preview pick.
2026-05-27 05:46:16 +00:00
Matthew Meszaros 692ea8135d site: home pricing - don't conflate trial perks with paid-plan perks
The user pointed out that listing '14-day trial' and 'No credit card' in
the same checkbox grid as the paid plans is misleading: those facts only
apply to the trial signup, and the trial has its own limits (1 connected
mailbox slot) that were never stated.

This commit splits the two:

- The 2x2 fact grid in the billing card now lists only things that are
  true for every paid plan: Unlimited mailboxes, Unlimited warmup,
  Cancel any time, Self-host on Apache 2.0. No trial-specific claims
  in this list.
- The trial scope gets its own honest one-liner below the grid, on its
  own divider: 'Free trial: 14 days, 1 connected mailbox, no card.
  Pick a plan from below to unlock everything in this list.' So the
  reader cannot mistake the trial scope for the paid-plan scope.
2026-05-27 05:44:34 +00:00
Matthew Meszaros 10aafa68d2 site: home pricing header to an asymmetric editorial block, drop per-mailbox claim
The user pointed out that pricing is per workspace not per mailbox, and
that the centered-marketing-block header was getting worse with each
iteration. Cards stay byte-for-byte untouched.

- Centered marketing block replaced with an asymmetric 2-column editorial
  layout: left holds the headline, right holds a control panel.
- Eyebrow drops 'billed per mailbox' entirely (factually wrong, and
  obvious enough that it does not need to be said).
- Headline rewritten to 'Same product. Four sizes.' which states what
  the four-card grid below it actually represents, without making any
  per-anything claim about billing.
- Subhead names the bundling promise (every paid plan ships with every
  feature) and the lever you pull (daily send volume).
- The right column is now a real control panel: a Billing eyebrow,
  the toggle plus Save 20% pill, then a 2x2 fact grid (14-day trial,
  no credit card, cancel any time, self-host on Apache 2.0) on a
  white ringed card with a soft shadow.
- Trust facts moved off a long horizontal strip into the panel grid so
  they read as facts about billing rather than a banner.

Edge-to-edge plan card grid below the header is unchanged.
2026-05-27 05:42:30 +00:00
Matthew Meszaros 9b65664746 site: home pricing header redesign (cards untouched)
User said the price boxes are good but the section header above them
was bad. Only the header block changes.

- Drop the brand-soft eyebrow chip and the italic flourish on
  'Predictable.' Both read as marketing-template noise.
- New eyebrow uses the standard site mono caps style:
  'Plans · billed per mailbox' in 10.5px sky-6.
- Headline rewritten to 'Pick a volume. Get every feature.' which
  states the actual product positioning (volume-priced, not
  feature-gated) instead of a vague adjective. Two-line break on
  sm+ so the line lengths balance; reflows on mobile.
- Headline scaled up the responsive ramp (40 / 52 / 64 px) so the
  section reads as a real chapter break rather than a small subsection.
- Subhead names every product surface that's bundled (warmup, sending,
  unified inbox, analytics, CRM, API), so the reason there is one
  number is obvious.
- New trust strip under the toggle: 14-day trial, one mailbox to start,
  no credit card, cancel any time. Four short reassurances, sky-color
  checks, dot separators, no banner theater.

Edge-to-edge card grid below is byte-for-byte unchanged.
2026-05-27 05:39:06 +00:00
Matthew Meszaros 658352b694 site: home pricing - swap price numbers with /pricing slide-up animation
Cards stay edge-to-edge. Only the price-number swap changes.

- Price markup adds the .plan-price > .plan-price-inner wrapper pair
  /pricing uses, with overflow-hidden on the parent so the new digit
  can rise from below the cropped box.
- Toggle JS replaces the motion.dev opacity fade on .price-amount with
  the same two-phase translateY swap /pricing runs on .plan-price:
  phase 1 lifts and fades the old value to translateY(-100%), phase 2
  drops the new value in from translateY(100%) with a
  cubic-bezier(0.34,1.56,0.64,1) overshoot.
- Cadence line keeps its existing class but now does a soft 180ms
  opacity-out, text-swap, opacity-in instead of a hard snap, so it
  reads in sync with the price.
- Thumb still animates with motion.dev animate(); the switcher
  behavior is unchanged.
2026-05-27 05:36:51 +00:00
Matthew Meszaros 243eb0e59b site: home pricing cards go edge-to-edge (gap-px shared border, no ring per card)
User confirmed the old pricing they meant is the 1d5a75d edge-to-edge
grid: cards share borders, no individual rounded ring on each card,
the whole grid sits inside one rounded ring with overflow-hidden.

This commit takes the layout from 1d5a75d:
- grid uses gap-px on a [color:var(--border)] background to render the
  internal separators as 1px lines instead of card gaps.
- Whole grid wrapped in a single ring-1 rounded-[10px] overflow-hidden
  container so the four cards read as one block.
- Each card is bare bg-white p-7, no rounded corners, no individual ring,
  no shadow.
- Plan name uses the 14px uppercase tracking-[0.12em] eyebrow style,
  not the 16px bold heading style.
- Featured card keeps the Most popular pill, brand-color name, and
  brand-filled CTA, but drops the ring-2 scale-[1.02] and the
  sweep/bloom decorations (they only made sense on a separated card).

Switcher and price animation are still the d256ba4 motion.dev versions
restored in the previous commit, so the switcher works and the price
fades on toggle.
2026-05-27 05:34:02 +00:00
Matthew Meszaros ec986891d5 site: revert home pricing section to the d256ba4 version verbatim
The user asked for the OLD pricing section back, the one before the
1d5a75d strip-down. This commit restores it exactly as it shipped in
commit d256ba4:

- Plans data: string prices ($29, $23, ..., 'Custom') instead of the
  numeric/null shape I borrowed from /pricing.
- Section header: small 'Pricing' eyebrow chip on brand-soft, big
  italic 'Per mailbox. Predictable.' headline, larger py-24/py-32
  padding restored.
- Toggle: ID-based premium thumb toggle (#billing-toggle / #toggle-thumb)
  with motion.dev sliding the thumb between Monthly and Annual. Save 20%
  emerald pill sits to the right.
- Cards: ring-1 standard / ring-2 brand + scale-1.02 featured, sweep
  + bloom on the featured card, Most-popular pill anchored top-right,
  sky-zap sends/day chip, brand-soft/sky checkmarks, brand-filled CTA
  on the featured plan.
- Toggle script: the original motion.dev driven version that swaps the
  .price-amount text via opacity flips and pushes the .cadence string.
- Kept the inline thumb width/left fallback (left:116px width:112px) so
  the thumb stays visible until motion has booted.
2026-05-27 05:31:11 +00:00
Matthew Meszaros f6fdd9731c site: home pricing now mirrors /pricing card layout 1:1
The user has been asking for the home pricing section to match /pricing.
This commit ports the /pricing card grid verbatim onto the home page:

- Plan data structure aligned: numeric price/annual (29, 23, etc),
  null for the Enterprise plan, all other fields preserved (summary,
  sendsPerDay, href, cta, highlight, features).
- Card markup is now the same as /pricing: rounded-[18px] white card,
  ring + layered shadow on standard plans, a 2px sky-6 ring + sky glow
  on the featured plan, sky-1-to-transparent gradient overlay at the
  top of the featured card, Most-popular pill anchored top-center.
- Plan name eyebrow is the sky-6 mono SHOUT label (matches /pricing).
- Price block: 48px number with a 28px $ prefix span, slide-up
  .plan-price > .plan-price-inner wrapper with the same
  cubic-bezier(0.34,1.56,0.64,1) rebound easing /pricing uses, then a
  / mo postfix; Enterprise renders the bare 'Custom' word at 44px.
- Cadence is .plan-note with data-monthly-note / data-annual-note that
  the toggle script swaps with a soft fade. Enterprise gets the static
  'volume-based · contact sales' string.
- Feature rows use sky-1 chip + sky-6 check, no neutral pills.
- Plan CTA: sky-6 filled button with lift+glow on the featured card,
  dark filled button on the others, matching /pricing.

The toggle and switcher script were already aligned in the prior
commit; this commit only changes the card grid so the layout and the
price-swap animation now match /pricing exactly.
2026-05-27 05:27:11 +00:00
Matthew Meszaros 756978f9c1 site: home pricing - drop clouds, mirror /pricing switcher + price slide-up, compact footer
Home pricing:
- Removed both clouds the user flagged: the big cloud-5 backdrop sitting
  behind the panel and the small cloud-4 bloom inside the featured card.
  The brand-ring + sweep + Most-popular pill carry the highlight alone now.
- Switcher swapped to the same vanilla implementation /pricing uses:
  data-billing-toggle wrapper with a .billing-indicator span that slides
  by getBoundingClientRect, no motion.dev dependency, indicator is
  positioned on first frame so it is never invisible.
- Price animation: the cards keep their original layout (large $48px
  number, cadence note line, no $ prefix, no /mo split, no 62px
  forced height), but the inner number now lives in a
  .plan-price > .plan-price-inner pair and swaps via the same slide-up
  cubic-bezier(0.34,1.56,0.64,1) rebound used on /pricing.
- Cadence text uses .plan-note with data-monthly-note / data-annual-note
  so it fades in sync with the price swap.

Footer:
- The Mindroot Ltd disclosure went from three stacked lines to a single
  compact line under the copyright, satisfying UK Companies Act s.82
  without dominating the footer.
2026-05-27 05:24:12 +00:00
Matthew Meszaros 734d149072 site: social avatar + full SEO icon set + Mindroot Ltd footer + home pricing restore
Brand and assets:
- New /public/brand/social-avatar.jpg: the official Warmbly social profile
  picture (paper-plane mark on a sky-with-clouds canvas, 1024x1024).
- scripts/gen-icons.mjs generates the full favicon set from that source
  using sharp. Rounded variants (16/32/48/96) so the icon reads as a
  soft chip in browser tabs, square apple-touch-icon (iOS rounds it),
  square maskable manifest icons (192/512), and a 1200x630 OG card on a
  sky gradient with the rounded avatar on the left.

SEO / head:
- Layout.astro head now declares all favicon sizes, full Open Graph
  set (type, site_name, url, locale, image w/h/alt), Twitter card with
  summary_large_image, application-name and PWA capability meta, dual
  light/dark theme-color tied to the sky tokens, format-detection off
  for phone-number autolink, and crawler hints with
  max-image-preview:large.
- Two JSON-LD blocks: Organization (legalName Mindroot Ltd, postal
  address, two ContactPoints, Companies House identifier, sameAs
  GitHub) and WebSite (publisher reference). Drives rich-result
  eligibility and gives crawlers a clean entity to attach signals to.
- site.webmanifest expanded: real name, description, start_url, scope,
  full icon set including the 96 rounded chip and apple-touch-icon
  alongside the maskable 192/512s, sky-token theme/background colors.

Brand page:
- New Social avatar section after Marks: 1024 source preview, round
  and square crop previews at 48/64/80/112, download CTA pointing at
  /brand/social-avatar.jpg. Asset manifest gets the avatar entry.

About page:
- Repo surface list now includes site/ (this marketing site, Astro 5
  + Tailwind v4), deploy/, docs/, resources/, scripts/. AGENTS.md
  (CLAUDE.md symlinked) System Shape section updated with the same
  extra paths so the agent context stays in sync.

Footer:
- UK Companies Act s.82 disclosure restored with real entity info:
  Mindroot Ltd, company number 16543299, registered office at
  71-75 Shelton Street, London, England, WC2H 9JQ. Copyright line
  flipped to Mindroot Ltd.

Home pricing regression fix:
- The home pricing cards had been flattened into a single edge-to-edge
  grid with no rings, no featured-card highlight, and no animations,
  while the toggle thumb had no inline width/left so it disappeared
  whenever motion.dev had not booted yet (the 'defected switcher'
  the user reported).
- Restored the premium card layout: each plan is a separated rounded
  card with its own ring, the featured plan picks up ring-2 of the
  brand color, a cloud bloom in the top-right, a continuously sweeping
  highlight (pricing-sweep keyframes already present in the page
  stylesheet), and a 'Most popular' pill.
- Toggle thumb now starts at left:116px width:112px so the Annual
  default is visually selected on first paint even before the JS
  upgrade animates it.
2026-05-27 05:19:52 +00:00
Matthew Meszaros 8813247b46 site: drop about hero stats card, fix legal hero readability against clouds
About:
- Remove the floating six-cell at-a-glance stats card under the hero
  (License/Self-hostable/Roadmap/Encryption/Worker SQL access/Default
  cold cap). The card looked busy and disrupted the page flow; the
  same facts are covered in the planes, principles, and posture
  sections lower down.
- Drop the now-unused facts array and the github.com/warmbly/warmbly
  caption.
- Tighten the hero bottom padding from pb-44/pb-56 to pb-20/pb-28
  since nothing floats over it anymore.

Legal layout:
- Add a top-to-bottom dark scrim above HeroAtmosphere so the white
  eyebrow, title, and metadata stay legible when a painterly cloud
  drifts behind them. Scrim uses the sky tokens (sky-8 to sky-6 to
  transparent) so it reads as deeper sky, not as a black bar.
- Drop the white-on-cloud type into a small text-shadow as belt-and-
  braces against the brightest cloud highlights.
2026-05-27 05:09:35 +00:00