pgkiln 0.31.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +2 -2
  2. package/SECURITY.md +10 -0
  3. package/db/migrations/076_form_lost_update.sql +50 -0
  4. package/db/migrations/077_warn_unsaved.sql +41 -0
  5. package/db/migrations/078_url_region.sql +12 -0
  6. package/db/migrations/079_developer_toolbar.sql +53 -0
  7. package/db/migrations/080_search.sql +326 -0
  8. package/db/migrations/081_mail.sql +621 -0
  9. package/db/migrations/082_app_backups.sql +73 -0
  10. package/db/migrations/083_availability.sql +156 -0
  11. package/db/migrations/084_feedback.sql +46 -0
  12. package/db/migrations/085_page_behaviour.sql +65 -0
  13. package/db/migrations/086_data_settings.sql +599 -0
  14. package/docs/apex-feature-parity.md +55 -35
  15. package/docs/guide/01-installation.md +1 -0
  16. package/docs/guide/03-builder.md +118 -2
  17. package/docs/guide/04-pages-and-regions.md +115 -0
  18. package/docs/guide/05-items.md +47 -0
  19. package/docs/guide/06-processing.md +210 -2
  20. package/docs/guide/07-dynamic-actions.md +60 -2
  21. package/docs/guide/08-security.md +13 -2
  22. package/docs/guide/09-reference.md +21 -0
  23. package/docs/guide/11-from-apex.md +5 -1
  24. package/docs/guide/12-development.md +33 -1
  25. package/docs/guide/14-globalization.md +15 -0
  26. package/docs/guide/15-extensions.md +1 -1
  27. package/docs/guide/18-cli.md +30 -1
  28. package/docs/guide/20-ai-agents.md +3 -2
  29. package/examples/hr/hr_50_search.sql +47 -0
  30. package/examples/hr/hr_51_mail.sql +70 -0
  31. package/examples/hr/hr_52_feedback.sql +15 -0
  32. package/examples/hr/hr_53_data_settings.sql +108 -0
  33. package/package.json +3 -1
  34. package/public/app.css +62 -0
  35. package/public/app.js +408 -11
  36. package/public/builder.css +11 -0
  37. package/public/builder.js +27 -0
  38. package/src/app.ts +8 -0
  39. package/src/appfiles.ts +12 -0
  40. package/src/automations.ts +12 -0
  41. package/src/builder/advisor.ts +54 -14
  42. package/src/builder/arrange.ts +2 -2
  43. package/src/builder/availability.ts +74 -0
  44. package/src/builder/backups.ts +134 -0
  45. package/src/builder/components.ts +109 -15
  46. package/src/builder/designer.ts +8 -3
  47. package/src/builder/feedback.ts +82 -0
  48. package/src/builder/forms.ts +4 -1
  49. package/src/builder/home.ts +3 -0
  50. package/src/builder/locks.ts +15 -5
  51. package/src/builder/mail.ts +425 -0
  52. package/src/builder/newapp.ts +1 -1
  53. package/src/builder/region-help.ts +71 -0
  54. package/src/builder/routes.ts +44 -1
  55. package/src/builder/search.ts +4 -4
  56. package/src/builder/shared.ts +3 -2
  57. package/src/builder/ui.ts +7 -1
  58. package/src/cli/apps.ts +46 -5
  59. package/src/cli/main.ts +21 -5
  60. package/src/cli/mcp.ts +16 -4
  61. package/src/cli/replace.ts +36 -4
  62. package/src/geocode.ts +44 -0
  63. package/src/i18n/ar.ts +39 -0
  64. package/src/i18n/cs.ts +39 -0
  65. package/src/i18n/da.ts +39 -0
  66. package/src/i18n/de.ts +39 -0
  67. package/src/i18n/el.ts +39 -0
  68. package/src/i18n/es.ts +39 -0
  69. package/src/i18n/fi.ts +39 -0
  70. package/src/i18n/fr.ts +39 -0
  71. package/src/i18n/he.ts +39 -0
  72. package/src/i18n/it.ts +39 -0
  73. package/src/i18n/ja.ts +39 -0
  74. package/src/i18n/ko.ts +39 -0
  75. package/src/i18n/nb.ts +39 -0
  76. package/src/i18n/pl.ts +39 -0
  77. package/src/i18n/pt.ts +39 -0
  78. package/src/i18n/ru.ts +39 -0
  79. package/src/i18n/sv.ts +39 -0
  80. package/src/i18n/tr.ts +39 -0
  81. package/src/i18n/uk.ts +39 -0
  82. package/src/i18n/zh.ts +39 -0
  83. package/src/i18n.ts +78 -0
  84. package/src/mail/mail.ts +148 -0
  85. package/src/mail/providers.ts +444 -0
  86. package/src/metadata.ts +26 -8
  87. package/src/runtime/ajax.ts +97 -0
  88. package/src/runtime/authz.ts +1 -1
  89. package/src/runtime/availability.ts +37 -0
  90. package/src/runtime/context.ts +46 -6
  91. package/src/runtime/data-export.ts +94 -0
  92. package/src/runtime/data-items.ts +194 -0
  93. package/src/runtime/devbar.ts +182 -0
  94. package/src/runtime/engine.ts +26 -1
  95. package/src/runtime/feedback.ts +128 -0
  96. package/src/runtime/items.ts +8 -0
  97. package/src/runtime/processes.ts +83 -2
  98. package/src/runtime/regions.ts +8 -0
  99. package/src/runtime/render.ts +33 -7
  100. package/src/runtime/rest.ts +4 -0
  101. package/src/runtime/routes.ts +59 -5
  102. package/src/runtime/search.ts +252 -0
  103. package/src/runtime/url-region.ts +93 -0
  104. package/src/security.ts +11 -1
  105. package/src/server.ts +2 -0
  106. package/src/session.ts +8 -2
  107. package/src/workingcopy.ts +2 -2
@@ -13,6 +13,25 @@ A page has a number (unique in the app), a name, a title, and these behaviours:
13
13
  | `requires_auth` | boolean | `false` makes the page public in an app with a login |
14
14
  | `authz` | scheme name | Who may open it (403 otherwise) |
15
15
  | `protection` | `checksum`, `unrestricted` | Whether URL item values need a checksum |
16
+ | `warn_unsaved` | boolean | Ask before the user leaves the page with unsaved changes, see [below](#warn-on-unsaved-changes) |
17
+
18
+ ### Warn on unsaved changes
19
+
20
+ With **Warn on unsaved changes** (Page Designer → Page → Appearance; APEX: the page attribute of the
21
+ same name), the browser asks *"You have unsaved changes. Leave this page anyway?"* when the user
22
+ leaves the page while an item has a value other than the one the page was shown with: a link, a
23
+ redirect button (such as *Cancel*), the navigation menu, closing a dialog (its × or Escape),
24
+ reloading or closing the tab. Submitting the page with a button doesn't ask (that saves). After a
25
+ submit that failed (a validation error), the values on the page are not saved yet, so leaving asks
26
+ even without a further change. The check is in the browser (`pgkiln.isChanged()` tells your own
27
+ JavaScript, like APEX's `apex.page.isChanged()`); without JavaScript there is none.
28
+
29
+ - **Leave an item out** with `{"warn_unsaved": false}` in its attributes (APEX: the item's *Warn on
30
+ Unsaved Changes: Ignore*), for example a search field.
31
+ - The interactive grid has its own check of unsaved rows, which is always on.
32
+ - A value a dynamic action changes on page load counts as a change.
33
+ - The create page wizards switch it on for the form pages they make; existing pages have it off
34
+ until you switch it on.
16
35
 
17
36
  ### Dialogs and drawers
18
37
 
@@ -113,6 +132,8 @@ database only checks the shape (at most 12 names of lower case letters, digits a
113
132
  | [`template_component`](#template-components) | Each row (or all rows) of a SELECT through a template component: badges, contact cards, timelines, your own |
114
133
  | [`tasks`](06-processing.md#approvals-and-the-task-list) | Task list: approvals and actions for the signed-in user |
115
134
  | [`workflows`](06-processing.md#workflows) | Workflow console: the workflows the user started or administers |
135
+ | [`search`](#search-search-region) | One search field over several sources (search configurations), results grouped per source |
136
+ | [`url`](#url-url-region) | Another site's page (or a page of this server) in a sandboxed frame |
116
137
  | [`static`](#static-and-dynamic-content) | Fixed HTML with `&ITEM.` substitutions |
117
138
  | [`dynamic`](#static-and-dynamic-content) | HTML produced by a SELECT |
118
139
 
@@ -411,6 +432,18 @@ that column.
411
432
  Typical form buttons (as generated by the wizard): *Cancel* (redirect), *Delete* (condition
412
433
  `:P3_ID is not null`), *Apply Changes* (same condition) and *Create* (condition `:P3_ID is null`).
413
434
 
435
+ **Lost updates** (APEX: *Prevent Lost Updates* and *Lock Row*). When the form fetches its row, the
436
+ page also gets a checksum of the row's form columns (file columns aside). On *Save* or *Delete*,
437
+ `form_dml` locks the row (`SELECT … FOR UPDATE`), computes the checksum again and refuses the
438
+ change when it differs: *"Someone else changed this record after you opened it. Reload the page to
439
+ see the current values, then make your changes again."* Nothing is written, and the other user's
440
+ change is kept. Columns that are not on the form don't count, and the checksum doesn't depend on
441
+ the user's time zone or date format. The check is on by default; switch it off for one form with
442
+ `{"lost_update": false}` in the region's attributes. The checksum is `meta.row_version(table,
443
+ key_column, key, columns)`, which runs as the application's role (so its grants and row level
444
+ security apply). A process that changes the same row *before* `form_dml` in the same submit makes
445
+ the check fail: change the row after `form_dml`, or switch the check off.
446
+
414
447
  The easiest way to get a form is the **Report and form** wizard, which opens the form as a modal
415
448
  dialog from the report, or the **Form** wizard for a form page on its own (see
416
449
  [Create pages from a table](03-builder.md#create-pages-from-a-table-wizards)). The Cards, Calendar,
@@ -1264,6 +1297,88 @@ first rows and a *Show all* button), `char-counter` (item: a maximum length with
1264
1297
  an event in `pgkiln_plugins.event_log`). HR example part 47 installs them and uses all four on page
1265
1298
  40 (*Plug-ins*).
1266
1299
 
1300
+ ### `search` (search region)
1301
+
1302
+ One search field over several sources, with the results grouped per source (APEX 22.2: the Search
1303
+ region and search configurations). Each source is a **search configuration** (Shared Components →
1304
+ Search configurations):
1305
+
1306
+ | Property | Meaning |
1307
+ |---|---|
1308
+ | Name | The static ID the region names, e.g. `CUSTOMERS` |
1309
+ | Label | The heading of its results (translated like the page's texts) |
1310
+ | Sequence | The order of the groups |
1311
+ | Source type, source | `table`: a table or view (`sales.customers`) · `sql`: a SELECT, `:ITEM` binds allowed · `list`: the entries of a [list](#list-lists) the user may see (their label and description are searched, they link where the entry links) |
1312
+ | Searchable columns | The columns the search text is looked for in. Empty: the title, subtitle and description columns |
1313
+ | Title, subtitle, description, badge column | What each result shows. The title is required (table, sql), and results are ordered by it |
1314
+ | Icon | An [icon name](09-reference.md#icons), or `#COLUMN#` for a column that holds icon names |
1315
+ | Link | Where a result goes: `{"page": 3, "items": {"P3_ID": "#ID#"}}` (`#COLUMN#` is the row's value; the link gets a checksum, a modal page opens as a dialog) |
1316
+ | Maximum results | Per configuration, 1–100 (default 10); "Showing the first *n* rows." when there are more |
1317
+
1318
+ **How it searches.** Each word of the search text must be found (case-insensitively, anywhere in
1319
+ the value) in one of the searchable columns, so `clerk mil` finds MILLER the clerk. `%` and `_` are
1320
+ plain characters. The text is a query parameter, never SQL; the queries run as the application's
1321
+ role (its grants and row level security apply), each on its own, so a configuration that fails
1322
+ shows its error and the others still answer. The found words are marked in the results.
1323
+
1324
+ ```sql
1325
+ -- a configuration on a query: employees with their department
1326
+ select e.empno, initcap(e.ename) as name, initcap(e.job) as job, d.dname as department
1327
+ from hr.emp e left join hr.dept d on d.deptno = e.deptno
1328
+ ```
1329
+
1330
+ with searchable columns `name, job, department`, title `name`, subtitle `job`, badge `department`
1331
+ and link `{"page": 3, "items": {"P3_EMPNO": "#EMPNO#"}}`.
1332
+
1333
+ **The region**: attributes
1334
+
1335
+ | Key | Meaning |
1336
+ |---|---|
1337
+ | `configs` | The configurations it searches, e.g. `["CUSTOMERS", "ORDERS"]`; empty or left out: all of the application's |
1338
+ | `item` | Search with this page item's value (`"P1_SEARCH"`) instead of a field of the region's own; refresh the region from a dynamic action, or let the item submit the page |
1339
+ | `placeholder` | The field's placeholder |
1340
+ | `min_length` | Search only from this many characters (default 1) |
1341
+ | `empty` | The text when nothing is found (default "Nothing found.") |
1342
+
1343
+ The region's own field is a GET form (`?s<region id>=text`, so a search can be bookmarked) and works
1344
+ without JavaScript; with JavaScript it searches while the user types and replaces only the results.
1345
+ The Advisor plans every configuration with the columns it names and reports names a region uses
1346
+ that don't exist. Search configurations are exported with the application. The HR example's page 42
1347
+ searches employees, departments and the shortcuts list.
1348
+
1349
+ ### `url` (URL region)
1350
+
1351
+ Shows another site's page in the region, in a sandboxed `<iframe>` (APEX: URL region).
1352
+
1353
+ **Source**: the URL. Its scheme and host are written out, so an item can never choose which site is
1354
+ shown; `&ITEM.` substitutions are allowed after the host and are URL-encoded:
1355
+
1356
+ ```text
1357
+ https://reports.example.com/dashboard?customer=&P3_CUSTOMER_ID.&lang=&APP_LANGUAGE.
1358
+ ```
1359
+
1360
+ - `https://` only (`http://` for `localhost` during development), no user name or password in the
1361
+ URL. A path that starts with `/` shows a page of this server, such as another pgkiln application
1362
+ (`/a/portal/1`).
1363
+ - The page's Content-Security-Policy gets `frame-src` for exactly the origins of its visible URL
1364
+ regions; other pages frame nothing. The Advisor reports a URL that isn't allowed.
1365
+ - The other site decides whether it may be framed: many sites forbid it (`X-Frame-Options` or
1366
+ `frame-ancestors`), and the browser then shows an empty frame. Under the frame, *Open in a new tab*
1367
+ opens the page itself.
1368
+ - The region's title is the frame's accessible name.
1369
+
1370
+ Attributes:
1371
+
1372
+ | Key | Meaning |
1373
+ |---|---|
1374
+ | `height` | The frame's height in pixels, 100–4000 (default 480); the width is the region's |
1375
+ | `sandbox` | The frame's sandbox flags, from `allow-scripts`, `allow-forms`, `allow-popups`, `allow-same-origin`, `allow-downloads`, `allow-modals` and `allow-popups-to-escape-sandbox`. Default: the first four (most sites need them to work). `false`: no sandbox. Navigating the whole window (`allow-top-navigation`) is never allowed |
1376
+ | `fullscreen` | `true`: the framed page may go full screen (videos, maps) |
1377
+
1378
+ ```json
1379
+ {"height": 640, "sandbox": ["allow-scripts", "allow-same-origin"], "fullscreen": true}
1380
+ ```
1381
+
1267
1382
  ### `static` and `dynamic` content
1268
1383
 
1269
1384
  - **`static`**: `source` is HTML written by the developer, with `&ITEM.` substitutions
@@ -33,6 +33,8 @@ page, optionally inside a region. Name them `P<page>_<NAME>` (uppercase letters,
33
33
  | `combobox` | free text with suggestions from the list of values; several values become tags | **colon-separated** values, e.g. `SQL:Sales` |
34
34
  | `daterange` | two date pickers, *From* and *To* | `from:to`, e.g. `2026-01-01:2026-06-30` |
35
35
  | `qrcode` | display only: the value as a QR code (SVG drawn on the server) | whatever was set |
36
+ | `image` | display only: a picture from a URL in the value, or a BLOB from a query ([Display image](#display-image-and-geocoded-address)) | a URL, or nothing |
37
+ | `address` | a text field with *Check address*: the installation's geocoder offers matching addresses ([Geocoded address](#display-image-and-geocoded-address)) | the address as text |
36
38
  | `plugin` | an [item plug-in](04-pages-and-regions.md#plug-ins-with-their-own-code): a text field its JavaScript enhances; `config`: `{"plugin": "char_counter", "attributes": {"MAX": "140"}}` | text |
37
39
 
38
40
  Use multi-value items in SQL with `string_to_array`:
@@ -70,6 +72,7 @@ Attributes (`config`):
70
72
  | Key | Meaning |
71
73
  |---|---|
72
74
  | `submit_on_change` | `true`: submit the page when the value changes (typical for report filters) |
75
+ | `warn_unsaved` | `false`: a change of this item doesn't count for the page's [warn on unsaved changes](04-pages-and-regions.md#warn-on-unsaved-changes) |
73
76
  | `null_label` | Text of the empty option in select lists (default `- Select -`); `false` removes the empty option |
74
77
  | `cascade_parents` | Comma-separated items this list depends on, see [cascading lists](#cascading-lists-of-values) |
75
78
  | `wide` | `true`: take the full width of the form |
@@ -129,6 +132,50 @@ the Markdown is shown as text, and the result passes through the same allow-list
129
132
 
130
133
  The HR example's page 20 *Reviews* (`examples/hr/hr_20_items.sql`) uses all of them.
131
134
 
135
+ ## Display image and geocoded address
136
+
137
+ **Display image** (`image`, APEX: *Display Image*) shows a picture and can't be submitted. Two
138
+ sources:
139
+
140
+ - **A URL in the item's value** (a computation, a process or a form fetch sets it): an `https://`
141
+ URL, a path on this server (`/a/hr/static/logo.png`) or a `data:image/png;base64,…` URL. Other
142
+ schemes (`http:`, `javascript:`, URLs with a user name) show nothing. The page's
143
+ Content-Security-Policy allows pictures from exactly the `https` origins its image items show
144
+ when it is rendered.
145
+ - **A BLOB from a query**: `{"sql": "select photo, photo_mime from hr.emp where empno = :P43_EMPNO::int"}`.
146
+ The first column of the first row is the picture (`bytea`), the second its MIME type (else it is
147
+ recognised from the bytes: PNG, JPEG, GIF, WebP). The browser loads it from
148
+ `/a/<alias>/<page>/image/<item>`, which runs the query as the application's role with the
149
+ session's item values, after the page's authorization and the item's own; it is never cached,
150
+ sandboxed and at most 20 MB.
151
+
152
+ `config`: `{"alt": "Photo of &P43_ENAME.", "width": 160, "height": 160}` (the alternative text,
153
+ `&ITEM.` substitutions allowed; it defaults to the label).
154
+
155
+ **Geocoded address** (`address`, APEX: *Geocoded Address*) is a text field with a *Check address*
156
+ button. The server asks the installation's geocoder for the typed text and shows up to five
157
+ matching addresses; the one chosen replaces the text, and with
158
+ `{"coordinates_item": "P43_LOCATION"}` its position goes into that item as `latitude,longitude`
159
+ (the format of a `location` item, so a map region can show it). `{"country": "nl"}` (or `"nl,be"`)
160
+ limits the search to those countries. The answers come in the user's language.
161
+
162
+ The geocoder is set per installation with **`GEOCODER_URL`**, any API that speaks Nominatim's
163
+ search (`?q=…&format=jsonv2`): your own [Nominatim](https://nominatim.org), LocationIQ
164
+ (`https://eu1.locationiq.com/v1/search` with `GEOCODER_KEY`) or, within its usage policy (an
165
+ absolute maximum of one request per second, no heavy use), `https://nominatim.openstreetmap.org/search`.
166
+ Each session may ask once a second. Without `GEOCODER_URL` an address item is a plain text item.
167
+
168
+ The HR example's page 43 *Data and settings* (`examples/hr/hr_53_data_settings.sql`) has both.
169
+
170
+ ## Keeping values per user
171
+
172
+ `{"persist": "user"}` on any item (APEX: *Maintain session state: Per User*) keeps its value for
173
+ the signed-in user across sessions: when a page shows the item and the session has no value for it
174
+ yet, the user's last value comes back; a submit of the page (also a plain submit, e.g. *submit on
175
+ change*) saves the value the user could change, in the same transaction as the page's processes.
176
+ The value is a [user preference](06-processing.md#user-preferences) named like the item. Public
177
+ pages and applications without sign-in keep nothing.
178
+
132
179
  ## Lists of values
133
180
 
134
181
  A list of values (LOV) provides the options of select lists, radio groups, checkbox groups,
@@ -167,6 +167,36 @@ end if;
167
167
  All changes of the submit are rolled back, and the page is shown again with the entered
168
168
  values so the user can correct them.
169
169
 
170
+ **Error handling function** (Settings → *Errors and sessions*; APEX: Error Handling Function). A
171
+ function of your own rewrites every error message users see: those raised in PL/pgSQL, the friendly
172
+ constraint messages and the "reference" messages of unexpected errors.
173
+
174
+ ```sql
175
+ create function sales.error_message(e jsonb) returns jsonb language plpgsql as $$
176
+ begin
177
+ if e->>'constraint' = 'order_customer_fk' then
178
+ return jsonb_build_object('message', 'Choose an existing customer.');
179
+ end if;
180
+ if (e->>'internal')::boolean then
181
+ return jsonb_build_object('message', 'Something went wrong. Quote ' || (e->>'reference') || ' to the help desk.');
182
+ end if;
183
+ return null; -- keep the message
184
+ end $$;
185
+ ```
186
+
187
+ It gets `message` (what pgkiln would show), `original_message`, `code` (SQLSTATE), `constraint`,
188
+ `table`, `column`, `detail`, `hint`, `component` (where it happened, e.g. `process "Save order"`),
189
+ `page`, `user`, `internal` (true for an unexpected error) and `reference` (its activity log id), and
190
+ returns `{"message": "…"}` or null. It runs as the application's role, in a transaction of its own,
191
+ with a 2-second limit; when it fails or returns null the message stays as it was. Debug mode shows
192
+ the real error and doesn't call it.
193
+
194
+ ### Ajax callbacks
195
+
196
+ A process with point **ajax** runs when JavaScript calls it by name, `pgkiln.server.process('NAME')`
197
+ (APEX: On Demand processes, `apex.server.process`), and answers JSON; see
198
+ [chapter 7](07-dynamic-actions.md#ajax-callbacks).
199
+
170
200
  ### Debug messages
171
201
 
172
202
  Like APEX's debug, pgkiln can record what each request of an application did and how long every
@@ -182,6 +212,10 @@ application's settings):
182
212
  | 6 trace | Also each region, process, application process and computation with its duration, and the session (page, user, language, time zone) |
183
213
  | 9 everything | Also the posted item values, computed values, skipped processes, hidden regions and other SQL notices |
184
214
 
215
+ A developer can also switch debug on for **their own session** only, from the
216
+ [developer toolbar](03-builder.md#developer-toolbar) on the running application: the application
217
+ stays out of debug for everyone else.
218
+
185
219
  Each recorded request lists its entries with the time since the start of the request and, for
186
220
  timed steps, their duration. Add your own from any application SQL (processes, region sources,
187
221
  conditions, your PL/pgSQL functions), as with `apex_debug.message`:
@@ -240,6 +274,27 @@ a MIME type that doesn't look like one becomes `application/octet-stream`; the r
240
274
  sniffed (`nosniff`), is sandboxed by its Content-Security-Policy and isn't cached. At most 1000
241
275
  files and 100 MB per download.
242
276
 
277
+ #### Data export
278
+
279
+ With `{"export": "<format>"}` the query returns **rows**, and the download is those rows as one
280
+ file (APEX: `APEX_DATA_EXPORT`): `csv`, `json`, `xml`, `html`, `xlsx` (Excel) or `pdf`.
281
+
282
+ ```json
283
+ {"export": "xlsx", "file_name": "employees-&P43_DEPTNO.", "title": "Employees"}
284
+ ```
285
+
286
+ | Key | Meaning |
287
+ |---|---|
288
+ | `export` | The format |
289
+ | `file_name` | The name without extension, `&ITEM.` substitutions allowed (default: the process name) |
290
+ | `title` | The title of an HTML or PDF file |
291
+ | `layout` | A [report layout](16-files.md#report-layouts) for the PDF (paper, margins, logo) |
292
+ | `separator` | CSV: one character instead of `,` |
293
+
294
+ Column names are the headings (`hire_date` → *Hire Date*, translated like report headings in Excel
295
+ and PDF). Excel keeps numbers and dates as numbers and dates; Excel and PDF hold at most 100,000
296
+ rows. From SQL, `meta.data_export` makes the same CSV, JSON, XML and HTML ([below](#data-export-from-sql)).
297
+
243
298
  ### Execution chains
244
299
 
245
300
  A `chain` process runs its **children**: the processes of the page whose `parent_process` is the
@@ -295,6 +350,30 @@ looks up the employee (a `sql` child) and then starts the ONBOARDING workflow (a
295
350
  *Stop onboarding* terminates it; *Year-end check* is a background chain whose jobs the region
296
351
  "My background jobs" lists from `meta.process_jobs`.
297
352
 
353
+ ### Send e-mail
354
+
355
+ A `send_email` process (APEX: *Send E-Mail*) queues a mail through the application's [mail
356
+ service](#sending-e-mail), without SQL:
357
+
358
+ ```json
359
+ {"to": "&P5_EMAIL.", "cc": "&P5_MANAGER_EMAIL.", "subject": "Order &P5_ID. shipped",
360
+ "body": "Dear &P5_NAME., your order is on its way.",
361
+ "body_html": "<p>Dear &P5_NAME.,</p><p>your order is on its way.</p>",
362
+ "attachments": "select content, filename, mime from sales.invoice where order_id = :P5_ID"}
363
+ ```
364
+
365
+ or with an [e-mail template](#e-mail-templates):
366
+
367
+ ```json
368
+ {"to": "&P5_EMAIL.", "template": "ORDER_SHIPPED", "placeholders": {"NAME": "&P5_NAME.", "ORDER_ID": "&P5_ID."}}
369
+ ```
370
+
371
+ `to`, `cc`, `bcc` (several addresses separated by commas), `from`, `reply_to`, `subject` and `body`
372
+ take `&ITEM.` substitutions as they are; in `body_html` the item values are HTML-escaped.
373
+ `attachments` is a SELECT (binds allowed) with the columns `content` (bytea), `filename` and `mime`,
374
+ one attachment per row. The mail is queued in the page's transaction: when a later process fails,
375
+ nothing is sent. It can run in a background chain too.
376
+
298
377
  ### Send push notification
299
378
 
300
379
  A `send_push` process (APEX: *Send Push Notification*) sends a [push
@@ -514,6 +593,61 @@ select (select empno from hr.emp where lower(username) = lower(:APP_USER)) as ai
514
593
 
515
594
  `&AI_ENAME.` then greets the user on the dashboard.
516
595
 
596
+ ## Application settings
597
+
598
+ **Shared Components → Application settings** (APEX: *Application Settings*, `APEX_APP_SETTING`)
599
+ are named values an administrator changes after deployment: a support address, a limit, a switch.
600
+ Each has a name (`SUPPORT_EMAIL`), a value, a description, optionally a list of **valid values**,
601
+ and *Value required*. Application code reads and changes them:
602
+
603
+ ```sql
604
+ select meta.get_app_setting('SUPPORT_EMAIL');
605
+ select meta.set_app_setting('LEAVE_APPROVAL', 'hr'); -- one of its valid values
606
+ ```
607
+
608
+ Both work for the current application only, called as its own database role (application code),
609
+ and raise an error for an unknown name, a value outside the valid values or an empty required one.
610
+ Settings are exported with the application, values included. An import over an installed
611
+ application (CLI, MCP, a working copy merge) keeps the **installed** value of every setting marked
612
+ *Keep the installed value on upgrade* (the default); with the mark off, the file's value wins. When
613
+ the application's build status is *Run only*, its existing settings can still be edited.
614
+
615
+ ## User preferences
616
+
617
+ Values kept per user and application across sessions (APEX: `APEX_UTIL.GET_PREFERENCE`,
618
+ `SET_PREFERENCE`, `REMOVE_PREFERENCE`):
619
+
620
+ ```sql
621
+ select meta.set_preference('report.rows', '50'); -- the signed-in user
622
+ select meta.get_preference('report.rows'); -- null when there is none
623
+ select meta.remove_preference('report.rows');
624
+ select meta.get_preference('report.rows', 'blake'); -- another user of the application
625
+ ```
626
+
627
+ Names are letters, digits, `_`, `.` and `-` (at most 100); values at most 32,000 bytes; at most 500
628
+ preferences per user. Setting one needs a signed-in user. Items with `{"persist": "user"}` keep their
629
+ values this way ([Keeping values per user](05-items.md#keeping-values-per-user)). Preferences are data of
630
+ the installation: not exported, kept by an import over the application, deleted with it.
631
+
632
+ ## Data export from SQL
633
+
634
+ `meta.data_export(query, format, options)` (APEX: `APEX_DATA_EXPORT.EXPORT`) returns a query's
635
+ rows as a file (`bytea`): `csv`, `json` (an array of objects), `xml` (`query_to_xml`) or `html` (a
636
+ table). It runs with the **caller's** privileges, so grants and row level security apply as to the
637
+ query itself. Attach the file to a mail, store it, or send it with a download process:
638
+
639
+ ```sql
640
+ select meta.add_mail_attachment(
641
+ meta.send_mail('boss@example.com', 'Staff', 'Attached.'),
642
+ meta.data_export('select empno, ename, sal from hr.emp order by ename', 'csv', '{"separator": ";"}'),
643
+ 'staff.csv', 'text/csv');
644
+ ```
645
+
646
+ Options: `separator` (CSV, one character), `header` (`false`: no heading line), `bom` (`false`: no
647
+ UTF-8 byte order mark, which Excel needs to read UTF-8), `title` (HTML). CSV fields are quoted when
648
+ needed, and text that a spreadsheet would run as a formula (`=`, `+`, `-`, `@`) gets a leading
649
+ apostrophe, as in report downloads. Excel and PDF come from the [download process](#data-export).
650
+
517
651
  ## Automations
518
652
 
519
653
  **Shared Components → Automations** run SQL or PL/pgSQL on a schedule, like APEX automations:
@@ -604,6 +738,79 @@ creates or replaces the single action (the column itself stays empty).
604
738
 
605
739
  If you prefer the database to schedule work, [pg_cron](15-extensions.md) still works next to this.
606
740
 
741
+ ## Sending e-mail
742
+
743
+ Like APEX's `APEX_MAIL`, applications send e-mail from SQL, from a *Send e-mail* process and from
744
+ e-mail templates. Where APEX sends only through an SMTP server with a user name and password,
745
+ pgkiln sends through any of these **mail services** (Workspace utilities → [Mail
746
+ services](03-builder.md#mail-services), administrators):
747
+
748
+ | Provider | How pgkiln hands mail over | Credentials |
749
+ |---|---|---|
750
+ | SMTP | Any SMTP server or relay, STARTTLS or TLS | User name and password, or **OAuth2** (SASL XOAUTH2 with a token from the client credentials grant: Microsoft Entra for Exchange Online, or another identity provider's token URL) |
751
+ | Microsoft 365 (Graph) | Microsoft Graph `sendMail` as the From mailbox | An Entra application (tenant, client ID, client secret) with the application permission **Mail.Send**; restrict it to the sending mailboxes with an application access policy |
752
+ | Amazon SES | SES API v2, the raw MIME message, Signature Version 4 | Region, access key ID and secret access key (an IAM user allowed `ses:SendEmail`) |
753
+ | Resend, Postmark, SendGrid, Mailgun | Their HTTP APIs | An API key (Postmark: a server token; Mailgun: also the sending domain and region) |
754
+ | Log only | Nothing is sent: the server's log shows each mail | – (development and demos) |
755
+
756
+ Microsoft retires basic authentication (a password) for SMTP in Exchange Online: use SMTP with OAuth2
757
+ or the Graph provider. Secrets are stored encrypted with `PGKILN_SECRET_KEY`, are write-only in the
758
+ builder, never exported and never readable by applications. Each service lists the **From
759
+ addresses** it may send as (an address, or `@example.com` for a whole domain); an administrator
760
+ chooses each application's service, its From address and an optional **limit per day**.
761
+
762
+ **How mail is sent.** `meta.send_mail` and the others only *queue* the mail, in the caller's
763
+ transaction: a transaction that rolls back sends nothing. After the commit, the server sends it at
764
+ once (`NOTIFY`), or on the scheduler's next pass. A failure that may pass (a network problem, HTTP
765
+ 429 or 5xx, an SMTP 4xx answer) is tried again after 1, 4, 16 and 64 minutes; the fifth failure, or a
766
+ refusal (an unknown recipient, a sender the provider doesn't accept), marks the mail *error*.
767
+ Applications → Activity → **Mail** shows each mail with its outcome, and sends a failed one again or
768
+ cancels a queued one. Mail is kept 30 days.
769
+
770
+ ### From SQL
771
+
772
+ ```sql
773
+ -- APEX_MAIL.SEND
774
+ select meta.send_mail(
775
+ p_to => 'jane@example.com, john@example.com',
776
+ p_subject => 'Your leave was approved',
777
+ p_body => 'Enjoy your holiday!', -- plain text
778
+ p_body_html => '<p>Enjoy your <b>holiday</b>!</p>', -- HTML (optional)
779
+ p_cc => null, p_bcc => null, p_reply_to => 'hr@example.com',
780
+ p_from => null); -- null: the application's From address
781
+
782
+ -- APEX_MAIL.ADD_ATTACHMENT, in the same transaction
783
+ with m as (select meta.send_mail('jane@example.com', 'Invoice 42', 'Attached.') as id)
784
+ select meta.add_mail_attachment(m.id, i.pdf, 'invoice-42.pdf', 'application/pdf')
785
+ from m, sales.invoice i where i.id = 42;
786
+ ```
787
+
788
+ See [chapter 9](09-reference.md#e-mail-from-sql) for every function and its limits. The functions
789
+ work for the **current application** only when the caller runs as that application's database role
790
+ (pages, processes, automations, workflows and background chains do); the SQL Workshop is not an
791
+ application.
792
+
793
+ ### E-mail templates
794
+
795
+ Shared Components → **E-mail templates** (APEX: Email Templates): a name, a subject, an HTML body
796
+ and a plain text body with `#PLACEHOLDERS#`. In the HTML body `#NAME#` is the value HTML-escaped and
797
+ `#NAME!RAW#` the value as it is (trusted HTML only); in the subject and the plain text the value is
798
+ as it is. Names are case-insensitive; a placeholder without a value stays as written.
799
+
800
+ ```sql
801
+ select meta.send_mail_template('LEAVE_DECIDED',
802
+ jsonb_build_object('NAME', 'Jane', 'STATUS', 'approved', 'START', '1 Aug 2026'),
803
+ 'jane@example.com');
804
+ ```
805
+
806
+ Templates are exported with the application; mail services, the application's service and the
807
+ mail log belong to the installation and are not.
808
+
809
+ **HR example.** The template `LEAVE_DECIDED` tells an employee that a leave request was approved or
810
+ rejected: a trigger on `hr.leave_request` queues it when the status changes. The example's mail
811
+ service `DEVELOPMENT_LOG` is of provider *log only*, so nothing leaves the server until an
812
+ administrator gives HR a real service.
813
+
607
814
  ## Approvals and the task list
608
815
 
609
816
  Approvals (APEX: Task Definitions, the Approvals component and the Unified Task List) let an
@@ -676,8 +883,9 @@ open tasks so they don't linger: `select meta.close_tasks('EXPENSE_APPROVAL', :P
676
883
  | `meta.task_events` | View: the history and comments of those tasks |
677
884
 
678
885
  The task tables themselves are closed to the application's role: rights are checked by these
679
- functions and views, for every call. pgkiln sends no e-mail; put a task list on the home page, or
680
- use an automation to remind owners of overdue tasks.
886
+ functions and views, for every call. To tell owners by e-mail, call
887
+ [`meta.send_mail`](#sending-e-mail) from the task definition's SQL or from an automation that
888
+ reminds owners of overdue tasks; or put a task list on the home page.
681
889
 
682
890
  **Example:** in the HR sample application, a leave request creates a `LEAVE_APPROVAL` task for the
683
891
  employee's manager; *My tasks* (page 14) approves or rejects it through `hr.decide_leave`, the same
@@ -10,8 +10,8 @@ your own from a static application file.
10
10
 
11
11
  | Property | Meaning |
12
12
  |---|---|
13
- | `event` | `change` (an item's value changed), `click` (a button was clicked), `load` (the page loaded) or `dialog_closed` (a modal dialog opened from the page was submitted and closed) |
14
- | `trigger_element` | For `change`: one or more item names, comma separated. For `click`: a button name. For `dialog_closed`: the dialog page numbers, comma separated (empty: any dialog) |
13
+ | `event` | When it runs, see [Events](#events) |
14
+ | `trigger_element` | What it listens to: item names (`change`, `focus`, `blur`, `key_release`), a button name (`click`), region ids or static ids (`after_refresh`, `selection_change`), dialog page numbers (`dialog_closed`; empty: any dialog) or event names (`custom`), comma separated. Empty for `load` and `before_submit` |
15
15
  | `condition_type`, `condition_value` | Optional client-side condition on the (first) trigger item's value (for `dialog_closed`: on the dialog's page number): `equals`, `not_equals`, `in_list` (comma-separated values), `is_null`, `is_not_null` |
16
16
  | `action` | What to do, see below |
17
17
  | `affected_items`, `affected_region_id` | What it acts on |
@@ -23,6 +23,31 @@ your own from a static application file.
23
23
  | `authz` | Only for authorized users |
24
24
  | `build_option` | Only while the [build option](06-processing.md#build-options) is included |
25
25
 
26
+ ## Events
27
+
28
+ | Event | Runs when | Trigger |
29
+ |---|---|---|
30
+ | `change` | An item's value changed | Item names |
31
+ | `click` | A button was clicked | A button name |
32
+ | `load` | The page has loaded | – |
33
+ | `dialog_closed` | A modal dialog opened from the page was submitted and closed ([below](#dialog-closed)) | Dialog page numbers (empty: any) |
34
+ | `focus` / `blur` | An item gets / loses the focus (APEX: Get Focus, Lose Focus) | Item names |
35
+ | `key_release` | A key is released in an item | Item names |
36
+ | `after_refresh` | A region was refreshed (`refresh_region`), loaded lazily or shown for another master row | Region ids or static ids |
37
+ | `selection_change` | Rows are selected in a report with [row selection](04-pages-and-regions.md#row-selection), or another master row in a grid; the condition sees the selected values, colon separated | Region ids or static ids |
38
+ | `before_submit` | The page is about to be submitted; a declined `confirm` keeps the page | – |
39
+ | `custom` | JavaScript raised the event: `pgkiln.trigger('ORDER_SAVED', value)` (APEX: custom events, `apex.event.trigger`) | Event names (case-insensitive) |
40
+
41
+ **The actions of one event and trigger run one after the other, in sequence order** (APEX: the true
42
+ actions of a dynamic action), each waiting for the one before (a server action for its answer). A
43
+ `confirm` that is declined stops the actions after it, and for `before_submit` the submit too:
44
+
45
+ | Sequence | Event | Action | Message / items |
46
+ |---|---|---|---|
47
+ | 10 | `click` on `DELETE_ALL` | `confirm` | *Delete every selected row?* |
48
+ | 20 | `click` on `DELETE_ALL` | `execute_sql` | `delete from sales.order_line where id = any(string_to_array(:P5_SELECTED, ':')::int[])` |
49
+ | 30 | `click` on `DELETE_ALL` | `refresh_region` | the order lines |
50
+
26
51
  ## Actions
27
52
 
28
53
  | Action | Runs in | Effect |
@@ -45,11 +70,44 @@ your own from a static application file.
45
70
  | `plugin` | browser | Runs the [dynamic action plug-in](04-pages-and-regions.md#plug-ins-with-their-own-code) named in `code`, with `config` `{"attributes": {…}}` (`&ITEM.` filled in); the function gets the same context as *Execute JavaScript* plus `attributes` |
46
71
  | `execute_javascript` | browser | Calls the function named in `code`, which a [static application file](03-builder.md#static-application-files) registered; see [below](#execute-javascript) |
47
72
  | `push_subscribe` | browser | Turns on [push notifications](17-mobile.md#push-notifications) on the user's device: the browser asks for permission, then the device is registered. Use it with `click` (browsers ask only after one). `message` is shown when it worked, the browser's reason when not. The app needs push notifications on |
73
+ | `confirm` | browser | Asks `message`; **No stops the actions after it** (and the submit, for `before_submit`) |
74
+ | `close_dialog` | browser | In a modal page: closes it; the page below refreshes, or runs its `dialog_closed` actions (APEX: Close Dialog) |
75
+ | `cancel_dialog` | browser | In a modal page: closes it without refreshing the page below |
76
+ | `clear` | browser | Empties the affected items |
77
+ | `open_region` / `close_region` | browser | Opens or closes a *collapsible* region; another region is shown or hidden |
78
+ | `download` | server | Downloads the file of the [download process](06-processing.md#download) named in `code` (a process of the page, with its authorization and condition), after sending `items_to_submit`; the page stays as it is |
79
+ | `print` | browser | The browser's print dialog |
80
+ | `current_position` | browser | The device's position as `latitude,longitude` (5 decimals) into the first affected item |
81
+ | `share` | browser | The device's share sheet with the page's title, `message` and address; where there is none, the address is copied |
48
82
 
49
83
  Server-side actions first store `items_to_submit` in session state, run as the application's
50
84
  database role like everything else, and check the page's and the dynamic action's authorization.
51
85
  The SQL itself never reaches the browser.
52
86
 
87
+ ## Ajax callbacks
88
+
89
+ A page process with point **ajax** (APEX: an On Demand process) runs only when JavaScript calls it by
90
+ name, and answers JSON; an application process with point *ajax* can be called from every page (a
91
+ page's own process of that name wins). Only processes of type `sql` answer.
92
+
93
+ ```js
94
+ // in a static application file
95
+ const r = await pgkiln.server.process('ORDER_TOTAL', { items: ['P5_ORDER_ID'], x01: 'EUR' });
96
+ r.data.total; // the first row of the process's last statement
97
+ r.rows; // its rows (at most 1000)
98
+ r.items; // the page and application items it set (also set on the page)
99
+ ```
100
+
101
+ ```sql
102
+ -- the process ORDER_TOTAL (point ajax): :X01 … :X10 are the x01 … x10 sent
103
+ select sum(qty * price) as total, :X01 as currency from sales.order_line where order_id = :P5_ORDER_ID::int
104
+ ```
105
+
106
+ `items` is a list of item names (their current values are sent) or `{"P5_ID": "7"}`; only items the
107
+ user may change are set, as on a submit. The process runs as the application's role with its
108
+ authorization, condition and build option; errors come back as `{ "error": "…" }` (the promise
109
+ rejects). The request carries the page's CSRF token and needs page access.
110
+
53
111
  ## Execute JavaScript
54
112
 
55
113
  APEX's *Execute JavaScript Code* runs code typed into the action. pgkiln's Content-Security-Policy
@@ -47,8 +47,9 @@ The builder and the runtime cover what APEX offers for its accounts:
47
47
  The `meta.set_password`/`expire`/`unexpire` functions are owner-only. Grant them to an app role if
48
48
  you want to build user administration pages inside an application.
49
49
 
50
- pgkiln doesn't send e-mail, so there is no self-service "forgot password" link (APEX apps don't
51
- have one out of the box either). A user who forgot their password asks an administrator, who
50
+ There is no self-service "forgot password" link (APEX apps don't have one out of the box either;
51
+ an application can build one with [e-mail](06-processing.md#sending-e-mail) and its own token
52
+ table). A user who forgot their password asks an administrator, who
52
53
  sets a temporary one under **Users → account → Set password**, with *Require change of password
53
54
  on first use* ticked (the default).
54
55
 
@@ -327,6 +328,16 @@ like for other users (an account of the same name, if there is one), or from the
327
328
  in authorization schemes. The login page has the user name and password form only: *Keep me signed
328
329
  in*, LDAP, single sign-on and the password-change form are not used by these apps.
329
330
 
331
+ ### Session timeout warning
332
+
333
+ Sessions end after the idle time (`SESSION_IDLE_MINUTES`, or Instance settings) and at their maximum
334
+ length (`SESSION_MAX_HOURS`). Signed-in users are warned before (Settings → *Errors and sessions* →
335
+ *Session timeout warning*, default 120 seconds; 0: no warning; APEX: Session Timeout Warning): a
336
+ dialog says when the session ends and offers **Stay signed in**, which marks the session active
337
+ (POST `/a/<alias>/session/extend`, with the session's CSRF token). At the maximum length the user can
338
+ only save their work; when the session has ended the dialog offers to sign in again and comes back
339
+ to the page.
340
+
330
341
  ### Keep me signed in
331
342
 
332
343
  Under **Settings → Sign-in methods**, *"Keep me signed in" for (days)* (1–365) adds a checkbox to the
@@ -13,6 +13,11 @@ triggers, your own functions).
13
13
  | `meta.jwt_claims()` | jsonb | The verified JWT claims of a PostgREST request, or NULL |
14
14
  | `meta.app_language()` | text | The language of the request (`nl`, `en-GB`, …) |
15
15
  | `meta.message(name, variadic params)` | text | A text message in the current language, `%0`…`%9` replaced; falls back to the base and primary language |
16
+ | `meta.lang(text, language default null)` | text | A [dynamic translation](14-globalization.md#dynamic-translations) of a value from a table in the session's (or the given) language, else the text (APEX: `APEX_LANG.LANG`) |
17
+ | `meta.get_app_setting(name)`, `meta.set_app_setting(name, value)` | text / void | An [application setting](06-processing.md#application-settings) of the current application (APEX: `APEX_APP_SETTING`); application code only |
18
+ | `meta.get_preference(name, user default null)`, `meta.set_preference(name, value, user default null)`, `meta.remove_preference(name, user default null)` | text / void | A [user preference](06-processing.md#user-preferences) of the signed-in (or the given) user in the current application (APEX: `APEX_UTIL.*_PREFERENCE`) |
19
+ | `meta.data_export(query, format default 'csv', options jsonb default '{}')` | bytea | A query's rows as a CSV, JSON, XML or HTML file, run with the caller's privileges ([data export](06-processing.md#data-export-from-sql); APEX: `APEX_DATA_EXPORT`) |
20
+ | `meta.csv_field(value, separator default ',', is_text default true)` | text | One CSV field: quoted when needed, a formula neutralised |
16
21
  | `meta.password_days_left(username)` | int | Days until the password expires (0: must change now, NULL: never) |
17
22
  | `meta.v(name)` | text | The session-state value of an item (use it inside functions and `DO` blocks) |
18
23
  | `meta.set_tenant(tenant)` | void | Sets (or with NULL or `''` clears) the current session's tenant (APEX: `apex_session.set_tenant_id`): at once and for the session's next requests. Workflows and tasks then carry it and show only to that tenant ([chapter 6](06-processing.md#tenants)) |
@@ -93,6 +98,22 @@ select meta.web_request_source('EXCHANGE', '{"currency": "EUR"}');
93
98
  - Application roles can't read `meta.web_request_log`; the owner can, e.g. in the SQL Workshop:
94
99
  `select id, status, status_code, url, message from meta.web_request_log order by id desc`.
95
100
 
101
+ ### E-mail from SQL
102
+
103
+ [Sending e-mail](06-processing.md#sending-e-mail) (APEX: `APEX_MAIL`). The application needs a mail
104
+ service (an administrator chooses it under Workspace utilities → Mail services).
105
+
106
+ | Function | Returns |
107
+ |---|---|
108
+ | `meta.send_mail(p_to, p_subject, p_body, p_body_html, p_from, p_cc, p_bcc, p_reply_to)` | Queues a mail and returns its id (APEX_MAIL.SEND). `p_to`, `p_cc`, `p_bcc`: addresses separated by commas or semicolons, at most 50 together (plain addresses, no display names); `p_subject`: one line, at most 998 characters; `p_body` (plain text) and/or `p_body_html`, each at most 1 MB; `p_from`: an address the service may send as (default: the application's); `p_reply_to`: one address |
109
+ | `meta.send_mail_template(p_template, p_placeholders jsonb, p_to, p_cc, p_bcc, p_from, p_reply_to)` | The same with an [e-mail template](06-processing.md#e-mail-templates) and its placeholder values (APEX_MAIL.SEND with `p_template_static_id`) |
110
+ | `meta.add_mail_attachment(p_mail_id, p_content bytea, p_filename, p_mime)` | Adds a file to a mail queued **in the same transaction** (APEX_MAIL.ADD_ATTACHMENT); at most 20 files and 20 MB per mail, 10 MB per file |
111
+ | `meta.mail_status(p_mail_id)` | `queued`, `sending`, `sent`, `error` or `cancelled`; NULL for another application's mail |
112
+
113
+ The mail is sent by the server **after the transaction commits**. Over the application's limit per
114
+ day `meta.send_mail` raises an error. Application roles can't read the queue; the owner can:
115
+ `select id, app_id, to_addresses, subject, status, attempts, message from meta.mail_queue order by id desc`.
116
+
96
117
  ### Push notifications from SQL
97
118
 
98
119
  APEX_PWA push notifications ([chapter 17](17-mobile.md#push-notifications)); the application needs
@@ -52,7 +52,11 @@ What's missing is listed in the [feature parity matrix](../apex-feature-parity.m
52
52
  | Pagination *Row Ranges X to Y*, *Maximum Row Count*, region *Lazy Loading*, *Server Cache* | `"pagination": "range"`, `max_rows`, `"lazy": true`, `"cache": {"scope", "seconds"}` ([large tables](04-pages-and-regions.md#large-tables)) |
53
53
  | `APEX_UTIL.CHANGE_CURRENT_USER_PW`, `RESET_PASSWORD`, `EXPIRE_END_USER_ACCOUNT` | My account page; `meta.set_password()`, `meta.expire_password()` ([chapter 8](08-security.md#passwords-and-my-account)) |
54
54
  | Progressive Web App push notifications: `APEX_PWA.SEND_PUSH_NOTIFICATION`, `HAS_PUSH_SUBSCRIPTION`, *Send Push Notification* process, the subscription settings page | `meta.send_push(user, title, body, page, items)`, `meta.has_push_subscription(user)`, process type `send_push`, *My account → Notifications* and the dynamic action `push_subscribe` ([chapter 17](17-mobile.md#push-notifications)) |
55
- | `APEX_MAIL`, Send E-Mail process, e-mail templates | Not included: pgkiln doesn't send mail. Queue mail in a table and deliver it with your own service, or use an extension such as `pg_smtp_client` ([chapter 15](15-extensions.md)) |
55
+ | `APEX_MAIL`, Send E-Mail process, e-mail templates | `meta.send_mail`, `meta.send_mail_template`, `meta.add_mail_attachment`, the `send_email` process and Shared Components → E-mail templates (`#NAME#` placeholders) ([chapter 6](06-processing.md#sending-e-mail)). Instead of the instance's SMTP settings: mail services with SMTP (password or OAuth2), Microsoft Graph, Amazon SES, Resend, Postmark, SendGrid or Mailgun. `APEX_MAIL.PUSH_QUEUE` is not needed: mail goes out right after the commit |
56
+ | `APEX_APP_SETTING`, `APEX_UTIL.GET/SET/REMOVE_PREFERENCE`, *Maintain session state: Per User* | Shared Components → Application settings and `meta.get_app_setting`/`meta.set_app_setting`; `meta.get_preference`/`set_preference`/`remove_preference`; `{"persist": "user"}` on an item ([chapter 6](06-processing.md#application-settings)) |
57
+ | `APEX_DATA_EXPORT.EXPORT` | `meta.data_export(query, format, options)` (CSV, JSON, XML, HTML) and the download process's `{"export": "xlsx"}` (also PDF) ([chapter 6](06-processing.md#data-export-from-sql)) |
58
+ | `APEX_LANG.LANG`, dynamic translations | Shared Components → Dynamic translations and `meta.lang(text)` ([chapter 14](14-globalization.md#dynamic-translations)) |
59
+ | Display Image, Geocoded Address items | Item types `image` and `address` (geocoder: `GEOCODER_URL`) ([chapter 5](05-items.md#display-image-and-geocoded-address)) |
56
60
  | Translated applications (XLIFF), `APEX_LANG.MESSAGE`, `&APP_TEXT$NAME.` | Translations in the app (XLIFF/CSV import and export), `meta.message()`, `&APP_TEXT$NAME.` ([chapter 14](14-globalization.md)) |
57
61
  | Application date format mask | Settings → Globalization → Date format (Oracle-style masks) |
58
62
  | Number format masks (`FML999G999G990D00`) on columns and items | `{"formats": {...}}` on report, grid and cards columns, `{"format_mask": "..."}` on charts and number/display items ([chapter 14](14-globalization.md#number-formats)) |