MCO Newsletter System — Implementation Brief for Claude Code
Mission
Build an email-subscription system for the Montana Climate Office website
(climate.umt.edu, a Jekyll site in the mt-climate-office/mco-website repo).
When a new post is published under _posts/, subscribers should automatically
receive a well-formatted, responsive HTML email of that news item.
The stack and high-level design are already decided (see “Decisions already
made”). Your job is to implement it inside our existing AWS + Terraform
conventions, not to invent a parallel way of running infrastructure.
How to use this brief
- Do Phase 0 first. Understand how our AWS infrastructure is currently
orchestrated before you create anything. Conform to what you find.
- Everything that can be Terraform, must be Terraform. We manage AWS as
code. Do not click resources together in the console. The only acceptable
manual steps are the ones explicitly flagged below (SES production-access
request, and any DNS changes that a human must approve).
- Confirm the open questions with me before provisioning anything that costs
money or touches DNS. Present a short plan and wait for a go-ahead.
- Work in phases. After each phase, summarize what changed and what you need
from me next.
Decisions already made (the architecture)
- List management + sending engine: Listmonk
(AGPL, self-hosted, single Go binary + Postgres). It owns the subscriber
database, the hosted signup form, double opt-in, unsubscribe handling, and
campaign sending. Subscriber data lives only in Listmonk’s Postgres —
never in a git repo.
- Email delivery: Amazon SES. Listmonk sends through SES via SMTP
credentials. (~$0.10 per 1,000 emails — negligible at our volume.)
- Email authoring: MJML (MIT) compiled to table-based,
CSS-inlined HTML so it renders correctly in Outlook/Gmail/Apple Mail.
- Orchestration: GitHub Actions in the
mco-website repo. On publish, the
workflow renders the post into the MJML template and calls the Listmonk API
to create and send a campaign.
- Sending identity: a subdomain of
climate.umt.edu (e.g.
news@mail.climate.umt.edu) to isolate newsletter sending reputation from
the university’s primary mail. (Confirm the exact address — see open
questions.)
Why this shape: it’s fully open-source, keeps subscriber PII in our control,
and the only stateful component is one small always-on host.
Phase 0 — Understand the existing AWS infrastructure (do this first)
Locate and read our Terraform before writing any. Produce a short written
summary of how we currently orchestrate AWS, then a recommendation for where
this new service fits. Specifically identify:
- Where the Terraform lives (which repo / directory) and its module
structure and naming + tagging conventions.
- The state backend (S3 bucket + DynamoDB lock table?) and how
plan/
apply are run (locally? CI? Atlantis? Terraform Cloud?).
- Account + region, and the VPC / subnet layout (public vs private).
- How we run long-lived services today — EC2 instances, ECS/Fargate,
Lightsail, EKS? Match this. This is the single most important convention to
follow.
- DNS: is
climate.umt.edu (or a delegated subdomain) managed in Route 53
in our account? If not, DNS records will be a manual, human-coordinated step.
- Secrets management: SSM Parameter Store, Secrets Manager, or other.
- Any existing SES setup, verified domains, or sending identities.
Deliverable for this phase: a INFRA-NOTES.md summary + a recommended
deployment target for Listmonk (see Phase 1), with rationale.
Default recommendation, if our conventions don’t dictate otherwise: the
smallest sensible always-on Docker host — an EC2 t4g.small (ARM) in a public
subnet with an Elastic IP, or Lightsail if that’s already a pattern we use.
Run Listmonk + Postgres on it via Docker Compose with a persistent volume.
However: if we standardize on ECS Fargate + RDS, use that instead —
Listmonk container on Fargate, Postgres on a small RDS instance. It costs more
(~$15+/mo for RDS) but may be required by our governance. Decide based on
Phase 0 and confirm with me.
Provision via Terraform in our existing repo/module style:
- Compute (EC2 or Fargate per above).
- Postgres (container volume or RDS, matching the compute choice).
- Security group: 443 inbound, SSH restricted to our IPs/SSM only.
- Static IP / Elastic IP.
- DNS A record for a
lists.climate.umt.edu subdomain → the host (flag if DNS
is manual).
- A backup mechanism for the Postgres subscriber database (snapshots or
pg_dump to S3).
- Bring up Listmonk + Postgres (Listmonk publishes an official
docker-compose.yml — adapt it; pin image versions).
- Front it with a reverse proxy (Caddy is simplest for automatic TLS via
Let’s Encrypt; nginx + certbot also fine) on the
lists. subdomain.
- Lock down the admin UI (strong admin creds in our secrets store, not in the
repo).
- Create one mailing list, enable double opt-in, build the public
subscription form, and confirm the unsubscribe flow works.
- Ensure outgoing mail sets the
List-Unsubscribe and List-Unsubscribe-Post
headers (one-click unsubscribe is required by Gmail/Yahoo bulk-sender rules).
- Verify the sending domain/subdomain in SES and create the DKIM records
via Terraform (Route 53 if we control the zone; otherwise output the records
for me to hand to UM IT).
- Add/confirm SPF and a DMARC policy for the sending subdomain.
- Create SMTP sending credentials (IAM user with
ses:SendRawEmail, converted
to SMTP creds) and store them in our secrets manager.
- Manual step (flag to me): SES starts in sandbox mode and can only send
to verified addresses. Submit the SES production-access request; this
needs a human and usually clears within a day.
- Wire the SES SMTP endpoint + credentials into Listmonk’s SMTP settings.
Phase 4 — Email template (MJML)
- Build a responsive MJML template matching MCO branding (logo, colors, footer).
- Compile to inlined HTML as part of the workflow.
- Critical: our posts embed the live tool with an
<iframe>. Email clients
strip iframes and most JS/CSS. The template must replace any iframe/embed with
a clickable banner image linking to the live page. Handle this in the
Markdown→HTML step (detect and swap), don’t ship raw iframes.
- Footer must include the unsubscribe link (Listmonk’s ``
token) and a physical mailing address (CAN-SPAM): Montana Climate Office,
32 Campus Drive, Missoula, MT 59812.
Phase 5 — GitHub Actions workflow (mco-website repo)
- Trigger on push to
main affecting _posts/**, plus a manual
workflow_dispatch with a dry-run option.
- Detect the newly added post (
git diff --diff-filter=A), not edits, so we
don’t re-email on every push. Also skip posts dated in the future.
- Idempotency: guard against double-sends. Recommended approach: a
front-matter flag (e.g.
email: pending → set to sent), or track sent
slugs. Decide and document it.
- Convert the post Markdown → HTML (respect front matter; perform the
iframe→image swap), render through the MJML template, compile to inlined HTML.
- Call the Listmonk API to create a campaign against the list and start it.
- Store the Listmonk API base URL + token and any other secrets as GitHub
Actions secrets, never in the repo.
- Note: this is separate from the GitHub Pages build that publishes the site.
Security & compliance — non-negotiable
- No subscriber PII in any repo, ever. It lives only in Listmonk’s Postgres.
- All secrets in SSM/Secrets Manager + GitHub Secrets; nothing committed.
- TLS everywhere; Listmonk admin not publicly exposed without auth.
- One-click unsubscribe headers present on every send.
- Coordinate all DNS changes with UM central IT; prefer subdomain sending.
- Regular backups of the subscriber database.
Open questions to confirm with me before provisioning
- Deployment target: single EC2/Lightsail Docker host (default) or ECS
Fargate + RDS to match our governance? (Your Phase 0 findings should inform
the recommendation.)
- Exact sending address —
news@mail.climate.umt.edu? Something else?
- Is
climate.umt.edu (or a delegated subdomain) in Route 53 in our account,
or do DNS records need to go through UM IT manually?
- Which AWS account/region and which Terraform repo should this live in?
Acceptance criteria
Out of scope (unless asked)
Advanced analytics beyond Listmonk’s built-ins, multi-list segmentation,
multi-language emails, and migrating any existing contact lists.
Rough cost expectation
~$12/month for a single small always-on host, plus near-zero SES at our volume.
The ECS Fargate + RDS route runs higher (RDS from ~$15/month).