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