A Go microservice in the LFX v2 platform that owns newsletter persistence and the draft → sent state transition.
- Persist newsletter drafts and sent history (CloudNativePG-backed Postgres).
- Resolve recipient lists from committees (read-only HTTP calls to the LFX v2 query service).
- Expose an HTTP REST API consumed by the lfx-v2-ui Express server.
Out of scope right now: actual email delivery.
/newsletters/test-sendand/newsletters/drafts/{id}/sendvalidate inputs, resolve recipient counts, and (for/send) flip the draft tostatus=sentin the database — but they do not dispatch any email. Wiring up a real email publisher (e.g. publishing tolfx-v2-email-serviceover NATS) is a planned follow-up. AI content generation continues to live in lfx-v2-ui.
Two supported paths for running the service locally:
- Path A — Go binary against host Postgres. Fastest inner loop. The service runs on the host; Postgres is whatever you already have installed (Homebrew, Postgres.app, Docker, etc.).
- Path B — Helm + CloudNativePG on OrbStack/kind. Mirrors production: the CNPG operator provisions an in-cluster Postgres and the chart wires everything together.
- Go 1.25+
- A running PostgreSQL 16+ instance (Path A) or OrbStack/kind with
kubectl,helm3.8+, andko(Path B) - A reachable
lfx-v2-query-service(or a stubbedCOMMITTEE_SERVICE_URL— the service starts without it being live, but recipient resolution will fail)
1. Create the database.
psql postgres://localhost/postgres -c 'CREATE DATABASE newsletters;'The service applies its DDL idempotently at startup (internal/schema/schema.sql);
you do not need to run any SQL files manually.
2. Set the required environment variables.
export DATABASE_URL='postgres://<your-user>@localhost:5432/newsletters?sslmode=disable'
export COMMITTEE_SERVICE_URL='http://localhost:8081' # lfx-v2-query-service / API gateway
export REQUIRE_USER_AUTH=false # local only — production must verify JWTs
export LOG_LEVEL=debugsslmode=disable is required for a vanilla Homebrew Postgres install, which
ships without TLS; pgx defaults to requiring SSL.
3. Build and run.
make runOn a successful start you should see something like:
level=INFO msg="schema applied" tables=...
level=INFO msg="newsletter-api listening" addr=:8080
4. Smoke-test.
curl -s http://localhost:8080/livez && echo
# → okIf you see missing required env vars: DATABASE_URL, COMMITTEE_SERVICE_URL,
the env vars above are not set in the shell you ran make run from — make
does not load your shell rc.
1. Install the CloudNativePG operator (once per cluster).
make helm-install-operatorsThis installs the operator into cnpg-system. It is cluster-wide and only
needs to be installed once. Helm validates resources against installed CRDs
before applying any release, so the operator must exist before the umbrella
or standalone chart is installed — that's why it is not a subchart.
2. Build the image with ko.
make ko-buildThis produces ko.local/newsletter-api:local. OrbStack shares the local
Docker image cache with Kubernetes, so no manual kind load or registry push
is needed.
3. Create your local values override.
cp charts/lfx-v2-newsletter-service/values.local.yaml.example \
charts/lfx-v2-newsletter-service/values.local.yamlThe example file pins the chart to database.mode=cluster+database, points
image.repository at ko.local/newsletter-api, disables requireUserAuth,
and disables the NetworkPolicy for easier debugging. Adjust
app.committeeServiceURL to point at your local query-service if needed.
4. Install the chart.
make helm-install-localWatch the operator provision the cluster, then the deployment come up:
kubectl get cluster,database,pods -n lfx --context orbstackOnce the pod is 1/1 Running, the service has already applied its schema.
Tail the logs to confirm:
kubectl logs -n lfx -l app.kubernetes.io/name=lfx-v2-newsletter-service \
--tail=50 --context orbstack
# → level=INFO msg="schema applied" ...
# → level=INFO msg="newsletter-api listening" addr=:80805. Smoke-test via port-forward.
kubectl port-forward -n lfx svc/lfx-v2-newsletter-service 18080:8080 \
--context orbstack &
curl -s localhost:18080/livez && echo
# → okThe chart supports three database.mode values; pick the right Make target:
| Target | database.mode |
When to use |
|---|---|---|
make helm-install-local |
(from values.local) | Local OrbStack/kind dev (defaults to cluster+database) |
make helm-install-cnpg |
cluster+database |
Standalone CNPG install — chart provisions both the Cluster and Database |
make helm-install-external |
external |
Connect to an existing Postgres via a Kubernetes Secret |
external mode requires a secret in the target namespace whose key (default
url) holds the DATABASE_URL. Set database.external.secretName to that
secret's name in your values override.
make helm-uninstall # remove the chart release
kubectl delete namespace lfx --context orbstack # remove the CNPG cluster + PVCsThe CNPG operator itself is left installed (it is cluster-wide). To remove it:
helm uninstall cnpg -n cnpg-system.
For Path A, drop the local database:
psql postgres://localhost/postgres -c 'DROP DATABASE IF EXISTS newsletters;'- Language: Go 1.25+
- HTTP: stdlib
net/httpwith Go 1.22+ mux pattern - Database: PostgreSQL via pgx + bun, provisioned by CloudNativePG in cluster
- Schema: single embedded
schema.sqlapplied idempotently on startup (CREATE … IF NOT EXISTS), serialized across pods via a Postgres advisory transaction lock - Observability: OpenTelemetry (traces, metrics, logs) + slog structured logging
- Container: Chainguard distroless images
- Orchestration: Kubernetes with Helm charts
cmd/newsletter-api/
├── main.go # bootstrap: OTel, DB pool, schema, HTTP, graceful shutdown
└── service/
├── config.go # env var reads — single source of truth
└── implementations.go # wires infrastructure into service structs
internal/domain/
├── model/ # Newsletter, Status, ContextType, CommitteeMember
├── port/ # interfaces: NewsletterRepository, CommitteeClient
└── errors.go # ErrNotFound, ErrVersionMismatch, ErrInvalidRequest, ErrAlreadySent
internal/service/
├── newsletter.go # CRUD + validation + state transitions
└── send_orchestrator.go # resolve recipients, mark draft sent (no email dispatch)
internal/repository/
└── postgres.go # bun-backed NewsletterRepository with optimistic locking
internal/schema/
├── schema.go # //go:embed schema.sql + Apply()
└── schema.sql # consolidated DDL (CREATE … IF NOT EXISTS)
internal/handler/
├── http.go # Routes(), JSON helpers
├── drafts.go # /newsletters/drafts CRUD handlers
├── send.go # send / test-send / recipients handlers
├── health.go # /livez and /readyz
└── middleware.go # JWKS auth, request log
internal/infrastructure/
├── observability/ # OTel SDK + slog handler
└── upstream/ # HTTP client for committee/query service
pkg/api/
└── newsletter.go # public DTOs (mirror lfx-v2-ui shared interfaces)
charts/lfx-v2-newsletter-service/ # Helm chart with three database.mode options
make build # compile to bin/lfx-v2-newsletter-service/newsletter-api
make test # go test -race
make check # fmt + lint + license-check + go vet
make docker-build # build OCI image
make helm-templates # render Helm chart locallyThe Helm chart supports three database modes (matching the upstream CloudNativePG example):
| Mode | Description |
|---|---|
external |
Connect to an existing Postgres via a Kubernetes Secret containing DATABASE_URL |
database |
Create a CloudNativePG Database CR pointing at an existing Cluster |
cluster+database |
Create both a Cluster and a Database CR (standalone deployment without an umbrella) |
external is the default for the standalone chart and the recommended mode for
production (per-service Postgres roles with least-privilege secrets).
| Method | Path | Description |
|---|---|---|
| GET | /livez |
liveness probe |
| GET | /readyz |
readiness probe (DB ping) |
| POST | /newsletters/drafts |
create draft |
| GET | /newsletters/drafts |
list drafts for a context |
| GET | /newsletters/drafts/{id} |
fetch draft (returns ETag) |
| PUT | /newsletters/drafts/{id} |
update draft (requires If-Match) |
| DELETE | /newsletters/drafts/{id} |
delete draft |
| POST | /newsletters/drafts/{id}/send |
mark draft as sent (no email) |
| POST | /newsletters/recipient-count |
preview unique recipient count |
| POST | /newsletters/recipients |
preview recipient list |
| POST | /newsletters/test-send |
validate-only stub (no email) |
| GET | /newsletters |
unified list of newsletters for a context |
| GET | /newsletter-analytics/{id} |
per-newsletter analytics (opens, recipients) |
| GET | /newsletter-opens/{id} |
open-tracking pixel (unauthenticated GIF) |
Optimistic concurrency control: every draft carries an integer version
column atomically incremented on each UPDATE. GET returns
ETag: "<version>"; PUT requires If-Match: "<version>" and returns
412 Precondition Failed on a mismatch.
| Service | Relationship |
|---|---|
lfx-v2-query-service |
Source of committee member emails (via /query/resources) |
lfx-v2-ui (Express server) |
HTTP client; proxies UI requests to this service |