LFX V2 Email Service
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.
Usage
Send an email
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 |
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.",
"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 |
email delivery failed |
Service accepted the request but SMTP delivery failed |
Example (NATS CLI):
nats req lfx.email-service.send_email \
'{"to":"alice@example.com","subject":"Test","html":"<p>Hi</p>","text":"Hi"}'
Query email status
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"}'
Query group engagement analytics
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"}'
Use with Go
The pkg/api package exports subject constants and request/response types.
go get github.com/linuxfoundation/lfx-v2-email-service/pkg/api
package 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.
req := emailapi.SendEmailRequest{
To: "user@example.com",
Subject: "You've been added",
HTML: "<p>Hello</p>",
Text: "Hello",
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)
}
Quick Start
Prerequisites
- 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)
Option 1 — Run directly with make run
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).
Option 2 — Build and deploy to local cluster with Helm
cp charts/lfx-v2-email-service/values.local.example.yaml \
charts/lfx-v2-email-service/values.local.yaml
make docker-build
make helm-install-local
values.local.yaml is gitignored.
Environment Variables
| 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) |
SMTP_FROM |
noreply@lfx.linuxfoundation.org |
Envelope From address |
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).
File Structure
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
Development
Run the test suite:
make test
Run make check before committing — it verifies formatting, runs the linter,
and checks license headers:
make check
All commits must be signed off per the DCO:
git commit -s -m "feat: ..."
SES Engagement Event Tracking
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.
How it works
-
Send time: SendEmailHandler injects two MIME headers into every outbound email:
X-SES-CONFIGURATION-SET: <name> — routes SES events to the configured event destination
X-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
EmailRecipientRecord to the email-recipients NATS KV bucket (key: email_id)
and appends the email_id to the caller's group in the email-group-index bucket
(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.Poller long-polls the queue (20-second
wait, up to 10 messages per call) using AWS SDK v2 with IRSA credentials.
On consecutive ReceiveMessage failures it applies exponential backoff (capped
at 30 s) and aborts after 3 consecutive errors, triggering graceful shutdown.
-
Event handler: EngagementEventHandler parses each SNS-wrapped SES event,
extracts the email_id from X-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.
Enabling the poller
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).
License
Copyright The Linux Foundation and each contributor to LFX.
SPDX-License-Identifier: MIT