pgkiln 0.52.0 → 0.53.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 (53) hide show
  1. package/README.md +1 -1
  2. package/SECURITY.md +5 -1
  3. package/db/migrations/125_app_change.sql +23 -0
  4. package/db/migrations/126_mail_queue_network_access.sql +119 -0
  5. package/docs/apex-feature-parity.md +40 -11
  6. package/docs/guide/01-installation.md +1 -1
  7. package/docs/guide/03-builder.md +76 -3
  8. package/docs/guide/06-processing.md +6 -1
  9. package/docs/guide/08-security.md +55 -0
  10. package/docs/guide/09-reference.md +3 -1
  11. package/docs/guide/11-from-apex.md +1 -0
  12. package/docs/guide/12-development.md +43 -8
  13. package/docs/guide/14-globalization.md +49 -1
  14. package/docs/guide/18-cli.md +21 -1
  15. package/docs/guide/19-rest-data-sources.md +12 -0
  16. package/docs/guide/20-ai-agents.md +8 -2
  17. package/examples/hr/hr_70_data_security.sql +79 -0
  18. package/package.json +1 -1
  19. package/public/app.css +97 -23
  20. package/public/builder.css +50 -0
  21. package/public/builder.js +4 -0
  22. package/src/app.ts +9 -0
  23. package/src/builder/app-updates.ts +55 -0
  24. package/src/builder/data-security.ts +182 -0
  25. package/src/builder/home.ts +16 -5
  26. package/src/builder/mail-queue.ts +212 -0
  27. package/src/builder/mail.ts +72 -45
  28. package/src/builder/network-access.ts +140 -0
  29. package/src/builder/object-actions.ts +1 -0
  30. package/src/builder/object-browser.ts +1 -1
  31. package/src/builder/rest-catalogs.ts +19 -4
  32. package/src/builder/sql.ts +2 -0
  33. package/src/builder/themeroller.ts +47 -6
  34. package/src/builder/ui.ts +5 -0
  35. package/src/builder/websources.ts +1 -1
  36. package/src/builder/workspace-mail.ts +121 -0
  37. package/src/builder/workspaces.ts +1 -0
  38. package/src/cli/main.ts +25 -3
  39. package/src/cli/mcp.ts +40 -4
  40. package/src/cli/only.ts +52 -0
  41. package/src/cli/replace.ts +5 -1
  42. package/src/datasec.ts +343 -0
  43. package/src/mail/mail.ts +50 -11
  44. package/src/mail/providers.ts +6 -3
  45. package/src/metadata.ts +2 -0
  46. package/src/network-acl.ts +126 -0
  47. package/src/objectstore.ts +3 -0
  48. package/src/runtime/engine.ts +3 -1
  49. package/src/runtime/render.ts +4 -2
  50. package/src/runtime/styles.ts +54 -12
  51. package/src/webclient.ts +45 -4
  52. package/src/webrequests.ts +5 -0
  53. package/src/websources.ts +30 -12
@@ -20,7 +20,7 @@ const SORTS: Record<Sort, string> = {
20
20
  id: 'a.id',
21
21
  alias: 'a.alias',
22
22
  pages: 'pages desc, lower(a.name)',
23
- updated: 'a.updated_at desc nulls last, lower(a.name)',
23
+ updated: 'greatest(a.updated_at, c.updated_at) desc nulls last, lower(a.name)',
24
24
  };
25
25
  const RECENT_MAX = 8;
26
26
 
@@ -45,6 +45,10 @@ export function ago(at: Date | string | null | undefined, now = Date.now()) {
45
45
  return 'just now';
46
46
  }
47
47
 
48
+ /** (116) the App Builder list's working copies count and lock state */
49
+ const copiesText = (n: number) => `${n} working cop${n === 1 ? 'y' : 'ies'}`;
50
+ const lockedText = (by: string) => `Locked by ${by}`;
51
+
48
52
  const appIcon = (a: { id: number; name: string }, cls = '') =>
49
53
  html`<span class="app-icon app-color-${a.id % APP_COLORS}${cls ? ` ${cls}` : ''}" aria-hidden="true">${a.name.slice(0, 1).toUpperCase()}</span>`;
50
54
 
@@ -108,10 +112,14 @@ export async function homeRoutes(app: FastifyInstance) {
108
112
  const group = groups.find((g) => String(g.id) === s.state.__BGROUP) ?? null;
109
113
  const [apps, total, recent] = await Promise.all([
110
114
  owner.query(
111
- `select a.id, a.name, a.alias, a.authentication, a.db_role, a.updated_at, g.name as group_name,
115
+ `select a.id, a.name, a.alias, a.authentication, a.db_role, greatest(a.updated_at, c.updated_at) as updated_at, g.name as group_name,
116
+ c.updated_by,
117
+ (select count(*) from meta.working_copy w where w.main_app_id = a.id)::int as copies,
118
+ (select locked_by from meta.builder_lock k where k.app_id = a.id and k.page_no = 0) as locked_by,
112
119
  (select count(*) from meta.page p where p.app_id = a.id)::int as pages,
113
120
  (select count(*) from meta.activity_log l where l.app_id = a.id and l.event = 'page_view' and l.at > now() - interval '1 day')::int as views
114
121
  from meta.app a
122
+ left join meta.app_change c on c.app_id = a.id
115
123
  left join meta.app_group_member gm on gm.app_id = a.id
116
124
  left join meta.app_group g on g.id = gm.group_id
117
125
  where ($1 = '' or a.name ilike '%' || $1 || '%' or a.alias ilike '%' || $1 || '%' or a.id::text = $1) and ${inWorkspaceSql('a', 2)}
@@ -137,22 +145,25 @@ export async function homeRoutes(app: FastifyInstance) {
137
145
  : view === 'grid'
138
146
  ? html`<div class="ab-cards">${rows.map((a) => html`<article class="ab-card" data-filter-row>
139
147
  <a class="ab-card-main" href="${BASE}/apps/${a.id}">${appIcon(a)}<span><strong>${a.name}</strong><small>${a.id} · /a/${a.alias}</small></span></a>
140
- <div class="ab-card-meta">${a.group_name ? html`<span class="tag">${a.group_name}</span>` : ''}<span>${a.pages} pages</span><span>${a.views} views today</span><span>${ago(a.updated_at)}</span></div>
148
+ <div class="ab-card-meta">${a.group_name ? html`<span class="tag">${a.group_name}</span>` : ''}<span>${a.pages} pages</span><span>${a.views} views today</span><span>${ago(a.updated_at)}${a.updated_by ? html` by <span class="ab-by">${a.updated_by}</span>` : ''}</span></div>
149
+ ${a.copies || a.locked_by ? html`<div class="ab-card-badges">${a.copies ? html`<a class="tag" href="${BASE}/apps/${a.id}/working-copies">${icon('layers')} ${copiesText(a.copies)}</a>` : ''}${a.locked_by ? html`<span class="tag ab-locked" title="${lockedText(a.locked_by)}">${icon('lock')} <span class="ab-by">${lockedText(a.locked_by)}</span></span>` : ''}</div>` : ''}
141
150
  <div class="ab-card-actions">
142
151
  <a class="tb-btn" href="${BASE}/apps/${a.id}" title="Edit ${a.name}">${icon('edit')}<span class="sr-only">Edit ${a.name}</span></a>
143
152
  <a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="Run ${a.name}">${icon('play')}<span class="sr-only">Run ${a.name}</span></a>
144
153
  </div></article>`)}</div>`
145
154
  : html`<div class="table-wrap ab-table"><table class="report ab-apps">
146
155
  <caption class="sr-only">Applications</caption>
147
- <thead><tr>${th('id', 'Application', 'num')}${th('name', 'Name')}${th('alias', 'Alias')}${th('pages', 'Pages', 'num')}<th scope="col" class="num">Views (24h)</th><th scope="col">Sign-in</th>${th('updated', 'Updated')}<th scope="col" class="ab-actions-col">Actions</th></tr></thead>
156
+ <thead><tr>${th('id', 'Application', 'num')}${th('name', 'Name')}${th('alias', 'Alias')}${th('pages', 'Pages', 'num')}<th scope="col" class="num">Views (24h)</th><th scope="col">Sign-in</th>${th('updated', 'Updated')}<th scope="col">Updated by</th><th scope="col" class="num">Working copies</th><th scope="col" class="ab-actions-col">Actions</th></tr></thead>
148
157
  <tbody>${rows.map((a) => html`<tr data-filter-row>
149
158
  <td class="num">${a.id}</td>
150
- <td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a></td>
159
+ <td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a>${a.locked_by ? html`<span class="ab-lock" role="img" aria-label="${lockedText(a.locked_by)}" title="${lockedText(a.locked_by)}">${icon('lock')}</span>` : ''}</td>
151
160
  <td>${a.alias}${a.group_name ? html` <span class="tag">${a.group_name}</span>` : ''}</td>
152
161
  <td class="num">${a.pages}</td>
153
162
  <td class="num">${a.views}</td>
154
163
  <td>${a.authentication === 'none' ? 'Public' : a.authentication === 'header' ? 'HTTP header' : a.authentication === 'database' ? 'Database accounts' : a.authentication === 'custom' ? 'Custom' : 'App users'}</td>
155
164
  <td title="${a.updated_at ? new Date(a.updated_at).toISOString().slice(0, 16).replace('T', ' ') : ''}">${ago(a.updated_at)}</td>
165
+ <td class="ab-by">${a.updated_by ?? ''}</td>
166
+ <td class="num">${a.copies ? html`<a href="${BASE}/apps/${a.id}/working-copies" aria-label="${copiesText(a.copies)} of ${a.name}">${a.copies}</a>` : '0'}</td>
156
167
  <td class="ab-actions">
157
168
  <a class="tb-btn" href="${BASE}/apps/${a.id}" title="Edit ${a.name}">${icon('edit')}<span class="sr-only">Edit ${a.name}</span></a>
158
169
  <a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="Run ${a.name}">${icon('play')}<span class="sr-only">Run ${a.name}</span></a>
@@ -0,0 +1,212 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { clientIp, logActivity, type Session } from '../session.ts';
6
+ import { isAdmin } from './locks.ts';
7
+ import { back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
8
+ import { workspaceAdmin } from './workspaces.ts';
9
+
10
+ // (sprint 118, WS-9; APEX 26.2 Administration → Mail queue) Delivery health of
11
+ // the mail queue (meta.mail_queue): queued, sent, failed and retrying mail per
12
+ // hour and per workspace / application, the slowest provider calls, and the
13
+ // failures with the provider's answer, with Retry and Purge.
14
+ //
15
+ // Who sees what: administrators see every workspace (or one); a workspace
16
+ // administrator sees the mail of their current workspace's applications only.
17
+ // Every action checks the mail's workspace again on the server. Bodies and
18
+ // attachments are never shown here.
19
+
20
+ export const PERIODS: [string, string][] = [['24', 'Last 24 hours'], ['48', 'Last 48 hours'], ['168', 'Last 7 days']];
21
+
22
+ /** The mail a developer may see: every workspace (null) or one; false when none. */
23
+ export async function queueScope(s: Session): Promise<{ admin: boolean; workspace: number | null } | false> {
24
+ if (await isAdmin(s.username)) return { admin: true, workspace: null };
25
+ if (s.workspace && (await workspaceAdmin(s.username, s.workspace.id))) return { admin: false, workspace: s.workspace.id };
26
+ return false;
27
+ }
28
+
29
+ async function refuse(s: Session, reply: FastifyReply) {
30
+ const main = html`<h1 class="u-mb1">Mail queue</h1><div class="alert alert-error" role="alert">Only administrators and workspace administrators see the mail queue.</div>`;
31
+ await send(reply.code(403), s, shell(s, 'Mail queue', [['Administration'], ['Mail queue']], main, 'users'));
32
+ }
33
+
34
+ const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
35
+ const id = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
36
+ /** the scope condition on meta.mail_queue q: $1 workspace (null: all), $2 application (null: all) */
37
+ const IN_SCOPE = '($1::int is null or meta.app_workspace(q.app_id) = $1) and ($2::int is null or q.app_id = $2)';
38
+
39
+ export async function mailQueueRoutes(app: FastifyInstance) {
40
+ app.get(`${BASE}/mail-queue`, async (req: Req, reply) => {
41
+ const s = await developer(req, reply);
42
+ if (!s) return;
43
+ const scope = await queueScope(s);
44
+ if (!scope) return refuse(s, reply);
45
+ const hours = PERIODS.some(([v]) => v === req.query.period) ? Number(req.query.period) : 24;
46
+ const ws = scope.admin ? id(req.query.ws) : scope.workspace;
47
+ const appId = id(req.query.app);
48
+ const p = [ws, appId, hours];
49
+ const since = `now() - make_interval(hours => $3)`;
50
+ const [workspaces, apps, totals, perHour, usage, slowest, failures] = await Promise.all([
51
+ scope.admin ? owner.query<{ id: number; name: string }>('select id, name from meta.workspace order by id = 1 desc, lower(name)').then((r) => r.rows) : [],
52
+ owner.query<{ id: number; alias: string }>('select id, alias from meta.app a where $1::int is null or meta.app_workspace(a.id) = $1 order by alias', [ws]).then((r) => r.rows),
53
+ owner.one(
54
+ `select count(*) filter (where status = 'queued' and attempts = 0)::int as queued,
55
+ count(*) filter (where status = 'queued' and attempts > 0)::int as retrying,
56
+ count(*) filter (where status = 'sending')::int as sending,
57
+ count(*) filter (where status = 'sent' and sent_at >= ${since})::int as sent,
58
+ count(*) filter (where status = 'error' and coalesce(last_attempt_at, created_at) >= ${since})::int as failed,
59
+ round(avg(duration_ms) filter (where last_attempt_at >= ${since}))::int as avg_ms
60
+ from meta.mail_queue q where ${IN_SCOPE}`, p),
61
+ owner.query(
62
+ `select h as hour, count(*) filter (where k = 'queued')::int as queued, count(*) filter (where k = 'sent')::int as sent,
63
+ count(*) filter (where k = 'failed')::int as failed, count(*) filter (where k = 'retrying')::int as retrying
64
+ from (select date_trunc('hour', q.created_at) as h, 'queued' as k from meta.mail_queue q where ${IN_SCOPE} and q.created_at >= ${since}
65
+ union all select date_trunc('hour', q.sent_at), 'sent' from meta.mail_queue q where ${IN_SCOPE} and q.status = 'sent' and q.sent_at >= ${since}
66
+ union all select date_trunc('hour', coalesce(q.last_attempt_at, q.created_at)), 'failed' from meta.mail_queue q
67
+ where ${IN_SCOPE} and q.status = 'error' and coalesce(q.last_attempt_at, q.created_at) >= ${since}
68
+ union all select date_trunc('hour', q.last_attempt_at), 'retrying' from meta.mail_queue q
69
+ where ${IN_SCOPE} and q.status = 'queued' and q.attempts > 0 and q.last_attempt_at >= ${since}) e
70
+ group by h order by h desc`, p).then((r) => r.rows),
71
+ owner.query(
72
+ `select w.name as workspace, a.alias, a.name, q.app_id,
73
+ count(*) filter (where q.status = 'sent' and q.sent_at >= ${since})::int as sent,
74
+ count(*) filter (where q.status = 'error' and coalesce(q.last_attempt_at, q.created_at) >= ${since})::int as failed,
75
+ count(*) filter (where q.status = 'queued' and q.attempts > 0)::int as retrying,
76
+ count(*) filter (where q.status in ('queued', 'sending') and q.attempts = 0)::int as queued,
77
+ round(avg(q.duration_ms) filter (where q.last_attempt_at >= ${since}))::int as avg_ms,
78
+ max(q.duration_ms) filter (where q.last_attempt_at >= ${since}) as max_ms
79
+ from meta.mail_queue q join meta.app a on a.id = q.app_id join meta.workspace w on w.id = meta.app_workspace(q.app_id)
80
+ where ${IN_SCOPE} and (q.created_at >= ${since} or q.last_attempt_at >= ${since} or q.status in ('queued', 'sending'))
81
+ group by w.name, a.alias, a.name, q.app_id order by lower(w.name), a.alias`, p).then((r) => r.rows),
82
+ owner.query(
83
+ `select q.id, q.last_attempt_at, a.alias, q.service, q.provider, q.status, q.duration_ms, q.subject
84
+ from meta.mail_queue q join meta.app a on a.id = q.app_id
85
+ where ${IN_SCOPE} and q.last_attempt_at >= ${since} and q.duration_ms is not null
86
+ order by q.duration_ms desc, q.id desc limit 10`, p).then((r) => r.rows),
87
+ // failures are listed until they are retried or purged (kept 30 days), whatever the period
88
+ owner.query(
89
+ `select q.id, q.created_at, coalesce(q.last_attempt_at, q.created_at) as failed_at, a.alias, w.name as workspace, q.to_addresses, q.cc_addresses,
90
+ q.bcc_addresses, q.subject, q.service, q.provider, q.attempts, q.message
91
+ from meta.mail_queue q join meta.app a on a.id = q.app_id join meta.workspace w on w.id = meta.app_workspace(q.app_id)
92
+ where ($1::int is null or meta.app_workspace(q.app_id) = $1) and ($2::int is null or q.app_id = $2) and q.status = 'error'
93
+ order by q.id desc limit 100`, [ws, appId]).then((r) => r.rows),
94
+ ]);
95
+ const wsName = ws ? (scope.admin ? workspaces.find((w) => w.id === ws)?.name : s.workspace?.name) : null;
96
+ const filters = html`<form method="get" class="filter-row mail-queue-filters">
97
+ ${select('period', 'Period', String(hours), PERIODS)}
98
+ ${scope.admin ? select('ws', 'Workspace', ws ? String(ws) : '', [['', 'All workspaces'], ...workspaces.map((w): [string, string] => [String(w.id), w.name])]) : ''}
99
+ ${select('app', 'Application', appId ? String(appId) : '', [['', 'All applications'], ...apps.map((a): [string, string] => [String(a.id), a.alias])])}
100
+ <div class="buttons"><button class="btn">Show</button></div></form>`;
101
+ const total = totals.sent + totals.failed;
102
+ const health = total ? Math.round((totals.sent / total) * 1000) / 10 : null;
103
+ const stat = (n: unknown, label: string, cls = '') => html`<div class="stat${cls}"><b>${n ?? '–'}</b><span>${label}</span></div>`;
104
+ const keep = new URLSearchParams(Object.entries({ period: String(hours), ...(scope.admin && ws ? { ws: String(ws) } : {}), ...(appId ? { app: String(appId) } : {}) })).toString();
105
+ const action = (path: string, label: unknown, cls: string, confirm?: string) =>
106
+ html`<form method="post" action="${BASE}/mail-queue${path}?${keep}" class="inline-form">${csrf(s)}<button class="btn btn-sm${cls}"${confirm ? html` data-confirm="${confirm}"` : ''}>${label}</button></form>`;
107
+ const main = html`
108
+ <div class="title-row"><h1>Mail queue</h1></div>
109
+ <p class="muted u-mt0">Delivery of the mail ${wsName ? html`of workspace <b>${wsName}</b>` : 'of every workspace'}: what was queued, sent and failed, which mail is being retried, how long the providers took, and the failures with the provider's answer. Mail is kept 30 days.${scope.admin ? '' : ' You see your workspace\'s applications only.'}</p>
110
+ ${filters}
111
+ <div class="stat-grid mail-queue-stats">
112
+ ${stat(totals.queued, 'queued now')}${stat(totals.retrying, 'retrying now')}${stat(totals.sending, 'being sent')}
113
+ ${stat(totals.sent, 'sent')}${stat(totals.failed, 'failed', totals.failed ? ' stat-bad' : '')}
114
+ ${stat(health === null ? null : `${health}%`, 'delivered')}${stat(totals.avg_ms === null ? null : `${totals.avg_ms} ms`, 'average provider call')}
115
+ </div>
116
+ <div class="columns">
117
+ ${region('Per hour', html`<div class="table-wrap"><table class="report report-reflow">
118
+ <thead><tr><th>Hour (UTC)</th><th class="num">Queued</th><th class="num">Sent</th><th class="num">Failed</th><th class="num">Retrying</th></tr></thead>
119
+ <tbody>${perHour.length ? perHour.map((h) => html`<tr><td data-label="Hour">${when(h.hour)}</td><td class="num" data-label="Queued">${h.queued}</td>
120
+ <td class="num" data-label="Sent">${h.sent}</td><td class="num" data-label="Failed">${h.failed ? html`<b>${h.failed}</b>` : 0}</td><td class="num" data-label="Retrying">${h.retrying}</td></tr>`)
121
+ : html`<tr><td colspan="5" class="empty">No mail in this period.</td></tr>`}</tbody></table></div>`)}
122
+ ${region('Slowest provider calls', html`<div class="table-wrap"><table class="report report-reflow">
123
+ <thead><tr><th>When (UTC)</th><th>Application</th><th>Service</th><th>Status</th><th class="num">Time</th></tr></thead>
124
+ <tbody>${slowest.length ? slowest.map((m) => html`<tr><td data-label="When">${when(m.last_attempt_at)}</td><td data-label="Application">${m.alias}</td>
125
+ <td data-label="Service" class="u-break">${m.service ?? ''}${m.provider ? html` <span class="muted">${m.provider}</span>` : ''}</td>
126
+ <td data-label="Status"><span class="tag${m.status === 'sent' ? ' tag-ok' : m.status === 'error' ? ' tag-error' : ''}">${m.status === 'queued' ? 'retrying' : m.status}</span></td>
127
+ <td class="num" data-label="Time">${m.duration_ms} ms</td></tr>`)
128
+ : html`<tr><td colspan="5" class="empty">No provider calls in this period.</td></tr>`}</tbody></table></div>`)}
129
+ </div>
130
+ ${region('Per workspace and application', html`<div class="table-wrap"><table class="report report-reflow">
131
+ <thead><tr><th>Workspace</th><th>Application</th><th class="num">Sent</th><th class="num">Failed</th><th class="num">Retrying</th><th class="num">Queued</th><th class="num">Average</th><th class="num">Slowest</th></tr></thead>
132
+ <tbody>${usage.length ? usage.map((u) => html`<tr><td data-label="Workspace">${u.workspace}</td>
133
+ <td data-label="Application"><a href="${BASE}/mail-queue?${new URLSearchParams({ period: String(hours), ...(scope.admin && ws ? { ws: String(ws) } : {}), app: String(u.app_id) }).toString()}">${u.alias}</a></td>
134
+ <td class="num" data-label="Sent">${u.sent}</td><td class="num" data-label="Failed">${u.failed ? html`<b>${u.failed}</b>` : 0}</td>
135
+ <td class="num" data-label="Retrying">${u.retrying}</td><td class="num" data-label="Queued">${u.queued}</td>
136
+ <td class="num" data-label="Average">${u.avg_ms === null ? '–' : `${u.avg_ms} ms`}</td><td class="num" data-label="Slowest">${u.max_ms === null ? '–' : `${u.max_ms} ms`}</td></tr>`)
137
+ : html`<tr><td colspan="8" class="empty">No mail in this period.</td></tr>`}</tbody></table></div>`)}
138
+ ${region('Failures', html`<p class="muted u-mt0">Mail that will not be tried again: the provider refused it, or it failed ${5} times. <b>Retry</b> queues it again with fresh attempts; <b>Purge</b> deletes it.</p>
139
+ <div class="table-wrap"><table class="report report-reflow mail-failures">
140
+ <thead><tr><th>Failed (UTC)</th><th>Application</th><th>To</th><th>Subject</th><th>Provider's answer</th><th>Actions</th></tr></thead>
141
+ <tbody>${failures.length ? failures.map((m) => html`<tr>
142
+ <td data-label="Failed">${when(m.failed_at)}<br><span class="muted small">queued ${when(m.created_at)} · ${m.attempts} attempt${m.attempts === 1 ? '' : 's'}</span></td>
143
+ <td data-label="Application">${m.alias}${scope.admin && !ws ? html`<br><span class="muted small">${m.workspace}</span>` : ''}</td>
144
+ <td data-label="To" class="u-break">${[...m.to_addresses, ...m.cc_addresses].join(', ')}${m.bcc_addresses.length ? html` <span class="muted">+${m.bcc_addresses.length} bcc</span>` : ''}</td>
145
+ <td data-label="Subject" class="u-break">${m.subject}</td>
146
+ <td data-label="Provider's answer" class="u-break">${m.service ? html`<span class="muted small">${m.service}${m.provider ? ` (${m.provider})` : ''}</span><br>` : ''}<span class="mail-answer">${m.message ?? ''}</span></td>
147
+ <td data-label="Actions"><div class="buttons u-mt0">${action(`/${m.id}/retry`, 'Retry', '')}${action(`/${m.id}/purge`, 'Purge', ' btn-danger', 'Delete this mail?')}</div></td></tr>`)
148
+ : html`<tr><td colspan="6" class="empty">No failed mail.</td></tr>`}</tbody></table></div>
149
+ ${failures.length ? html`<div class="buttons">${action('/retry', html`${icon('refresh')} Retry all failed`, '')}${action('/purge', html`${icon('trash')} Purge all failed`, ' btn-danger', 'Delete every failed mail shown here?')}</div>` : ''}`,
150
+ '', 'failures')}`;
151
+ return send(reply, s, shell(s, 'Mail queue', [['Administration'], ['Mail queue']], main, 'users'));
152
+ });
153
+
154
+ /** One mail's id when this developer may act on it (its application is in their scope). */
155
+ const mine = async (s: Session, mailId: unknown) => {
156
+ const scope = await queueScope(s);
157
+ if (!scope) return null;
158
+ const m = await owner.one<{ id: string; app_id: number }>(
159
+ 'select q.id, q.app_id from meta.mail_queue q where q.id = $1 and ($2::int is null or meta.app_workspace(q.app_id) = $2)', [id(mailId), scope.workspace]);
160
+ return m;
161
+ };
162
+ const redirect = (req: Req) => {
163
+ const q = new URLSearchParams();
164
+ for (const k of ['period', 'ws', 'app']) if (id(req.query[k])) q.set(k, String(req.query[k]));
165
+ return `${BASE}/mail-queue${q.size ? `?${q}` : ''}#failures`;
166
+ };
167
+
168
+ app.post(`${BASE}/mail-queue/:mid(^\\d{1,12}$)/retry`, async (req: Req, reply) => {
169
+ const s = await developer(req, reply);
170
+ if (!s) return;
171
+ if (!(await queueScope(s))) return refuse(s, reply);
172
+ const m = await mine(s, req.params.mid);
173
+ if (!m) return reply.code(404).send('Not found');
174
+ const r = await owner.query(`update meta.mail_queue set status = 'queued', attempts = 0, not_before = now(), message = null where id = $1 and status in ('error', 'cancelled')`, [m.id]);
175
+ if (r.rowCount) await owner.query(`select pg_notify('pgkiln_mail', $1)`, [String(m.id)]);
176
+ await logActivity({ appId: m.app_id, username: s.username, event: 'mail_queue', ip: clientIp(req), detail: `retry mail ${m.id}` });
177
+ flash(s, r.rowCount ? 'The mail is queued again.' : 'The mail has changed meanwhile.', r.rowCount ? 'ok' : 'error');
178
+ return back(reply, s, redirect(req));
179
+ });
180
+
181
+ app.post(`${BASE}/mail-queue/:mid(^\\d{1,12}$)/purge`, async (req: Req, reply) => {
182
+ const s = await developer(req, reply);
183
+ if (!s) return;
184
+ if (!(await queueScope(s))) return refuse(s, reply);
185
+ const m = await mine(s, req.params.mid);
186
+ if (!m) return reply.code(404).send('Not found');
187
+ // mail that is waiting or being sent is cancelled on the application's Mail page, not purged
188
+ const r = await owner.query(`delete from meta.mail_queue where id = $1 and status in ('error', 'cancelled', 'sent')`, [m.id]);
189
+ await logActivity({ appId: m.app_id, username: s.username, event: 'mail_queue', ip: clientIp(req), detail: `purged mail ${m.id}` });
190
+ flash(s, r.rowCount ? 'The mail is deleted.' : 'Only sent, failed or cancelled mail can be purged.', r.rowCount ? 'ok' : 'error');
191
+ return back(reply, s, redirect(req));
192
+ });
193
+
194
+ // all failed mail in the developer's scope (and the filter's workspace and application)
195
+ for (const what of ['retry', 'purge'] as const)
196
+ app.post(`${BASE}/mail-queue/${what}`, async (req: Req, reply) => {
197
+ const s = await developer(req, reply);
198
+ if (!s) return;
199
+ const scope = await queueScope(s);
200
+ if (!scope) return refuse(s, reply);
201
+ const ws = scope.admin ? id(req.query.ws) : scope.workspace;
202
+ const appId = id(req.query.app);
203
+ const r = what === 'retry'
204
+ ? await owner.query(`update meta.mail_queue q set status = 'queued', attempts = 0, not_before = now(), message = null where ${IN_SCOPE} and q.status = 'error'`, [ws, appId])
205
+ : await owner.query(`delete from meta.mail_queue q where ${IN_SCOPE} and q.status = 'error'`, [ws, appId]);
206
+ if (what === 'retry' && r.rowCount) await owner.query(`select pg_notify('pgkiln_mail', '')`);
207
+ await logActivity({ appId: appId, username: s.username, event: 'mail_queue', ip: clientIp(req),
208
+ detail: `${what === 'retry' ? 'retried' : 'purged'} ${r.rowCount} failed mail(s)${ws ? ` of workspace ${ws}` : ''}${appId ? ` of application ${appId}` : ''}` });
209
+ flash(s, `${r.rowCount} mail${r.rowCount === 1 ? '' : 's'} ${what === 'retry' ? 'queued again' : 'deleted'}.`);
210
+ return back(reply, s, redirect(req));
211
+ });
212
+ }
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
3
  import { html, raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
- import { senderAllowed, sendWith, type MailService } from '../mail/mail.ts';
5
+ import { senderAllowed, serviceSettings, verifyService, type MailService } from '../mail/mail.ts';
6
6
  import { deliver, MailError, PROVIDER_LABELS, PROVIDERS, settingsProblems, verifyConnection, type Provider, type ServiceSettings } from '../mail/providers.ts';
7
7
  import { decryptSecret, encryptSecret, secretKeyConfigured } from '../secrets.ts';
8
8
  import { clientIp, logActivity, type Session } from '../session.ts';
@@ -31,7 +31,7 @@ const SECRET_LABEL: Record<Provider, string> = {
31
31
  };
32
32
 
33
33
  /** What each provider is, for the choice of a new service. */
34
- const PROVIDER_TEXT: Record<Provider, [icon: string, text: string]> = {
34
+ export const PROVIDER_TEXT: Record<Provider, [icon: string, text: string]> = {
35
35
  smtp: ['send', 'Any SMTP server or relay. Sign in with a password, or with OAuth2 (Exchange Online, Google Workspace).'],
36
36
  graph: ['building', 'Microsoft 365 through the Graph API, as an Entra application with Mail.Send.'],
37
37
  ses: ['database', 'Amazon Simple Email Service (API v2), with an IAM access key.'],
@@ -48,16 +48,21 @@ function providerChoice() {
48
48
  html`<a class="ab-tile" href="${BASE}/mail/new?provider=${p}"><span class="ab-tile-icon">${icon(PROVIDER_TEXT[p][0])}</span><strong>${PROVIDER_LABELS[p]}</strong><span>${PROVIDER_TEXT[p][1]}</span></a>`)}</div>`;
49
49
  }
50
50
 
51
- /** The settings of one provider only, in groups (a new service, or an existing one switching provider). */
52
- function serviceForm(x: Partial<MailService> & { has_secret?: boolean }, provider: Provider, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean,
53
- tried: { ok: boolean; text: string; to?: string; from?: string } | null = null) {
51
+ /**
52
+ * The settings of one provider only, in groups (a new service, or an existing one switching provider).
53
+ * (sprint 118) `ws`: a workspace's own service: no name (it is the workspace's), no base URL or token URL
54
+ * (the providers' own APIs only), and the checks go to the workspace's page.
55
+ */
56
+ export function serviceForm(x: Partial<MailService> & { has_secret?: boolean }, provider: Provider, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean,
57
+ tried: { ok: boolean; text: string; to?: string; from?: string } | null = null, ws: { id: number } | null = null) {
58
+ const checkAction = ws ? `${BASE}/workspaces/${ws.id}/mail/check` : `${BASE}/mail/check`;
54
59
  const s: ServiceSettings = x.provider === provider ? x.settings ?? {} : { default_from: x.settings?.default_from };
55
60
  const group = (legend: string, body: unknown, help?: string) =>
56
61
  html`<fieldset class="prop-group"><legend>${legend}</legend>${help ? html`<p class="muted u-mt0">${help}</p>` : ''}${body}</fieldset>`;
57
62
  const oauth = (scope: string) => html`<div class="form-grid">
58
63
  ${input('tenant_id', 'Tenant ID (Microsoft Entra)', s.tenant_id, { placeholder: 'contoso.onmicrosoft.com or its GUID' })}
59
64
  ${input('client_id', 'Client (application) ID', s.client_id)}
60
- ${input('token_url', 'Token URL', s.token_url, { placeholder: 'empty: Microsoft Entra of the tenant', help: 'Another identity provider\'s token endpoint (client credentials grant), e.g. Google\'s.' })}
65
+ ${ws ? '' : input('token_url', 'Token URL', s.token_url, { placeholder: 'empty: Microsoft Entra of the tenant', help: 'Another identity provider\'s token endpoint (client credentials grant), e.g. Google\'s.' })}
61
66
  ${input('scope', 'Scope', s.scope, { placeholder: `empty: ${scope}` })}
62
67
  </div>`;
63
68
  const settings =
@@ -92,11 +97,11 @@ function serviceForm(x: Partial<MailService> & { has_secret?: boolean }, provide
92
97
  help: x.has_secret && x.provider === provider ? 'A secret is stored (encrypted). It is never shown; type a new one to replace it.' : 'Stored encrypted; never shown again.' })}
93
98
  ${x.has_secret ? check('remove_secret', 'Remove the stored secret', false) : ''}
94
99
  ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">The server has no <code>PGKILN_SECRET_KEY</code>: secrets can't be stored until it is set.</div>`}`);
95
- const viaHttp = provider !== 'smtp' && provider !== 'log';
100
+ const viaHttp = provider !== 'smtp' && provider !== 'log' && !ws;
96
101
  return html`<form method="post" action="${action}" class="mail-service-form">${csrfField}
97
102
  <input type="hidden" name="provider" value="${provider}">
98
103
  ${group('Service', html`<div class="form-grid">
99
- ${isNew ? input('name', 'Name', '', { required: true, placeholder: 'e.g. OFFICE365', help: 'Upper case letters, digits and _.' }) : ''}
104
+ ${isNew && !ws ? input('name', 'Name', '', { required: true, placeholder: 'e.g. OFFICE365', help: 'Upper case letters, digits and _.' }) : ''}
100
105
  ${input('description', 'Description', x.description)}
101
106
  </div>${check('enabled', 'Enabled', x.enabled !== false, 'A disabled service sends nothing: mail stays queued until it is enabled, or fails after its retries.')}`)}
102
107
  ${group('Senders', html`<div class="form-grid">
@@ -110,20 +115,20 @@ function serviceForm(x: Partial<MailService> & { has_secret?: boolean }, provide
110
115
  ${input('base_url', 'API base URL', s.base_url, { placeholder: 'empty: the provider\'s own API', help: 'A gateway or proxy that speaks the provider\'s API. Administrators only.' })}</div></details>` : ''}
111
116
  ${group('Check before saving', html`
112
117
  ${x.id ? html`<input type="hidden" name="service_id" value="${x.id}">` : ''}
113
- <div class="buttons mail-check-buttons"><button class="btn" formaction="${BASE}/mail/check" formnovalidate name="check" value="connection">${icon('play')} Test connection</button></div>
118
+ <div class="buttons mail-check-buttons"><button class="btn" formaction="${checkAction}" formnovalidate name="check" value="connection">${icon('play')} Test connection</button></div>
114
119
  <div class="form-grid">
115
120
  ${input('test_to', 'Send a test mail to', tried?.to ?? '', { type: 'email', placeholder: 'you@example.com' })}
116
121
  ${input('test_from', 'From', tried?.from ?? s.default_from ?? (x.senders ?? []).find((a) => !a.startsWith('@')) ?? '', { type: 'email', help: 'One of the allowed senders.' })}
117
122
  </div>
118
- <div class="buttons mail-check-buttons"><button class="btn" formaction="${BASE}/mail/check" formnovalidate name="check" value="send">${icon('send')} Send test mail</button></div>
123
+ <div class="buttons mail-check-buttons"><button class="btn" formaction="${checkAction}" formnovalidate name="check" value="send">${icon('send')} Send test mail</button></div>
119
124
  <div class="mail-check-result" role="status" aria-live="polite">${tried ? html`<div class="alert ${tried.ok ? 'alert-success' : 'alert-error'}">${tried.text}</div>` : ''}</div>`,
120
125
  `Uses the settings above as they are filled in, without saving them.${x.has_secret ? ' An empty secret field uses the stored secret.' : ''} Test connection signs in (SMTP) or makes a read-only call with the key; a test mail is really sent.`)}
121
- <div class="buttons">${isNew ? html`<a class="btn" href="${BASE}/mail/new">Choose another provider</a>` : ''}<button class="btn btn-hot">${isNew ? 'Add service' : 'Save'}</button></div>
126
+ <div class="buttons">${isNew && !ws ? html`<a class="btn" href="${BASE}/mail/new">Choose another provider</a>` : ''}<button class="btn btn-hot">${isNew ? 'Add service' : 'Save'}</button></div>
122
127
  </form>`;
123
128
  }
124
129
 
125
130
  /** The form's values for the chosen provider, as typed (not checked). */
126
- function parseForm(b: Record<string, string | undefined>) {
131
+ export function parseForm(b: Record<string, string | undefined>) {
127
132
  const provider = (PROVIDERS as readonly string[]).includes(b.provider ?? '') ? (b.provider as Provider) : 'smtp';
128
133
  const t = (k: string) => (b[k] ?? '').trim() || undefined;
129
134
  const senders = [...new Set((b.senders ?? '').split(/[\s,;]+/).map((x) => x.trim().toLowerCase()).filter(Boolean))];
@@ -148,8 +153,13 @@ function parseForm(b: Record<string, string | undefined>) {
148
153
  * The form's values, checked; throws with a message. A check of the connection
149
154
  * (opts.connection) needs the provider's settings only, not yet the senders.
150
155
  */
151
- function formValues(b: Record<string, string | undefined>, hasSecret: boolean, opts: { connection?: boolean } = {}) {
156
+ export function formValues(b: Record<string, string | undefined>, hasSecret: boolean, opts: { connection?: boolean; workspace?: boolean } = {}) {
152
157
  const v = parseForm(b);
158
+ // (sprint 118) a workspace's own service: the providers' own APIs only
159
+ if (opts.workspace) {
160
+ delete v.settings.base_url;
161
+ delete v.settings.token_url;
162
+ }
153
163
  if (!opts.connection || v.senders.length) {
154
164
  if (!v.senders.length) throw new Error('Name at least one allowed sender (an address, or @domain).');
155
165
  const bad = v.senders.find((x) => !SENDER.test(x));
@@ -185,13 +195,49 @@ async function adminOnly(s: Session, reply: any, title: string) {
185
195
 
186
196
  const MAIL_COLUMNS = 'q.id, q.app_id, q.to_addresses, q.cc_addresses, q.bcc_addresses, q.subject, q.template, q.from_address, q.attempts, q.status, q.message, q.created_at, q.sent_at';
187
197
 
198
+ /**
199
+ * Test connection / Send test mail with the settings as filled in (the stored secret when none is typed).
200
+ * (sprint 118) `workspaceId`: a workspace's own service, with the checks of serviceSettings().
201
+ */
202
+ export async function runCheck(b: Record<string, string | undefined>, stored: MailService | null, username: string | null, workspaceId: number | null = null): Promise<{ ok: boolean; text: string }> {
203
+ const typed = parseForm(b);
204
+ const to = String(b.test_to ?? '').trim();
205
+ const from = String(b.test_from ?? '').trim();
206
+ try {
207
+ const sameProvider = stored?.provider === typed.provider;
208
+ const v = formValues(b, !!(sameProvider && stored?.secret_enc), { connection: b.check !== 'send', workspace: workspaceId !== null });
209
+ let secret: string | null = b.secret || null;
210
+ if (!secret && sameProvider && stored?.secret_enc) {
211
+ try {
212
+ secret = decryptSecret(stored.secret_enc);
213
+ } catch (e) {
214
+ throw new MailError(`The stored secret can't be read: ${(e as Error).message}`, false);
215
+ }
216
+ }
217
+ const svc = { name: stored?.name ?? 'new', provider: v.provider, settings: v.settings, workspace_id: workspaceId };
218
+ if (b.check === 'send') {
219
+ if (!ADDRESS.test(to)) throw new MailError('Give the address to send the test mail to.', false);
220
+ if (!ADDRESS.test(from) || !senderAllowed(v.senders, from)) throw new MailError('The From address must be one of the allowed senders.', false);
221
+ const pid = await deliver(v.provider, await serviceSettings(svc), secret, {
222
+ from, to: [to], cc: [], bcc: [], replyTo: null, subject: `pgkiln: test of a mail service (${PROVIDER_LABELS[v.provider]})`,
223
+ text: `This is a test of a pgkiln mail service${stored ? ` (${stored.name})` : ''}, sent from the App Builder by ${username}.`, html: null, attachments: [],
224
+ });
225
+ return { ok: true, text: `The test mail was handed to ${PROVIDER_LABELS[v.provider]}${pid ? ` (its id: ${pid})` : ''}. Check the mailbox of ${to}.` };
226
+ }
227
+ return { ok: true, text: await verifyService(svc, secret) };
228
+ } catch (e) {
229
+ return { ok: false, text: e instanceof MailError || !(e instanceof TypeError) ? (e as Error).message : 'The check failed.' };
230
+ }
231
+ }
232
+
188
233
  export async function mailRoutes(app: FastifyInstance) {
189
234
  app.get(`${BASE}/mail`, async (req: Req, reply) => {
190
235
  const s = await developer(req, reply);
191
236
  if (!s || !(await adminOnly(s, reply, 'Mail services'))) return;
192
237
  const services = (await owner.query(`select s.id, s.name, s.description, s.provider, s.senders, s.enabled, s.secret_enc is not null as has_secret,
193
- (select string_agg(a.alias, ', ' order by a.alias) from meta.app_mail_service x join meta.app a on a.id = x.app_id where x.service_id = s.id) as apps
194
- from meta.mail_service s order by s.name`)).rows;
238
+ (select string_agg(a.alias, ', ' order by a.alias) from meta.app_mail_service x join meta.app a on a.id = x.app_id where x.service_id = s.id) as apps,
239
+ (select w.name from meta.workspace w where w.id = s.workspace_id) as workspace
240
+ from meta.mail_service s order by s.workspace_id is not null, s.name`)).rows;
195
241
  const recent = (await owner.query(`select ${MAIL_COLUMNS}, a.alias as app from meta.mail_queue q left join meta.app a on a.id = q.app_id order by q.id desc limit 100`)).rows;
196
242
  const main = html`
197
243
  <div class="title-row"><h1>Mail services</h1></div>
@@ -203,7 +249,7 @@ export async function mailRoutes(app: FastifyInstance) {
203
249
  <td data-label="Provider">${PROVIDER_LABELS[x.provider as Provider]}</td>
204
250
  <td data-label="Senders" class="u-break">${x.senders.join(', ')}</td>
205
251
  <td data-label="Secret">${x.has_secret ? 'stored (encrypted)' : x.provider === 'log' ? '–' : html`<b>missing</b>`}</td>
206
- <td data-label="Applications">${x.apps ?? html`<span class="muted">none</span>`}</td>
252
+ <td data-label="Applications">${x.workspace ? html`<span class="tag">workspace ${x.workspace}</span> ` : ''}${x.apps ?? (x.workspace ? html`<span class="muted">its applications without a service of their own</span>` : html`<span class="muted">none</span>`)}</td>
207
253
  <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
208
254
  : html`<tr><td colspan="6" class="empty">No mail services yet.</td></tr>`}</tbody></table></div>`,
209
255
  html`<a class="btn btn-sm btn-hot" href="${BASE}/mail/new">${icon('plus')} Add service</a>`)}
@@ -275,12 +321,14 @@ export async function mailRoutes(app: FastifyInstance) {
275
321
  const s = await developer(req, reply);
276
322
  if (!s || !(await adminOnly(s, reply, 'Mail services'))) return;
277
323
  const x = await owner.one<MailService & { has_secret: boolean }>(
278
- 'select id, name, description, provider, settings, senders, enabled, secret_enc is not null as has_secret from meta.mail_service where id = $1', [req.params.id]);
324
+ 'select id, name, description, provider, settings, senders, enabled, workspace_id, secret_enc is not null as has_secret from meta.mail_service where id = $1', [req.params.id]);
279
325
  if (!x) return reply.code(404).send('Not found');
280
326
  const provider = (PROVIDERS as readonly string[]).includes(String(req.query.provider)) ? (req.query.provider as Provider) : x.provider;
327
+ // (sprint 118) a workspace's own service serves that workspace's applications only
281
328
  const apps = (await owner.query(`select a.id, a.alias, a.name, x.service_id, x.from_address, x.max_per_day, o.name as other
282
329
  from meta.app a left join meta.app_mail_service x on x.app_id = a.id left join meta.mail_service o on o.id = x.service_id and o.id <> $1
283
- order by a.alias`, [x.id])).rows;
330
+ where $2::int is null or meta.app_workspace(a.id) = $2
331
+ order by a.alias`, [x.id, x.workspace_id ?? null])).rows;
284
332
  const recent = (await owner.query(`select ${MAIL_COLUMNS}, a.alias as app from meta.mail_queue q left join meta.app a on a.id = q.app_id
285
333
  where q.service = $1 or q.app_id in (select app_id from meta.app_mail_service where service_id = $2) order by q.id desc limit 50`, [x.name, x.id])).rows;
286
334
  const main = html`
@@ -334,10 +382,11 @@ export async function mailRoutes(app: FastifyInstance) {
334
382
  const b = req.body as Record<string, string | string[] | undefined>;
335
383
  const allow = new Set([b.allow ?? []].flat().map(Number).filter(Number.isInteger));
336
384
  try {
337
- const service = await owner.one<{ senders: string[] }>('select senders from meta.mail_service where id = $1', [req.params.id]);
385
+ const service = await owner.one<{ senders: string[]; workspace_id: number | null }>('select senders, workspace_id from meta.mail_service where id = $1', [req.params.id]);
338
386
  if (!service) throw new Error('Not found.');
339
387
  await owner.tx(async (c) => {
340
- const apps = (await c.query<{ id: number }>('select id from meta.app')).rows.map((a) => a.id);
388
+ // (sprint 118) a workspace's own service: that workspace's applications only
389
+ const apps = (await c.query<{ id: number }>('select id from meta.app where $1::int is null or meta.app_workspace(id) = $1', [service.workspace_id])).rows.map((a) => a.id);
341
390
  await c.query('delete from meta.app_mail_service where service_id = $1 and not (app_id = any($2::int[]))', [req.params.id, [...allow]]);
342
391
  for (const id of apps.filter((a) => allow.has(a))) {
343
392
  const from = String(b[`from_${id}`] ?? '').trim() || null;
@@ -369,30 +418,7 @@ export async function mailRoutes(app: FastifyInstance) {
369
418
  const typed = parseForm(b);
370
419
  const to = String(b.test_to ?? '').trim();
371
420
  const from = String(b.test_from ?? '').trim();
372
- let result: { ok: boolean; text: string };
373
- try {
374
- const sameProvider = stored?.provider === typed.provider;
375
- const v = formValues(b, !!(sameProvider && stored?.secret_enc), { connection: b.check !== 'send' });
376
- let secret: string | null = b.secret || null;
377
- if (!secret && sameProvider && stored?.secret_enc) {
378
- try {
379
- secret = decryptSecret(stored.secret_enc);
380
- } catch (e) {
381
- throw new MailError(`The stored secret can't be read: ${(e as Error).message}`, false);
382
- }
383
- }
384
- if (b.check === 'send') {
385
- if (!ADDRESS.test(to)) throw new MailError('Give the address to send the test mail to.', false);
386
- if (!ADDRESS.test(from) || !senderAllowed(v.senders, from)) throw new MailError('The From address must be one of the allowed senders.', false);
387
- const pid = await deliver(v.provider, v.settings, secret, {
388
- from, to: [to], cc: [], bcc: [], replyTo: null, subject: `pgkiln: test of a mail service (${PROVIDER_LABELS[v.provider]})`,
389
- text: `This is a test of a pgkiln mail service${stored ? ` (${stored.name})` : ''}, sent from the App Builder by ${s.username}.`, html: null, attachments: [],
390
- });
391
- result = { ok: true, text: `The test mail was handed to ${PROVIDER_LABELS[v.provider]}${pid ? ` (its id: ${pid})` : ''}. Check the mailbox of ${to}.` };
392
- } else result = { ok: true, text: await verifyConnection(v.provider, v.settings, secret) };
393
- } catch (e) {
394
- result = { ok: false, text: e instanceof MailError || !(e instanceof TypeError) ? (e as Error).message : 'The check failed.' };
395
- }
421
+ const result = await runCheck(b, stored ?? null, s.username, stored?.workspace_id ?? null);
396
422
  await logActivity({ username: s.username, event: 'mail_check', ip: clientIp(req), detail: `${b.check === 'send' ? 'send' : 'connection'} ${typed.provider}${stored ? ` ${stored.name}` : ''}: ${result.ok ? 'ok' : 'failed'}` });
397
423
  if (String(req.headers.accept ?? '').includes('application/json')) return reply.header('cache-control', 'no-store').send(result);
398
424
  const x = { id: stored?.id, name: stored?.name, description: typed.description, provider: typed.provider, settings: typed.settings, senders: typed.senders, enabled: typed.enabled,
@@ -416,8 +442,9 @@ export async function mailRoutes(app: FastifyInstance) {
416
442
  if (!s) return;
417
443
  const a = await owner.one<{ id: number; alias: string; name: string }>('select id, alias, name from meta.app where id = $1', [req.params.id]);
418
444
  if (!a) return reply.code(404).send('Not found');
445
+ // the application's service, else (sprint 118) its workspace's own
419
446
  const service = await owner.one(`select s.name, s.provider, s.enabled, x.from_address, x.max_per_day, s.settings->>'default_from' as default_from
420
- from meta.app_mail_service x join meta.mail_service s on s.id = x.service_id where x.app_id = $1`, [a.id]);
447
+ from meta.mail_service s left join meta.app_mail_service x on x.app_id = $1 and x.service_id = s.id where s.id = meta.mail_service_for_app($1)`, [a.id]);
421
448
  const today = (await owner.one(`select count(*)::int as n from meta.mail_queue where app_id = $1 and created_at >= date_trunc('day', now() at time zone 'UTC') at time zone 'UTC' and status <> 'cancelled'`, [a.id])).n;
422
449
  const status = String(req.query.status ?? '');
423
450
  const rows = (await owner.query(`select ${MAIL_COLUMNS} from meta.mail_queue q where q.app_id = $1 and ($2 = '' or q.status = $2) order by q.id desc limit 200`, [a.id, status])).rows;