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 as a typed Ref 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 (paged list, unread count, mark read, delete), scoped to the owner's Ref so that one user cannot reach another user's notifications by ID.
Errors ¶
Errors wrap the family sentinels ErrNotFound, ErrInvalid and ErrConflict, so an application maps them to HTTP statuses with errors.Is.
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) NotifiableRef() notifications.Ref {
return notifications.Ref{Type: "account", ID: 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 := ¬ificationstest.Mailer{} // use mail.NewSMTP in production
texts := ¬ificationstest.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.NotifiableRef().ID != "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 ¶
- Constants
- Variables
- type Channel
- type Config
- type Cursor
- type DatabaseMessage
- type Delivery
- type DeliveryError
- type Dispatcher
- func (d *Dispatcher) Deliver(ctx context.Context, del Delivery) (Result, error)
- func (d *Dispatcher) Prepare(ctx context.Context, recipients []Notifiable, n Notification) ([]Delivery, Report, error)
- func (d *Dispatcher) Send(ctx context.Context, recipients []Notifiable, n Notification) (Report, error)
- type Inbox
- func (in *Inbox) Delete(ctx context.Context, owner Ref, id string) error
- func (in *Inbox) List(ctx context.Context, owner Ref, opts ListOptions) (Page, error)
- func (in *Inbox) MarkAllRead(ctx context.Context, owner Ref) (int, error)
- func (in *Inbox) MarkRead(ctx context.Context, owner Ref, id string) error
- func (in *Inbox) UnreadCount(ctx context.Context, owner Ref) (int, error)
- type InboxOptions
- type ListOptions
- type Notifiable
- type Notification
- type Notifier
- type Page
- type Preferences
- type PreferencesFunc
- type Ref
- type Report
- type Result
- type Routes
- type SkipReason
- type Status
- type Store
- type StoredNotification
- type ToDatabase
- type ToMail
- type ToPush
- type ToSMS
- type Via
Examples ¶
Constants ¶
const ( DefaultPageSize = 50 MaxPageSize = 200 )
Default and maximum page sizes for Store.List and Inbox.List.
const MaxDataSize = 64 << 10
MaxDataSize is the largest DatabaseMessage.Data, in bytes, that the database channel and every Store accept. In-app notifications carry references and short strings; the bound keeps a runaway payload from being stored once per recipient.
Variables ¶
var ( // 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") // ErrInvalid is wrapped by every validation error: the input or the // configuration cannot be used as given. ErrInvalid = errors.New("notifications: invalid input") // ErrConflict is wrapped by errors that report a uniqueness violation. ErrConflict = errors.New("notifications: conflict") )
The family sentinels. Every error this package returns that has one of these meanings wraps the matching sentinel, so an application maps them to HTTP statuses with errors.Is regardless of the more specific error below.
var ( // ErrInvalidNotification is returned (wrapped) for a nil notification, // an empty notification type, or an invalid stored notification. It // wraps ErrInvalid. ErrInvalidNotification = newKindError("notifications: invalid notification", ErrInvalid) // ErrInvalidRecipient is returned (wrapped) for a nil recipient or a Ref // without both a type and an ID. It wraps ErrInvalid. ErrInvalidRecipient = newKindError("notifications: invalid recipient", ErrInvalid) // ErrUnknownChannel is reported for a channel name that is not built in. // It wraps ErrInvalid. ErrUnknownChannel = newKindError("notifications: unknown channel", ErrInvalid) // ErrUnsupportedChannel is reported when Via selects a channel the // notification does not implement. It wraps ErrInvalid. ErrUnsupportedChannel = newKindError("notifications: notification does not support channel", ErrInvalid) // ErrChannelNotConfigured is reported when a channel is selected but the // Dispatcher has no sender (or store) for it. It wraps ErrInvalid. ErrChannelNotConfigured = newKindError("notifications: channel not configured", ErrInvalid) // ErrInvalidDelivery is returned (wrapped) by Deliver for a Delivery // whose payload does not match its channel. It wraps ErrInvalid. ErrInvalidDelivery = newKindError("notifications: invalid delivery", ErrInvalid) // ErrInvalidConfig is returned (wrapped) by NewDispatcher and NewInbox. // It wraps ErrInvalid. ErrInvalidConfig = newKindError("notifications: invalid configuration", ErrInvalid) // ErrDuplicateID is returned by Store.Insert when the ID already exists. // It wraps ErrConflict. ErrDuplicateID = newKindError("notifications: duplicate notification id", ErrConflict) // ErrInvalidListOptions is returned (wrapped) for a malformed cursor or a // negative limit. It wraps ErrInvalid. ErrInvalidListOptions = newKindError("notifications: invalid list options", ErrInvalid) )
Specific errors. Each wraps one of the family sentinels.
Functions ¶
This section is empty.
Types ¶
type Channel ¶
type Channel string
Channel names a delivery channel.
type Config ¶
type Config struct {
// Mailer enables the mail channel.
Mailer mail.Mailer
// MailFrom is used for messages that do not set From. A zero address
// leaves From empty, and such messages fail validation in the Mailer.
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 most deliveries Send runs at once. Zero means
// one: deliveries run sequentially. Negative values are invalid.
Concurrency int
// Now stamps database notifications. Nil means 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. Nil fields disable their channel or feature.
type Cursor ¶
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. It marshals to JSON so that an API can hand it to clients; treat a cursor from a client as untrusted, which ListOptions.Validate does.
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 of at most MaxDataSize bytes without \u0000 or unpaired surrogate escapes, which PostgreSQL's jsonb cannot store.
func NewDatabaseMessage ¶
func NewDatabaseMessage(v any) (DatabaseMessage, error)
NewDatabaseMessage marshals v, which must encode as a JSON object of at most MaxDataSize bytes, into a DatabaseMessage. Errors wrap ErrInvalidNotification.
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 is the Type of the notification that was rendered.
NotificationType string `json:"notification_type"`
// Recipient identifies who the delivery is for.
Recipient Ref `json:"recipient"`
// Channel selects which one of the payload fields below is set.
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 ¶
Validate reports whether d is well formed: an ID, a notification type, a valid recipient, and exactly the payload its channel needs, with valid data for the database channel. Errors wrap ErrInvalidDelivery. Message contents are validated by the sender when the Delivery is delivered.
type DeliveryError ¶
type DeliveryError struct {
// NotificationType, Recipient and Channel say what failed; Channel is
// empty for an invalid recipient.
NotificationType string
Recipient Ref
Channel Channel
// Err is the cause.
Err error
}
DeliveryError describes a failed channel. Unwrap exposes the cause, so errors.Is works with provider and package sentinels alike.
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. It returns an error wrapping ErrInvalidConfig when no channel is configured or Concurrency is negative.
func (*Dispatcher) Deliver ¶
Deliver sends one prepared Delivery and reports the outcome. The returned error is the Result's Err, a *DeliveryError wrapping ErrInvalidDelivery, ErrChannelNotConfigured, the context's error or the sender's error. 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. It returns an error with no deliveries only when n itself is invalid; otherwise the error joins the failures in the Report.
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) NotifiableRef() notifications.Ref {
return notifications.Ref{Type: "account", ID: 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: ¬ificationstest.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.
At most Config.Concurrency deliveries run at once. When ctx is cancelled, deliveries not yet started fail with the context's error instead of starting, and Send returns once the deliveries in flight have returned.
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. Every method is scoped to the owner Ref, which the application takes from the authenticated session, never from the request: an ID owned by someone else behaves as missing (ErrNotFound). 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) NotifiableRef() notifications.Ref {
return notifications.Ref{Type: "account", ID: 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)
}
// The owner comes from the authenticated session, never from the
// request, so an ID from the request cannot reach another inbox.
me := ann.NotifiableRef()
var newest string
opts := notifications.ListOptions{Limit: 1}
for {
page, err := inbox.List(ctx, me, opts)
if err != nil {
log.Fatal(err)
}
for _, n := range page.Items {
if newest == "" {
newest = n.ID
}
fmt.Println(n.Type, string(n.Data))
}
if page.Next == nil {
break
}
opts.Before = page.Next
}
if err := inbox.MarkRead(ctx, me, newest); 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. It returns an error wrapping ErrInvalidConfig when store is nil.
func (*Inbox) Delete ¶
Delete removes one of owner's notifications. It returns ErrNotFound if owner has no notification with that ID.
func (*Inbox) List ¶
List returns a page of owneowner's notifications, newest first. Page.Next is nil on the last page. Errors wrap ErrInvalidRecipient or ErrInvalidListOptions for bad input, or come from the Store.
func (*Inbox) MarkAllRead ¶
MarkAllRead marks all of owner's unread notifications as read and returns how many changed.
type InboxOptions ¶
InboxOptions configures an Inbox. The zero value is valid.
type ListOptions ¶
type ListOptions struct {
// OnlyUnread restricts the result to unread notifications.
OnlyUnread bool
// Limit is the page size: zero means DefaultPageSize and values above
// MaxPageSize are capped.
Limit int
// Before, when set, returns only notifications after this position in
// newest-first order; pass the previous Page.Next to get the next page.
Before *Cursor
}
ListOptions filters and pages Store.List. The zero value lists the first DefaultPageSize notifications.
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. Errors wrap ErrInvalidListOptions.
type Notifiable ¶
Notifiable is a recipient: usually a user, sometimes a team or an anonymous address. Applications implement it on their own models.
NotifiableRef identifies the recipient in the inbox and to Preferences; it must return a Ref with both fields set, otherwise the recipient fails with ErrInvalidRecipient. NotificationRoutes says where to reach it. Both are called once per Send or Prepare and must be safe for concurrent use when the Dispatcher is used concurrently.
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. Application code depends on it rather than on *Dispatcher so that tests can substitute notificationstest.Dispatcher. Applications call it and do not usually implement it; it may gain methods in a minor release.
type Page ¶ added in v0.2.0
type Page struct {
// Items are the notifications, newest first.
Items []StoredNotification
// Next is the cursor for the following page, or nil when there are no
// more notifications. Pass it as ListOptions.Before.
Next *Cursor
}
Page is one page of a List.
func NewPage ¶ added in v0.2.0
func NewPage(rows []StoredNotification, opts ListOptions) Page
NewPage builds a Page for a Store that fetched up to limit+1 rows, where limit is opts.EffectiveLimit(): the extra row only signals that another page exists and is dropped.
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 Ref ¶ added in v0.2.0
Ref identifies a record or party by type and ID, such as a recipient ("user", "42") or an inbox owner. Compare Refs as structs; never use String as a key.
func (Ref) Validate ¶ added in v0.2.0
Validate reports whether r can identify a recipient: both fields set, valid UTF-8 without NUL bytes, and at most 255 bytes each. The error wraps ErrInvalidRecipient and so ErrInvalid. A zero Ref is invalid because every notification has a recipient. Store implementations call it on every Ref they receive.
type Report ¶
type Report struct {
// Results holds one Result per recipient and channel.
Results []Result
}
Report collects the results of a Send or Prepare, ordered by recipient and then channel.
func (Report) Err ¶
Err joins the errors of every failed result with errors.Join, or returns nil when nothing failed.
func (Report) InvalidTokens ¶
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 is the Type of the notification.
NotificationType string
// Recipient identifies who the result is for; it may be invalid when
// the result reports an invalid recipient.
Recipient Ref
// Channel is empty only for a result reporting an invalid recipient.
Channel Channel
// Status is the outcome.
Status Status
// SkipReason says why a StatusSkipped result was skipped.
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, whoever
// owns it.
Insert(ctx context.Context, n StoredNotification) error
// List returns a page of owner's notifications, newest first, ordered by
// (CreatedAt, ID) descending.
List(ctx context.Context, owner Ref, opts ListOptions) (Page, error)
// UnreadCount returns the number of owner's unread notifications.
UnreadCount(ctx context.Context, owner Ref) (int, error)
// MarkRead sets ReadAt to at unless it is already set, in which case the
// original time is kept. It returns ErrNotFound if owner has no such
// notification.
MarkRead(ctx context.Context, owner Ref, id string, at time.Time) error
// MarkAllRead marks every unread notification of owner as read at at
// and returns how many changed.
MarkAllRead(ctx context.Context, owner Ref, at time.Time) (int, error)
// Delete removes one of owner's notifications. It returns ErrNotFound if
// owner has no such notification.
Delete(ctx context.Context, owner Ref, id string) error
}
Store persists in-app notifications. Applications usually use memstore or pgxstore; implement it only to keep the inbox in another database, and run the storetest conformance suite against the implementation.
Every method that reads or changes existing notifications 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, Ref.Validate and ListOptions.Validate and return those errors unchanged; return times in UTC truncated to microseconds; and treat an ID that is not a UUID as not found. MarkRead must not overwrite a read time set concurrently: use a conditional update or a row lock, never an unguarded read-modify-write.
type StoredNotification ¶
type StoredNotification struct {
// ID is a UUID; stores return it in canonical lowercase form.
ID string `json:"id"`
// Recipient owns the notification.
Recipient Ref `json:"recipient"`
// Type is the notification type, such as "invoice.paid".
Type string `json:"type"`
// Data is the JSON object rendered by ToDatabase.
Data json.RawMessage `json:"data"`
// ReadAt is nil while the notification is unread.
ReadAt *time.Time `json:"read_at"`
// CreatedAt orders the inbox.
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 of at most 255 bytes of UTF-8 without NUL, Data accepted by NewDatabaseMessage, and a creation time. Errors wrap ErrInvalidNotification or ErrInvalidRecipient, and so ErrInvalid.
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 ¶
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 ¶
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 ¶
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.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
httpx
Package httpx holds the HTTP details shared by the provider adapters.
|
Package httpx holds the HTTP details shared by the provider adapters. |
|
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. |
|
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. |