Thin transactional email relay for the LFX Self-Service platform. Receives pre-rendered email payloads over NATS request/reply, delivers them via Amazon SES SMTP, and tracks engagement events (opens, deliveries, bounces, complaints) in NATS KV.
Subject: lfx.email-service.send_email
Queue group: lfx.email-service.queue
Request payload fields:
| Field | Type | Required | Description |
|---|---|---|---|
to |
string | yes | Recipient email address |
subject |
string | yes | Email subject line |
html |
string | yes | HTML body — callers render this before publishing |
text |
string | yes | Plain-text body — shown by clients that don't render HTML |
from |
string | no | Sender address (e.g. newsletter@lfx.linuxfoundation.org). When omitted the service default (DEFAULT_SMTP_FROM) is used. The domain must be in the service's allowed list — see Configuring the sender address. |
from_display_name |
string | no | Display name shown in the From header (e.g. LFX Newsletter). When omitted the service default (DEFAULT_SMTP_FROM_DISPLAY_NAME, default: "LFX Self Serve") is used. |
reply_to |
string | no | Email address set on the SMTP Reply-To header. When set, mail client replies go to this address instead of the From address. The domain must be in the service's reply-to allowlist (SMTP_ALLOWED_REPLY_TO_DOMAINS, default: linuxfoundation.org). Subdomain suffix matching applies — the default permits @linuxfoundation.org and @*.linuxfoundation.org. Omitted from the message when not provided. |
group_id |
string | no | Caller-supplied ID grouping related emails (e.g. an invite batch). Use it to query aggregate engagement counts via lfx.email-service.get_email_engagement_analytics. If omitted, a UUID is generated and returned but is not meaningful for analytics. |
{
"to": "user@example.com",
"subject": "You've been added as a Writer on Demo Project",
"html": "<html>...</html>",
"text": "You've been added as a Writer on Demo Project.",
"from": "newsletter@lfx.linuxfoundation.org",
"from_display_name": "LFX Newsletter",
"reply_to": "support@lfx.linuxfoundation.org",
"group_id": "invite-batch-abc123"
}Success response:
{ "email_id": "<uuid>", "group_id": "<group_id>" }email_id is a UUID generated by the service and injected as the X-LFX-TRACKING-ID
MIME header. Store it if you want to query delivery/open status later.
Error response:
{ "error": "<reason>" }error value |
Cause |
|---|---|
invalid request payload |
Request body is not valid JSON |
to, subject, html, and text are required |
One or more required fields are missing |
invalid from address |
from field is not a valid email address |
from address domain not allowed |
from domain is not in the service's allowed list |
invalid reply_to address |
reply_to field is not a valid email address |
reply_to address domain not allowed |
reply_to domain is not in the service's allowed list |
email delivery failed |
Service accepted the request but SMTP delivery failed |
Examples (NATS CLI):
# Default sender ("LFX Self Serve <noreply@lfx.linuxfoundation.org>")
nats req lfx.email-service.send_email \
'{"to":"alice@example.com","subject":"Test","html":"<p>Hi</p>","text":"Hi"}'
# Custom sender address and display name
nats req lfx.email-service.send_email \
'{"to":"alice@example.com","subject":"Test","html":"<p>Hi</p>","text":"Hi","from":"newsletter@lfx.linuxfoundation.org","from_display_name":"LFX Newsletter"}'The from field lets callers send from any address whose domain is in the service's
allowlist. The allowlist is configured via the SMTP_ALLOWED_FROM_DOMAINS env var
(comma-separated, default: lfx.linuxfoundation.org).
| Scenario | Behaviour |
|---|---|
from omitted |
Service default (DEFAULT_SMTP_FROM) is used |
from domain is in the allowlist |
Email is sent from the specified address |
from domain is not in the allowlist |
Request is rejected — {"error":"from address domain not allowed"} |
from_display_name omitted |
Service default (DEFAULT_SMTP_FROM_DISPLAY_NAME, default: "LFX Self Serve") is used |
Note: Because delivery goes through Amazon SES, the
fromdomain must also be a verified SES sending identity. Contact the platform team to add a new domain to both the SES configuration andSMTP_ALLOWED_FROM_DOMAINS.
Subject: lfx.email-service.get_email_status
Returns the tracking record(s) for one or more emails. Only available when NATS KV is configured (JetStream enabled and both KV buckets exist).
Exactly one of email_id or group_id must be provided.
Request:
{ "email_id": "<uuid returned by send>" }{ "group_id": "<group id>" }Success response — by email_id — an EmailRecipientRecord:
{
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"group_id": "invite-batch-abc123",
"to": "user@example.com",
"subject": "You've been added as a Writer on Demo Project",
"sent_at": "2025-01-15T10:30:00Z",
"delivered": true,
"delivered_at": "2025-01-15T10:30:02Z",
"opened": true,
"opened_at_list": [
{ "event_id": "abc-sns-message-id-1", "opened_at": "2025-01-15T11:05:33Z" },
{ "event_id": "abc-sns-message-id-2", "opened_at": "2025-01-15T14:22:10Z" }
],
"last_opened_at": "2025-01-15T14:22:10Z",
"failed": false
}opened_at_list contains one entry per unique open event (keyed by SNS MessageId to survive replays). Use len(opened_at_list) for the open count.
Success response — by group_id — an array of EmailRecipientRecord:
[
{
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"group_id": "invite-batch-abc123",
"to": "user@example.com",
"subject": "You've been added as a Writer on Demo Project",
"sent_at": "2025-01-15T10:30:00Z",
"delivered": true,
"delivered_at": "2025-01-15T10:30:02Z",
"opened": false,
"failed": false
}
]delivered, opened, and failed are set by the SES engagement event poller
(see SES Engagement Event Tracking below).
They remain false until the poller is enabled and the corresponding SES event
arrives.
Error response:
{ "error": "<reason>" }error value |
Cause |
|---|---|
invalid request payload |
Request body is not valid JSON |
email_id or group_id is required |
Neither field was set |
only one of email_id or group_id may be set |
Both fields were set |
not found |
No record exists for the given email_id or group_id |
Examples (NATS CLI):
nats req lfx.email-service.get_email_status \
'{"email_id":"550e8400-e29b-41d4-a716-446655440000"}'
nats req lfx.email-service.get_email_status \
'{"group_id":"invite-batch-abc123"}'Subject: lfx.email-service.get_email_engagement_analytics
Returns aggregate counts across all emails in a group. Only available when NATS KV is configured.
Request:
{ "group_id": "invite-batch-abc123" }Success response:
{
"group_id": "invite-batch-abc123",
"total_sent": 42,
"delivered": 40,
"opened": 31,
"unique_opened": 18,
"failed": 2
}Error response:
{ "error": "<reason>" }error value |
Cause |
|---|---|
invalid request payload |
Request body is not valid JSON or group_id is missing |
not found |
No emails have been sent under the given group_id |
Example (NATS CLI):
nats req lfx.email-service.get_email_engagement_analytics \
'{"group_id":"invite-batch-abc123"}'The pkg/api package exports subject constants and request/response types.
go get github.com/linuxfoundation/lfx-v2-email-service/pkg/apipackage main
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/nats-io/nats.go"
emailapi "github.com/linuxfoundation/lfx-v2-email-service/pkg/api"
)
func main() {
nc, err := nats.Connect(nats.DefaultURL)
if err != nil {
panic(err)
}
defer nc.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Send an email with a custom sender address and display name.
// From and FromDisplayName are optional — omit them to use the service defaults.
req := emailapi.SendEmailRequest{
To: "user@example.com",
Subject: "You've been added",
HTML: "<p>Hello</p>",
Text: "Hello",
From: "newsletter@lfx.linuxfoundation.org", // optional
FromDisplayName: "LFX Newsletter", // optional
GroupID: "my-batch-id",
}
data, _ := json.Marshal(req)
reply, err := nc.RequestWithContext(ctx, emailapi.SendEmailSubject, data)
if err != nil {
panic(err)
}
// Check for an error response first — SendEmailErrorResponse and
// SendEmailResponse are distinguished by the presence of the "error" field.
var errResp emailapi.SendEmailErrorResponse
if err := json.Unmarshal(reply.Data, &errResp); err == nil && errResp.Error != "" {
fmt.Println("send failed:", errResp.Error)
return
}
var sendResp emailapi.SendEmailResponse
if err := json.Unmarshal(reply.Data, &sendResp); err != nil {
panic(err)
}
fmt.Println("sent, email_id:", sendResp.EmailID)
// Query the delivery/open status of the email we just sent.
statusReq, _ := json.Marshal(emailapi.GetEmailStatusRequest{EmailID: sendResp.EmailID})
statusReply, err := nc.RequestWithContext(ctx, emailapi.GetEmailStatusSubject, statusReq)
if err != nil {
panic(err)
}
var record emailapi.EmailRecipientRecord
if err := json.Unmarshal(statusReply.Data, &record); err != nil {
panic(err)
}
fmt.Printf("status: delivered=%v opened=%v failed=%v\n", record.Delivered, record.Opened, record.Failed)
// Query status for all emails in the group.
groupStatusReq, _ := json.Marshal(emailapi.GetEmailStatusRequest{GroupID: sendResp.GroupID})
groupStatusReply, err := nc.RequestWithContext(ctx, emailapi.GetEmailStatusSubject, groupStatusReq)
if err != nil {
panic(err)
}
var groupRecords []emailapi.EmailRecipientRecord
if err := json.Unmarshal(groupStatusReply.Data, &groupRecords); err != nil {
panic(err)
}
fmt.Printf("group status: %d emails\n", len(groupRecords))
// Query aggregate engagement counts for the whole group.
analyticsReq, _ := json.Marshal(emailapi.GetEmailEngagementAnalyticsRequest{GroupID: sendResp.GroupID})
analyticsReply, err := nc.RequestWithContext(ctx, emailapi.GetEmailEngagementAnalyticsSubject, analyticsReq)
if err != nil {
panic(err)
}
var analytics emailapi.GetEmailEngagementAnalyticsResponse
if err := json.Unmarshal(analyticsReply.Data, &analytics); err != nil {
panic(err)
}
fmt.Printf("group analytics: sent=%d delivered=%d opened=%d failed=%d\n",
analytics.TotalSent, analytics.Delivered, analytics.Opened, analytics.Failed)
}- Go 1.24+
- NATS Server or Docker
- Local Kubernetes cluster with OrbStack or similar
- Mailpit running in the cluster for local SMTP capture (UI at
http://localhost:8025)
cp .env.example .env
source .env && make run.env is gitignored. SMTP_USERNAME and SMTP_PASSWORD can be left empty when
pointing at Mailpit (no auth required).
cp charts/lfx-v2-email-service/values.local.example.yaml \
charts/lfx-v2-email-service/values.local.yaml
make docker-build
make helm-install-localvalues.local.yaml is gitignored.
| Variable | Default | Description |
|---|---|---|
NATS_URL |
nats://localhost:4222 |
NATS server URL |
PORT |
8080 |
HTTP health probe port |
EMAIL_ENABLED |
false |
Set true to enable SMTP delivery; when false requests succeed but delivery is skipped via NoOpSender |
SMTP_HOST |
localhost |
SMTP server hostname |
SMTP_PORT |
587 |
SMTP server port (STARTTLS) |
DEFAULT_SMTP_FROM |
noreply@lfx.linuxfoundation.org |
Default envelope From address (falls back to legacy SMTP_FROM if unset) |
DEFAULT_SMTP_FROM_DISPLAY_NAME |
LFX Self Serve |
Default display name in the From header; overridable per message via from_display_name |
SMTP_ALLOWED_FROM_DOMAINS |
lfx.linuxfoundation.org |
Comma-separated domains permitted for per-message from overrides; set to "" to disable overrides entirely |
SMTP_ALLOWED_REPLY_TO_DOMAINS |
linuxfoundation.org |
Comma-separated base domains permitted for reply_to; subdomains are also permitted (e.g. linuxfoundation.org allows lfx.linuxfoundation.org); set to "" to disable |
SMTP_ALLOWED_RECIPIENT_DOMAINS |
(empty — permit all) | Comma-separated base domains permitted as recipients (subdomain suffix matching applies). When empty all recipient domains are permitted (production default). Set in non-prod (e.g. linuxfoundation.org) to prevent test mail from reaching real users' personal addresses. |
SMTP_USERNAME |
(empty) | SMTP credential (from Kubernetes Secret in production) |
SMTP_PASSWORD |
(empty) | SMTP credential (from Kubernetes Secret in production) |
SES_CONFIGURATION_SET |
(empty) | SES configuration set name. When set, X-SES-CONFIGURATION-SET is added to every outbound email to route engagement events. Omitted when empty. |
SES_EVENTING_ENABLED |
false |
Set true to start the SQS engagement event poller. Requires SES_ENGAGEMENT_SQS_QUEUE_URL and NATS KV — missing either is a fatal startup error. |
SES_ENGAGEMENT_SQS_QUEUE_URL |
(empty) | SQS queue URL that receives SNS-wrapped SES engagement events. Required when SES_EVENTING_ENABLED=true. |
LOG_LEVEL |
info |
Log level (debug, info, warn, error) |
LOG_ADD_SOURCE |
false |
Set true to include source file/line in log entries |
In production, SES_CONFIGURATION_SET and SES_ENGAGEMENT_SQS_QUEUE_URL are
injected from a Kubernetes Secret managed by External Secrets Operator
(see app.ses.engagementSecretName in charts/lfx-v2-email-service/values.yaml).
lfx-v2-email-service/
├── cmd/email-service/
│ ├── main.go # Entry point: NATS subscriptions, SQS poller, HTTP health, graceful shutdown
│ └── config.go # Environment variable parsing
├── internal/
│ ├── domain/
│ │ └── email.go # Sender interface
│ ├── infrastructure/
│ │ ├── smtp/
│ │ │ ├── sender.go # SMTPSender — delivers via net/smtp, injects tracking headers
│ │ │ ├── noop.go # NoOpSender — logs only (EMAIL_ENABLED=false)
│ │ │ └── message.go # MIME message builder
│ │ └── sqs/
│ │ └── poller.go # Long-polling SQS consumer (AWS SDK v2, IRSA credentials)
│ ├── logging/
│ │ └── logging.go # Structured log helpers
│ └── service/
│ ├── send_email_handler.go # Handles send_email — sends and writes KV records
│ ├── engagement_event_handler.go # Handles SES engagement events from SQS
│ ├── get_email_status_handler.go # Handles get_email_status
│ ├── get_email_engagement_analytics_handler.go # Handles get_email_engagement_analytics
│ └── mocks/
│ └── kv.go # Thread-safe in-memory KeyValue mock for tests
├── pkg/
│ ├── api/
│ │ └── nats.go # Public NATS subjects, request/response types, KV bucket constants
│ └── redaction/
│ └── redaction.go # Email address redaction for logs
└── charts/lfx-v2-email-service/
├── Chart.yaml
├── values.yaml
└── templates/
├── deployment.yaml
├── externalsecret.yaml # ESO secret for SES config set + SQS queue URL
├── nats-kv-buckets.yaml # Declares email-recipients and email-group-index KV buckets
└── service.yaml
Run the test suite:
make testRun make check before committing — it verifies formatting, runs the linter,
and checks license headers:
make checkAll commits must be signed off per the DCO:
git commit -s -m "feat: ..."The service optionally captures SES engagement events (open, delivery, bounce, complaint) and stores them in NATS KV so callers can query whether their emails were opened or delivered.
-
Send time:
SendEmailHandlerinjects two MIME headers into every outbound email:X-SES-CONFIGURATION-SET: <name>— routes SES events to the configured event destinationX-LFX-TRACKING-ID: <group_id>/<email_id>— a stable key SES echoes back in every engagement event
-
KV write on send: After each successful SMTP delivery the handler writes an
EmailRecipientRecordto theemail-recipientsNATS KV bucket (key:email_id) and appends theemail_idto the caller's group in theemail-group-indexbucket (key:group_id). Both writes use optimistic locking with a single retry on conflict. -
SES event pipeline: SES → SNS topic → SQS queue. The email service polls the SQS queue in a background goroutine.
-
Poller:
internal/infrastructure/sqs.Pollerlong-polls the queue (20-second wait, up to 10 messages per call) using AWS SDK v2 with IRSA credentials. On consecutiveReceiveMessagefailures it applies exponential backoff (capped at 30 s) and aborts after 3 consecutive errors, triggering graceful shutdown. -
Event handler:
EngagementEventHandlerparses each SNS-wrapped SES event, extracts theemail_idfromX-LFX-TRACKING-ID, looks up the KV record, updates the relevant fields using SES-provided RFC3339 timestamps, and writes back with optimistic locking. Unrecognised event types and missing records are silently skipped.
Set SES_EVENTING_ENABLED=true. The service then requires both
SES_ENGAGEMENT_SQS_QUEUE_URL and a reachable NATS KV — missing either is a fatal
startup error (the pod exits without restarting rather than looping).
The AWS-side infrastructure (SES configuration set, SNS topic, SQS queue) is
provisioned separately in lfx-v2-opentofu. The Helm chart reads the configuration
set name and queue URL from a Kubernetes Secret created by External Secrets Operator
(secret name configured via app.ses.engagementSecretName in values.yaml).
Copyright The Linux Foundation and each contributor to LFX. SPDX-License-Identifier: MIT