notifications

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 23, 2026 License: MIT Imports: 10 Imported by: 0

README

go-notifications

CI Go Reference

Send notifications from a Go SaaS application through email, SMS, push and an in-app inbox behind one API. A notification is a plain Go type that says how it renders on each channel; a Dispatcher picks the channels for each recipient, respects their opt-outs, delivers everywhere it should, and tells you exactly what happened per recipient and channel, including which push tokens to delete. SMTP, Twilio and Firebase Cloud Messaging are built in using only the standard library (plus golang.org/x/oauth2 for FCM), and the inbox is stored in PostgreSQL with pgx.

Install

go get github.com/byteminus/go-notifications

Requires Go 1.26 or later.

Quick start

package main

import (
	"context"
	"log"
	"os"

	"github.com/jackc/pgx/v5/pgxpool"

	notifications "github.com/byteminus/go-notifications"
	"github.com/byteminus/go-notifications/mail"
	"github.com/byteminus/go-notifications/pgxstore"
	"github.com/byteminus/go-notifications/sms"
	"github.com/byteminus/go-notifications/sms/twilio"
)

// User is your recipient model.
type User struct {
	ID, Name, Email, Phone string
}

func (u User) NotifiableType() string { return "user" }
func (u User) NotifiableID() string   { return u.ID }
func (u User) NotificationRoutes() notifications.Routes {
	return notifications.Routes{Name: u.Name, Email: u.Email, Phone: u.Phone}
}

// InvoicePaid goes out by email and SMS and lands in the in-app inbox.
type InvoicePaid struct{ Number, Amount string }

func (InvoicePaid) Type() string { return "invoice.paid" }

func (n InvoicePaid) ToMail(_ context.Context, _ notifications.Notifiable) (mail.Message, error) {
	return mail.Message{
		Subject: "Invoice " + n.Number + " paid",
		Text:    "We received " + n.Amount + ".",
		HTML:    "<p>We received <b>" + n.Amount + "</b>.</p>",
	}, nil
}

func (n InvoicePaid) ToSMS(_ context.Context, _ notifications.Notifiable) (sms.Message, error) {
	return sms.Message{Body: "Invoice " + n.Number + " paid"}, nil
}

func (n InvoicePaid) ToDatabase(_ context.Context, _ notifications.Notifiable) (notifications.DatabaseMessage, error) {
	return notifications.NewDatabaseMessage(map[string]string{"number": n.Number, "amount": n.Amount})
}

func main() {
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	store, err := pgxstore.New(pool)
	if err != nil {
		log.Fatal(err)
	}
	if err := store.CreateSchema(ctx); err != nil {
		log.Fatal(err)
	}
	mailer, err := mail.NewSMTP(mail.SMTPConfig{
		Host: "smtp.example.com", Username: os.Getenv("SMTP_USER"), Password: os.Getenv("SMTP_PASS"),
	})
	if err != nil {
		log.Fatal(err)
	}
	texts, err := twilio.New(twilio.Config{
		AccountSID: os.Getenv("TWILIO_ACCOUNT_SID"), AuthToken: os.Getenv("TWILIO_AUTH_TOKEN"), From: "+15005550006",
	})
	if err != nil {
		log.Fatal(err)
	}

	dispatcher, err := notifications.NewDispatcher(notifications.Config{
		Mailer:   mailer,
		MailFrom: mail.Address{Name: "Acme Billing", Email: "billing@acme.example"},
		SMS:      texts,
		Store:    store,
	})
	if err != nil {
		log.Fatal(err)
	}

	user := User{ID: "42", Name: "Ann", Email: "ann@example.com", Phone: "+14155550100"}
	report, err := dispatcher.Send(ctx, []notifications.Notifiable{user}, InvoicePaid{Number: "INV-7", Amount: "$120.00"})
	for _, r := range report.Results {
		log.Println(r.Recipient, r.Channel, r.Status, r.SkipReason)
	}
	if err != nil {
		log.Print(err) // every failure, joined; the other channels were still attempted
	}
}

Concepts

  • Notification: any type with Type() string, a stable name such as invoice.paid that is stored with inbox items and passed to preferences. It joins a channel by implementing ToMail, ToSMS, ToPush or ToDatabase, each of which renders a plain, JSON-serializable message.
  • Notifiable: the recipient. NotifiableType and NotifiableID identify it (for example user / 42); NotificationRoutes returns its email address, E.164 phone number and push device tokens. A rendered message that names no recipient is addressed from these routes; a recipient with no route for a channel is skipped (SkipNoRoute), which is not an error.
  • Channel selection: by default a notification goes to every channel it implements and the dispatcher has configured. Implement Via(to) to choose per recipient; an explicit choice that cannot be honoured (not implemented, not configured, unknown) is reported as a failure.
  • Preferences: Allows(ctx, to, type, channel) is asked before rendering. false skips the channel (SkipOptedOut); an error fails it, so an outage of the preference store never sends to someone who opted out. Allow security-critical types such as password resets unconditionally in your implementation.
  • Results: Send returns a Report with one Result per recipient and channel (sent, skipped or failed) and an error that joins every failure with errors.Join. Each failure is a *DeliveryError naming the notification, recipient and channel and wrapping the cause, so errors.Is(err, smtpErr) and errors.As(err, &twilioErr) work. One failure never stops the other channels or recipients.
  • Invalid push tokens: tokens FCM reports as unregistered, belonging to another sender, or malformed are listed in Result.InvalidTokens and Report.InvalidTokens(). Delete them; they will never work again.
  • Inbox: the database channel stores a StoredNotification per recipient. Inbox lists them newest first with keyset pagination, counts unread ones, marks one or all as read, and deletes. Every operation is scoped to the recipient, so an ID from a request cannot touch another user's inbox.
Channels
Channel Message Interface Built-in implementation
mail mail.Message mail.Mailer mail.NewSMTP
sms sms.Message sms.Sender twilio.New (package sms/twilio)
push push.Message push.Sender fcm.New (package push/fcm)
database DatabaseMessage Store memstore.New, pgxstore.New

SMTP. Port 465 uses implicit TLS; any other port requires STARTTLS and refuses to continue if the server does not offer it, so credentials and content never travel in plaintext. Authentication is AUTH PLAIN. Messages are multipart/alternative when both text and HTML are set, bodies are quoted-printable, non-ASCII subjects, display names and custom headers are RFC 2047 encoded and folded, and any CR or LF in a header value is rejected with mail.ErrHeaderInjection before a connection is opened. Each message uses its own connection and is bounded by Timeout and the context.

Twilio. Plain net/http against the Messages resource, authenticated with an auth token or (recommended) an API key. Sends from From, or from a Messaging Service pool. API failures are *twilio.Error with Twilio's error code and a Temporary() hint.

FCM. HTTP v1 API with a service account key (only service_account credentials are accepted) or any oauth2.TokenSource. Access tokens are cached. Tokens are sent one request each, sequentially. Failures are *fcm.Error with the FCM error code, Temporary() and InvalidToken().

Asynchronous delivery

Send delivers inline. To deliver from your job system, either:

  1. enqueue the inputs (recipient IDs and the notification's fields) and call Send inside the job; or
  2. call Prepare in the request, enqueue each returned Delivery as JSON, and call Deliver in the worker, returning its error to trigger a retry.

The second form renders once (templates see the state at request time) and retries per channel, so a failing SMS is retried without resending the email. Enqueue inside the same database transaction as the write that caused the notification. Each Delivery carries a UUIDv7 that the database channel uses as the stored notification's ID, so a retried database delivery is stored exactly once. Preferences are evaluated by Prepare.

deliveries, skipped, err := dispatcher.Prepare(ctx, recipients, InvoicePaid{Number: "INV-7"})
// log skipped / err, then for each delivery:
payload, _ := json.Marshal(delivery) // enqueue payload

// in the worker
var d notifications.Delivery
_ = json.Unmarshal(payload, &d)
_, err = dispatcher.Deliver(ctx, d) // non-nil: retry the job

PostgreSQL

pgxstore keeps the inbox in one table. CreateSchema runs this idempotent DDL (shown for the default table name); copy it into your migrations if you prefer:

CREATE TABLE IF NOT EXISTS "notifications" (
	id uuid PRIMARY KEY,
	notifiable_type text NOT NULL CHECK (notifiable_type <> ''),
	notifiable_id text NOT NULL CHECK (notifiable_id <> ''),
	type text NOT NULL CHECK (type <> ''),
	data jsonb NOT NULL CHECK (jsonb_typeof(data) = 'object'),
	read_at timestamptz,
	created_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS "notifications_recipient_idx" ON "notifications" (notifiable_type, notifiable_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS "notifications_unread_idx" ON "notifications" (notifiable_type, notifiable_id, created_at DESC, id DESC) WHERE read_at IS NULL;

The partial index serves unread counts and unread lists, which are the hot path of a notification bell. WithTable("app.inbox") changes the (optionally schema-qualified) table name; Schema() returns the DDL for it.

To store an in-app notification in the same transaction as your own write, give the store a WithConn resolver that returns the transaction carried in the context (nil means "use the pool"):

type txKey struct{}

store, err := pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
	if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
		return tx
	}
	return nil
}))

tx, err := pool.Begin(ctx)
// ... your writes on tx ...
txCtx := context.WithValue(ctx, txKey{}, tx)
_, err = dispatcher.Deliver(txCtx, databaseDelivery) // stored only if tx commits
err = tx.Commit(ctx)

Only the database channel joins the transaction; email, SMS and push leave the process immediately, which is why they belong in a job enqueued in the transaction rather than in the transaction itself.

API overview

Package Contents
notifications Notification, ToMail, ToSMS, ToPush, ToDatabase, Via; Notifiable, Routes, Recipient; Preferences, PreferencesFunc; Dispatcher (NewDispatcher, Send, Prepare, Deliver), Config, Notifier, Delivery; Report, Result, Status, SkipReason, DeliveryError; Store, StoredNotification, ListOptions, Cursor; Inbox; sentinel errors
mail Message, Address, Header, Mailer, MailerFunc, SMTP, SMTPConfig, Security
sms Message, Receipt, Sender, SenderFunc, IsE164
sms/twilio New, Config, Sender, Error
push Message, Response, Delivery, Failure, Sender, SenderFunc
push/fcm New, Config, Sender, Error
memstore in-memory, non-transactional Store
pgxstore PostgreSQL Store, Querier, WithTable, WithConn, Schema, CreateSchema
storetest Run, the Store conformance suite
notificationstest recording Mailer, SMSSender, PushSender; fake Dispatcher

Testing

In application tests, plug the recording senders from notificationstest into a real Dispatcher to assert on rendered messages, or depend on the Notifier interface and use notificationstest.Dispatcher to assert only what was sent to whom:

fake := &notificationstest.Dispatcher{}
svc := billing.NewService(fake) // takes a notifications.Notifier
// ...
if got := fake.SentTo(notifications.Recipient{Type: "user", ID: "42"}, "invoice.paid"); len(got) != 1 {
	t.Fatalf("sent %d invoice.paid notifications", len(got))
}

A custom Store should pass the conformance suite:

func TestStore(t *testing.T) {
	storetest.Run(t, func(t *testing.T) notifications.Store { return newMyStore(t) })
}

To run this module's tests, see CONTRIBUTING.md. PostgreSQL tests read TEST_DATABASE_URL; provider tests use in-process fakes (an SMTP server with STARTTLS and AUTH, Twilio, and FCM including the OAuth token exchange).

Roadmap

Not in v0.1.0, planned for later releases:

  • Mail attachments and inline images; SMTPUTF8 (non-ASCII email addresses).
  • SMTP connection reuse for bulk sends, and a plaintext mode for local development servers.
  • Provider adapters for SES, Postmark and SendGrid, and APNs direct.
  • FCM platform overrides (Android, APNs, web push options), topics and conditions, and concurrent per-token sends.
  • Built-in retries with backoff that honour Retry-After.
  • Delivery logging/audit table and per-provider rate limiting.
  • Digests and batching, scheduled notifications, and localisation helpers.
  • Inbox retention (pruning old read notifications) and archived state.

License

MIT; see LICENSE.

Documentation

Overview

Package notifications sends notifications from SaaS applications through email, SMS, push and an in-app database inbox behind one API.

A notification is a type with a stable name that implements one interface per channel it supports (ToMail, ToSMS, ToPush, ToDatabase). A recipient implements Notifiable, which provides its identity and its Routes: email address, E.164 phone number and push device tokens. A Dispatcher selects the channels for each recipient, asks Preferences whether the recipient has opted out, renders the messages and delivers them:

rep, err := dispatcher.Send(ctx, []notifications.Notifiable{user}, InvoicePaid{Invoice: inv})

Every recipient and channel is attempted even when others fail. The Report has one Result per recipient and channel; the error joins the failures with errors.Join. Push tokens the provider reports as invalid are listed in the results (Report.InvalidTokens) so the application can delete them.

Channels

Senders live in subpackages so that applications import only the providers they use: mail (with a built-in SMTP Mailer), sms with sms/twilio, and push with push/fcm. The database channel stores notifications through a Store; memstore and pgxstore implement it and Inbox provides the read side (list, unread count, mark read, delete).

Asynchronous delivery

Send delivers inline. To deliver from a job queue, either enqueue the inputs (recipient IDs and the notification's fields) and call Send in the job, or call Prepare in the request, enqueue each returned Delivery as JSON, and call Deliver from the worker. The second form renders once and retries per channel: a failed SMS is retried without resending the email. Prepare assigns each Delivery an ID that the database channel reuses as the stored notification's ID, so a retried database delivery is stored once.

Testing

Package notificationstest provides recording senders and a Dispatcher fake that implements Notifier, and package storetest is a conformance suite for Store implementations.

Example
package main

import (
	"context"
	"fmt"
	"log"

	notifications "github.com/byteminus/go-notifications"
	"github.com/byteminus/go-notifications/mail"
	"github.com/byteminus/go-notifications/memstore"
	"github.com/byteminus/go-notifications/notificationstest"
	"github.com/byteminus/go-notifications/sms"
)

// Account is an application's user model.
type Account struct {
	ID    string
	Name  string
	Email string
	Phone string
}

func (a Account) NotifiableType() string { return "account" }
func (a Account) NotifiableID() string   { return a.ID }
func (a Account) NotificationRoutes() notifications.Routes {
	return notifications.Routes{Name: a.Name, Email: a.Email, Phone: a.Phone}
}

// InvoicePaid is sent by email and SMS and kept in the in-app inbox.
type InvoicePaid struct {
	Number string
	Amount string
}

func (InvoicePaid) Type() string { return "invoice.paid" }

func (n InvoicePaid) ToMail(_ context.Context, to notifications.Notifiable) (mail.Message, error) {
	return mail.Message{
		Subject: "Invoice " + n.Number + " paid",
		Text:    "We received " + n.Amount + ". Thank you.",
		HTML:    "<p>We received <strong>" + n.Amount + "</strong>. Thank you.</p>",
	}, nil
}

func (n InvoicePaid) ToSMS(context.Context, notifications.Notifiable) (sms.Message, error) {
	return sms.Message{Body: "Invoice " + n.Number + " paid: " + n.Amount}, nil
}

func (n InvoicePaid) ToDatabase(context.Context, notifications.Notifiable) (notifications.DatabaseMessage, error) {
	return notifications.NewDatabaseMessage(map[string]string{"number": n.Number, "amount": n.Amount})
}

func main() {
	mailer := &notificationstest.Mailer{} // use mail.NewSMTP in production
	texts := &notificationstest.SMSSender{}
	store := memstore.New() // use pgxstore in production

	d, err := notifications.NewDispatcher(notifications.Config{
		Mailer:   mailer,
		MailFrom: mail.Address{Name: "Acme Billing", Email: "billing@acme.example"},
		SMS:      texts,
		Store:    store,
		// Ann has turned off SMS for invoices.
		Preferences: notifications.PreferencesFunc(func(_ context.Context, to notifications.Notifiable, typ string, ch notifications.Channel) (bool, error) {
			return to.NotifiableID() != "ann" || ch != notifications.ChannelSMS, nil
		}),
	})
	if err != nil {
		log.Fatal(err)
	}

	recipients := []notifications.Notifiable{
		Account{ID: "ann", Name: "Ann", Email: "ann@example.com", Phone: "+14155550100"},
		Account{ID: "bob", Name: "Bob", Email: "bob@example.com"},
	}
	rep, err := d.Send(context.Background(), recipients, InvoicePaid{Number: "INV-7", Amount: "$120.00"})
	if err != nil {
		log.Fatal(err)
	}
	for _, r := range rep.Results {
		line := fmt.Sprintf("%s %s %s", r.Recipient, r.Channel, r.Status)
		if r.SkipReason != "" {
			line += " " + string(r.SkipReason)
		}
		fmt.Println(line)
	}
	fmt.Println("emails:", len(mailer.Messages()), "first to:", mailer.Messages()[0].To[0])
}
Output:
account/ann mail sent
account/ann sms skipped opted_out
account/ann database sent
account/bob mail sent
account/bob sms skipped no_route
account/bob database sent
emails: 2 first to: "Ann" <ann@example.com>

Index

Examples

Constants

View Source
const (
	DefaultListLimit = 50
	MaxListLimit     = 200
)

Default and maximum page sizes for Store.List.

Variables

View Source
var (
	// ErrInvalidNotification is returned (wrapped) for a nil notification,
	// an empty notification type, or an invalid stored notification.
	ErrInvalidNotification = errors.New("notifications: invalid notification")

	// ErrInvalidRecipient is returned (wrapped) for a nil recipient or one
	// without a type and ID.
	ErrInvalidRecipient = errors.New("notifications: invalid recipient")

	// ErrUnknownChannel is reported for a channel name that is not built in.
	ErrUnknownChannel = errors.New("notifications: unknown channel")

	// ErrUnsupportedChannel is reported when Via selects a channel the
	// notification does not implement.
	ErrUnsupportedChannel = errors.New("notifications: notification does not support channel")

	// ErrChannelNotConfigured is reported when a channel is selected but the
	// Dispatcher has no sender (or store) for it.
	ErrChannelNotConfigured = errors.New("notifications: channel not configured")

	// ErrInvalidDelivery is returned (wrapped) by Deliver for a Delivery
	// whose payload does not match its channel.
	ErrInvalidDelivery = errors.New("notifications: invalid delivery")

	// ErrInvalidConfig is returned (wrapped) by NewDispatcher and NewInbox.
	ErrInvalidConfig = errors.New("notifications: invalid configuration")

	// ErrNotFound is returned when a stored notification does not exist or
	// belongs to another recipient. The two cases are indistinguishable on
	// purpose, so callers cannot probe other recipients' inboxes.
	ErrNotFound = errors.New("notifications: notification not found")

	// ErrDuplicateID is returned by Store.Insert when the ID already exists.
	ErrDuplicateID = errors.New("notifications: duplicate notification id")

	// ErrInvalidListOptions is returned (wrapped) for a malformed cursor or a
	// negative limit.
	ErrInvalidListOptions = errors.New("notifications: invalid list options")
)

Functions

This section is empty.

Types

type Channel

type Channel string

Channel names a delivery channel.

const (
	ChannelMail     Channel = "mail"
	ChannelSMS      Channel = "sms"
	ChannelPush     Channel = "push"
	ChannelDatabase Channel = "database"
)

The built-in channels.

func (Channel) Valid

func (c Channel) Valid() bool

Valid reports whether c is one of the built-in channels.

type Config

type Config struct {
	// Mailer enables the mail channel.
	Mailer mail.Mailer
	// MailFrom is used for messages that do not set From.
	MailFrom mail.Address
	// SMS enables the sms channel.
	SMS sms.Sender
	// Push enables the push channel.
	Push push.Sender
	// Store enables the database channel.
	Store Store
	// Preferences, when set, is consulted for every recipient and channel
	// before rendering.
	Preferences Preferences
	// Concurrency is the number of deliveries Send runs at once. Zero means
	// one: deliveries run sequentially.
	Concurrency int
	// Now stamps database notifications. Defaults to time.Now.
	Now func() time.Time
}

Config configures a Dispatcher. A channel is enabled by setting its sender (or Store for the database channel); at least one is required.

type Cursor

type Cursor struct {
	CreatedAt time.Time `json:"created_at"`
	ID        string    `json:"id"`
}

Cursor is a keyset pagination position: List returns notifications older than it in (CreatedAt, ID) order. Keyset pagination keeps pages stable while new notifications arrive.

type DatabaseMessage

type DatabaseMessage struct {
	Data json.RawMessage `json:"data"`
}

DatabaseMessage is the payload stored for an in-app notification. Data must be a JSON object.

func NewDatabaseMessage

func NewDatabaseMessage(v any) (DatabaseMessage, error)

NewDatabaseMessage marshals v, which must encode as a JSON object, into a DatabaseMessage.

type Delivery

type Delivery struct {
	// ID is a UUIDv7 assigned by Prepare. The database channel uses it as
	// the stored notification's ID, which makes redelivery of the same
	// Delivery idempotent.
	ID               string           `json:"id"`
	NotificationType string           `json:"notification_type"`
	Recipient        Recipient        `json:"recipient"`
	Channel          Channel          `json:"channel"`
	Mail             *mail.Message    `json:"mail,omitempty"`
	SMS              *sms.Message     `json:"sms,omitempty"`
	Push             *push.Message    `json:"push,omitempty"`
	Database         *DatabaseMessage `json:"database,omitempty"`
}

Delivery is one rendered message for one recipient on one channel. It is JSON-serializable so that it can be queued and delivered later by a worker calling Deliver; exactly one payload field is set, matching Channel.

func (Delivery) Validate

func (d Delivery) Validate() error

Validate reports whether d is well formed: an ID, a notification type, a valid recipient, and exactly the payload its channel needs.

type DeliveryError

type DeliveryError struct {
	NotificationType string
	Recipient        Recipient
	Channel          Channel
	Err              error
}

DeliveryError describes a failed channel. Unwrap exposes the cause, so errors.Is works with provider and package sentinels alike.

func (*DeliveryError) Error

func (e *DeliveryError) Error() string

Error implements error.

func (*DeliveryError) Unwrap

func (e *DeliveryError) Unwrap() error

Unwrap returns the cause.

type Dispatcher

type Dispatcher struct {
	// contains filtered or unexported fields
}

Dispatcher renders notifications and delivers them on every selected channel. It is safe for concurrent use.

func NewDispatcher

func NewDispatcher(cfg Config) (*Dispatcher, error)

NewDispatcher validates cfg and returns a Dispatcher.

func (*Dispatcher) Deliver

func (d *Dispatcher) Deliver(ctx context.Context, del Delivery) (Result, error)

Deliver sends one prepared Delivery and reports the outcome. The returned error is the Result's Err. Delivering the same database Delivery twice stores it once.

func (*Dispatcher) Prepare

func (d *Dispatcher) Prepare(ctx context.Context, recipients []Notifiable, n Notification) ([]Delivery, Report, error)

Prepare selects channels, applies Preferences and renders n for every recipient without delivering anything. It returns the deliveries to make and a Report of the channels that were skipped or failed while preparing. Enqueue the deliveries (they marshal to JSON) and call Deliver from a worker to send asynchronously. Preferences are evaluated now, not at delivery time.

Example
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"

	notifications "github.com/byteminus/go-notifications"
	"github.com/byteminus/go-notifications/mail"
	"github.com/byteminus/go-notifications/memstore"
	"github.com/byteminus/go-notifications/notificationstest"
	"github.com/byteminus/go-notifications/sms"
)

// Account is an application's user model.
type Account struct {
	ID    string
	Name  string
	Email string
	Phone string
}

func (a Account) NotifiableType() string { return "account" }
func (a Account) NotifiableID() string   { return a.ID }
func (a Account) NotificationRoutes() notifications.Routes {
	return notifications.Routes{Name: a.Name, Email: a.Email, Phone: a.Phone}
}

// InvoicePaid is sent by email and SMS and kept in the in-app inbox.
type InvoicePaid struct {
	Number string
	Amount string
}

func (InvoicePaid) Type() string { return "invoice.paid" }

func (n InvoicePaid) ToMail(_ context.Context, to notifications.Notifiable) (mail.Message, error) {
	return mail.Message{
		Subject: "Invoice " + n.Number + " paid",
		Text:    "We received " + n.Amount + ". Thank you.",
		HTML:    "<p>We received <strong>" + n.Amount + "</strong>. Thank you.</p>",
	}, nil
}

func (n InvoicePaid) ToSMS(context.Context, notifications.Notifiable) (sms.Message, error) {
	return sms.Message{Body: "Invoice " + n.Number + " paid: " + n.Amount}, nil
}

func (n InvoicePaid) ToDatabase(context.Context, notifications.Notifiable) (notifications.DatabaseMessage, error) {
	return notifications.NewDatabaseMessage(map[string]string{"number": n.Number, "amount": n.Amount})
}

func main() {
	d, err := notifications.NewDispatcher(notifications.Config{
		Mailer:   &notificationstest.Mailer{},
		MailFrom: mail.Address{Email: "billing@acme.example"},
		Store:    memstore.New(),
	})
	if err != nil {
		log.Fatal(err)
	}
	ctx := context.Background()
	ann := Account{ID: "ann", Email: "ann@example.com"}

	// In the request: render now and enqueue one job per delivery, ideally
	// in the same transaction as the write that caused the notification.
	deliveries, _, err := d.Prepare(ctx, []notifications.Notifiable{ann}, InvoicePaid{Number: "INV-8", Amount: "$5.00"})
	if err != nil {
		log.Fatal(err)
	}
	var queue [][]byte
	for _, del := range deliveries {
		payload, err := json.Marshal(del)
		if err != nil {
			log.Fatal(err)
		}
		queue = append(queue, payload)
	}

	// In the worker: decode and deliver; return the error to retry the job.
	for _, payload := range queue {
		var del notifications.Delivery
		if err := json.Unmarshal(payload, &del); err != nil {
			log.Fatal(err)
		}
		res, err := d.Deliver(ctx, del)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Println(res.Channel, res.Status)
	}
}
Output:
mail sent
database sent

func (*Dispatcher) Send

func (d *Dispatcher) Send(ctx context.Context, recipients []Notifiable, n Notification) (Report, error)

Send prepares n for every recipient and delivers it on every selected channel. A failure on one channel or recipient never stops the others. The Report holds one Result per recipient and channel; the error joins every failure with errors.Join and is nil when nothing failed. Skips (opt-outs, missing routes) are not failures.

Send returns an error with an empty Report only when n itself is invalid.

type Inbox

type Inbox struct {
	// contains filtered or unexported fields
}

Inbox is the read side of the database channel: what an application's notification bell and inbox page call. It is safe for concurrent use when its Store is.

Example
package main

import (
	"context"
	"fmt"
	"log"

	notifications "github.com/byteminus/go-notifications"
	"github.com/byteminus/go-notifications/mail"
	"github.com/byteminus/go-notifications/memstore"
	"github.com/byteminus/go-notifications/sms"
)

// Account is an application's user model.
type Account struct {
	ID    string
	Name  string
	Email string
	Phone string
}

func (a Account) NotifiableType() string { return "account" }
func (a Account) NotifiableID() string   { return a.ID }
func (a Account) NotificationRoutes() notifications.Routes {
	return notifications.Routes{Name: a.Name, Email: a.Email, Phone: a.Phone}
}

// InvoicePaid is sent by email and SMS and kept in the in-app inbox.
type InvoicePaid struct {
	Number string
	Amount string
}

func (InvoicePaid) Type() string { return "invoice.paid" }

func (n InvoicePaid) ToMail(_ context.Context, to notifications.Notifiable) (mail.Message, error) {
	return mail.Message{
		Subject: "Invoice " + n.Number + " paid",
		Text:    "We received " + n.Amount + ". Thank you.",
		HTML:    "<p>We received <strong>" + n.Amount + "</strong>. Thank you.</p>",
	}, nil
}

func (n InvoicePaid) ToSMS(context.Context, notifications.Notifiable) (sms.Message, error) {
	return sms.Message{Body: "Invoice " + n.Number + " paid: " + n.Amount}, nil
}

func (n InvoicePaid) ToDatabase(context.Context, notifications.Notifiable) (notifications.DatabaseMessage, error) {
	return notifications.NewDatabaseMessage(map[string]string{"number": n.Number, "amount": n.Amount})
}

func main() {
	ctx := context.Background()
	store := memstore.New()
	d, err := notifications.NewDispatcher(notifications.Config{Store: store})
	if err != nil {
		log.Fatal(err)
	}
	ann := Account{ID: "ann"}
	for _, number := range []string{"INV-1", "INV-2"} {
		if _, err := d.Send(ctx, []notifications.Notifiable{ann}, InvoicePaid{Number: number, Amount: "$1"}); err != nil {
			log.Fatal(err)
		}
	}

	inbox, err := notifications.NewInbox(store, notifications.InboxOptions{})
	if err != nil {
		log.Fatal(err)
	}
	me := notifications.RecipientOf(ann)
	items, err := inbox.List(ctx, me, notifications.ListOptions{Limit: 20})
	if err != nil {
		log.Fatal(err)
	}
	for _, n := range items {
		fmt.Println(n.Type, string(n.Data))
	}
	if err := inbox.MarkRead(ctx, me, items[0].ID); err != nil {
		log.Fatal(err)
	}
	unread, err := inbox.UnreadCount(ctx, me)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("unread:", unread)
}
Output:
invoice.paid {"amount":"$1","number":"INV-2"}
invoice.paid {"amount":"$1","number":"INV-1"}
unread: 1

func NewInbox

func NewInbox(store Store, opts InboxOptions) (*Inbox, error)

NewInbox returns an Inbox over store.

func (*Inbox) Delete

func (in *Inbox) Delete(ctx context.Context, r Recipient, id string) error

Delete removes one of r's notifications. It returns ErrNotFound if r has no notification with that ID.

func (*Inbox) List

func (in *Inbox) List(ctx context.Context, r Recipient, opts ListOptions) ([]StoredNotification, error)

List returns r's notifications, newest first.

func (*Inbox) MarkAllRead

func (in *Inbox) MarkAllRead(ctx context.Context, r Recipient) (int, error)

MarkAllRead marks all of r's unread notifications as read and returns how many changed.

func (*Inbox) MarkRead

func (in *Inbox) MarkRead(ctx context.Context, r Recipient, id string) error

MarkRead marks one of r's notifications as read. Marking an already read notification keeps its original read time and succeeds. It returns ErrNotFound if r has no notification with that ID.

func (*Inbox) UnreadCount

func (in *Inbox) UnreadCount(ctx context.Context, r Recipient) (int, error)

UnreadCount returns the number of r's unread notifications.

type InboxOptions

type InboxOptions struct {
	// Now stamps read times. Defaults to time.Now.
	Now func() time.Time
}

InboxOptions configures an Inbox.

type ListOptions

type ListOptions struct {
	// OnlyUnread restricts the result to unread notifications.
	OnlyUnread bool
	// Limit is the page size: zero means DefaultListLimit and values above
	// MaxListLimit are capped.
	Limit int
	// Before, when set, returns only notifications after this position in
	// newest-first order; pass the last item's Cursor to get the next page.
	Before *Cursor
}

ListOptions filters and pages Store.List.

func (ListOptions) EffectiveLimit

func (o ListOptions) EffectiveLimit() int

EffectiveLimit returns the page size a Store should use.

func (ListOptions) Validate

func (o ListOptions) Validate() error

Validate reports whether the options are usable.

type Notifiable

type Notifiable interface {
	NotifiableType() string
	NotifiableID() string
	NotificationRoutes() Routes
}

Notifiable is a recipient: usually a user, sometimes a team or an anonymous address. Type and ID identify it in the inbox and to Preferences; Routes says where to reach it.

type Notification

type Notification interface {
	Type() string
}

Notification is something to tell a recipient. Type is a stable name such as "invoice.paid": it is stored with database notifications and passed to Preferences, so renaming it is a breaking change for stored data and opt-outs.

A notification takes part in a channel by implementing the matching interface: ToMail, ToSMS, ToPush or ToDatabase. It may also implement Via to choose channels per recipient.

type Notifier

type Notifier interface {
	Send(ctx context.Context, recipients []Notifiable, n Notification) (Report, error)
}

Notifier sends a notification to recipients. *Dispatcher implements it; notificationstest.Dispatcher is a recording fake for application tests.

type Preferences

type Preferences interface {
	Allows(ctx context.Context, to Notifiable, notificationType string, ch Channel) (bool, error)
}

Preferences decides whether a recipient accepts a notification type on a channel. Notifications that must always be delivered, such as password resets, should be allowed by the implementation regardless of opt-outs.

An error from Allows fails that channel rather than sending: when the preference store is unavailable the safe default is to respect a possible opt-out.

type PreferencesFunc

type PreferencesFunc func(ctx context.Context, to Notifiable, notificationType string, ch Channel) (bool, error)

PreferencesFunc adapts a function to the Preferences interface.

func (PreferencesFunc) Allows

func (f PreferencesFunc) Allows(ctx context.Context, to Notifiable, notificationType string, ch Channel) (bool, error)

Allows calls f.

type Recipient

type Recipient struct {
	Type string `json:"type"`
	ID   string `json:"id"`
}

Recipient identifies a Notifiable by type and ID. It is how inbox operations name an owner without loading the full recipient.

func RecipientOf

func RecipientOf(n Notifiable) Recipient

RecipientOf returns the Recipient identifying n.

func (Recipient) String

func (r Recipient) String() string

String returns "type/id".

func (Recipient) Validate

func (r Recipient) Validate() error

Validate reports whether both Type and ID are set.

type Report

type Report struct {
	Results []Result
}

Report collects the results of a Send or Prepare, ordered by recipient and then channel.

func (Report) Count

func (r Report) Count(s Status) int

Count returns the number of results with the given status.

func (Report) Err

func (r Report) Err() error

Err joins the errors of every failed result with errors.Join, or returns nil when nothing failed.

func (Report) Failed

func (r Report) Failed() []Result

Failed returns the failed results.

func (Report) InvalidTokens

func (r Report) InvalidTokens() map[Recipient][]string

InvalidTokens groups the push tokens to delete by recipient.

type Result

type Result struct {
	// DeliveryID identifies the delivery; empty when the channel failed or
	// was skipped before a Delivery was prepared. For the database channel
	// it is also the stored notification's ID.
	DeliveryID       string
	NotificationType string
	Recipient        Recipient
	// Channel is empty only for a result reporting an invalid recipient.
	Channel    Channel
	Status     Status
	SkipReason SkipReason
	// MessageID is the provider's identifier when it returns one (Twilio
	// SID, stored notification ID). Push results leave it empty because a
	// message fans out to several devices.
	MessageID string
	// InvalidTokens lists push device tokens the provider rejected as
	// unregistered or malformed; delete them from the recipient. It can be
	// non-empty on a sent result when other devices were reached.
	InvalidTokens []string
	// Err is a *DeliveryError when Status is StatusFailed, nil otherwise.
	// For push it may coexist with successful delivery to some devices.
	Err error
}

Result is the outcome of one channel for one recipient.

type Routes

type Routes struct {
	// Name is the display name used in email headers.
	Name string `json:"name,omitempty"`
	// Email is a bare address such as ann@example.com.
	Email string `json:"email,omitempty"`
	// Phone is an E.164 number such as +14155550100.
	Phone string `json:"phone,omitempty"`
	// PushTokens are the recipient's device tokens.
	PushTokens []string `json:"push_tokens,omitempty"`
}

Routes holds a recipient's addresses. Empty fields mean the recipient cannot be reached on that channel, and the channel is skipped.

type SkipReason

type SkipReason string

SkipReason explains a StatusSkipped result.

const (
	// SkipOptedOut means Preferences did not allow the channel.
	SkipOptedOut SkipReason = "opted_out"
	// SkipNoRoute means the recipient has no address for the channel and
	// the rendered message named none.
	SkipNoRoute SkipReason = "no_route"
)

The possible skip reasons.

type Status

type Status string

Status is the outcome of one channel for one recipient.

const (
	// StatusSent means the provider (or store) accepted the message.
	StatusSent Status = "sent"
	// StatusSkipped means nothing was attempted, for the reason given in
	// Result.SkipReason. A skip is not an error.
	StatusSkipped Status = "skipped"
	// StatusFailed means the attempt failed; Result.Err says why.
	StatusFailed Status = "failed"
)

The possible statuses.

type Store

type Store interface {
	// Insert stores n. It returns ErrDuplicateID if n.ID exists.
	Insert(ctx context.Context, n StoredNotification) error
	// List returns r's notifications, newest first.
	List(ctx context.Context, r Recipient, opts ListOptions) ([]StoredNotification, error)
	// UnreadCount returns the number of r's unread notifications.
	UnreadCount(ctx context.Context, r Recipient) (int, error)
	// MarkRead sets ReadAt to at unless it is already set, in which case the
	// original time is kept. It returns ErrNotFound if r has no such
	// notification.
	MarkRead(ctx context.Context, r Recipient, id string, at time.Time) error
	// MarkAllRead marks every unread notification of r as read at at and
	// returns how many changed.
	MarkAllRead(ctx context.Context, r Recipient, at time.Time) (int, error)
	// Delete removes a notification. It returns ErrNotFound if r has no such
	// notification.
	Delete(ctx context.Context, r Recipient, id string) error
}

Store persists in-app notifications. Every method that addresses an existing notification takes the owning Recipient and treats a notification owned by someone else as missing (ErrNotFound), so an ID taken from a request can never reach another recipient's inbox.

Implementations must be safe for concurrent use, validate their input with StoredNotification.Validate, Recipient.Validate and ListOptions.Validate, return times in UTC with microsecond precision, and pass the storetest conformance suite.

type StoredNotification

type StoredNotification struct {
	// ID is a UUID in canonical lowercase form.
	ID        string          `json:"id"`
	Recipient Recipient       `json:"recipient"`
	Type      string          `json:"type"`
	Data      json.RawMessage `json:"data"`
	ReadAt    *time.Time      `json:"read_at"`
	CreatedAt time.Time       `json:"created_at"`
}

StoredNotification is an in-app notification as kept by a Store.

func (StoredNotification) Cursor

func (n StoredNotification) Cursor() Cursor

Cursor returns the position after n, for fetching the next page.

func (StoredNotification) Unread

func (n StoredNotification) Unread() bool

Unread reports whether the notification has not been read.

func (StoredNotification) Validate

func (n StoredNotification) Validate() error

Validate reports whether n satisfies the constraints every Store enforces: a UUID ID, a valid recipient, a type, a JSON object as data and a creation time.

type ToDatabase

type ToDatabase interface {
	ToDatabase(ctx context.Context, to Notifiable) (DatabaseMessage, error)
}

ToDatabase is implemented by notifications that are stored in the recipient's in-app inbox.

type ToMail

type ToMail interface {
	ToMail(ctx context.Context, to Notifiable) (mail.Message, error)
}

ToMail is implemented by notifications that can be sent by email. A message without recipients is addressed to the recipient's Routes.Email, and a message without From uses Config.MailFrom.

type ToPush

type ToPush interface {
	ToPush(ctx context.Context, to Notifiable) (push.Message, error)
}

ToPush is implemented by notifications that can be sent as push notifications. A message without Tokens is addressed to the recipient's Routes.PushTokens.

type ToSMS

type ToSMS interface {
	ToSMS(ctx context.Context, to Notifiable) (sms.Message, error)
}

ToSMS is implemented by notifications that can be sent by SMS. A message without To is addressed to the recipient's Routes.Phone.

type Via

type Via interface {
	Via(to Notifiable) []Channel
}

Via is implemented by notifications that choose their channels per recipient. Without it, a notification goes to every channel it implements and the Dispatcher has configured. Channels named by Via must be both implemented and configured, otherwise the result for that channel is a failure: an explicit choice that cannot be honoured is a programming error worth surfacing.

Directories

Path Synopsis
Package mail defines the email message type used by go-notifications, the Mailer interface that provider adapters implement, and a built-in SMTP Mailer.
Package mail defines the email message type used by go-notifications, the Mailer interface that provider adapters implement, and a built-in SMTP Mailer.
Package memstore is an in-memory notifications.Store for tests, examples and single-process prototypes.
Package memstore is an in-memory notifications.Store for tests, examples and single-process prototypes.
Package notificationstest provides test doubles for applications that use go-notifications.
Package notificationstest provides test doubles for applications that use go-notifications.
Package pgxstore is a PostgreSQL notifications.Store built on pgx v5.
Package pgxstore is a PostgreSQL notifications.Store built on pgx v5.
Package push defines the push notification message type used by go-notifications and the Sender interface that provider adapters implement.
Package push defines the push notification message type used by go-notifications and the Sender interface that provider adapters implement.
fcm
Package fcm implements push.Sender over the Firebase Cloud Messaging HTTP v1 API, authenticating with a Google service account through golang.org/x/oauth2/google.
Package fcm implements push.Sender over the Firebase Cloud Messaging HTTP v1 API, authenticating with a Google service account through golang.org/x/oauth2/google.
sms
Package sms defines the SMS message type used by go-notifications and the Sender interface that provider adapters implement.
Package sms defines the SMS message type used by go-notifications and the Sender interface that provider adapters implement.
twilio
Package twilio implements sms.Sender over the Twilio Programmable Messaging REST API using only net/http.
Package twilio implements sms.Sender over the Twilio Programmable Messaging REST API using only net/http.
Package storetest is a conformance suite for notifications.Store implementations.
Package storetest is a conformance suite for notifications.Store implementations.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL