Skip to content

Handoff: Sip N Serve email platform (Mautic/Listmonk on Resend)

URL: https://mkdocs.justinsforge.com/memory/handoffs/sipnserve-email-platform-2026-07-01/

Date: 2026-07-01 For: sipnserve-email worker (Fable 5) Directive from Justin (hard): Do NOT keep suggesting we give up and switch to a SaaS (Klaviyo/MailerLite). Problem-solve and make the self-hosted solution work. He got frustrated when the parent kept offering the SaaS escape hatch. Also: keep momentum, don't propose pausing/wrapping (see feedback_no_stopping_suggestions).

Goal

Give Krystal a self-hosted, self-serve newsletter tool for Sip N Serve Society (Austin volleyball community) that produces good-looking emails without Justin/Claude hand-coding each one. Delivery is owned via Resend. This is a pilot; if it works, extend to the other 3 brands (Nova, Gus, JustinWieb) — those need Resend Pro ($20/mo) since free tier = 1 domain (deferred, Justin's spend call).

Read for full detail

  • forge/memory/daily/2026-07-01.md — session checkpoints (search "Listmonk multi-brand email platform BUILT" and later).
  • forge/memory/plans/multi-brand-email-listmonk-resend-2026-07-01.md — the 16-task plan.

Infrastructure (all live)

  • forge-mail = dedicated LXC CT 112, 192.168.86.87 (Debian 13, Docker), created on Finn for this. ssh [email protected]. 4GB RAM / 4 cores.
  • Listmonk v6.2: /opt/listmonk compose, port 9000, lists.justinsforge.com. Repo: forge/infra/listmonk/. Basic editor — likely being retired in favor of Mautic.
  • Mautic 5.2.11: /opt/mautic compose (db/web/cron/worker), port 8090, mautic.justinsforge.com. Repo: forge/infra/mautic/. This is the front-runner (real GrapesJS drag-drop builder + asset library).
  • Cloudflare tunnel rides media-server's cloudflared (tunnel id a55b7768-b42a-4d78-82b5-4a427bdbb9b7, account 5b4138008d635b79e07af0c7388cd46f). Access apps gate Listmonk /admin+/api and Mautic /s; public paths (unsubscribe, tracking) stay open. Allowlist: [email protected], [email protected], [email protected]. OTP idp.
  • Resend: sender for everything. Keys in ~/.forge-secrets/resend.env (RESEND_API_KEY full, RESEND_SMTP_KEY sending). sipnservesociety.com verified (DKIM+SPF). SMTP smtp.resend.com:587 user resend.
  • Secrets: ~/.forge-secrets/{resend,listmonk,mautic,cloudflare}.env (chmod 600). Mautic admin admin/pw + DB creds in mautic.env; Listmonk admin justin/pw in listmonk.env. NOTE: Listmonk v6 API-token auth never worked → all Listmonk data changes were done via direct Postgres inserts.

Mautic state (the important part)

  • Branded sipnserve theme (MJML, GrapesJS-editable) installed; email id=1 "Sip N Serve — Test Newsletter" built on it. Its custom_html renders CLEAN MJML (cobalt #0137f4 header + wordmark logo + intro + shaded event card with blue "Reserve your spot" button + footer). Confirmed clean (0 CKEditor <figure> junk).
  • The jank Justin saw was NOT the theme — it was using the wrong send path: contact → "Send Email" → "import from existing template" dumps the design into Mautic's CKEditor and mangles it (figure-wrapped tables, default margins, ugly mailjet social icons). The clean path is a Segment email sent to a segment.
  • A background agent (Mautic deploy agent) is CURRENTLY wiring: make email id=1 a segment email (email_type='list'), create "Sip N Serve Members" segment (lead_lists) with contact [email protected] (lead_lists_leads), link email→segment (email_list_xref), fix the mailjet dark-square social icons → mailchimp CDN icons, and fire a verified test send to the segment. CHECK THAT AGENT'S RESULT FIRST before redoing anything. Mautic tokens: {unsubscribe_text}, {webview_text}.
  • Logo currently hotlinks Mailchimp CDN (mcusercontent.com/.../69f8650a-...png) — should be self-hosted (tech debt).

Design DNA (Sip N Serve)

Cobalt blue #0137f4, cream #ecefe6, white canvas, dark text #1a1a2e, shaded card #f7f8fb, warm lowercase voice. Socials: Instagram (instagram.com/sipnservesociety), website. Reference design: forge/infra/listmonk/templates/sip-n-serve.html.

Next steps (updated 2026-07-01 evening, worker session)

  1. DONE. Segment send verified end-to-end twice: sync path 19:41Z and async doctrine-queue path 19:44Z, both landed in Gmail INBOX (not Promotions), email_stats clean, 0 failed.
  2. DONE. Logo + both social icons self-hosted at https://mautic.justinsforge.com/media/images/ (public through tunnel, no Access block). Rewrote emails.custom_html, bundle_grapesjsbuilder.custom_mjml (builder source — do not miss this table when editing email HTML by hand), theme twigs on forge-mail, and repo copies incl. email-compiled.html. Images also checked into forge/infra/mautic/media/images/. Justin should still eyeball the builder once.
  3. DONE. Krystal login: user krystal / password in ~/.forge-secrets/mautic.env (MAUTIC_KRYSTAL_PASSWORD), role "Sip Editor" (emails/assets/contacts/segments/categories full; 403 on user/config admin — verified). Created via Mautic API enabled temporarily with basic auth, then disabled again (verified 403 api_disabled). She's already on the Cloudflare Access allowlist.
  4. FIXED en route: worker container was crash-looping (messenger:consume vs default sync:// transport, hundreds of restarts/min). Compose now sets MAUTIC_MESSENGER_DSN_EMAIL/HIT=doctrine://default (forge-mail + repo copy); worker consumes cleanly, sends flow queue→worker→Resend.
  5. OPEN: Listmonk vs Mautic call is Justin's; recommendation is Mautic (theme, builder, segment flow, roles all proven; Listmonk v6 API auth never worked). On approval retire Listmonk container + repo dir.
  6. OPEN: Commit forge/infra/{listmonk,mautic} + plan + handoff (Justin hasn't approved the commit yet — ask). Branch, don't commit to main directly.
  7. Later (needs Resend Pro): onboard Nova/Gus/JustinWieb brands, DMARC hardening.

Gotchas

  • context-mode hook BLOCKS curl/wget/WebFetch on Console; run curl only remotely via ssh, use WebSearch for docs, python urllib works with a browser User-Agent (Resend API is behind Cloudflare, blocks python-urllib default UA — set User-Agent: curl/8.5.0).
  • Mautic needs trusted_proxies=['0.0.0.0/0','::/0'] in local.php (already set) or it infinite-redirects through the tunnel.