Thanks
", } mailer.Send(msg) // ❌ error swallowed return e.Next() }) ``` **Correct (sender from settings, per-send client, explicit error path):** ```go import ( "net/mail" pbmail "github.com/pocketbase/pocketbase/tools/mailer" ) app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error { // IMPORTANT: resolve the sender from settings at send-time, not at // startup - an admin can change it live from the UI meta := e.App.Settings().Meta from := mail.Address{ Name: meta.SenderName, Address: meta.SenderAddress, } msg := &pbmail.Message{ From: from, To: []mail.Address{{Address: e.Record.GetString("email")}}, Subject: "Order confirmed", HTML: renderOrderEmail(e.Record), // your template function } // Create the client per send - avoids stale TCP sessions if err := e.App.NewMailClient().Send(msg); err != nil { e.App.Logger().Error("order email send failed", "err", err, "recordId", e.Record.Id, ) // Do NOT return the error - a failed email should not roll back the order } return e.Next() }) ``` ```javascript // JSVM - $mails global exposes message factories onRecordAfterCreateSuccess((e) => { const meta = $app.settings().meta; const message = new MailerMessage({ from: { address: meta.senderAddress, name: meta.senderName, }, to: [{ address: e.record.get("email") }], subject: "Order confirmed", html: `Thanks for order ${e.record.id}
`, }); try { $app.newMailClient().send(message); } catch (err) { $app.logger().error("order email send failed", "err", err, "id", e.record.id); // swallow - do not rollback the order } e.next(); }, "orders"); ``` **Templated emails via the built-in verification/reset templates:** ```go // PocketBase has baked-in templates for verification, password reset, and // email change. Trigger them via apis.*Request helpers rather than building // your own message: // apis.RecordRequestPasswordReset(app, authRecord) // apis.RecordRequestVerification(app, authRecord) // apis.RecordRequestEmailChange(app, authRecord, newEmail) // // These use the templates configured in Admin UI → Settings → Mail templates. ``` **Rules:** - **Always change `Meta.SenderAddress`** before shipping. In development, use Mailpit or MailHog; in production, use a verified domain that matches your SPF/DKIM records. - **Resolve the sender from `app.Settings().Meta` at send-time**, not at startup. Settings are mutable from the admin UI. - **Create the client per send** (`app.NewMailClient()` / `$app.newMailClient()`). It is cheap - it re-reads the SMTP settings each time, so config changes take effect without a restart. - **Never return a send error from a hook** unless the user's action genuinely depends on the email going out. Email failure is common (transient SMTP, address typo) and should not roll back a business transaction. - **Log failures with context** (record id, recipient domain) so you can grep them later. PocketBase does not retry failed sends. - **For bulk sending, queue it**. The mailer is synchronous - looping `Send()` over 10k records blocks the request. Push to a cron-drained queue collection instead. - **Template rendering**: Go users should use `html/template`; JS users can use template literals or pull in a tiny template lib. PocketBase itself only renders templates for its baked-in flows. Reference: [Go Mailer](https://pocketbase.io/docs/go-sending-emails/) · [JS Mailer](https://pocketbase.io/docs/js-sending-emails/) ## 15. Register Custom Routes Safely with Built-in Middlewares **Impact: HIGH (Protects custom endpoints with auth, avoids /api path collisions, inherits rate limiting)** PocketBase routing is built on top of `net/http.ServeMux`. Custom routes are registered inside the `OnServe()` hook (Go) or via `routerAdd()` / `routerUse()` (JSVM). **Always** namespace custom routes under `/api/{yourapp}/...` to avoid colliding with built-in endpoints, and attach `apis.RequireAuth()` / `$apis.requireAuth()` (or stricter) to anything that is not meant to be public. **Incorrect (path collision, no auth, raw ResponseWriter):** ```go // ❌ "/api/records" collides with /api/collections/{name}/records built-in se.Router.POST("/api/records", func(e *core.RequestEvent) error { // ❌ no auth check - anyone can call this // ❌ returns raw text; no content-type e.Response.Write([]byte("ok")) return nil }) ``` **Correct (namespaced, authenticated, group-scoped middleware):** ```go app.OnServe().BindFunc(func(se *core.ServeEvent) error { // Group everything under /api/myapp/ and require auth for the entire group g := se.Router.Group("/api/myapp") g.Bind(apis.RequireAuth()) // authenticated users only g.Bind(apis.Gzip()) // compress responses g.Bind(apis.BodyLimit(10 << 20)) // per-route override of default 32MB limit g.GET("/profile", func(e *core.RequestEvent) error { return e.JSON(http.StatusOK, map[string]any{ "id": e.Auth.Id, "email": e.Auth.GetString("email"), }) }) // Superuser-only admin endpoint g.POST("/admin/rebuild-index", func(e *core.RequestEvent) error { // ... do the work return e.JSON(http.StatusOK, map[string]bool{"ok": true}) }).Bind(apis.RequireSuperuserAuth()) // Resource the owner (or a superuser) can access g.GET("/users/{id}/private", func(e *core.RequestEvent) error { return e.JSON(http.StatusOK, map[string]string{"private": "data"}) }).Bind(apis.RequireSuperuserOrOwnerAuth("id")) return se.Next() }) ``` ```javascript // JSVM routerAdd("GET", "/api/myapp/profile", (e) => { return e.json(200, { id: e.auth.id, email: e.auth.getString("email"), }); }, $apis.requireAuth()); routerAdd("POST", "/api/myapp/admin/rebuild-index", (e) => { return e.json(200, { ok: true }); }, $apis.requireSuperuserAuth()); ``` **Built-in middlewares (Go: `apis.*`, JS: `$apis.*`):** | Middleware | Use | |---|---| | `RequireGuestOnly()` | Reject authenticated clients (e.g. public signup forms) | | `RequireAuth(...collections)` | Require any auth record; optionally restrict to specific auth collections | | `RequireSuperuserAuth()` | Alias for `RequireAuth("_superusers")` | | `RequireSuperuserOrOwnerAuth("id")` | Allow superusers OR the auth record whose id matches the named path param | | `Gzip()` | Gzip-compress the response | | `BodyLimit(bytes)` | Override the default 32MB request body cap (0 = no limit) | | `SkipSuccessActivityLog()` | Suppress activity log for successful responses | **Path details:** - Patterns follow `net/http.ServeMux`: `{name}` = single segment, `{name...}` = catch-all. - A trailing `/` acts as a prefix wildcard; use `{$}` to anchor to the exact path only. - **Always** prefix custom routes with `/api/{yourapp}/` - do not put them under `/api/` alone, which collides with built-in collection / realtime / settings endpoints. - Order: global middlewares → group middlewares → route middlewares → handler. Use negative priorities to run before built-ins if needed. Reference: [Go Routing](https://pocketbase.io/docs/go-routing/) · [JS Routing](https://pocketbase.io/docs/js-routing/) ## 16. Read Settings via app.Settings(), Encrypt at Rest with PB_ENCRYPTION **Impact: HIGH (Hardcoded secrets and unencrypted settings storage are the #1 source of credential leaks)** PocketBase stores every runtime-mutable setting (SMTP credentials, S3 keys, OAuth2 client secrets, JWT secrets for each auth collection) in the `_params` table as JSON. Admin UI edits write to the same place. There are two knobs that matter: (1) **how you read settings from Go/JS** - always via `app.Settings()` at call time, never captured at startup; (2) **how they are stored on disk** - set the `PB_ENCRYPTION` env var to a 32-char key so the whole blob is encrypted at rest. Without encryption, anyone with a copy of `data.db` has your SMTP password, OAuth2 secrets, and every collection's signing key. **Incorrect (hardcoded secret, captured at startup, unencrypted at rest):** ```go // ❌ Secret compiled into the binary - leaks via `strings ./pocketbase` const slackWebhook = "https://hooks.slack.com/services/T00/B00/XXXX" // ❌ Captured once at startup - if an admin rotates the SMTP password via the // UI, this stale value keeps trying until restart var smtpHost = app.Settings().SMTP.Host // ❌ No PB_ENCRYPTION set - `sqlite3 pb_data/data.db "SELECT * FROM _params"` // prints every secret in plaintext ./pocketbase serve ``` **Correct (env + settings lookup at call time + encryption at rest):** ```bash # Generate a 32-char encryption key once and store it in your secrets manager # (1Password, SOPS, AWS SSM, etc). Commit NOTHING related to this value. openssl rand -hex 16 # 32 hex chars # Start with the key exported - PocketBase AES-encrypts _params on write # and decrypts on read. Losing the key == losing access to settings. export PB_ENCRYPTION="3a7c...deadbeef32charsexactly" ./pocketbase serve ``` ```go // Reading mutable settings at call time - reflects live UI changes func notifyAdmin(app core.App, msg string) error { meta := app.Settings().Meta from := mail.Address{Name: meta.SenderName, Address: meta.SenderAddress} // ... } // Mutating settings programmatically (e.g. during a migration) settings := app.Settings() settings.Meta.AppName = "MyApp" settings.SMTP.Enabled = true settings.SMTP.Host = os.Getenv("SMTP_HOST") // inject from env at write time if err := app.Save(settings); err != nil { return err } ``` ```javascript // JSVM onBootstrap((e) => { e.next(); const settings = $app.settings(); settings.meta.appName = "MyApp"; $app.save(settings); }); // At send-time const meta = $app.settings().meta; ``` **Secrets that do NOT belong in `app.Settings()`:** - Database encryption key itself → `PB_ENCRYPTION` env var (not in the DB, obviously) - Third-party webhooks your code calls (Slack, Stripe, etc) → env vars, read via `os.Getenv` / `$os.getenv` - CI tokens, deploy keys → your secrets manager, not PocketBase `app.Settings()` is for things an **admin** should be able to rotate through the UI. Everything else lives in env vars, injected by your process supervisor (systemd, Docker, Kubernetes). **Key details:** - **`PB_ENCRYPTION` must be exactly 32 characters.** Anything else crashes at startup. - **Losing the key is unrecoverable** - the settings blob cannot be decrypted, and the server refuses to boot. Back up the key alongside (but separately from) your `pb_data` backups. - **Rotating the key**: start with the old key set, call `app.Settings()` → `app.Save(settings)` to re-encrypt under the new key, then restart with the new key. Do this under a maintenance window. - **Settings changes fire `OnSettingsReload`** - use it if you have in-memory state that depends on a setting (e.g. a rate limiter sized from `app.Settings().RateLimits.Default`). - **Do not call `app.Settings()` in a hot loop.** It returns a fresh copy each time. Cache for the duration of a single request, not the process. - **`app.Save(settings)`** persists and broadcasts the reload event. Mutating the returned struct without saving is a no-op. Reference: [Settings](https://pocketbase.io/docs/going-to-production/#use-encryption-for-the-pb_data-settings) · [OnSettingsReload hook](https://pocketbase.io/docs/go-event-hooks/#app-hooks) ## 17. Test Hooks and Routes with tests.NewTestApp and ApiScenario **Impact: HIGH (Without the tests package you cannot exercise hooks, middleware, and transactions in isolation)** PocketBase ships a `tests` package specifically for integration-testing Go extensions. `tests.NewTestApp(testDataDir)` builds a fully-wired `core.App` over a **temp copy** of your test data directory, so you can register hooks, fire requests through the real router, and assert on the resulting DB state without spinning up a real HTTP server or touching `pb_data/`. The `tests.ApiScenario` struct drives the router the same way a real HTTP client would, including middleware and transactions. Curl-based shell tests cannot do either of these things. **Incorrect (hand-rolled HTTP client, shared dev DB, no hook reset):** ```go // ❌ Hits the actual dev server - depends on side-effects from a previous run func TestCreatePost(t *testing.T) { resp, _ := http.Post("http://localhost:8090/api/collections/posts/records", "application/json", strings.NewReader(`{"title":"hi"}`)) if resp.StatusCode != 200 { t.Fatal("bad status") } // ❌ No DB assertion, no cleanup, no hook verification } ``` **Correct (NewTestApp + ApiScenario + AfterTestFunc assertions):** ```go // internal/app/posts_test.go package app_test import ( "net/http" "strings" "testing" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" "myapp/internal/hooks" // your hook registration ) // testDataDir is a checked-in pb_data snapshot with your collections. // Create it once with `./pocketbase --dir ./test_pb_data migrate up` // and commit it to your test fixtures. const testDataDir = "../../test_pb_data" func TestCreatePostFiresAudit(t *testing.T) { // Each test gets its own copy of testDataDir - parallel-safe app, err := tests.NewTestApp(testDataDir) if err != nil { t.Fatal(err) } defer app.Cleanup() // REQUIRED - removes the temp copy // Register the hook under test against this isolated app hooks.RegisterPostHooks(app) scenario := tests.ApiScenario{ Name: "POST /api/collections/posts/records as verified user", Method: http.MethodPost, URL: "/api/collections/posts/records", Body: strings.NewReader(`{"title":"hello","slug":"hello"}`), Headers: map[string]string{ "Authorization": testAuthHeader(app, "users", "alice@example.com"), "Content-Type": "application/json", }, ExpectedStatus: 200, ExpectedContent: []string{ `"title":"hello"`, `"slug":"hello"`, }, NotExpectedContent: []string{ `"internalNotes"`, // the enrich hook should hide this }, ExpectedEvents: map[string]int{ "OnRecordCreateRequest": 1, "OnRecordAfterCreateSuccess": 1, "OnRecordEnrich": 1, }, AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) { // Assert side-effects in the DB using the SAME app instance audits, err := app.FindRecordsByFilter( "audit", "action = 'post.create'", "-created", 10, 0, ) if err != nil { t.Fatal(err) } if len(audits) != 1 { t.Fatalf("expected 1 audit record, got %d", len(audits)) } }, TestAppFactory: func(t testing.TB) *tests.TestApp { return app }, } scenario.Test(t) } ``` **Table-driven variant (authz matrix):** ```go func TestPostsListAuthz(t *testing.T) { for _, tc := range []struct { name string auth string // "", "users:alice", "users:bob", "_superusers:root" expect int }{ {"guest gets public posts", "", 200}, {"authed gets own + public", "users:alice", 200}, {"superuser sees everything", "_superusers:root",200}, } { t.Run(tc.name, func(t *testing.T) { app, _ := tests.NewTestApp(testDataDir) defer app.Cleanup() hooks.RegisterPostHooks(app) tests.ApiScenario{ Method: http.MethodGet, URL: "/api/collections/posts/records", Headers: authHeaderFor(app, tc.auth), ExpectedStatus: tc.expect, TestAppFactory: func(t testing.TB) *tests.TestApp { return app }, }.Test(t) }) } } ``` **Unit-testing a hook in isolation (no HTTP layer):** ```go func TestAuditHookRollsBackOnAuditFailure(t *testing.T) { app, _ := tests.NewTestApp(testDataDir) defer app.Cleanup() hooks.RegisterPostHooks(app) // Delete the audit collection so the hook's Save fails audit, _ := app.FindCollectionByNameOrId("audit") _ = app.Delete(audit) col, _ := app.FindCollectionByNameOrId("posts") post := core.NewRecord(col) post.Set("title", "should rollback") post.Set("slug", "rollback") if err := app.Save(post); err == nil { t.Fatal("expected Save to fail because audit hook errored") } // Assert the post was NOT persisted (tx rolled back) _, err := app.FindFirstRecordByFilter("posts", "slug = 'rollback'", nil) if err == nil { t.Fatal("post should not exist after rollback") } } ``` **Rules:** - **Always `defer app.Cleanup()`** - otherwise temp directories leak under `/tmp`. - **Use a checked-in `test_pb_data/` fixture** with the collections you need. Do not depend on the dev `pb_data/` - tests must be hermetic. - **Register hooks against the test app**, not against a package-level `app` singleton. The test app is a fresh instance each time. - **`ExpectedEvents`** asserts that specific hooks fired the expected number of times - use it to catch "hook silently skipped because someone forgot `e.Next()`" regressions. - **`AfterTestFunc`** runs with the same app instance the scenario used, so you can query the DB to verify side-effects. - **Parallelize with `t.Parallel()`** - `NewTestApp` gives each goroutine its own copy, so there's no shared state. - **Tests run pure-Go SQLite** (`modernc.org/sqlite`) - no CGO, no extra setup, works on `go test ./...` out of the box. - **For JSVM**, there is no equivalent test harness yet - test pb_hooks by booting `tests.NewTestApp` with the `pb_hooks/` directory populated and exercising the router from Go. Pure-JS unit testing of hook bodies requires extracting the logic into a `require()`able module. Reference: [Testing](https://pocketbase.io/docs/go-testing/) · [tests package GoDoc](https://pkg.go.dev/github.com/pocketbase/pocketbase/tests) ## 18. Use RunInTransaction with the Scoped txApp, Never the Outer App **Impact: CRITICAL (Mixing scoped and outer app inside a transaction silently deadlocks or writes outside the tx)** `app.RunInTransaction` (Go) and `$app.runInTransaction` (JS) wrap a block of work in a SQLite write transaction. The callback receives a **transaction-scoped app instance** (`txApp` / `txApp`). Every database call inside the block must go through that scoped instance - reusing the outer `app` / `$app` bypasses the transaction (silent partial writes) or deadlocks (SQLite allows only one writer). **Incorrect (outer `app` used inside the tx block):** ```go // ❌ Uses the outer app for the second Save - deadlocks on the writer lock err := app.RunInTransaction(func(txApp core.App) error { user := core.NewRecord(usersCol) user.Set("email", "a@b.co") if err := txApp.Save(user); err != nil { return err } audit := core.NewRecord(auditCol) audit.Set("user", user.Id) return app.Save(audit) // ❌ NOT txApp - blocks forever }) ``` **Correct (always `txApp` inside the block, return errors to roll back):** ```go err := app.RunInTransaction(func(txApp core.App) error { user := core.NewRecord(usersCol) user.Set("email", "a@b.co") if err := txApp.Save(user); err != nil { return err // rollback } audit := core.NewRecord(auditCol) audit.Set("user", user.Id) if err := txApp.Save(audit); err != nil { return err // rollback } return nil // commit }) if err != nil { return err } ``` ```javascript // JSVM - the callback receives the transactional app $app.runInTransaction((txApp) => { const user = new Record(txApp.findCollectionByNameOrId("users")); user.set("email", "a@b.co"); txApp.save(user); const audit = new Record(txApp.findCollectionByNameOrId("audit")); audit.set("user", user.id); txApp.save(audit); // throw anywhere in this block to roll back the whole tx }); ``` **Rules of the transaction:** - **Use only `txApp` / the callback's scoped app** inside the block. Capturing the outer `app` defeats the purpose and can deadlock. - Inside event hooks, `e.App` is already the transactional app when the hook fires inside a tx - prefer it over a captured parent-scope `app` for the same reason. - Return an error (Go) or `throw` (JS) to roll back. A successful return commits. - SQLite serializes writers - keep transactions **short**. Do not make HTTP calls, send emails, or wait on external systems inside the block. - Do not start a transaction inside another transaction on the same app - nested `RunInTransaction` on `txApp` is supported and reuses the existing transaction, but nested calls on the outer `app` will deadlock. - Hooks (`OnRecordAfterCreateSuccess`, etc.) fired from a `Save` inside a tx run **inside that tx**. Anything they do through `e.App` participates in the rollback; anything they do through a captured outer `app` does not. Reference: [Go database](https://pocketbase.io/docs/go-database/#transaction) · [JS database](https://pocketbase.io/docs/js-database/#transaction)