Help us shape the future of Go! Take the 2026 Go Developer Survey

notify

package
v0.53.0 Latest Latest
Warning

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

Go to latest
Published: Oct 7, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package notify is filex' notification subsystem.

Two delivery channels:

  • Webhook: a single configurable POST endpoint that always receives a generic JSON body. Retry 3× exponential backoff.
  • In-app: per-user bell + history + read/unread, surfaced via the /api/notifications/... endpoints. Persisted in the notifications table so they survive a server restart.

Both channels fan out from one Service.Send call. The webhook URL + optional bearer token come from FILEX_WEBHOOK_URL / FILEX_WEBHOOK_TOKEN (.env bootstrap) or DB-stored override (notification_settings tabular override is per-user; the webhook URL itself is global, stored in the settings table). v0.1 reads them from config; the admin panel exposes PATCH /admin/api/notifications/webhook-config to change them at runtime.

Index

Constants

View Source
const (
	// AudienceEveryone — every bell (a notice for everybody that names no file).
	AudienceEveryone = "everyone"
	// AudienceViewers — administrators, and the members who can see the file it
	// names.
	AudienceViewers = "viewers"
	// AudienceAdmins — administrators only.
	AudienceAdmins = "admins"
	// AudienceNobody — no bell at all: routine file activity recorded without
	// the person who did it. It is kept here, in the history, and nowhere else.
	AudienceNobody = "nobody"
)

The admin history's Scope column for a broadcast: who it reaches, said by the same rule the bells apply (model.Notification.Audience).

View Source
const (
	DigestWindowDefault = 1
	DigestWindowMin     = 1
	DigestWindowMax     = 15
)

Digest window bounds, in minutes.

View Source
const (
	TargetNone  = model.TargetNone
	TargetFile  = model.TargetFile
	TargetDir   = model.TargetDir
	TargetShare = model.TargetShare
	TargetTrash = model.TargetTrash
	TargetApp   = model.TargetApp
)

The six target kinds, re-exported so an emitter never imports model just to name one.

Variables

View Source
var ErrDigestOff = errors.New("notify: the digest is off")

ErrDigestOff answers a digest question on a service that runs without one (Config.Digest nil).

Functions

func AdminAlertEvents added in v0.53.0

func AdminAlertEvents() []string

AdminAlertEvents are the administrator alerts: the operator alarms and the notices about a tenant's own address. One switch in the settings pane.

func BroadcastAudience

func BroadcastAudience(n *model.Notification) string

BroadcastAudience says who a broadcast of this row's kind reaches. "" for a row addressed to somebody — that row's scope is its owner.

func ClampDigestWindow added in v0.53.0

func ClampDigestWindow(minutes int) int

ClampDigestWindow keeps a window in DigestWindowMin..DigestWindowMax.

func DefaultUrgentEvents added in v0.53.0

func DefaultUrgentEvents() []string

DefaultUrgentEvents is the built-in urgent list: EVERY kind. The owner's decision (2026-10-06): "hepsi acil türde kalsın defaultta, admin ya da kullanıcı girip kısabilir olsun" - everything stays urgent by default, and an administrator (for their tenant) or a person (for themselves) turns kinds off to hold them for the digest. So an upgrade changes nobody's notifications, and a person whose administrator and who themselves chose nothing has no digest point, no window and no extra query on a write.

func DigestEvents added in v0.53.0

func DigestEvents() []string

DigestEvents is every kind a person may hold for the digest — the catalogue a choice, the person's or an administrator's, is made from.

func DomainReason added in v0.50.0

func DomainReason(lang string, d *model.ProviderDomain) string

DomainReason says in lang why a check did not find a domain pointed at its tenant: the catalogue's sentence for its code, with its names; the English sentence the check recorded for a code it does not know.

func ForEveryone

func ForEveryone(event string) bool

ForEveryone reports whether a broadcast of this event is meant for every person (everyoneEvents) — the kind a member keeps even when it names no file.

func KnownDigestEvent added in v0.53.0

func KnownDigestEvent(event string) bool

KnownDigestEvent reports whether a choice can be made about event.

func NamesAFile

func NamesAFile(meta json.RawMessage) bool

NamesAFile reports whether a stored row names a file — whether it carries a node with a name or a path. What a member may be shown of such a row is decided by that file, never by the kind alone.

func Signature

func Signature(secret string, body []byte) string

Signature computes the X-Filex-Signature header value for a payload signed with secret: "sha256=" + hex(HMAC-SHA256(secret, body)). Exported so receivers/tests can verify deliveries.

Types

type ActorRef

type ActorRef struct {
	ID    int64  `json:"id,omitempty"`
	Email string `json:"email,omitempty"`
}

ActorRef identifies who triggered the event, best-effort (webhook v2 payload `actor` object). Anonymous surfaces (public drop) omit it.

type Bell

type Bell int

Who reads a notification — the ONE audience rule, for every door a row goes out through: the bell and its badge, the full list behind it, the browser notification and the desktop toast (both read GET /api/notifications), the admin history's Scope column, and "mark read".

A row ADDRESSED to somebody is theirs and nobody else's. A BROADCAST (no user) is decided in two layers:

  1. By kind, here, in SQL (Bell → model.BroadcastFilter), so a bell's page is filled with rows its reader can be shown rather than cut first and emptied after (PR #42, Berk Başarır: 60 operator rows once left a member `{"items":[],"total":0}` under a badge of 1).
  2. Per row, in the HTTP layer (handlers/notifications.go → visibleTo): the tenant the file's storage belongs to, and for a member the explorer's own "may they see this path" (acl.Set.CanSee).

The kinds, and why:

  • Routine file activity (personalEvents) is always addressed to whoever did it; a broadcast of one is an actor that got lost on the way (the ops worker, before PR #42), not a message. No bell reads it; the admin history keeps it.
  • Operator alarms (operatorEvents, event.go) are administrators' at ANY address — the v0.43.0 rule is by event (Bell.never).
  • A notice meant for everybody (everyoneEvents: the admin page's test, an app's instance-wide notice) reaches every bell — unless it names a file, and then only the bells of those who can see that file.
  • Every other broadcast is for administrators: a drop or share notice with no owner carries the link's bearer token, an escrow notice with no owner is addressed to admins by its emitter.
const (
	// AdminBell is the bell of an admin nobody confines — a single-tenant
	// install's admin, or the supertenant's: every broadcast except routine
	// file activity nobody was addressed by (personalEvents).
	AdminBell Bell = iota
	// TenantAdminBell is a tenant admin's: only the broadcasts that name a
	// file (fileBroadcastEvents), because only those can be placed in a
	// tenant. An operator alarm names no storage and would be dropped after
	// it had already taken a place on the page.
	TenantAdminBell
	// MemberBell is everybody else's: only memberBroadcastEvents.
	MemberBell
	// PlatformAdminBell is the bell of an administrator of the supertenant on
	// a multi-tenant install, the platform operator: AdminBell's, less a new
	// encryption request (platformOmitted). Such a request is the tenant's
	// own administrators' to decide; the operator sees it on Admin ->
	// Encryption and is not told of each one (operator decision 2026-10-03).
	// The platform's own requests reach the operator addressed to them
	// (e2epolicy announce), never as a broadcast.
	PlatformAdminBell
)

func (Bell) Admits

func (b Bell) Admits(event string) bool

Admits reports whether b reads a broadcast of this event at all — the Go twin of the SQL filter, for a caller holding one row rather than a query (marking a row read by its id). Which of the admitted rows a reader keeps is still the per-row pass in the HTTP layer.

func (Bell) Unconfined added in v0.51.0

func (b Bell) Unconfined() bool

Unconfined reports whether b is the bell of an administrator nobody confines to a tenant (AdminBell, PlatformAdminBell): its broadcasts are read as stored, by kind alone, with no per-row pass.

type Config

type Config struct {
	WebhookURL    string
	WebhookToken  string
	HTTPTimeout   time.Duration
	RetryBackoffs []time.Duration // attempt delays; default {1s,3s,9s}

	// Digest switches the notification digest on (digest.go). Nil: every
	// notification is told on its own, as before 0.53.
	Digest *DigestConfig
	// Mail sends the emails an event asks for (Event.Mail) and the digest's.
	// Nil: no email.
	Mail MailFunc
	// Now is the clock the digest windows are measured with (tests). Nil:
	// time.Now.
	Now func() time.Time
}

Config bootstraps a Service. WebhookURL and WebhookToken are optional — leave empty to skip webhook delivery (in-app still records the event).

type DigestConfig added in v0.53.0

type DigestConfig struct {
	// MultiTenant reads a person's defaults from their tenant (scope = its
	// provider id); otherwise from the instance (scope 0).
	MultiTenant bool
	// Every overrides how often the background pass runs (tests).
	Every time.Duration
}

DigestConfig switches the digest on (Config.Digest). Nil leaves every notification told on its own, as before 0.53 — what a test of something else wants.

type Digests added in v0.53.0

type Digests interface {
	// SetViewer hands in the HTTP layer's View of a person, for the
	// background pass. Without one that pass judges a person's own rows only
	// (their broadcasts are then told on their own, see the file header).
	SetViewer(v Viewer)
	// StartDigests runs the background pass until ctx ends or Stop.
	StartDigests(ctx context.Context)
	// FlushDueDigests runs one background pass now and answers how many
	// digests it wrote.
	FlushDueDigests(ctx context.Context) (int, error)
	// SettleDigest tells userID's held notifications through view if their
	// window has ended — or at once, with force (before their choices
	// change). It answers whether it wrote a digest.
	SettleDigest(ctx context.Context, userID int64, view View, force bool) (bool, error)
	// DigestPolicy is the defaults of one scope as they apply (the built-in
	// ones where nothing was saved), and whether any were saved.
	DigestPolicy(ctx context.Context, scope int64) (model.DigestPolicy, bool, error)
	// SaveDigestPolicy replaces the defaults of p.Scope. The window is
	// clamped to DigestWindowMin..DigestWindowMax; Urgent nil restores the
	// built-in list. Unknown event ids are dropped.
	SaveDigestPolicy(ctx context.Context, p model.DigestPolicy) (model.DigestPolicy, error)
	// PersonDigest is what the settings pane shows a person.
	PersonDigest(ctx context.Context, userID int64) (PersonDigestView, error)
	// DigestScope is which scope a person's defaults are read from.
	DigestScope(ctx context.Context, userID int64) int64
	// ForgetDigest drops what is remembered of a person's choice.
	ForgetDigest(userID int64)
}

Digests is the digest half of the service. The concrete service implements it; a test double of Service does not need to.

type Event

type Event struct {
	Event    EventType      `json:"event"`
	Severity Severity       `json:"severity"`
	Title    string         `json:"title"`
	Body     string         `json:"body"`
	Meta     map[string]any `json:"meta,omitempty"`
	TS       time.Time      `json:"ts"`

	// At mirrors TS under the webhook-v2 documented field name; Send
	// fills it from TS so the wire payload always carries `at`.
	At time.Time `json:"at"`

	// Structured webhook-v2 payload objects (all optional). Node points
	// at the file/folder the event concerns, Share at the public link,
	// Actor at the triggering user. Send also folds them into the
	// persisted meta_json so the in-app history keeps the context.
	Node  *NodeRef  `json:"node,omitempty"`
	Share *ShareRef `json:"share,omitempty"`
	Actor *ActorRef `json:"actor,omitempty"`

	// Target is where a click on this notification goes. Emitters set it
	// with FileTarget/DirTarget/ShareTarget/ParentDirTarget; Send fills in
	// the storage NAME from Node.StorageID and, failing that, downgrades the
	// target to TargetNone rather than shipping half an address. It is
	// always non-nil on the wire, so a receiver can switch on `target.kind`
	// without a nil check.
	Target *Target `json:"target,omitempty"`

	// UserID, when non-nil, scopes the in-app notification to a single
	// user. Otherwise the row is broadcast, and who reads it is bell.go's
	// rule: by kind, then — for a member — only when the file it names is one
	// the member can see (handlers/notifications.go). The webhook delivery is
	// unaffected.
	UserID *int64 `json:"-"`

	// NoWebhook keeps the event out of every webhook: the row is stored and
	// read like any other, and its webhook status reads "skipped". For an
	// event addressed to several people one row at a time, where the webhook
	// is told once, with the first row (e2epolicy announce).
	NoWebhook bool `json:"-"`

	// Mail asks for the addressee (UserID) to be told by email as well. When
	// they hold this kind for their digest (digest.go) the email waits too
	// and comes as the digest's, once for the window; otherwise Title and
	// Body go at once, the Link under them. Kept in the row's meta (`mail`)
	// so a server that restarts mid-window still knows; never in the webhook
	// body.
	Mail *Mail `json:"-"`
}

Event is the in-memory shape that subsystems hand to Service.Send.

On the wire (webhook payload) and in the DB it is encoded as JSON — the field tags below match the documented public contract.

func LegacyAccountElsewhere added in v0.50.0

func LegacyAccountElsewhere(driver, account, accountTenant, loginTenant string) Event

LegacyAccountElsewhere is the notice itself. It is a BROADCAST (no user) of an operator event: only a platform administrator's bell carries it — never a tenant administrator's (TenantAdminBell reads only broadcasts that name a file) and never a member's (operatorEvents). Names only: the account's login name and the two tenants' short names; no password, no token.

func PermissionGaps added in v0.52.0

func PermissionGaps(userID *int64, count int) Event

PermissionGaps is the notice that count roles may have lost files.encrypt, for one administrator (userID) or, nil, as a broadcast the administrators nobody confines to a tenant read.

func TenantDomainChanged added in v0.50.0

func TenantDomainChanged(userID int64, d *model.ProviderDomain, suspended bool) Event

TenantDomainChanged is the notice for one administrator of the tenant (userID), about one domain. suspended false is the notice that it is back.

type EventType

type EventType string

EventType is a stable string id for a kind of event. Kept as a typed alias rather than an enum so consumers can introduce new types without touching this package.

const (
	EventReplicaFail          EventType = "replica_fail"
	EventReplicaFailSpike     EventType = "replica_fail_spike"
	EventReplicaReconcileDone EventType = "replica_reconcile_done"
	EventReplicaStatusReport  EventType = "replica_status_report"
	EventPrimaryReadFail      EventType = "primary_read_fail"
	EventQuotaNearFull        EventType = "quota_near_full"
	EventQuotaFull            EventType = "quota_full"
	EventQueueStuck           EventType = "queue_stuck"
	EventAuthFailSpike        EventType = "auth_fail_spike"
	// EventAuthProviderDown fires when an operating-system sign-in provider the
	// administrator switched on cannot start (the machine changed, the API is
	// gone). The provider is left out of the running set; every other way in
	// keeps working. Once per distinct reason.
	EventAuthProviderDown EventType = "auth_provider_down"
	// EventLDAPLegacyAccountElsewhere fires ONCE per older directory account
	// (keyed by a bare login name before the e-mail rule) that a sign-in by the
	// same name found in ANOTHER tenant. The account is left alone — the tenant
	// boundary is not crossed — and the person gets the first sign-in rule in
	// their own tenant; the platform operator decides what, if anything, to do
	// (auth.AdoptAccount, LegacyAccountElsewhere). Meta `provider`, `account`,
	// `account_tenant`, `login_tenant`, and the sentence per language
	// (`title_<lang>`, `body_<lang>`, from srvtext).
	EventLDAPLegacyAccountElsewhere EventType = "ldap_legacy_account_elsewhere"
	// EventTenantDomainSuspended / EventTenantDomainRestored tell a tenant's
	// administrators (one notification each, UserID set) that the CNAME of
	// the tenant's own domain stopped pointing at its platform subdomain, so
	// the domain stopped routing, or that it points there again
	// (tenantdomain.Service.Check, docs/TENANT-ADMIN.md). Meta `domain`,
	// `reason`, and the sentence per language (`title_<lang>`, `body_<lang>`).
	EventTenantDomainSuspended EventType = "tenant_domain_suspended"
	EventTenantDomainRestored  EventType = "tenant_domain_restored"
	// EventPermissionGaps tells administrators that some saved roles allow a
	// permission but not the one carved out of it - adding files but not
	// encrypting - as a save on a version without the newer permission
	// leaves them (perm/gaps.go, internal/permgap). Admin -> Roles lists them
	// with one click to give the permission back. Once per new gap: a
	// broadcast to the administrators nobody confines, and one notification
	// each to a tenant's administrators about the tenant's own roles. Meta
	// `count`, and the sentence per language (`title_<lang>`, `body_<lang>`).
	EventPermissionGaps EventType = "permission_gaps"
	EventDiskFull       EventType = "disk_full"
	// EventUpdateAvailable fires ONCE per newly published release — the
	// "already announced" mark is persisted, so a restart loop cannot turn it
	// into a stream.
	EventUpdateAvailable EventType = "update_available"
	// EventUpdateApplied fires after a self-upgrade replaced the binary. The
	// version moving is exactly the kind of change an operator must find in
	// the log even if nobody was watching when it happened.
	EventUpdateApplied EventType = "update_applied"

	// An installed app following the source it came from
	// (wasmplugin/updates.go). Operator alarms like the two above: an app is
	// the platform operator's to manage, so nobody else is told. Meta carries
	// `plugin` (its name), `plugin_label_<lang>` (its label in every language
	// it has), `version` (the version found or reached) and, per event,
	// `from`, `added`, `error`.
	//
	// EventAppUpdated: an administrator's approved change of an app's
	// version — an upgrade, or going back (meta `rollback`). Since 0.48
	// nothing updates itself (wasmplugin/versions.go afterUpgrade).
	EventAppUpdated EventType = "app_updated"
	// EventAppUpdateAvailable: a newer version waits for the administrator.
	// Once per version.
	EventAppUpdateAvailable EventType = "app_update_available"
	// EventAppUpdateNeedsApproval: a newer version asks for permissions the
	// app was not granted (meta `added`), or a language pack now brings a
	// module (meta `adds_module`). Nothing moves until an administrator
	// reviews it. Once per version.
	EventAppUpdateNeedsApproval EventType = "app_update_needs_approval"
	// EventAppUpdateFailed: an automatic update was tried and undone — the
	// app runs the version it had (meta `error`). Written only by filex 0.47,
	// which updated apps by itself; kept so its rows still read.
	EventAppUpdateFailed EventType = "app_update_failed"
	// EventPluginUpdateAvailable: a binary STORAGE plugin's source has a
	// newer version for this platform (plugin/updates.go). Meta `plugin`,
	// `version`. Once per version; nothing is installed until an
	// administrator reviews it.
	EventPluginUpdateAvailable EventType = "plugin_update_available"
	// EventPluginRequested: somebody who may not install plugins — an API key:
	// an agent, a script, the CLI — left a request to install or upgrade one,
	// and it waits for an administrator on the Plugins page
	// (internal/pluginreq). Meta `plugin`, `plugin_label_<lang>` (an app's
	// label in every language it has; a storage plugin's name), `version`,
	// `kind` (app | storage), `op` (install | upgrade), `requester`,
	// `request_id`, `reason`. Once per request: asking again for the same
	// source answers the pending request and tells nobody.
	EventPluginRequested EventType = "plugin_requested"
)

Canonical events fired by filex itself. Subsystems may emit other types; the webhook payload echoes whatever is given.

⚠ DECLARED IS NOT EMITTED. Seven of the operational alerts below have no producer anywhere in the tree: replica_fail_spike, quota_near_full, quota_full, queue_stuck, auth_fail_spike, disk_full, update_applied. A webhook target may still name one — MatchesEvent accepts it — so the subscription saves and then waits forever, which reads exactly like a subsystem with no problems. They are kept (rather than deleted) because the alarms are wanted; docs/NOTIFICATIONS.md carries an "Emitted" column so an operator is not misled meanwhile. Wire the producer before removing a name from that list.

const (
	EventFileUploaded EventType = "file.uploaded"
	// EventFileUpdated fires when a write REPLACED the bytes of a file that
	// already existed, where file.uploaded means the write created it. Both
	// come from the same post-write gate (internal/writehook), so every write
	// surface — editor save, browser re-upload, WebDAV PUT, S3 PutObject,
	// SFTP/FTPS/NFS, the AI/MCP surface — splits them the same way.
	//
	// ⚠ Adding it NARROWS file.uploaded: a subscriber that watched
	// file.uploaded to see edits stops hearing them and has to subscribe to
	// file.updated as well. That is deliberate — an event called "uploaded"
	// that also fires for an in-place edit cannot be filtered by anyone.
	EventFileUpdated EventType = "file.updated"
	// EventFileUploadFailed fires when bytes filex already acknowledged could
	// not be written to the storage driver — the staged upload path answers the
	// client before it transfers, so a failure after that point is invisible
	// unless it is announced (issue #16).
	EventFileUploadFailed EventType = "file.upload_failed"
	EventFileDeleted      EventType = "file.deleted"
	EventFileMoved        EventType = "file.moved"
	EventFileTrashed      EventType = "file.trashed"
	EventShareCreated     EventType = "share.created"
	EventDropReceived     EventType = "drop.received"
	/* koru:k2 av */
	// EventFileInfected fires when the async ClamAV scan flags an
	// uploaded file; the payload carries the node plus a `signature`
	// meta field, and the file is quarantined into trash.
	EventFileInfected EventType = "file.infected"
	/* calisma:d3 comments */
	// EventCommentAdded fires when a user comments on a file/folder
	// node (v0.6 "Çalışma" (Work)). The payload carries the node (path/name),
	// the actor, and meta {comment_id, body (first 200 chars)}.
	EventCommentAdded EventType = "comment.added"
	// Archive events describe the completed user operation rather than each
	// low-level file write it performed. Extraction can create hundreds of
	// files; one completion event is useful, hundreds of "new file" alerts are
	// not.
	EventArchiveCreated   EventType = "archive.created"
	EventArchiveExtracted EventType = "archive.extracted"
	// EventE2EEscrowUsed fires when an encrypted folder was opened with the
	// operator's ESCROW key rather than its owner's passphrase. Not the
	// recovery key — that one the owner holds; escrow means somebody else's
	// key was used on their folder, which is why the owner is told. The payload
	// carries the node plus meta {escrow_kid, storage, folder} and, when the
	// caller was authenticated, actor_email.
	//
	// It was written as an inline notify.EventType("e2e.escrow_used") in the
	// handler, which is why it never appeared in the admin UI's list: an
	// event that is not a constant here is invisible to everything that reads
	// this block. catalog_test.go now refuses that shape.
	EventE2EEscrowUsed EventType = "e2e.escrow_used"
	// EventE2EPasswordChanged fires when an encrypted folder's password was
	// changed — or reset with its recovery key — in the web UI, which
	// announces it after writing the new key file (the change happens in the
	// browser; the server sees no password). It goes to the folder's OWNER,
	// who may not be the person who changed it: a change you did not make is
	// how you learn somebody else holds your password or recovery key. Meta
	// carries {storage, folder, via: "password"|"recovery_key", rekey} and,
	// when the caller was signed in, actor_email.
	EventE2EPasswordChanged EventType = "e2e.password_changed"
	// EventE2ERequestCreated fires when somebody asks to encrypt in a folder
	// under their tenant's `approval` policy (internal/e2epolicy
	// requests.go) and the request waits for an administrator. ONE broadcast
	// placed on the folder (its NodeRef — for a single file, the folder it is
	// in): the bell hands it to the administrators of the tenant that storage
	// belongs to — and to the platform operator — and to nobody else (bell.go
	// fileBroadcastEvents), and a webhook gets one delivery. Meta carries
	// {request_id, target_kind: "folder"|"file", storage, requester, reason}.
	// ⚠ target_kind, not kind: meta.kind picks another event's file wording.
	EventE2ERequestCreated EventType = "e2e.request_created"
	// EventE2ERequestDecided fires when an administrator approved or rejected
	// an encryption request. Addressed to the person who asked, and to nobody
	// else; the node is the request's folder. Meta carries {request_id,
	// target_kind, storage, decision: "approved"|"rejected", note, decider}.
	EventE2ERequestDecided EventType = "e2e.request_decided"
	// EventPluginNotice is an app plugin (internal/wasmplugin) speaking to
	// people through the notify_send host function: a signature request, a
	// finished conversion, anything the plugin's author phrased. The row's
	// Title/Body are the plugin's English text; meta carries `plugin` (its
	// name), `title_en`/`title_tr`/`body_en`/`body_tr` for the reader's
	// language, and `job` when sent from a queued action.
	EventPluginNotice EventType = "plugin.notice"
	// EventAdminTest is the admin notifications page's "Send test
	// notification": one broadcast through the bell and every webhook, so an
	// operator can see the plumbing work. It names nothing and is meant for
	// every bell (bell.go → everyoneEvents); e2e 109 seeds a non-admin's bell
	// with it.
	EventAdminTest EventType = "admin_test"
	// EventNotificationDigest is one person's digest (digest.go): the
	// notifications of the kinds they did not mark urgent, held for the
	// window an administrator set and told in this one row, folder by folder.
	// Addressed to that person. Meta carries `count`, `groups` (per folder:
	// `storage`, `path`, `name`, `counts` by event, `encrypted`), `other` (the
	// counts of rows that name no folder), `from`/`to` (the window) and, for a
	// digest of one, `item` (that row's event and meta).
	//
	// ⚠ Delivered only to a webhook target that names it in its allow-list:
	// every row it sums up was delivered to the webhooks on its own, so a
	// target with an empty list (everything) and the legacy global webhook do
	// not receive it twice over.
	EventNotificationDigest EventType = "notification.digest"
)

Canonical file/share events (webhook v2 — "Bağlan" (Connect) wave). Emitted asynchronously from the API mutation handlers; webhook targets filter on these names via their per-target events allow-list.

⚠ This const block is the SINGLE SOURCE OF TRUTH for the subscribable event catalogue. The rule is mechanical: an EventType whose value contains a dot is a webhook-v2 event an operator can tick in the admin UI, and the admin UI's list must match this block exactly. Two tests hold that:

  • catalog_test.go here refuses an inline notify.EventType("x.y") anywhere in the backend, so a new event cannot be born outside this block (that is how "e2e.escrow_used" existed for a release without ever reaching the UI);
  • web/tests/webhooks/eventCatalog.test.ts parses this file and fails when packages/core/src/lib/webhookEvents.ts drifts from it, or when an event is missing an operator-readable label in en.json or tr.json.

The older underscore-named alerts above (replica_fail, disk_full, …) are operational alarms from the pre-v2 global webhook; MatchesEvent will still filter on them if a target names one, but they are not part of the catalogue the UI offers.

type Mail added in v0.53.0

type Mail struct {
	// Lang is the language Title and Body are written in — the addressee's.
	Lang string `json:"lang,omitempty"`
	// Link is put under the text: where the person acts on it.
	Link string `json:"link,omitempty"`
}

Mail is how an event asks for an email to its addressee (Event.Mail).

type MailFunc added in v0.53.0

type MailFunc func(ctx context.Context, lang, to, subject, body string) error

MailFunc sends one email (Config.Mail): the server's mailer, in lang.

type NodeRef

type NodeRef struct {
	StorageID int64  `json:"storage_id"`
	Path      string `json:"path"`
	Name      string `json:"name"`
	Size      int64  `json:"size,omitempty"`
}

NodeRef identifies the file/folder an event is about (webhook v2 payload `node` object).

type PersonDigestView added in v0.53.0

type PersonDigestView struct {
	// WindowMinutes is how long their non-urgent notifications are held.
	WindowMinutes int `json:"window_minutes"`
	// Urgent is the kinds told to them at once: their own choices over the
	// administrator's defaults.
	Urgent []string `json:"urgent_events"`
	// Defaults is the administrator's urgent list, the one a kind they did
	// not choose follows.
	Defaults []string `json:"default_urgent"`
	// Events is every kind a choice can be made about, and AdminEvents the
	// administrator alerts among them (one switch in the pane).
	Events      []string `json:"events"`
	AdminEvents []string `json:"admin_events"`
}

PersonDigestView is a person's digest as the settings pane draws it.

type Service

type Service interface {
	// Send fans the event out to (a) the in-app history table and
	// (b) every configured webhook destination: the legacy global
	// webhook plus each enabled, event-matching webhook_targets row.
	// Errors from webhook delivery are recorded against the row, not
	// bubbled up — webhook failure should never break the originating
	// request.
	Send(ctx context.Context, e Event) (id int64, err error)

	// List + Mark + Settings just delegate to the store; they're on
	// the Service interface so handlers don't have to know about the
	// store. Pass userID nil for admin-global views; bell (which
	// broadcasts a per-user read takes) is ignored there.
	List(ctx context.Context, userID *int64, bell Bell, onlyUnread bool, limit, offset int) ([]*model.Notification, int64, error)
	UnreadCount(ctx context.Context, userID *int64, bell Bell) (int64, error)
	// ListVisible is a per-user read whose BROADCASTS the caller judges one by
	// one after the store (keep — a member's grants, a tenant): the reader's
	// own rows are counted in SQL and only the broadcasts their bell admits are
	// walked, so the total is exact and a page is cut where the reader sees
	// it. keep nil is List. keepOwn, when set, judges the reader's own rows too
	// (a folder-confined token reads only its folder's), walked the same way.
	ListVisible(ctx context.Context, userID int64, bell Bell, onlyUnread bool, limit, offset int, keep, keepOwn func(*model.Notification) bool) ([]*model.Notification, int64, error)
	// History is the admin-global list with each broadcast's read state as
	// readerID has it (0: the rows' own column).
	History(ctx context.Context, readerID int64, onlyUnread bool, limit, offset int) ([]*model.Notification, int64, error)
	// MarkRead and MarkAllRead stamp rows ADDRESSED to userID only. A
	// broadcast is read per reader: MarkBroadcastsRead marks single ones,
	// after the caller has checked the reader may see them, and
	// MarkAllBroadcastsRead reads every broadcast up to now for the reader —
	// ones their bell does not show included, which only ever changes what
	// that reader sees.
	MarkRead(ctx context.Context, id int64, userID *int64) error
	MarkAllRead(ctx context.Context, userID *int64) error
	MarkBroadcastsRead(ctx context.Context, readerID int64, ids []int64) error
	MarkAllBroadcastsRead(ctx context.Context, readerID int64) error
	GetSettings(ctx context.Context, userID int64) (*model.NotificationSettings, error)
	UpsertSettings(ctx context.Context, s *model.NotificationSettings) error

	// SetWebhook is invoked by the admin handler when the operator
	// changes the webhook URL/token at runtime. Pass empty values to
	// disable webhook delivery without taking the in-app channel down.
	SetWebhook(url, bearerToken string)

	// WebhookConfig returns the currently effective webhook URL and a
	// flag indicating whether a token is set (the token itself is
	// never echoed back).
	WebhookConfig() (url string, tokenSet bool)

	// TestTarget synchronously fires a sample event at one webhook
	// target (single attempt, no retries) and returns the outcome.
	// Backs the admin "Test" button; the result is also recorded as
	// the target's in-memory last-delivery status.
	TestTarget(ctx context.Context, target *model.WebhookTarget) TargetDeliveryStatus

	// TargetStatuses returns the last-delivery status per target id.
	// In-memory only (process lifetime) — the schema stays at the
	// frozen webhook_targets contract, so per-target status is not
	// persisted; the notifications table remains the durable audit.
	TargetStatuses() map[int64]TargetDeliveryStatus

	// Wait blocks until any in-flight webhook deliveries return. Used
	// by tests; production calls Stop() instead which cancels the
	// dispatch context AND waits.
	Wait()

	// Stop cancels in-flight dispatches and waits for them to finish.
	Stop()
}

Service is the public façade subsystems use to emit events. Send persists to DB synchronously and dispatches the webhook in a background goroutine — callers do not block on the upstream POST.

func New

func New(store db.Store, cfg Config) Service

New returns a Service backed by the given store.

type Severity

type Severity string

Severity classifies an event's urgency. Used by the UI for color coding and webhook receivers for filtering.

const (
	SeverityInfo     Severity = "info"
	SeverityWarning  Severity = "warning"
	SeverityError    Severity = "error"
	SeverityCritical Severity = "critical"
)

Severity values. Free-form strings are accepted by the store but the UI only knows these four.

type ShareRef

type ShareRef struct {
	Token string `json:"token"`
	Path  string `json:"path,omitempty"`
}

ShareRef identifies the share link an event is about (webhook v2 payload `share` object).

type Target

type Target = model.NotificationTarget

Target is the typed "where does a click on this notification go" — re-exported from model so subsystems only ever import notify.

⚠ There is exactly ONE of these per notification and exactly one resolver per surface reading it. `node`/`share` above stay what they always were: descriptive context for a webhook receiver. The target is the ADDRESS, and it is a separate field because the two are not the same thing — a `file.trashed` event describes the file at its original path and has to open the copy in the trash, and `share.created` carries both a node and a share while only one of them is the thing to open.

func AppTarget

func AppTarget(plugin, view, section string) *Target

AppTarget addresses one of an app plugin's home pages, optionally at a section of it. The plugin and view are checked by the caller (the host's notify_send answers only for the app's own `home` views).

func DirTarget

func DirTarget(p string) *Target

DirTarget addresses one folder by its path inside its storage.

func FileTarget

func FileTarget(p string) *Target

FileTarget addresses one file by its path inside its storage. The click opens the file's FOLDER with the file selected — the folder is derived by the client, never stored, so a rename of the parent cannot leave a target pointing at a folder and a file that disagree.

func ParentDirTarget

func ParentDirTarget(p string) *Target

ParentDirTarget addresses the FOLDER a path sits in — the honest target for an event about something that is no longer there (a permanent delete).

func ShareTarget

func ShareTarget(token string) *Target

ShareTarget addresses a public share link by its token.

func TrashTarget

func TrashTarget(origPath string) *Target

TrashTarget addresses the Trash view with the item that was deleted FROM origPath selected — the target of a soft delete and of an antivirus quarantine. origPath is where the file lived, never its `.filex-trash/…` key: the Trash view lists items by where they came from, and the key is a path no surface serves (syspath.Sealed).

type TargetDeliveryStatus

type TargetDeliveryStatus struct {
	Status string    `json:"status"` // "sent" | "failed"
	Error  string    `json:"error,omitempty"`
	At     time.Time `json:"at"`
}

TargetDeliveryStatus is the outcome of the most recent delivery (or admin test fire) to one webhook target.

type TargetKind

type TargetKind = model.NotificationTargetKind

TargetKind re-exports model.NotificationTargetKind.

type View added in v0.53.0

type View struct {
	Bell Bell
	Keep func(*model.Notification) bool
}

View is how one person's bell reads: which broadcasts it takes (Bell) and the per-row pass after the store (Keep; nil reads the broadcasts as stored). The HTTP layer's own rule — handlers/notifications.go bellFor and bellJudge.

type Viewer added in v0.53.0

type Viewer func(ctx context.Context, userID int64) (View, error)

Viewer answers a person's View without a request (the background pass).

type WebhookStatus

type WebhookStatus string

WebhookStatus enumerates the lifecycle of a single webhook attempt chain. Persisted to notifications.webhook_status and surfaced in the admin UI.

const (
	// WebhookStatusPending — the webhook attempt has not yet been made.
	WebhookStatusPending WebhookStatus = "pending"
	// WebhookStatusSent — the upstream returned 2xx within the budget.
	WebhookStatusSent WebhookStatus = "sent"
	// WebhookStatusFailed — exhausted retries; the operator should
	// investigate.
	WebhookStatusFailed WebhookStatus = "failed"
	// WebhookStatusSkipped — no webhook URL configured. The in-app
	// row still exists.
	WebhookStatusSkipped WebhookStatus = "skipped"
)

Jump to

Keyboard shortcuts

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