pgkiln 0.52.0 → 0.54.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.
- package/README.md +1 -1
- package/SECURITY.md +6 -1
- package/db/migrations/125_app_change.sql +23 -0
- package/db/migrations/126_mail_queue_network_access.sql +119 -0
- package/docs/apex-feature-parity.md +41 -12
- package/docs/guide/01-installation.md +1 -1
- package/docs/guide/03-builder.md +116 -10
- package/docs/guide/06-processing.md +6 -1
- package/docs/guide/08-security.md +55 -0
- package/docs/guide/09-reference.md +3 -1
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +52 -11
- package/docs/guide/14-globalization.md +49 -1
- package/docs/guide/18-cli.md +21 -1
- package/docs/guide/19-rest-data-sources.md +12 -0
- package/docs/guide/20-ai-agents.md +8 -2
- package/examples/gallery/README.md +37 -6
- package/examples/gallery/gallery.json +23 -1
- package/examples/gallery/samples/sample-calendar.sql +172 -0
- package/examples/gallery/samples/sample-cards.sql +157 -0
- package/examples/gallery/samples/sample-charts.sql +265 -0
- package/examples/gallery/samples/sample-dynamic-actions.sql +177 -0
- package/examples/gallery/samples/sample-grids.sql +151 -0
- package/examples/gallery/samples/sample-reporting.sql +151 -0
- package/examples/gallery/samples/ux-patterns.sql +295 -0
- package/examples/gallery/utilities/announcements.json +24 -0
- package/examples/gallery/utilities/lookup-lists.json +28 -0
- package/examples/gallery/utilities/release-notes.json +41 -0
- package/examples/hr/hr_70_data_security.sql +79 -0
- package/package.json +1 -1
- package/public/app.css +99 -23
- package/public/builder.css +71 -1
- package/public/builder.js +4 -0
- package/src/app.ts +9 -0
- package/src/builder/app-updates.ts +55 -0
- package/src/builder/data-security.ts +182 -0
- package/src/builder/gallery.ts +94 -47
- package/src/builder/home.ts +16 -5
- package/src/builder/mail-queue.ts +212 -0
- package/src/builder/mail.ts +72 -45
- package/src/builder/network-access.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +1 -1
- package/src/builder/rest-catalogs.ts +19 -4
- package/src/builder/sql.ts +2 -0
- package/src/builder/themeroller.ts +47 -6
- package/src/builder/ui.ts +5 -0
- package/src/builder/websources.ts +1 -1
- package/src/builder/workspace-mail.ts +121 -0
- package/src/builder/workspaces.ts +1 -0
- package/src/cli/main.ts +25 -3
- package/src/cli/mcp.ts +40 -4
- package/src/cli/only.ts +52 -0
- package/src/cli/replace.ts +5 -1
- package/src/datasec.ts +343 -0
- package/src/mail/mail.ts +50 -11
- package/src/mail/providers.ts +6 -3
- package/src/metadata.ts +2 -0
- package/src/network-acl.ts +126 -0
- package/src/objectstore.ts +3 -0
- package/src/runtime/charts.ts +4 -1
- package/src/runtime/engine.ts +3 -1
- package/src/runtime/regions.ts +8 -5
- package/src/runtime/render.ts +4 -2
- package/src/runtime/styles.ts +54 -12
- package/src/webclient.ts +45 -4
- package/src/webrequests.ts +5 -0
- package/src/websources.ts +30 -12
|
@@ -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
|
+
}
|
package/src/builder/mail.ts
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
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="${
|
|
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="${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html, raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { parseHostList, scopeForWorkspace } from '../network-acl.ts';
|
|
6
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
7
|
+
import { matches, restHosts, scopeProblem, urlProblem } from '../webclient.ts';
|
|
8
|
+
import { clearResponseCache } from '../websources.ts';
|
|
9
|
+
import { isAdmin } from './locks.ts';
|
|
10
|
+
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
11
|
+
import { workspaceAdmin } from './workspaces.ts';
|
|
12
|
+
|
|
13
|
+
// (sprint 118, WS-9; APEX 26.2 workspace network ACLs) Administration →
|
|
14
|
+
// Network access: the installation's allow-list (PGKILN_REST_ALLOWED_HOSTS,
|
|
15
|
+
// read-only: the server's environment), a workspace's own network access list
|
|
16
|
+
// (meta.workspace.network_restricted / network_hosts, src/network-acl.ts),
|
|
17
|
+
// "would this URL be called?", and the recent refusals. Administrators see and
|
|
18
|
+
// change every workspace's list; a workspace administrator their current
|
|
19
|
+
// workspace's only.
|
|
20
|
+
|
|
21
|
+
const id = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
|
|
22
|
+
|
|
23
|
+
/** The workspace this developer may manage here (?ws=, else the current one; administrators: any), or null. */
|
|
24
|
+
async function manageable(s: Session, wanted: unknown): Promise<{ admin: boolean; workspace: number } | null> {
|
|
25
|
+
const admin = await isAdmin(s.username);
|
|
26
|
+
// ?ws= or the current workspace; a workspace administrator only one they administer
|
|
27
|
+
const ws = id(wanted) ?? s.workspace?.id ?? (admin ? 1 : null);
|
|
28
|
+
if (!ws || !(await workspaceAdmin(s.username, ws))) return null;
|
|
29
|
+
return { admin, workspace: ws };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function refuse(s: Session, reply: FastifyReply) {
|
|
33
|
+
const main = html`<h1 class="u-mb1">Network access</h1><div class="alert alert-error" role="alert">Only administrators and workspace administrators manage network access.</div>`;
|
|
34
|
+
await send(reply.code(403), s, shell(s, 'Network access', [['Administration'], ['Network access']], main, 'users'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
38
|
+
const entries = (list: string[]) => (list.length ? html`<ul class="host-list">${list.map((h) => html`<li><code>${h}</code></li>`)}</ul>` : html`<p class="muted">None.</p>`);
|
|
39
|
+
|
|
40
|
+
export async function networkAccessRoutes(app: FastifyInstance) {
|
|
41
|
+
app.get(`${BASE}/network`, async (req: Req, reply) => {
|
|
42
|
+
const s = await developer(req, reply);
|
|
43
|
+
if (!s) return;
|
|
44
|
+
const m = await manageable(s, req.query.ws);
|
|
45
|
+
if (!m) return refuse(s, reply);
|
|
46
|
+
const w = await owner.one<{ id: number; name: string; network_restricted: boolean; network_hosts: string[] }>(
|
|
47
|
+
'select id, name, network_restricted, network_hosts from meta.workspace where id = $1', [m.workspace]);
|
|
48
|
+
if (!w) return reply.code(404).send('Not found');
|
|
49
|
+
const inst = restHosts();
|
|
50
|
+
const [all, refusals] = await Promise.all([
|
|
51
|
+
m.admin ? owner.query('select id, name, network_restricted, cardinality(network_hosts)::int as n from meta.workspace order by id = 1 desc, lower(name)').then((r) => r.rows) : [],
|
|
52
|
+
owner.query(
|
|
53
|
+
`select r.at, r.what, r.host, r.url, r.list, r.message, a.alias from meta.network_refusal r left join meta.app a on a.id = r.app_id
|
|
54
|
+
where r.workspace_id = $1 order by r.id desc limit 100`, [w.id]).then((r) => r.rows),
|
|
55
|
+
]);
|
|
56
|
+
// entries the installation's list never lets through (they narrow nothing: worth a warning)
|
|
57
|
+
const unreachable = w.network_hosts.filter((h) => {
|
|
58
|
+
const host = h.replace(/^\*\./, 'x.');
|
|
59
|
+
try {
|
|
60
|
+
return !matches([...inst.allowed, ...inst.private], new URL(`https://${host}`)) && !inst.allowed.includes('*');
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
const tryUrl = String(req.query.url ?? '').trim().slice(0, 2000);
|
|
66
|
+
let verdict: { ok: boolean; text: string } | null = null;
|
|
67
|
+
if (tryUrl) {
|
|
68
|
+
let url: URL | null = null;
|
|
69
|
+
try {
|
|
70
|
+
url = new URL(tryUrl);
|
|
71
|
+
} catch {
|
|
72
|
+
verdict = { ok: false, text: 'The URL is not valid.' };
|
|
73
|
+
}
|
|
74
|
+
if (url) {
|
|
75
|
+
const scope = await scopeForWorkspace(w.id, 'check');
|
|
76
|
+
const problem = urlProblem(url) ?? scopeProblem(scope, url);
|
|
77
|
+
verdict = problem ? { ok: false, text: problem } : { ok: true, text: `${url.host} may be called by this workspace's applications (the address it resolves to is checked when the call is made).` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const main = html`
|
|
81
|
+
<div class="title-row"><h1>Network access</h1></div>
|
|
82
|
+
<p class="muted u-mt0">Which web services the applications may call: <code>meta.web_request</code>, REST data sources, the Invoke API process and workflow step, web credential token endpoints, object storage and REST source catalogs. The installation's list applies to every workspace; a workspace's own list can only narrow it, never widen it. Private and loopback addresses stay refused unless the installation allows them, whatever a workspace lists.</p>
|
|
83
|
+
<div class="columns wide-left">
|
|
84
|
+
${region(`Workspace ${w.name}`, html`<form method="post" action="${BASE}/network">${csrf(s)}<input type="hidden" name="ws" value="${w.id}">
|
|
85
|
+
<fieldset class="field"><legend class="label">Outgoing calls of this workspace's applications</legend><div class="radio-group">
|
|
86
|
+
<label class="check"><input type="radio" name="restricted" value="false"${w.network_restricted ? '' : raw(' checked')}> Any host the installation allows</label>
|
|
87
|
+
<label class="check"><input type="radio" name="restricted" value="true"${w.network_restricted ? raw(' checked') : ''}> Only the hosts below (and the installation must allow them too)</label>
|
|
88
|
+
</div></fieldset>
|
|
89
|
+
<div class="field"><label class="label" for="f_hosts">Hosts</label>
|
|
90
|
+
<textarea id="f_hosts" name="hosts" rows="6" spellcheck="false">${w.network_hosts.join('\n')}</textarea>
|
|
91
|
+
<small class="help">One per line: <code>api.example.com</code>, <code>*.example.com</code> for its subdomains, <code>api.example.com:8443</code> for one port only. No scheme or path. Restricted with no hosts: no outgoing calls at all.</small></div>
|
|
92
|
+
${unreachable.length ? html`<div class="alert alert-warning" role="status">Not on the installation's list, so never reachable: ${unreachable.join(', ')}.</div>` : ''}
|
|
93
|
+
<div class="buttons"><button class="btn btn-hot">Save</button></div></form>`, '', 'workspace-list')}
|
|
94
|
+
${region('Installation', html`<p class="muted u-mt0">Set in the server's environment by its administrators (<code>PGKILN_REST_ALLOWED_HOSTS</code>).</p>
|
|
95
|
+
${inst.allowed.includes('*') ? html`<p>Any public host (<code>*</code>).</p>` : entries(inst.allowed)}
|
|
96
|
+
${m.admin ? html`<h3 class="u-mt1">Private hosts</h3><p class="muted small">Hosts on private or loopback addresses that may be called (<code>PGKILN_REST_PRIVATE_HOSTS</code>).</p>${entries(inst.private)}` : ''}`)}
|
|
97
|
+
</div>
|
|
98
|
+
${region('Check a URL', html`<form method="get" action="${BASE}/network" class="search u-mwnone">
|
|
99
|
+
<input type="hidden" name="ws" value="${w.id}">
|
|
100
|
+
${input('url', 'URL', tryUrl, { type: 'url', placeholder: 'https://api.example.com/v1/items' })}
|
|
101
|
+
<div class="buttons"><button class="btn">${icon('search')} Check</button></div></form>
|
|
102
|
+
<div role="status">${verdict ? html`<div class="alert ${verdict.ok ? 'alert-success' : 'alert-error'}">${verdict.text}</div>` : ''}</div>
|
|
103
|
+
<p class="muted small">Nothing is called: this applies the lists to the URL as they are now.</p>`)}
|
|
104
|
+
${m.admin ? region('Workspaces', html`<div class="table-wrap"><table class="report report-reflow">
|
|
105
|
+
<thead><tr><th>Workspace</th><th>Outgoing calls</th></tr></thead>
|
|
106
|
+
<tbody>${all.map((x) => html`<tr><td data-label="Workspace"><a href="${BASE}/network?ws=${x.id}">${x.name}</a></td>
|
|
107
|
+
<td data-label="Outgoing calls">${x.network_restricted ? html`<span class="tag">${icon('lock')} ${x.n} host${x.n === 1 ? '' : 's'}</span>` : html`<span class="muted">the installation's list</span>`}</td></tr>`)}</tbody></table></div>`) : ''}
|
|
108
|
+
${region('Refused calls', html`<p class="muted u-mt0">The last 100 calls of this workspace that a list refused (kept 7 days). The application's debug log has them too.</p>
|
|
109
|
+
<div class="table-wrap"><table class="report report-reflow">
|
|
110
|
+
<thead><tr><th>When (UTC)</th><th>Application</th><th>Call</th><th>Host</th><th>Refused by</th></tr></thead>
|
|
111
|
+
<tbody>${refusals.length ? refusals.map((r) => html`<tr><td data-label="When">${when(r.at)}</td><td data-label="Application">${r.alias ?? html`<span class="muted">–</span>`}</td>
|
|
112
|
+
<td data-label="Call" class="u-break">${r.what}<br><span class="muted small">${r.url ?? ''}</span></td>
|
|
113
|
+
<td data-label="Host" class="u-break"><code>${r.host}</code></td>
|
|
114
|
+
<td data-label="Refused by">${r.list === 'workspace' ? 'the workspace\'s list' : 'the installation\'s list'}</td></tr>`)
|
|
115
|
+
: html`<tr><td colspan="5" class="empty">No refused calls.</td></tr>`}</tbody></table></div>`, '', 'refusals')}`;
|
|
116
|
+
return send(reply, s, shell(s, 'Network access', [['Administration'], ['Network access']], main, 'users'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
app.post(`${BASE}/network`, async (req: Req, reply) => {
|
|
120
|
+
const s = await developer(req, reply);
|
|
121
|
+
if (!s) return;
|
|
122
|
+
const m = await manageable(s, req.body?.ws);
|
|
123
|
+
if (!m) return refuse(s, reply);
|
|
124
|
+
const target = `${BASE}/network?ws=${m.workspace}`;
|
|
125
|
+
try {
|
|
126
|
+
const hosts = parseHostList(String(req.body?.hosts ?? ''));
|
|
127
|
+
const restricted = req.body?.restricted === 'true';
|
|
128
|
+
const r = await owner.query('update meta.workspace set network_restricted = $2, network_hosts = $3 where id = $1', [m.workspace, restricted, hosts]);
|
|
129
|
+
if (!r.rowCount) return reply.code(404).send('Not found');
|
|
130
|
+
// cached REST responses were fetched under the old list
|
|
131
|
+
clearResponseCache();
|
|
132
|
+
await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req),
|
|
133
|
+
detail: `network access of workspace ${m.workspace}: ${restricted ? `only ${hosts.join(', ') || 'no hosts'}` : 'the installation\'s list'}`.slice(0, 1000) });
|
|
134
|
+
flash(s, restricted ? `Saved: this workspace's applications call ${hosts.length ? 'only these hosts' : 'no hosts at all'}.` : 'Saved: the installation\'s list applies.');
|
|
135
|
+
} catch (e) {
|
|
136
|
+
flash(s, (e as Error).message, 'error');
|
|
137
|
+
}
|
|
138
|
+
return back(reply, s, target);
|
|
139
|
+
});
|
|
140
|
+
}
|