Last updated 2026-05-28
Email channel
The email channel delivers a notification as an outbound email. v0.1
ships one provider — emailservice, a thin translator over
a caller-supplied Sender interface — with slots for
ses, acs, smtp, and
sendgrid in later waves.
When you'd use this
Any time a user needs an out-of-band message they will see whether or not your app is open. Password resets, comment notifications, billing receipts, weekly summaries — anything where the email is the delivery rail itself, not a fallback hint.
Provider: emailservice
emailservice is intentionally small: it maps a
notify.Message onto a SendEmailInput and
hands it to a Sender the caller supplies. The
Sender contract is narrow on purpose so the provider
package never takes a dependency on any specific email proto.
// channels/email/emailservice/provider.go (verbatim)type Sender interface { SendEmail(ctx context.Context, in SendEmailInput) (SendEmailOutput, error)}
type SendEmailInput struct { From string To string Subject string BodyText string BodyHTML string}
type SendEmailOutput struct { MessageID string}Wire it like this in library mode:
import ( "github.com/elloloop/notify" "github.com/elloloop/notify/channels/email/emailservice")
// 1. Implement Sender against whatever email transport you have.// For elloloop/emailservice over Connect, wrap the generated client.type emailServiceClient struct{ /* generated client */ }
func (c *emailServiceClient) SendEmail(ctx context.Context, in emailservice.SendEmailInput) (emailservice.SendEmailOutput, error) { resp, err := c.client.SendEmail(ctx, connect.NewRequest(&emailpb.SendEmailRequest{ From: in.From, To: in.To, Subject: in.Subject, BodyText: in.BodyText, })) if err != nil { return emailservice.SendEmailOutput{}, err } return emailservice.SendEmailOutput{MessageID: resp.Msg.GetMessageId()}, nil}
// 2. Construct the provider.sender := &emailServiceClient{ /* ... */ }provider, err := emailservice.New("emailservice", sender, "noreply@example.com")if err != nil { /* config error: nil sender or empty from */ }
registry := notify.NewProviderRegistry()registry.Register(provider)
The Name argument is the label that surfaces in
observability (notify_send_total{provider="emailservice"}).
Pass "" to default to "emailservice". For
an SES-backed Sender, pass "ses"; the channel kind
disambiguates.
Standalone container
Set the provider block in env vars:
-e NOTIFY_EMAIL_PROVIDER=emailservice \-e NOTIFY_EMAIL_SERVICE_ADDRESS=emailservice.internal:50053 \-e NOTIFY_EMAIL_FROM=noreply@example.com
With NOTIFY_EMAIL_PROVIDER=none (the default) the channel
is disabled. Email rows in a Notify call are stored but
counted as Pending rather than dispatched.
Sending an email
Producer-side, every email send rides the standard
Notify RPC. The only additional thing the producer
supplies is the address.
curl -sX POST http://notify:8081/elloloop.notify.v1.NotificationInternalService/Notify \ -H 'Content-Type: application/json' \ -H "X-Notify-Internal-Token: $NOTIFY_INTERNAL_TOKEN" \ -d '{ "tenantId": "acme", "notificationId": "comment-7", "userIds": ["user-alice"], "channels": ["DELIVERY_CHANNEL_EMAIL"], "subjectRef": "task:42", "subjectType": "task", "title": "New comment", "body": "Bob commented on Task 42", "addresses": { "user-alice": { "byChannel": { "email": "alice@example.com" } } } }'Response on a successful send:
{"delivered": 1, "pending": 0, "failed": 0}Title vs Body mapping
The provider maps the notify fields straight through:
From= the provider's configured default From address.To=Message.To.Subject=Title.BodyText=Body.BodyHTML="". (HTML rendering is the Sender's concern; wrap the Sender with a template step if you need it.)
Receipts and error handling
On success, Provider.Send returns a
Receipt{ProviderMessageID, StatusDelivered} populated
with the Sender's MessageID. The orchestrator persists
StatusDelivered on the row and stamps
DeliveredAtMS.
On error, the receipt is StatusFailed and the error is
wrapped with %w so the caller can
errors.Is / errors.As against the
underlying transport error.
Empty Message.To short-circuits with
StatusFailed — every email transport rejects an empty
recipient and the platform avoids a wasted RPC.
Future providers
- SES — direct AWS SDK integration.
- ACS — Azure Communication Services Email.
- SMTP — direct submission to an MX or relay.
- SendGrid — REST API.
All four slot into the same ChannelEmail registry entry
via different Sender implementations. None of them
require changes to notify.Notifier; the contract
stops at the provider boundary.
Related
- Channels & Providers
- Send a notification — Go / Python / cURL
- API Reference — Notify RPC