Development Conventions
Repo-owned conventions for lfx-v2-email-service. This service is a thin NATS request/reply email relay with optional NATS KV engagement tracking. It is not a Goa resource service, does not own an OpenFGA type, and does not publish indexer or FGA messages.
Use this skill alongside:
lfx-skills:lfxfor cross-repo topology, owner lookup, and missing local checkouts.lfx-skills:lfx-platform-architecturefor platform service classes, NATS/KV placement, Helm/ArgoCD handoffs, and cross-service flow.docs/email-service-contract.mdfor the public NATS subject and payload contract.docs/email-engagement-tracking.mdfor SES, SQS, tracking headers, and KV record behavior.docs/service-helm-chart.mdfor service-local chart values, secrets, and deployment surfaces.
Repo Layout
cmd/email-service/ entry point, env parsing, NATS subscriptions, health probes, shutdown
internal/domain/ Sender, TrackingStore, AddressPolicy, NullTrackingStore
internal/infrastructure/kv/ kv.Store — concrete TrackingStore; owns JSON marshaling, CAS retry, group fan-out
internal/infrastructure/smtp/ SMTP/SES sender, MIME builder, NoOpSender
internal/infrastructure/sqs/ SQS long-polling engagement-event consumer
internal/logging/ slog context helper and global logger setup
internal/service/ NATS handlers and SES engagement event handler
internal/service/mocks/ TrackingStore mock for unit tests
pkg/api/ public NATS subjects, payloads, KV bucket constants
pkg/redaction/ email redaction helpers for logs
charts/lfx-v2-email-service/ service-local Helm chart
docs/ service-owned public contract and deployment docs
Keep implementation details in internal/. Anything callers import belongs in pkg/api/.
Public Contract
pkg/api/nats.gois the public Go contract for other services. Any subject, payload, response, or KV bucket change must updatedocs/email-service-contract.mdin the same PR.- Do not hardcode subject strings at call sites. Use
api.SendEmailSubject,api.GetEmailStatusSubject,api.GetEmailEngagementAnalyticsSubject, andapi.QueueGroup. - Callers send pre-rendered HTML and text. Do not add a template registry or rendering engine here unless the product architecture changes.
SendEmailHandler.HandleDatamust call the response callback exactly once on every path. NATS callers use request/reply and must not hang.- Group status lookups return
[]api.EmailRecipientRecord; single-email status returns oneapi.EmailRecipientRecord.
NATS And KV
- The service subscribes with queue group
lfx.email-service.queueso replicas share work. email-recipientsstores oneapi.EmailRecipientRecordperemail_id.email-group-indexmaps agroup_idto[]stringofemail_idvalues.- Tracking is optional. If JetStream or either KV bucket is missing, send requests still work. All four subjects are always subscribed; status and analytics handlers respond with "not found" when KV is unavailable (via NullTrackingStore).
- KV writes use optimistic locking where concurrent updates are possible. Preserve the retry behavior in group-index and engagement-event updates.
- Never write to another service's KV bucket. This service owns only the two email tracking buckets above.
SMTP And SES
- The service accepts already-rendered content and sends via
net/smtp. EMAIL_ENABLED=falseselectsNoOpSender, which returns an emptySendEmailResponse(email_idandgroup_idboth empty) without sending SMTP mail or writing tracking records. Because the returnedemail_idis empty,SendEmailHandlerwrites no KV tracking records on this path.SMTPSender.Sendcreates bothemail_idandgroup_id, builds a MIME multipart message, and applies a 30-second timeout around the blocking SMTP call.- Domain allowlist validation happens in
SendEmailHandler, not in the sender: per-messagefromrequires an exact-match domain inSMTP_ALLOWED_FROM_DOMAINS;reply_torequires a suffix-match domain inSMTP_ALLOWED_REPLY_TO_DOMAINS. Disallowed values return documented error replies. - Recipient filtering (
SMTP_ALLOWED_RECIPIENT_DOMAINS, empty = permit all) also happens in the handler. A blocked recipient is not an error: the handler replies with an emptySendEmailResponseand writes no tracking records. Preserve this empty-success semantic. - The MIME builder strips CR/LF from header values. Preserve this when changing headers.
SES_CONFIGURATION_SET, when non-empty, addsX-SES-CONFIGURATION-SET.X-LFX-TRACKING-IDisgroup_id/email_id. The SQS engagement handler extracts the part after the last/.
SES Engagement Tracking
SES_EVENTING_ENABLED=truestarts the SQS poller and makes missingSES_ENGAGEMENT_SQS_QUEUE_URL, AWS config, oremail-recipientsKV a fatal startup error.- The poller long-polls up to 10 SQS messages with a 20-second wait, deletes only successfully handled messages, and aborts after three consecutive receive failures.
- The handler processes SNS-wrapped SES
OPEN,DELIVERY,BOUNCE, andCOMPLAINTevents. Unknown events and missing records are ignored. - Open events are deduplicated by SNS
MessageId; delivery, bounce, and complaint are idempotent booleans. - Update
docs/email-engagement-tracking.mdwith any change to headers, event handling, KV fields, retry behavior, or required AWS resources.
Logging
- Use
log/slogwithslog.*Contextvariants. - Add ambient fields with
logging.AppendCtx(ctx, slog.String(...))when a field should appear through a handler call. - Use
logging.ErrKeyfor error fields. - Redact email addresses in log fields with
redaction.RedactEmail. - Never log SMTP credentials, AWS secrets, raw payloads containing message bodies, raw SQS body content, or full recipient email addresses.
Errors
- NATS error replies use JSON
api.SendEmailErrorResponsewith stable short strings. Keep these documented indocs/email-service-contract.md. - Return caller-safe errors at the NATS boundary. Log internal details server-side.
- Wrap internal errors with
%wwhere they cross function boundaries so tests and callers can classify root causes.
Tests
- Co-locate
*_test.gowith the code under test. - Use table-driven tests for branching behavior.
- Use
SendEmailHandler.HandleDataand the handler-levelHandleDatahelpers instead of standing up a real NATS server in unit tests. - Use
internal/service/mocks.NewTrackingStore()for KV behavior. - Same-package tests are acceptable when exercising unexported SMTP helpers such as
buildEmailMessage. - Run
make testbefore handoff; it enables the race detector.
Formatting, Linting, License
make fmtrunsgo fmt ./...andgofmt -s.make lintrequiresgolangci-lint;make checkruns format check, lint, and license check.Every new
.gofile starts with:// Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MITEvery new
.mdfile in this repo starts with the HTML-comment license header.Document exported Go symbols when the linter requires it. Add implementation comments only when they clarify non-obvious behavior.
References
references/go-conventions.md: package boundaries, handler shape, logging, testing, and review checklist for this repo.
Chart Work
- Service-local chart truth lives under
charts/lfx-v2-email-service/anddocs/service-helm-chart.md. - Shared chart conventions live in
lfx-v2-helm/docs/service-chart-patterns.md. - Deployed environment values, image tags, IRSA role annotations, and environment promotion live in
lfx-v2-argocd. - SMTP credentials and SES engagement config are delivered through Kubernetes Secrets, optionally created by External Secrets Operator. Do not put secret values in docs, chart defaults, or logs.
Boundaries
- This repo owns transactional email delivery and engagement tracking. It does not render email templates.
- This repo does not own newsletter composition or newsletter persistence; those live in
lfx-v2-newsletter-service. - This repo does not own Auth0, OpenFGA, query-service, indexer-service, platform NATS deployment, ArgoCD values, or AWS-side SES/SNS/SQS provisioning.
- If a change requires a peer repo, use
lfx-skills:lfxto locate or clone it and read that repo'sCLAUDE.mdplus contract docs.