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
- Variables
- func AdminAlertEvents() []string
- func BroadcastAudience(n *model.Notification) string
- func ClampDigestWindow(minutes int) int
- func DefaultUrgentEvents() []string
- func DigestEvents() []string
- func DomainReason(lang string, d *model.ProviderDomain) string
- func ForEveryone(event string) bool
- func KnownDigestEvent(event string) bool
- func NamesAFile(meta json.RawMessage) bool
- func Opens(t *model.NotificationTarget) bool
- func PersonLang(u *model.User) string
- func SayRows(lang string, rows []*model.Notification)
- func Signature(secret string, body []byte) string
- func ToastBody(s Said) string
- func WebhookReason(lang string, n *model.Notification) string
- type ActorRef
- type Bell
- type Config
- type DigestConfig
- type Digests
- type Event
- type EventType
- type Mail
- type MailFunc
- type Message
- type MessagePart
- type NodeRef
- type PersonDigestView
- type PushConfig
- type PushIcons
- type PushInfo
- type PushOff
- type PushRefusal
- type PushSubscriptionInput
- type PushTestResult
- type Pushes
- type Said
- type Service
- type Severity
- type ShareRef
- type Target
- type TargetDeliveryStatus
- type TargetKind
- type View
- type Viewer
- type WebhookStatus
Constants ¶
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).
const ( DigestWindowDefault = 1 DigestWindowMin = 1 DigestWindowMax = 15 )
Digest window bounds, in minutes.
const ( TargetNone = model.TargetNone TargetFile = model.TargetFile TargetDir = model.TargetDir TargetTrash = model.TargetTrash TargetApp = model.TargetApp )
The six target kinds, re-exported so an emitter never imports model just to name one.
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.
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 ¶
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
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 ¶
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
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
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 ¶
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
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 ¶
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:
- 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).
- 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 ¶
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
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"`
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
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
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" 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
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`).
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.
type PushRefusal ¶ added in v0.54.0
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
PushSubscriptionInput is what a browser hands over (PushSubscription.toJSON) and what it calls itself.
type PushTestResult ¶ added in v0.54.0
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.
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.
type Severity ¶
type Severity string
Severity classifies an event's urgency. Used by the UI for color coding and webhook receivers for filtering.
type ShareRef ¶
type ShareRef struct {
}
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 ¶
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 FileTarget ¶
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 ¶
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 ¶
ShareTarget addresses a public share link by its token.
func TrashTarget ¶
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
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" )