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.
- package/README.md +1 -1
- package/SECURITY.md +5 -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 +40 -11
- package/docs/guide/01-installation.md +1 -1
- package/docs/guide/03-builder.md +76 -3
- 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 +43 -8
- 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/hr/hr_70_data_security.sql +79 -0
- package/package.json +1 -1
- package/public/app.css +97 -23
- package/public/builder.css +50 -0
- 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/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/engine.ts +3 -1
- 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
package/src/builder/home.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
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;
|