notifications

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Sep 16, 2026 License: MIT Imports: 29 Imported by: 0

Documentation

Overview

Package notifications owns durable mail delivery records and announcement baselines.

Index

Constants

View Source
const (
	StatusQueued    = "queued"
	StatusSending   = "sending"
	StatusDelivered = "delivered"
	StatusFailed    = "failed"
	StatusUncertain = "uncertain"
	// StatusSkipped means send-time validation found no eligible recipient
	// or content, so nothing was sent and nothing needs a Curator.
	StatusSkipped = "skipped"
)
View Source
const (
	AlbumNew     = "new"
	AlbumUpdated = "updated"

	ResultNotified  = "notified"
	ResultDismissed = "dismissed"
	ResultSkipped   = "skipped"
)
View Source
const KindUpdate = "update"

KindUpdate marks the optional email for an approved Update Notification. Unlike transactional kinds it is re-validated and re-rendered when sent.

Variables

View Source
var (
	ErrMailUnconfigured = &errcodes.Error{HTTPCode: 409, Code: "mail_unconfigured", Message: "Email is not configured for this installation. Add SMTP settings to send email."}
	ErrAlreadyDelivered = &errcodes.Error{HTTPCode: 409, Code: "already_delivered", Message: "This email was already delivered."}
)
View Source
var ErrInvitationRetry = &errcodes.Error{HTTPCode: 409, Code: "invitation_retry", Message: "Retry this invitation from the person's page."}

Functions

func RegisterRoutes

func RegisterRoutes(e *echo.Echo, module UseCases, diagnostic Diagnostic, requirePerson, requireCurator echo.MiddlewareFunc)

RegisterRoutes keeps previews, approval, delivery state, and the email diagnostic behind the Curator guard and a Person's own notifications behind the signed-in guard. The unsubscribe link is public by design: it is opened from an email without a session, and only its POST changes anything. Its token is a query value so the access log, which records paths, never carries it.

Types

type Approval

type Approval struct {
	People []PersonResult `json:"people"`
}

Approval reports the outcome of sending or dismissing reviewed updates.

type ApprovePerson

type ApprovePerson struct {
	PersonID         string   `json:"person_id" validate:"required,uuid"`
	ReviewToken      string   `json:"review_token" validate:"required"`
	ExcludedAlbumIDs []string `json:"excluded_album_ids" validate:"dive,uuid"`
}

ApprovePerson names one reviewed row. ExcludedAlbumIDs drops whole Album updates from that row; a Person the Curator left out is simply omitted.

type ApproveRequest

type ApproveRequest struct {
	Note   string          `json:"note" mod:"trim" validate:"max=1000"`
	People []ApprovePerson `json:"people" validate:"required,min=1,dive"`
}

func (ApproveRequest) ValidationMessage

func (ApproveRequest) ValidationMessage(field, rule string) string

ValidationMessage keeps approval failures in Curator words. A stale or malformed row means the preview must be reviewed again, never edited by hand.

type Baseline

type Baseline struct {
	Albums  int `json:"albums"`
	Entries int `json:"entries"`
}

Baseline counts the content announced to a Person so far.

type Delivery

type Delivery struct {
	ID          string     `json:"id"`
	Status      string     `json:"status"`
	Attempts    int        `json:"attempts"`
	Message     string     `json:"message"`
	UpdatedAt   time.Time  `json:"updated_at"`
	DeliveredAt *time.Time `json:"delivered_at"`
}

Delivery is the Curator-facing state of one email. Status is one of queued, sending, delivered, failed, uncertain, or skipped.

type DeliveryError

type DeliveryError struct {
	Outcome Outcome
	Summary string
	Cause   error
}

DeliveryError carries the outcome and a short operator-safe summary. The cause keeps provider details for logs without exposing them to Curators.

func (*DeliveryError) Error

func (e *DeliveryError) Error() string

func (*DeliveryError) Unwrap

func (e *DeliveryError) Unwrap() error

type DeliveryWork

type DeliveryWork struct {
	Delivery     `tstype:",extends"`
	Kind         string `json:"kind"`
	Recipient    string `json:"recipient"`
	PersonID     string `json:"person_id"`
	PersonName   string `json:"person_name"`
	InvitationID string `json:"invitation_id"`
}

DeliveryWork is one email a Curator may need to watch or act on. PersonID and PersonName are set for update and Invitation email; InvitationID is set for Invitations, whose retry lives on the Person page.

type Diagnostic added in v0.2.0

type Diagnostic interface {
	CheckMail(context.Context) MailStatus
}

Diagnostic is the Settings seam: one read-only check of the mail server.

type DismissRequest

type DismissRequest struct {
	People []ApprovePerson `json:"people" validate:"required,min=1,dive"`
}

DismissRequest selects reviewed changes to add to the baseline silently.

func (DismissRequest) ValidationMessage

func (DismissRequest) ValidationMessage(field, rule string) string

type EnqueueDelivery

type EnqueueDelivery func(ctx context.Context, tx bun.Tx, deliveryID string) error

EnqueueDelivery records durable work in the caller's transaction. It must not send.

type MailStatus added in v0.2.0

type MailStatus struct {
	Configured bool   `json:"configured"`
	Usable     bool   `json:"usable"`
	Sender     string `json:"sender"`
	Message    string `json:"message"`
}

MailStatus is the Settings diagnostic for email. Configured is false when the installation has no SMTP settings, which is a quiet state rather than a failure. Usable is true once the mail server accepted a connection and any sign-in. Sender is the configured From address; the server address and credentials are never reported.

type Mailer

type Mailer interface {
	Send(ctx context.Context, message Message) error
	Check(ctx context.Context) MailStatus
}

Mailer is the adapter seam for SMTP and its recording test counterpart. Send returns nil when the server accepted the message and a *DeliveryError otherwise. Check connects and signs in without sending anything.

type Message

type Message struct {
	Kind    string
	To      string
	Subject string
	Body    string
}

Message is one transactional email. Bodies are plain text.

type Module

type Module struct {

	// PublicURL is the origin update emails link to. Empty renders relative links.
	PublicURL string
	// contains filtered or unexported fields
}

Module owns delivery records, announcement baselines, and Update Notifications. A nil mailer means SMTP is not configured; every other use case still works.

func New

func New(db *bun.DB, mailer Mailer, enqueue EnqueueDelivery, content VisibleContent, now func() time.Time) *Module

func (*Module) Announced

func (m *Module) Announced(ctx context.Context, db bun.IDB, personID string) (Baseline, error)

Announced counts the Person's baseline. Callers use it to show or assert that later visibility changes stay unannounced.

func (*Module) AnnouncedEntryIDs

func (m *Module) AnnouncedEntryIDs(ctx context.Context, personID string) ([]string, error)

AnnouncedEntryIDs lists announced Album Entries for exact assertions.

func (*Module) ApproveUpdates

func (m *Module) ApproveUpdates(ctx context.Context, request ApproveRequest) (Approval, error)

ApproveUpdates creates one immutable notification per reviewed Person and records exactly its Album Entries as announced, all in one transaction.

func (*Module) CheckMail added in v0.2.0

func (m *Module) CheckMail(ctx context.Context) MailStatus

CheckMail is the Settings diagnostic: it connects to the configured mail server without sending anything. Without SMTP it reports the quiet unconfigured state instead of a failure.

func (*Module) Configured

func (m *Module) Configured() bool

Configured reports whether email can be sent from this installation.

func (*Module) Deliveries

func (m *Module) Deliveries(ctx context.Context, db bun.IDB, ids []string) (map[string]Delivery, error)

Deliveries loads Curator-facing state for the given IDs in one query.

func (*Module) DeliveryStates

func (m *Module) DeliveryStates(ctx context.Context, ids []string) (map[string]Delivery, error)

DeliveryStates loads Curator-facing state for the given IDs, for pages that watch a batch settle.

func (*Module) DismissUpdates

func (m *Module) DismissUpdates(ctx context.Context, request DismissRequest) (Approval, error)

DismissUpdates advances the baseline for the selected, reviewed changes without creating a notification or email. Excluded Albums stay pending.

func (*Module) Enqueue

func (m *Module) Enqueue(ctx context.Context, tx bun.Tx, message Message) (Delivery, error)

Enqueue stores the complete message and commits its durable work with the caller's transaction. Preferences are the caller's concern: transactional mail such as Invitations never consults the update-email setting.

func (*Module) Execute

func (m *Module) Execute(ctx context.Context, deliveryID string, final bool) error

Execute is the worker body for one delivery. It claims the record, sends outside any transaction, then records the outcome. A record still marked sending when a new attempt starts means an earlier process died mid-session, so the delivery becomes uncertain instead of being sent again. Update email is re-validated while claiming: an ineligible recipient or fully revoked content skips the send without touching the in-app notification.

func (*Module) ListNotifications

func (m *Module) ListNotifications(ctx context.Context, personID string) (NotificationList, error)

ListNotifications returns the Person's own notifications, newest first.

func (*Module) MarkAllRead

func (m *Module) MarkAllRead(ctx context.Context, personID string) (NotificationList, error)

MarkAllRead is the bulk action; already-read rows keep their original time.

func (*Module) MarkRead

func (m *Module) MarkRead(ctx context.Context, personID, notificationID string) (Notification, error)

MarkRead sets the read time once. Reading is independent from browsing and from announcement state, and never changes the stored summary.

func (*Module) OpenDeliveries

func (m *Module) OpenDeliveries(ctx context.Context) ([]DeliveryWork, error)

OpenDeliveries lists every email that has not settled: queued and sending work still running, and failed or uncertain work waiting for a Curator. Invitations are read here too so one list covers the dashboard; their retry stays with Identity.

func (*Module) PreviewUpdates

func (m *Module) PreviewUpdates(ctx context.Context) (Preview, error)

PreviewUpdates lists every eligible Person with unannounced content. It changes nothing; approval freezes what each row showed through its token.

func (*Module) RecordBaseline

func (m *Module) RecordBaseline(ctx context.Context, tx bun.Tx, personID string) (Baseline, error)

RecordBaseline marks everything the Person can currently view as announced, inside the caller's transaction so it commits with the owning transition. Existing associations are kept, so repeating the call never announces more.

func (*Module) RecoverInterrupted

func (m *Module) RecoverInterrupted(ctx context.Context) (int, error)

RecoverInterrupted runs once at startup. A delivery still marked sending belonged to a process that died mid-session, so its outcome is unknown and only a Curator may send it again. River's rescued job later finds nothing to do. This assumes Memento's single-process deployment: a second process starting while the first is mid-send would mark that send uncertain too.

func (*Module) Retry

func (m *Module) Retry(ctx context.Context, tx bun.Tx, deliveryID string) (Delivery, error)

Retry requeues a failed, uncertain, or skipped delivery. Repeated clicks and concurrent submissions are idempotent: an already queued or sending delivery is returned unchanged.

func (*Module) RetryDelivery

func (m *Module) RetryDelivery(ctx context.Context, deliveryID string) (Delivery, error)

RetryDelivery is the Curator's deliberate resend for a failed or uncertain update or alert email. Invitations keep their own retry, which also checks that the Person may still be invited.

func (*Module) Unsubscribe

func (m *Module) Unsubscribe(ctx context.Context, token string) (UnsubscribeStatus, error)

Unsubscribe switches off update email only. In-app notifications, Invitations, and account-security email are unaffected, and the selected destination stays so the Person can opt back in from their profile.

func (*Module) UnsubscribeStatus

func (m *Module) UnsubscribeStatus(ctx context.Context, token string) (UnsubscribeStatus, error)

UnsubscribeStatus describes the link's owner without changing anything, so a mail scanner that loads the link cannot unsubscribe anyone.

type Notification

type Notification struct {
	ID        string              `json:"id"`
	CreatedAt time.Time           `json:"created_at"`
	ReadAt    *time.Time          `json:"read_at"`
	Albums    []NotificationAlbum `json:"albums"`
	Note      string              `json:"note"`
}

Notification is a Person's own approved summary. It renders from stored JSON, so it survives title edits and Album deletion; the Album list and pages still decide what the Person may open today.

type NotificationAlbum

type NotificationAlbum struct {
	ID         string `json:"id"`
	Title      string `json:"title"`
	Status     string `json:"status"`
	PhotoCount int    `json:"photo_count"`
	VideoCount int    `json:"video_count"`
}

NotificationAlbum is one Album inside an approved summary. Status is new when the Album had never been announced to the Person, otherwise updated. Photos and videos are only counted, never listed.

type NotificationList

type NotificationList struct {
	Notifications []Notification `json:"notifications"`
	Unread        int            `json:"unread"`
}

type Outcome

type Outcome string

Outcome classifies a failed send so the module can choose between automatic retry, a final failure, and a deliberate Curator decision.

const (
	// OutcomeTransient means the server did not accept the message and a later attempt is safe.
	OutcomeTransient Outcome = "transient"
	// OutcomePermanent means the server rejected the message and retrying the same message is pointless.
	OutcomePermanent Outcome = "permanent"
	// OutcomeUncertain means the message may have been accepted; only a Curator may resend it.
	OutcomeUncertain Outcome = "uncertain"
)

type PersonResult

type PersonResult struct {
	PersonID       string    `json:"person_id"`
	DisplayName    string    `json:"display_name"`
	Status         string    `json:"status"`
	Message        string    `json:"message"`
	NotificationID string    `json:"notification_id"`
	AlbumCount     int       `json:"album_count"`
	PhotoCount     int       `json:"photo_count"`
	VideoCount     int       `json:"video_count"`
	Email          string    `json:"email"`
	Delivery       *Delivery `json:"delivery"`
}

PersonResult reports one review outcome. Status is notified, dismissed, or skipped; Message explains a skip in Curator-facing words. Email and Delivery are set only when an email was queued.

type Preview

type Preview struct {
	People          []PreviewPerson `json:"people"`
	EmailConfigured bool            `json:"email_configured"`
}

Preview is the batch a Curator reviews. EmailConfigured is false when the installation has no SMTP settings, so every row is in app only.

type PreviewPerson

type PreviewPerson struct {
	PersonID      string              `json:"person_id"`
	DisplayName   string              `json:"display_name"`
	UpdateEmail   string              `json:"update_email"`
	EmailUpdates  bool                `json:"email_updates"`
	EmailEligible bool                `json:"email_eligible"`
	Albums        []NotificationAlbum `json:"albums"`
	ReviewToken   string              `json:"review_token"`
}

PreviewPerson is one collapsible row. EmailEligible reports whether a later email delivery could go anywhere; the in-app notification is created either way. ReviewToken freezes what the row showed for approval.

type Recorder

type Recorder struct {

	// BeforeSend runs before the message is recorded. A returned error is the send result.
	BeforeSend func(context.Context, Message) error
	// AfterSend runs after the message is recorded. A returned error is the send result.
	AfterSend func(context.Context, Message) error
	// contains filtered or unexported fields
}

Recorder is the deterministic Mailer for tests and fixtures. Hooks run before and after the simulated acceptance so tests can pause, fail, or cancel at the same points where a real SMTP session can break.

func (*Recorder) Check added in v0.2.0

func (r *Recorder) Check(context.Context) MailStatus

Check reports the recorder as a connected mail server.

func (*Recorder) Send

func (r *Recorder) Send(ctx context.Context, message Message) error

func (*Recorder) Sent

func (r *Recorder) Sent() []Message

Sent returns every message the server would have accepted, in order.

type SMTPMailer

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

SMTPMailer delivers through one configured server. smtp:// connects in plain text and upgrades with STARTTLS when the server offers it; smtps:// uses TLS from the first byte.

func NewSMTPMailer

func NewSMTPMailer(rawURL, from string) (*SMTPMailer, error)

NewSMTPMailer validates the configured URL without connecting. Errors name the setting, never the URL, because it may contain credentials.

func (*SMTPMailer) Check added in v0.2.0

func (m *SMTPMailer) Check(ctx context.Context) MailStatus

Check connects and signs in without sending anything, so Settings can show whether email would work. The result names the sender, never the server or its credentials.

func (*SMTPMailer) Send

func (m *SMTPMailer) Send(ctx context.Context, message Message) error

Send performs one complete SMTP session. Every failure before the final end-of-data reply is safe to retry; a lost connection afterwards is not.

type UnsubscribeStatus

type UnsubscribeStatus struct {
	DisplayName string `json:"display_name"`
	Email       string `json:"email"`
	Subscribed  bool   `json:"subscribed"`
}

UnsubscribeStatus describes an unsubscribe link's owner. Subscribed is false once update email is off, or when no destination is selected.

type UseCases

type UseCases interface {
	PreviewUpdates(context.Context) (Preview, error)
	ApproveUpdates(context.Context, ApproveRequest) (Approval, error)
	DismissUpdates(context.Context, DismissRequest) (Approval, error)
	ListNotifications(ctx context.Context, personID string) (NotificationList, error)
	MarkRead(ctx context.Context, personID, notificationID string) (Notification, error)
	MarkAllRead(ctx context.Context, personID string) (NotificationList, error)
	DeliveryStates(ctx context.Context, ids []string) (map[string]Delivery, error)
	RetryDelivery(ctx context.Context, deliveryID string) (Delivery, error)
	UnsubscribeStatus(ctx context.Context, token string) (UnsubscribeStatus, error)
	Unsubscribe(ctx context.Context, token string) (UnsubscribeStatus, error)
}

UseCases is the HTTP seam. Handlers translate requests; the module owns every transaction and the announcement invariants.

type VisibleContent

type VisibleContent interface {
	VisibleEntries(ctx context.Context, db bun.IDB, personID string) ([]VisibleEntry, error)
}

VisibleContent is the consumer-owned view of Publishing that baselines need. It must apply viewer eligibility only, never a Curator's administrative bypass.

type VisibleEntry

type VisibleEntry struct {
	AlbumID    string
	AlbumTitle string
	EntryID    string
	Kind       string
}

VisibleEntry is one Album Entry a Person can currently view. Kind is IMAGE or VIDEO.

Directories

Path Synopsis
Package smtptest runs a controllable SMTP server for adapter tests and the QA fixture.
Package smtptest runs a controllable SMTP server for adapter tests and the QA fixture.

Jump to

Keyboard shortcuts

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