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

notify

package
v0.56.0 Latest Latest
Warning

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

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 26 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.

View Source
const (
	// PushOffDisabled: switched off (FILEX_PUSH_ENABLED=false), or the
	// service runs without it.
	PushOffDisabled = "disabled"
	// PushOffNoSecretKey: FILEX_SECRET_KEY is not set, so the private key
	// could not be stored sealed.
	PushOffNoSecretKey = "no_secret_key"
	// PushOffKeyUnreadable: the stored key does not open (FILEX_SECRET_KEY
	// changed); an administrator rotates it.
	PushOffKeyUnreadable = "key_unreadable"
	// PushOffError: the key could not be read or made (the database).
	PushOffError = "error"
)

Why push is off, as PushInfo and the settings pane say it.

View Source
const (
	// SkipNoDestination: no webhook URL and no enabled target that takes
	// the event.
	SkipNoDestination = "no_destination"
	// SkipDigestUnnamed: a digest goes only to a target that names
	// notification.digest, and none does.
	SkipDigestUnnamed = "digest_unnamed"
	// SkipSibling: the webhooks received this event with another row of it
	// (one row per recipient, one delivery per event).
	SkipSibling = "sibling"
	// SkipStopped: the service was shutting down before the delivery began.
	SkipStopped = "stopped"
)

Why a row's webhook delivery was skipped. The row keeps the CODE in its webhook_error column (status "skipped"); the words are the server catalogue's (`server.webhooks.skipped.<code>`), said in the reader's language when the row is read (WebhookReason).

⚠ Before 0.54 the row kept one of four English sentences and the admin page printed "no webhook is set up" for every one of them - wrong for two: a row whose event the webhooks had already received with another row, and a row the service stopped before sending.

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 Opens added in v0.54.0

func Opens(t *model.NotificationTarget) bool

Opens reports whether a click on a row with target t goes somewhere: a share with its token, an app's home page with its view, the Trash view, a file or a folder with its storage. ⚠ The ONE rule: the API says it on every row (`opens`) and a push carries it (`open`), so the bell, the page's pop-up and a phone agree; Send already downgrades half an address to `none` (resolveTarget), and this is the verdict on what is left.

func PersonLang added in v0.54.0

func PersonLang(u *model.User) string

PersonLang is the language a person is told in, on every channel - the bell, a push, an email, the desktop app, an agent reading their bell: their account's (one setting, Settings -> Language, whichever surface they set it on), else the instance's (FILEX_DEFAULT_LOCALE), else English. ⚠ Translated at the LAST stop: a notification travels as its facts and keys and is said only here, for the one who reads it (the maintainers' rule, 2026-10-08).

func SayRows added in v0.54.0

func SayRows(lang string, rows []*model.Notification)

SayRows puts the reader's sentence on rows read for them, in place: Title, Body and, for a row that names an encrypted item, E2E. The API's bell (GET /api/notifications) and the administrators' history answer this.

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.

func ToastBody added in v0.54.0

func ToastBody(s Said) string

ToastBody is a notification as a TOAST lays it out under the instance's name (the toast's title): the sentence, then its detail - "New file: report.pdf - Reports/report.pdf". The page's own pop-up lays the same two strings out the same way (web lib/browserNotify.ts brandedNotification), so a push that replaces it says what it said.

func WebhookReason added in v0.54.0

func WebhookReason(lang string, n *model.Notification) string

WebhookReason is the second half of a row's webhook cell, in lang: why a delivery was skipped, in the server's words; for a failed delivery, the receiver's own error as it came back (those are not ours to translate); "" otherwise.

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
	// WebhookLang is the language the legacy webhook's title and body are
	// said in (FILEX_WEBHOOK_LANG); "" is the instance's. A webhook target
	// has its own (model.WebhookTarget.Lang).
	WebhookLang   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
	// Push switches Web Push on (push.go): what a person's bell tells them
	// reaches their devices while filex is closed. Nil: no push.
	Push *PushConfig
	// 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:"-"`

	// I18n is the event untranslated - the catalogue keys its title and body
	// are said with and their values (say.go Message) - set on a webhook body
	// only, beside the sentence said in the receiver's language.
	I18n *Message `json:"i18n,omitempty"`

	// 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 the words
	// their bell says (say.go) go at once, in their language, 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 the password or the recovery slot
	// of an encrypted folder's key file (or of a single encrypted file's
	// header) changed. The SERVER sends it, from the rewrite it saw
	// (e2e/slotchange via e2e/keyfilewatch and e2e/fxewatch), never from a
	// client's announcement - 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 warning then): a change you did not make is how
	// you learn somebody else holds your password or recovery key. Meta
	// carries {storage, folder | file + kind, changes, rekey, origin,
	// versions_deleted | versions_kept} and, when the writer is known,
	// 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 addressee's language as the emitter knew it. ⚠ Not what
	// the mail is said in: that is decided at the last stop, from the
	// addressee's account (PersonLang) - an emitter's guess never outranks
	// the person's own setting (#191).
	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 Message added in v0.54.0

type Message struct {
	// Lang is the language Title and Body of the same payload are said in.
	Lang  string       `json:"lang"`
	Title *MessagePart `json:"title,omitempty"`
	// Body is absent where the body is not one phrase - a digest's lines are
	// the phrases its meta lists (groups[].parts, `server.notify.digest.*`).
	Body *MessagePart `json:"body,omitempty"`
}

Message is a notification UNTRANSLATED: the catalogue keys its title and body are said with and the values they place - what a webhook receiver that translates for itself reads (the payload's `i18n`), beside the sentence already said in the receiver's language. Keys are those of the catalogue a translator gets (`server.notify.*`, filex-catalogue-en.json); a key's plural form is chosen by Count, by the CLDR category of the receiver's own language (`<key>_one`, `_few`...; the plain key is `other`).

func MessageOf added in v0.54.0

func MessageOf(lang string, e Event) *Message

MessageOf is e untranslated (Message); nil for an event the catalogue has no phrase for. lang is the language the payload's own sentence is in.

type MessagePart added in v0.54.0

type MessagePart struct {
	Key   string            `json:"key"`
	Count *int              `json:"count,omitempty"`
	Vars  map[string]string `json:"vars,omitempty"`
}

MessagePart is one key and its values. An item inside an encrypted folder is the lock word in Vars, as in the sentence.

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 PushConfig added in v0.54.0

type PushConfig struct {
	// Box seals the VAPID private key (FILEX_SECRET_KEY). Without a key in it
	// push stays off.
	Box *secretbox.Box
	// Subject is the VAPID contact (FILEX_PUSH_SUBJECT): mailto: or https:.
	Subject string
	// Endpoints is which push services a device may name (FILEX_PUSH_HOSTS).
	Endpoints webpush.Policy
	// Client sends the pushes. Nil: webpush.SafeClient.
	Client *http.Client
	// Debounce overrides pushDebounce (tests).
	Debounce time.Duration
}

PushConfig switches Web Push on (Config.Push). Nil: no push, and the settings pane says so.

type PushIcons added in v0.56.0

type PushIcons interface {
	// SetPushIcon sets where a push's icon comes from: the PNG icon the
	// installed app's manifest names for a person's branding (api
	// spa_brand.go), as an address relative to the worker (`<base>/admin/`).
	// Unset, or "" for a person: no icon in the payload, and the worker shows
	// filex's own PNG.
	SetPushIcon(fn func(ctx context.Context, u *model.User) string)
}

PushIcons is how the HTTP layer hands push the installed app's icon (#219). Its own interface for the same reason as Pushes.

type PushInfo added in v0.54.0

type PushInfo struct {
	Available    bool       `json:"available"`
	Reason       string     `json:"reason,omitempty"`
	PublicKey    string     `json:"public_key,omitempty"`
	KeyCreatedAt *time.Time `json:"key_created_at,omitempty"`
}

PushInfo is whether push works on this server, and the key a browser subscribes with.

type PushOff added in v0.54.0

type PushOff struct{ Reason string }

PushOff answers a push request on a server where push does not work.

func (*PushOff) Error added in v0.54.0

func (e *PushOff) Error() string

type PushRefusal added in v0.54.0

type PushRefusal struct {
	Code string
	Err  error
}

PushRefusal is a device push cannot be sent to: Code says why (push_endpoint_refused, push_keys_invalid).

func (*PushRefusal) Error added in v0.54.0

func (e *PushRefusal) Error() string

func (*PushRefusal) Unwrap added in v0.54.0

func (e *PushRefusal) Unwrap() error

type PushSubscriptionInput added in v0.54.0

type PushSubscriptionInput struct {
	Endpoint string
	P256dh   string
	Auth     string
	Label    string
}

PushSubscriptionInput is what a browser hands over (PushSubscription.toJSON) and what it calls itself.

type PushTestResult added in v0.54.0

type PushTestResult struct {
	Sent   int `json:"sent"`
	Failed int `json:"failed"`
}

PushTestResult is how many of a person's devices took the test push.

type Pushes added in v0.54.0

type Pushes interface {
	// PushInfo says whether push works here, and with which public key.
	PushInfo(ctx context.Context) PushInfo
	// PushDevices is a person's devices (never their endpoints or keys).
	PushDevices(ctx context.Context, userID int64) ([]*model.PushSubscription, error)
	// PushSubscribe records a device for a person: from now on what their
	// bell tells them is pushed to it. Rows from before are not.
	PushSubscribe(ctx context.Context, userID int64, in PushSubscriptionInput) (*model.PushSubscription, error)
	// PushForget removes one of the person's devices by id; PushForgetEndpoint
	// by its endpoint (the browser turning push off, or signing out). Both
	// answer false for a device that is not theirs.
	PushForget(ctx context.Context, userID, id int64) (bool, error)
	PushForgetEndpoint(ctx context.Context, userID int64, endpoint string) (bool, error)
	// PushTest pushes a test to every device of the person, now.
	PushTest(ctx context.Context, userID int64) (PushTestResult, error)
	// PushCount is how many devices there are, everybody's.
	PushCount(ctx context.Context) (int64, error)
	// RotatePushKey replaces the VAPID key and forgets every device; it
	// answers the new key and how many devices were forgotten.
	RotatePushKey(ctx context.Context) (PushInfo, int64, error)
	// StartPush runs the background pass until ctx ends or Stop.
	StartPush(ctx context.Context)
	// FlushPush runs one pass now over the people woken since the last one
	// and answers how many pushes the push services took.
	FlushPush(ctx context.Context) (int, error)
}

Pushes is the Web Push half of the service. ⚠ Its own interface, not methods of Service: a test double of Service does not need it, and the queue tests' double writes every Service method out by hand.

type Said added in v0.54.0

type Said struct {
	// Title and Body are the sentence, in the reader's language, with no
	// encrypted name in them (the lock word stands there).
	Title string
	Body  string
	// Lines is Body one line at a time - a digest's folders, which the bell
	// says on one line ("; ") and an email one under the other.
	Lines []string
	// E2E is where the encrypted names stand, for a reader that can name them;
	// nil when the sentence names none.
	E2E *model.NotificationE2E
}

Said is one notification as a reader reads it.

func Say added in v0.54.0

func Say(lang string, n *model.Notification) Said

Say is row n in lang - any tag; srvtext.Pick decides which language serves it (a pack's, the instance default's, English).

func SayEvent added in v0.54.0

func SayEvent(lang string, e Event) Said

SayEvent is an event that has not been read back from the store - an email sent as the row is written, a webhook's body - said exactly as its row will be.

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 — not delivered, and webhook_error says why as a
	// code (webhook_reason.go: no_destination, digest_unnamed, sibling,
	// stopped). 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