Commit Graph
613 Commits
Author SHA1 Message Date
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
Matthew Meszaros 8316ab333e site: trim legal to terms + privacy, unify layout, real entity, contact emails only
Removes pages we do not need:
- dpa
- acceptable-use
- subprocessors
- cookies

Footer Legal column drops the four removed entries and keeps just
Terms and Privacy.

Shared Legal layout (src/layouts/Legal.astro) now drives both pages:
- Hero uses HeroAtmosphere for consistency with the product pages.
- Sticky sidebar lists the in-page sections passed as a sections prop.
- Scroll-spy script highlights the active section as you scroll and
  slides an animated vertical pill indicator next to it, matching the
  learn handbook treatment.
- Documents block under the section list cross-links Terms <-> Privacy.
- Footer line points to hello@warmbly.com (the only inbound legal
  address advertised on /contact).

Real legal entity sourced from ~/warmbly-web:
- Mindroot Ltd, company number 16543299, registered in England and Wales
- Registered office: 71-75 Shelton Street, London, England, WC2H 9JQ
- Governing law: England and Wales; jurisdiction: courts of London

Terms rewritten as 17 numbered sections using the shared layout, with
a Who-we-are section that names Mindroot Ltd and the registered
office, Stripe as the payment processor, an Acceptable use section
that absorbs the relevant bits from the removed acceptable-use page,
and a contact section that points to hello@warmbly.com.

Privacy rewritten as 13 numbered sections, mirroring the real
encryption model (AWS KMS root, per-user 32-byte DEK, AES-GCM at the
app layer, DEK ciphertext in DynamoDB, plaintext DEK cached briefly
in Redis, control-plane state in PostgreSQL, workers operating over
Kafka/KMS/DynamoDB/S3/Redis only). UK ICO named as supervisory
authority; UK IDTA and EU SCCs referenced for international transfers.

Email cleanup across the site so only addresses that appear on the
contact page are used (hello@ and sales@):
- trust: security@warmbly.com -> hello@warmbly.com in the hero CTA,
  the responsible-disclosure body and email chip, and the final CTA;
  CTA secondary link swapped from /subprocessors/ to /privacy/.
- trust: subprocessors-summary block keeps the table of infrastructure
  providers (real, sourced from CLAUDE.md) but drops the link out to
  the deleted /subprocessors/ page and the DPA-on-request note.
- brand: press@warmbly.com -> hello@warmbly.com in the press contact.
- faq: DPA answer email updated from security@ to hello@.

Build: 36 pages (was 40); no remaining links to the removed pages.
2026-05-27 05:05:41 +00:00
Matthew Meszaros f64c7e8922 site: full redesign of company, legal, and deliverability pages
Ten pages rewritten end to end against the shared HeroAtmosphere hero,
container-page max-width, and sky color tokens, so they finally match
the visual language of the product pages (sending, warmup, developers,
roadmap, changelog).

Company:
- about: open-source positioning, control plane vs execution plane
  origin essay, six numbered build principles, posture comparison,
  real repo path strip, small-team note linking to roadmap and
  changelog and GitHub.
- brand: marks grid with wordmark and mark variants plus clear-space
  and min-size strip, full sky palette ramp with hex and token names,
  typography specimen (Inter plus system mono), four voice principles,
  do and dont misuse grid, press contact and download manifest.
- trust: posture badges with shipped vs roadmap items called out
  honestly, real envelope encryption flow (KMS, per-user DEK, AES-GCM,
  Redis-cached plaintext DEK, encrypted DEK in DynamoDB) with source
  anchors, worker boundary table, collect vs never-collect split, real
  auto-block thresholds, subprocessors link, responsible disclosure
  with security@warmbly.com only, status and changelog quick links.

Deliverability:
- mailbox-first positioning, per-mailbox health dashboard mocking the
  five real states (healthy, watch, throttled, quarantined, blocked),
  opinionated four-point stance, defaults spec sheet pulled straight
  from CLAUDE.md (50/day, 600s, 10/day, 40/day, +1/day, 100/5min,
  500/hr, 3/24h tokens, score > 50), four-pool isolation card grid,
  auto-quarantine band gauge with thresholds and Google/SES anchors,
  SPF/DKIM/DMARC/PTR coverage grid, six-row suppression engine table,
  recommended cold posture, by-the-numbers strip with source paths,
  FAQ and CTA.

Legal (consistent treatment, sticky sidebar TOC + numbered sections):
- terms: 17 numbered sections, Stripe payment processor note,
  placeholder governing-law phrasing, legal@warmbly.com contact.
- privacy: 13 sections mirroring the real encryption model in copy,
  print stylesheet for clean printing.
- dpa: 20 sections plus three annexes, SCC and UK IDTA references,
  72-hour breach notification, sub-processor authorization model,
  cross-references to terms and trust and subprocessors.
- acceptable-use: opinionated 14-section AUP, allowed vs not-allowed
  two-column grid, real mailbox health enforcement ladder, no fake
  enforcement statistics, abuse@warmbly.com.
- subprocessors: designer-grade table of real vendors only (AWS,
  Cloudflare, Stripe, Postmark, Sentry, Plain) with real security or
  privacy URLs, mobile stacked-card fallback, subscribe-to-changes
  mailto, evaluation criteria, notice and objection window.
- cookies: per-category tables (strictly necessary, functional,
  third-party) with real cookie names, analytics and marketing
  explicitly empty rather than invented, manage-preferences anchor.

Constraints enforced across all ten: no em dashes, no fabricated
metrics, no fake compliance badges, no fictional company or employee
names, no AI-cute filenames or terminal logs. Astro JSX parser
caveat respected throughout (no < or <= comparisons inside JSX
expression blocks; all comparisons hoisted to frontmatter).
2026-05-27 04:51:27 +00:00
Matthew Meszaros 988d5bb2c3 site(recruiters): replace leftover candidate.X refs in sequence preview header
The earlier slop scrub renamed the sequence to use template variables
({{ first_name }}, {{ current_company }} etc), but the sequence preview
strip header and variables footer still referenced a candidate object
literal (candidate.first_name, candidate.current_company, candidate.current_role,
candidate.why_now) and fictional from:/to: addresses (ben@vector-talent.com,
priya@stripe.com), which crashed the build with 'candidate is not defined'.

- Preview chip: 'Preview · candidate.first_name' becomes 'Plain text'.
- Strip filename 'staff-platform-fy26-q2' becomes 'Sequence preview'.
- from:/to: replaced with neutral 'From recruiter mailbox' / 'To candidate'.
- Variables footer maps real template vars (first_name, current_company,
  open_role, sender.first_name) to short descriptive labels instead of
  pulling from a non-existent candidate object.
2026-05-27 04:42:03 +00:00
Matthew Meszaros 2668ce8e8f site: strip AI-cute slop labels, tweak dropdown title, instant-close on sibling nav
- founders: drop the 'founder-math.txt' filename + 'Founder math' frame in
  the floating panel header. Replace with a plain 'The plan' eyebrow
  plus a descriptive heading.
- recruiters: drop the fake 'candidate-step-01.body.txt' filename in the
  code block header. Replace candidate body fanfic (Stripe / Series B
  fintech / Bay Area band / hub days in SF) with the actual variable
  placeholders the template engine would receive.
- sales: remove the made-up route: round_robin(...) / by_territory(...) /
  crm.account_owner / manual(queue: triage) CLI snippets that looked
  real but were not. Replace the 'routing: by_territory(field: account.region)'
  line in the routing demo with a plain 'Routed by territory' label.
- header: replace 'Built for five seats.' use-cases dropdown title with
  'Who uses Warmbly.' + a plainer tagline.
- header: hovering any non-mega nav link (Deliverability, Pricing, Learn)
  now instantly closes any open mega panel via forceClose.
2026-05-27 04:39:02 +00:00
Matthew Meszaros 24dfa0919c site(use-cases): full redesign of recruiters page, drops shared UseCasePage component 2026-05-27 04:26:52 +00:00
Matthew Meszaros 432852cb79 site(use-cases): full redesign of founders page, drops shared UseCasePage component 2026-05-27 04:26:16 +00:00
Matthew Meszaros 04330ff6d1 site(use-cases): full redesign of agencies page, drops shared UseCasePage component 2026-05-27 04:25:58 +00:00
Matthew Meszaros 15c3028903 site(contact): drop security/support/api/press channels, keep general + sales 2026-05-27 04:23:46 +00:00
Matthew Meszaros cebbe7e17e site(header): hovering one mega panel instantly closes the other 2026-05-27 04:22:41 +00:00
Matthew Meszaros a42bd242aa site(changelog): drop featured-release hero graph, full-bleed body sections 2026-05-27 04:17:19 +00:00
Matthew Meszaros f0b50d807d site(header): add Use cases mega-menu next to Product 2026-05-27 04:15:07 +00:00
Matthew Meszaros 705557a1e3 site(developers): full redesign as API & webhooks page
Replace the previous single-screen layout with the established marketing
page structure shared by warmup, campaigns, sending, inbox, analytics,
and crm.

Sections:
- HeroAtmosphere hero with eyebrow chip, two-line headline, dual CTA
- Floating dashboard mock mirroring the real /settings/api-keys page:
  topbar, 4-up stat strip, 24-bucket traffic graph, keys table with
  status pills and rate limits
- Three-panel Hello world: curl, TypeScript SDK, Python SDK
- Webhook delivery: signed payload sample with Node verification
  pseudocode and a 5-step retry schedule panel ending in dead-letter
- Event catalog: 14 subscribable event types in a 2-col grid
- Limits and quotas spec sheet (rate limits, retry counts, idempotency
  window, payload max)
- Per-key scopes table covering mailboxes, sequences, contacts, events,
  crm, and admin
- Animated FAQ with 4 developer questions
- CTA footer linking to api-keys settings and docs
2026-05-27 04:09:22 +00:00
Matthew Meszaros 0a50f14046 site(changelog): full redesign in the Warmbly theme
Replace the single-column timeline with a layered editorial page:
HeroAtmosphere hero matching warmup and pricing, a featured card
floating under the hero with a terminal-style detail of the BYOK
envelope flow, a 3-card count strip, filter chips, a hairline
timeline of all entries, and a 4-card subscribe block linking the
feed, GitHub, roadmap, and trust center.

Reseeds the entry list with the prior 9 entries and 3 new ones
grounded in the real codebase: BYOK envelope encryption, SAML SSO,
and SOC 2 Type I in progress.
2026-05-27 04:08:32 +00:00
Matthew Meszaros 3beeea2ca5 site(roadmap): full redesign in the Warmbly theme
Replace the three-card roadmap with the marketing-page hero pattern
(HeroAtmosphere, badge, large headline, two CTAs) and a richer body:

- Floating roadmap board floats up over the hero with three columns
  (Shipped 90d, In progress Q2 to Q3, Researching H2). Each item card
  carries a category chip, title, note, and changelog link when shipped.
- Quarter-by-quarter strip for 2026 with ship counts and themes,
  highlighting the current quarter.
- Cadence spec sheet with mono-aligned values for items per quarter,
  release cadence and refresh frequency.
- Editorial "How we pick" split with sticky title and a numbered list
  of prioritisation inputs.
- Smooth grid-rows FAQ accordion with four questions.
- CTA block with Request a feature and See the changelog.

No fabricated metrics. Seed items come from the previous page, CLAUDE.md
and the live changelog.
2026-05-27 04:08:00 +00:00
Matthew Meszaros dba5ead5fe site(header): swap Sending engine for API & webhooks in product dropdown 2026-05-27 04:04:24 +00:00
Matthew Meszaros d89366031f site(analytics): real dashboard mock floats under the hero
Replaced the drill-down ladder panel with the actual product analytics
dashboard (PageTopbar + StatStrip + Email performance + Breakdown +
Warmup mailbox strip), and removed the duplicate sample-dashboard
section that appeared further down the page. The hero now flows
directly into the screenshot-worthy product view.
2026-05-27 04:02:41 +00:00
Matthew Meszaros bc2a6eb2b8 site(analytics): dashboard mock now mirrors the real product page
Modelled directly on web/src/app/app/analytics/page.tsx. Same chrome,
same components, populated with illustrative demo-workspace numbers
so the panel works as a marketing screenshot.

- PageTopbar: Analytics eyebrow + 'Deliverability across the
  workspace' subtitle + 7d / 30d / 90d range tabs (7d active in
  slate-900).
- StatStrip cols=4: Total sent 12,847 · Open rate 34.2% · Reply rate
  6.1% · Bounce rate 1.8% with sub-labels matching the real component.
- Body grid 1fr | 320px:
  - Email performance: SectionBar + 28 sky-100 bars, '7d ago / today'
    axis labels.
  - Breakdown column: Delivered / Opened / Replied / Bounced / Spam
    rows with colored dots and right-aligned tabular numbers.
- Warmup SectionBar with 'All accounts ↗' link, plus a 4-card strip
  of mailbox warmup state (ben/sara/mark/kai with day-count, today
  send / cap, state chip, progress bar).
All chrome, hairlines, colors mirror the actual product.
2026-05-27 03:57:59 +00:00
Matthew Meszaros 261354438b site(inbox): mock now mirrors the actual product unibox
Modelled directly on web/src/components/app/unibox/* (ConversationList +
ThreadView). No app shell, no URL window chrome.

Left pane (340px):
- SectionBar with 'Inbox' + count chip + filter button
- Search input + 4 quick filter chips (All / Unread / Today / This week)
- ConversationItems: slate avatars with initials, semibold sender names
  when unread, sky-500 left bar for unread, sky-50 bg when selected,
  relative-time stamp, subject, snippet preview
Right pane (ThreadView):
- 48px top bar with 'Thread · subject' eyebrow + mark-unread / archive /
  delete icon buttons
- Sub-header strip with '3 messages · 2 participants' + Positive chip
- Message stream: hairline-divided articles, sender + email + time, body
- Auto-action receipt strip (paused sequence, routed to assignee,
  posted to Slack)
- ReplyComposer at the bottom with attach/template buttons on the left
  and Save draft / Send buttons aligned right

All chrome, colors and spacing match the real React components.
2026-05-27 03:53:44 +00:00