pgkiln 0.84.0 → 0.85.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/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/12-development.md +6 -1
- package/package.json +1 -1
- package/src/app.ts +9 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3453 -0
- package/src/builder/i18n/en.ts +3453 -0
- package/src/builder/i18n/es.ts +3453 -0
- package/src/builder/i18n/fr.ts +3453 -0
- package/src/builder/i18n/it.ts +3453 -0
- package/src/builder/i18n/ja.ts +3453 -0
- package/src/builder/i18n/ko.ts +3453 -0
- package/src/builder/i18n/nl.ts +3453 -0
- package/src/builder/i18n/pt.ts +3453 -0
- package/src/builder/i18n/zh-cn.ts +3453 -0
- package/src/builder/i18n/zh-tw.ts +3453 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +27 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
package/src/builder/mail.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { decryptSecret, encryptSecret, secretKeyConfigured } from '../secrets.ts
|
|
|
8
8
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
9
9
|
import { isAdmin } from './locks.ts';
|
|
10
10
|
import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
11
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
11
12
|
|
|
12
13
|
// Workspace utilities → Mail services (administrators): the mail services of
|
|
13
14
|
// this installation (SMTP with a password or OAuth2, Microsoft Graph, Amazon
|
|
@@ -25,27 +26,27 @@ const check = (name: string, label: string, on: boolean, help?: string) =>
|
|
|
25
26
|
html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label>${help ? html`<small class="help">${help}</small>` : ''}</div>`;
|
|
26
27
|
const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 19).replace('T', ' ') : '');
|
|
27
28
|
|
|
28
|
-
const SECRET_LABEL:
|
|
29
|
-
smtp: '
|
|
30
|
-
postmark: '
|
|
31
|
-
};
|
|
29
|
+
const SECRET_LABEL = (p: Provider): string => ({
|
|
30
|
+
smtp: tx('ml.password_or_the_client_secret'), graph: tx('ml.client_secret'), ses: tx('ml.secret_access_key'), resend: tx('ml.api_key'),
|
|
31
|
+
postmark: tx('ml.server_token'), sendgrid: tx('ml.api_key'), mailgun: tx('ml.api_key'), log: tx('ml.secret_not_used'),
|
|
32
|
+
})[p];
|
|
32
33
|
|
|
33
34
|
/** What each provider is, for the choice of a new service. */
|
|
34
|
-
export const PROVIDER_TEXT:
|
|
35
|
-
smtp: ['send', '
|
|
36
|
-
graph: ['building', '
|
|
37
|
-
ses: ['database', '
|
|
38
|
-
resend: ['send', '
|
|
39
|
-
postmark: ['send', '
|
|
40
|
-
sendgrid: ['send', '
|
|
41
|
-
mailgun: ['send', '
|
|
42
|
-
log: ['list', '
|
|
43
|
-
};
|
|
35
|
+
export const PROVIDER_TEXT = (p: Provider): [icon: string, text: string] => ({
|
|
36
|
+
smtp: ['send', tx('ml.any_smtp_server_or_relay')],
|
|
37
|
+
graph: ['building', tx('ml.microsoft_365_through_the_graph')],
|
|
38
|
+
ses: ['database', tx('ml.amazon_simple_email_service_api')],
|
|
39
|
+
resend: ['send', tx('ml.resend_s_http_api_with')],
|
|
40
|
+
postmark: ['send', tx('ml.postmark_s_http_api_with')],
|
|
41
|
+
sendgrid: ['send', tx('ml.sendgrid_s_http_api_with')],
|
|
42
|
+
mailgun: ['send', tx('ml.mailgun_s_http_api_with')],
|
|
43
|
+
log: ['list', tx('ml.for_development_and_demos_the')],
|
|
44
|
+
} as Record<Provider, [string, string]>)[p];
|
|
44
45
|
|
|
45
46
|
/** Step 1 of a new service: choose the provider. */
|
|
46
47
|
function providerChoice() {
|
|
47
48
|
return html`<div class="ab-tiles ab-tiles-wrap">${PROVIDERS.map((p) =>
|
|
48
|
-
html`<a class="ab-tile" href="${BASE}/mail/new?provider=${p}"><span class="ab-tile-icon">${icon(PROVIDER_TEXT
|
|
49
|
+
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
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
@@ -60,70 +61,70 @@ export function serviceForm(x: Partial<MailService> & { has_secret?: boolean },
|
|
|
60
61
|
const group = (legend: string, body: unknown, help?: string) =>
|
|
61
62
|
html`<fieldset class="prop-group"><legend>${legend}</legend>${help ? html`<p class="muted u-mt0">${help}</p>` : ''}${body}</fieldset>`;
|
|
62
63
|
const oauth = (scope: string) => html`<div class="form-grid">
|
|
63
|
-
${input('tenant_id', '
|
|
64
|
-
${input('client_id', '
|
|
65
|
-
${ws ? '' : input('token_url', '
|
|
66
|
-
${input('scope', '
|
|
64
|
+
${input('tenant_id', tx('ml.tenant_id_microsoft_entra'), s.tenant_id, { placeholder: tx('ml.contoso_onmicrosoft_com_or_its') })}
|
|
65
|
+
${input('client_id', tx('ml.client_application_id'), s.client_id)}
|
|
66
|
+
${ws ? '' : input('token_url', tx('ml.token_url'), s.token_url, { placeholder: tx('ml.empty_microsoft_entra_of_the'), help: tx('ml.another_identity_provider_s_token') })}
|
|
67
|
+
${input('scope', tx('ml.scope'), s.scope, { placeholder: `empty: ${scope}` })}
|
|
67
68
|
</div>`;
|
|
68
69
|
const settings =
|
|
69
70
|
provider === 'smtp'
|
|
70
|
-
? html`${group('
|
|
71
|
-
${input('host', '
|
|
72
|
-
${input('port', '
|
|
73
|
-
${select('security', '
|
|
74
|
-
${select('auth', '
|
|
71
|
+
? html`${group(tx('ml.smtp_server'), html`<div class="form-grid">
|
|
72
|
+
${input('host', tx('ml.host'), s.host, { required: true, placeholder: 'smtp.office365.com' })}
|
|
73
|
+
${input('port', tx('ml.port'), s.port ?? '', { type: 'number', placeholder: '587', help: tx('ml.empty_587_starttls_or_465') })}
|
|
74
|
+
${select('security', tx('ml.security'), s.security ?? 'starttls', [['starttls', tx('ml.starttls_required')], ['tls', tx('ml.tls_from_the_start_port')], ['none', tx('ml.none_a_local_relay_only')]])}
|
|
75
|
+
${select('auth', tx('ml.sign_in'), s.auth ?? 'password', [['password', tx('ml.user_name_and_password')], ['oauth2', tx('ml.oauth2_xoauth2_client_credentials')], ['none', tx('ml.none')]],
|
|
75
76
|
'Microsoft 365 no longer takes passwords for SMTP: choose OAuth2 with an Entra application (SMTP.SendAsApp), or the Microsoft 365 (Graph) provider.')}
|
|
76
|
-
${input('username', '
|
|
77
|
-
</div>${check('tls_verify', '
|
|
78
|
-
<details class="prop-group prop-group-more"${s.auth === 'oauth2' ? raw(' open') : ''}><summary>OAuth2 sign-in <span class="muted"
|
|
77
|
+
${input('username', tx('ml.user_name'), s.username, { placeholder: tx('ml.the_mailbox_e_g_noreply') })}
|
|
78
|
+
</div>${check('tls_verify', tx('ml.check_the_server_s_certificate'), s.tls_verify !== false, tx('ml.switch_off_only_for_a'))}`)}
|
|
79
|
+
<details class="prop-group prop-group-more"${s.auth === 'oauth2' ? raw(' open') : ''}><summary>OAuth2 sign-in <span class="muted">${tx('ml.only_with_sign_in_oauth2')}</span></summary>${oauth('https://outlook.office365.com/.default')}</details>`
|
|
79
80
|
: provider === 'graph'
|
|
80
|
-
? group('
|
|
81
|
-
${check('save_to_sent_items', '
|
|
81
|
+
? group(tx('ml.microsoft_entra_application'), html`${oauth('https://graph.microsoft.com/.default')}
|
|
82
|
+
${check('save_to_sent_items', tx('ml.keep_a_copy_in_the'), s.save_to_sent_items === true)}`,
|
|
82
83
|
'An application in Microsoft Entra with a client secret and the application permission Mail.Send, limited to the sending mailboxes.')
|
|
83
84
|
: provider === 'ses'
|
|
84
|
-
? group('
|
|
85
|
-
${input('region', '
|
|
86
|
-
${input('access_key_id', '
|
|
85
|
+
? group(tx('ml.amazon_ses'), html`<div class="form-grid">
|
|
86
|
+
${input('region', tx('ml.aws_region'), s.region, { required: true, placeholder: tx('ml.eu_west_1') })}
|
|
87
|
+
${input('access_key_id', tx('ml.access_key_id'), s.access_key_id, { required: true })}</div>`,
|
|
87
88
|
'An IAM user allowed ses:SendEmail; the From addresses must be verified identities in SES.')
|
|
88
89
|
: provider === 'mailgun'
|
|
89
|
-
? group('
|
|
90
|
-
${input('domain', '
|
|
91
|
-
${select('mailgun_region', '
|
|
90
|
+
? group(tx('ml.mailgun'), html`<div class="form-grid">
|
|
91
|
+
${input('domain', tx('ml.sending_domain'), s.domain, { required: true, placeholder: 'mg.example.com' })}
|
|
92
|
+
${select('mailgun_region', tx('ml.region'), s.mailgun_region ?? 'us', [['us', 'US'], ['eu', 'EU']])}</div>`)
|
|
92
93
|
: provider === 'postmark'
|
|
93
|
-
? group('
|
|
94
|
+
? group(tx('ml.postmark'), html`<div class="form-grid">${input('message_stream', tx('ml.message_stream'), s.message_stream, { placeholder: 'outbound' })}</div>`)
|
|
94
95
|
: '';
|
|
95
|
-
const secret = provider === 'log' ? '' : group(SECRET_LABEL
|
|
96
|
-
${input('secret', SECRET_LABEL
|
|
97
|
-
help: x.has_secret && x.provider === provider ? 'A secret is stored (encrypted). It is never shown; type a new one to replace it.' : '
|
|
98
|
-
${x.has_secret ? check('remove_secret', '
|
|
99
|
-
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert"
|
|
96
|
+
const secret = provider === 'log' ? '' : group(SECRET_LABEL(provider), html`
|
|
97
|
+
${input('secret', SECRET_LABEL(provider), '', { type: 'password', auto: 'new-password',
|
|
98
|
+
help: x.has_secret && x.provider === provider ? 'A secret is stored (encrypted). It is never shown; type a new one to replace it.' : tx('ml.stored_encrypted_never_shown_again') })}
|
|
99
|
+
${x.has_secret ? check('remove_secret', tx('ml.remove_the_stored_secret'), false) : ''}
|
|
100
|
+
${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">${tx.code('ml.no_secret_key')}</div>`}`);
|
|
100
101
|
const viaHttp = provider !== 'smtp' && provider !== 'log' && !ws;
|
|
101
102
|
return html`<form method="post" action="${action}" class="mail-service-form">${csrfField}
|
|
102
103
|
<input type="hidden" name="provider" value="${provider}">
|
|
103
|
-
${group('
|
|
104
|
-
${isNew && !ws ? input('name', '
|
|
105
|
-
${input('description', '
|
|
106
|
-
</div>${check('enabled', '
|
|
107
|
-
${group('
|
|
108
|
-
${input('senders', '
|
|
109
|
-
help: '
|
|
110
|
-
${input('default_from', '
|
|
104
|
+
${group(tx('ml.service'), html`<div class="form-grid">
|
|
105
|
+
${isNew && !ws ? input('name', tx('ml.name'), '', { required: true, placeholder: tx('ml.e_g_office365'), help: tx('ml.upper_case_letters_digits_and') }) : ''}
|
|
106
|
+
${input('description', tx('ml.description'), x.description)}
|
|
107
|
+
</div>${check('enabled', tx('ml.enabled'), x.enabled !== false, 'A disabled service sends nothing: mail stays queued until it is enabled, or fails after its retries.')}`)}
|
|
108
|
+
${group(tx('ml.senders'), html`<div class="form-grid">
|
|
109
|
+
${input('senders', tx('ml.allowed_senders'), (x.senders ?? []).join(', '), { required: true, placeholder: tx('ml.noreply_example_com_example_com'),
|
|
110
|
+
help: tx('ml.the_from_addresses_this_service') })}
|
|
111
|
+
${input('default_from', tx('ml.default_from_address'), s.default_from, { placeholder: tx('ml.noreply_example_com'), help: tx('ml.for_applications_that_set_none') })}
|
|
111
112
|
</div>`)}
|
|
112
113
|
${settings}
|
|
113
114
|
${secret}
|
|
114
|
-
${viaHttp ? html`<details class="prop-group prop-group-more"${s.base_url ? raw(' open') : ''}><summary
|
|
115
|
-
${input('base_url', '
|
|
116
|
-
${group('
|
|
115
|
+
${viaHttp ? html`<details class="prop-group prop-group-more"${s.base_url ? raw(' open') : ''}><summary>${tx('ml.advanced')}</summary><div class="form-grid">
|
|
116
|
+
${input('base_url', tx('ml.api_base_url'), s.base_url, { placeholder: tx('ml.empty_the_provider_s_own'), help: tx('ml.a_gateway_or_proxy_that') })}</div></details>` : ''}
|
|
117
|
+
${group(tx('ml.check_before_saving'), html`
|
|
117
118
|
${x.id ? html`<input type="hidden" name="service_id" value="${x.id}">` : ''}
|
|
118
|
-
<div class="buttons mail-check-buttons"><button class="btn" formaction="${checkAction}" formnovalidate name="check" value="connection">${icon('play')}
|
|
119
|
+
<div class="buttons mail-check-buttons"><button class="btn" formaction="${checkAction}" formnovalidate name="check" value="connection">${icon('play')} ${tx('ml.test_connection')}</button></div>
|
|
119
120
|
<div class="form-grid">
|
|
120
|
-
${input('test_to', '
|
|
121
|
-
${input('test_from', '
|
|
121
|
+
${input('test_to', tx('ml.send_a_test_mail_to'), tried?.to ?? '', { type: 'email', placeholder: tx('ml.you_example_com') })}
|
|
122
|
+
${input('test_from', tx('ml.from'), tried?.from ?? s.default_from ?? (x.senders ?? []).find((a) => !a.startsWith('@')) ?? '', { type: 'email', help: tx('ml.one_of_the_allowed_senders') })}
|
|
122
123
|
</div>
|
|
123
|
-
<div class="buttons mail-check-buttons"><button class="btn" formaction="${checkAction}" formnovalidate name="check" value="send">${icon('send')}
|
|
124
|
+
<div class="buttons mail-check-buttons"><button class="btn" formaction="${checkAction}" formnovalidate name="check" value="send">${icon('send')} ${tx('ml.send_test_mail')}</button></div>
|
|
124
125
|
<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>`,
|
|
125
|
-
|
|
126
|
-
<div class="buttons">${isNew && !ws ? html`<a class="btn" href="${BASE}/mail/new"
|
|
126
|
+
x.has_secret ? tx('ml.check_help_secret') : tx('ml.check_help'))}
|
|
127
|
+
<div class="buttons">${isNew && !ws ? html`<a class="btn" href="${BASE}/mail/new">${tx('ml.choose_another_provider')}</a>` : ''}<button class="btn btn-hot">${isNew ? tx('ml.add_service') : tx('ml.save')}</button></div>
|
|
127
128
|
</form>`;
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -161,11 +162,11 @@ export function formValues(b: Record<string, string | undefined>, hasSecret: boo
|
|
|
161
162
|
delete v.settings.token_url;
|
|
162
163
|
}
|
|
163
164
|
if (!opts.connection || v.senders.length) {
|
|
164
|
-
if (!v.senders.length) throw new Error('
|
|
165
|
+
if (!v.senders.length) throw new Error(tx('ml.name_at_least_one_allowed'));
|
|
165
166
|
const bad = v.senders.find((x) => !SENDER.test(x));
|
|
166
167
|
if (bad) throw new Error(`"${bad}" is not an address or @domain.`);
|
|
167
168
|
const from = v.settings.default_from;
|
|
168
|
-
if (from && (!ADDRESS.test(from) || !senderAllowed(v.senders, from))) throw new Error('
|
|
169
|
+
if (from && (!ADDRESS.test(from) || !senderAllowed(v.senders, from))) throw new Error(tx('ml.the_default_from_address_must'));
|
|
169
170
|
}
|
|
170
171
|
const problems = settingsProblems(v.provider, v.settings, hasSecret || !!b.secret);
|
|
171
172
|
if (problems.length) throw new Error(problems.join(' '));
|
|
@@ -175,21 +176,21 @@ export function formValues(b: Record<string, string | undefined>, hasSecret: boo
|
|
|
175
176
|
const STATUS_CLASS: Record<string, string> = { sent: ' tag-ok', queued: '', sending: '', error: ' tag-error', cancelled: '' };
|
|
176
177
|
|
|
177
178
|
const mailTable = (rows: any[], withApp: boolean, actions?: (m: any) => unknown) => html`<div class="table-wrap"><table class="report report-reflow">
|
|
178
|
-
<thead><tr><th
|
|
179
|
+
<thead><tr><th>${tx('ml.queued_utc')}</th>${withApp ? html`<th>${tx('ml.application')}</th>` : ''}<th>${tx('ml.to')}</th><th>${tx('ml.subject')}</th><th>${tx('ml.from')}</th><th class="num">${tx('ml.attempts')}</th><th>${tx('ml.status')}</th>${actions ? html`<th>${tx('ml.actions')}</th>` : ''}</tr></thead>
|
|
179
180
|
<tbody>${rows.length ? rows.map((m) => html`<tr>
|
|
180
|
-
<td data-label="
|
|
181
|
-
<td data-label="
|
|
182
|
-
<td data-label="
|
|
183
|
-
<td data-label="
|
|
184
|
-
<td class="num" data-label="
|
|
185
|
-
<td data-label="
|
|
186
|
-
${actions ? html`<td data-label="
|
|
187
|
-
: html`<tr><td colspan="${7 + (withApp ? 1 : 0) + (actions ? 1 : 0)}" class="empty"
|
|
181
|
+
<td data-label="${tx('ml.queued')}">${when(m.created_at)}</td>${withApp ? html`<td data-label="${tx('ml.application')}">${m.app ?? ''}</td>` : ''}
|
|
182
|
+
<td data-label="${tx('ml.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>
|
|
183
|
+
<td data-label="${tx('ml.subject')}" class="u-break">${m.subject}${m.template ? html` <span class="muted">(${m.template})</span>` : ''}</td>
|
|
184
|
+
<td data-label="${tx('ml.from')}" class="u-break">${m.from_address ?? ''}</td>
|
|
185
|
+
<td class="num" data-label="${tx('ml.attempts')}">${m.attempts}</td>
|
|
186
|
+
<td data-label="${tx('ml.status')}"><span class="tag${STATUS_CLASS[m.status] ?? ''}">${m.status}</span>${m.sent_at ? html` <span class="muted">${when(m.sent_at)}</span>` : ''}${m.message ? html`<br><span class="muted u-break">${m.message}</span>` : ''}</td>
|
|
187
|
+
${actions ? html`<td data-label="${tx('ml.actions')}">${actions(m)}</td>` : ''}</tr>`)
|
|
188
|
+
: html`<tr><td colspan="${7 + (withApp ? 1 : 0) + (actions ? 1 : 0)}" class="empty">${tx('ml.no_mail_yet')}</td></tr>`}</tbody></table></div>`;
|
|
188
189
|
|
|
189
190
|
async function adminOnly(s: Session, reply: any, title: string) {
|
|
190
191
|
if (await isAdmin(s.username)) return true;
|
|
191
|
-
const main = html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert"
|
|
192
|
-
await send(reply.code(403), s, shell(s, title, [['
|
|
192
|
+
const main = html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">${tx('ml.only_administrators_configure_mail_services')}</div>`;
|
|
193
|
+
await send(reply.code(403), s, shell(s, title, [[tx('ml.app_builder'), BASE], [tx('ml.workspace_utilities'), `${BASE}/utilities`], [title]], main));
|
|
193
194
|
return false;
|
|
194
195
|
}
|
|
195
196
|
|
|
@@ -211,73 +212,73 @@ export async function runCheck(b: Record<string, string | undefined>, stored: Ma
|
|
|
211
212
|
try {
|
|
212
213
|
secret = decryptSecret(stored.secret_enc);
|
|
213
214
|
} catch (e) {
|
|
214
|
-
throw new MailError(
|
|
215
|
+
throw new MailError(tx('ml.secret_unreadable', { error: (e as Error).message }), false);
|
|
215
216
|
}
|
|
216
217
|
}
|
|
217
218
|
const svc = { name: stored?.name ?? 'new', provider: v.provider, settings: v.settings, workspace_id: workspaceId };
|
|
218
219
|
if (b.check === 'send') {
|
|
219
|
-
if (!ADDRESS.test(to)) throw new MailError('
|
|
220
|
-
if (!ADDRESS.test(from) || !senderAllowed(v.senders, from)) throw new MailError('
|
|
220
|
+
if (!ADDRESS.test(to)) throw new MailError(tx('ml.test_to_missing'), false);
|
|
221
|
+
if (!ADDRESS.test(from) || !senderAllowed(v.senders, from)) throw new MailError(tx('ml.from_not_allowed'), false);
|
|
221
222
|
const pid = await deliver(v.provider, await serviceSettings(svc), secret, {
|
|
222
223
|
from, to: [to], cc: [], bcc: [], replyTo: null, subject: `pgkiln: test of a mail service (${PROVIDER_LABELS[v.provider]})`,
|
|
223
224
|
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
|
});
|
|
225
|
-
return { ok: true, text:
|
|
226
|
+
return { ok: true, text: pid ? tx('ml.test_handed_id', { provider: PROVIDER_LABELS[v.provider], id: pid, to }) : tx('ml.test_handed', { provider: PROVIDER_LABELS[v.provider], to }) };
|
|
226
227
|
}
|
|
227
228
|
return { ok: true, text: await verifyService(svc, secret) };
|
|
228
229
|
} catch (e) {
|
|
229
|
-
return { ok: false, text: e instanceof MailError || !(e instanceof TypeError) ? (e as Error).message : '
|
|
230
|
+
return { ok: false, text: e instanceof MailError || !(e instanceof TypeError) ? (e as Error).message : tx('ml.the_check_failed') };
|
|
230
231
|
}
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
export async function mailRoutes(app: FastifyInstance) {
|
|
234
235
|
app.get(`${BASE}/mail`, async (req: Req, reply) => {
|
|
235
236
|
const s = await developer(req, reply);
|
|
236
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
237
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
237
238
|
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,
|
|
238
239
|
(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
240
|
(select w.name from meta.workspace w where w.id = s.workspace_id) as workspace
|
|
240
241
|
from meta.mail_service s order by s.workspace_id is not null, s.name`)).rows;
|
|
241
242
|
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;
|
|
242
243
|
const main = html`
|
|
243
|
-
<div class="title-row"><h1
|
|
244
|
-
<p class="muted u-mt0"
|
|
245
|
-
${region('
|
|
246
|
-
<thead><tr><th
|
|
244
|
+
<div class="title-row"><h1>${tx('ml.mail_services')}</h1></div>
|
|
245
|
+
<p class="muted u-mt0">${tx.code('ml.intro', { process: tx('ml.send_e_mail') })}</p>
|
|
246
|
+
${region(tx('ml.services'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
247
|
+
<thead><tr><th>${tx('ml.service')}</th><th>${tx('ml.provider')}</th><th>${tx('ml.senders')}</th><th>${tx('ml.secret')}</th><th>${tx('ml.applications')}</th><th>${tx('ml.status')}</th></tr></thead>
|
|
247
248
|
<tbody>${services.length ? services.map((x) => html`<tr>
|
|
248
|
-
<td data-label="
|
|
249
|
-
<td data-label="
|
|
250
|
-
<td data-label="
|
|
251
|
-
<td data-label="
|
|
252
|
-
<td data-label="
|
|
253
|
-
<td data-label="
|
|
254
|
-
: html`<tr><td colspan="6" class="empty"
|
|
255
|
-
html`<a class="btn btn-sm btn-hot" href="${BASE}/mail/new">${icon('plus')}
|
|
249
|
+
<td data-label="${tx('ml.service')}"><a href="${BASE}/mail/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
|
|
250
|
+
<td data-label="${tx('ml.provider')}">${PROVIDER_LABELS[x.provider as Provider]}</td>
|
|
251
|
+
<td data-label="${tx('ml.senders')}" class="u-break">${x.senders.join(', ')}</td>
|
|
252
|
+
<td data-label="${tx('ml.secret')}">${x.has_secret ? 'stored (encrypted)' : x.provider === 'log' ? '–' : html`<b>missing</b>`}</td>
|
|
253
|
+
<td data-label="${tx('ml.applications')}">${x.workspace ? html`<span class="tag">workspace ${x.workspace}</span> ` : ''}${x.apps ?? (x.workspace ? html`<span class="muted">${tx('ml.its_applications_without_a_service')}</span>` : html`<span class="muted">none</span>`)}</td>
|
|
254
|
+
<td data-label="${tx('ml.status')}">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
|
|
255
|
+
: html`<tr><td colspan="6" class="empty">${tx('ml.no_mail_services_yet')}</td></tr>`}</tbody></table></div>`,
|
|
256
|
+
html`<a class="btn btn-sm btn-hot" href="${BASE}/mail/new">${icon('plus')} ${tx('ml.add_service')}</a>`)}
|
|
256
257
|
${await builderMailRegion(s, services)}
|
|
257
|
-
${region('
|
|
258
|
-
return send(reply, s, shell(s, '
|
|
258
|
+
${region(tx('ml.recent_mail'), mailTable(recent, true))}`;
|
|
259
|
+
return send(reply, s, shell(s, tx('ml.mail_services'), [[tx('ml.app_builder'), BASE], [tx('ml.workspace_utilities'), `${BASE}/utilities`], [tx('ml.mail_services')]], main));
|
|
259
260
|
});
|
|
260
261
|
|
|
261
262
|
// (096) the builder's own mail ("Find my workspace"): a service and a From address it may send as
|
|
262
263
|
app.post(`${BASE}/mail/builder`, async (req: Req, reply) => {
|
|
263
264
|
const s = await developer(req, reply);
|
|
264
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
265
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
265
266
|
const name = String(req.body?.service ?? '');
|
|
266
267
|
const from = String(req.body?.from ?? '').trim();
|
|
267
268
|
try {
|
|
268
269
|
if (!name) await owner.query(`delete from meta.setting where name in ('builder_mail_service', 'builder_mail_from')`);
|
|
269
270
|
else {
|
|
270
271
|
const svc = await owner.one<{ senders: string[] }>('select senders from meta.mail_service where name = $1', [name]);
|
|
271
|
-
if (!svc) throw new Error('
|
|
272
|
-
if (!/^[^@\s<>"]+@[^@\s<>"]+$/.test(from)) throw new Error('
|
|
273
|
-
if (!senderAllowed(svc.senders, from)) throw new Error(
|
|
272
|
+
if (!svc) throw new Error(tx('ml.choose_one_of_the_mail'));
|
|
273
|
+
if (!/^[^@\s<>"]+@[^@\s<>"]+$/.test(from)) throw new Error(tx('ml.the_from_address_is_an'));
|
|
274
|
+
if (!senderAllowed(svc.senders, from)) throw new Error(tx('ml.may_not_send_as', { name, from }));
|
|
274
275
|
await owner.tx(async (c) => {
|
|
275
276
|
for (const [k, v] of [['builder_mail_service', name], ['builder_mail_from', from]])
|
|
276
277
|
await c.query('insert into meta.setting (name, value) values ($1, $2) on conflict (name) do update set value = excluded.value', [k, v]);
|
|
277
278
|
});
|
|
278
279
|
}
|
|
279
280
|
await logActivity({ appId: null, username: s.username, event: 'mail_service', ip: clientIp(req), detail: name ? `builder mail: ${name} as ${from}` : 'builder mail: none' });
|
|
280
|
-
flash(s, name ? '
|
|
281
|
+
flash(s, name ? tx('ml.builder_mail_saved_find_my') : tx('ml.the_builder_sends_no_mail'));
|
|
281
282
|
} catch (e) {
|
|
282
283
|
flash(s, (e as Error).message, 'error');
|
|
283
284
|
}
|
|
@@ -287,29 +288,29 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
287
288
|
// a new service: choose the provider, then its settings only
|
|
288
289
|
app.get(`${BASE}/mail/new`, async (req: Req, reply) => {
|
|
289
290
|
const s = await developer(req, reply);
|
|
290
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
291
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
291
292
|
const provider = (PROVIDERS as readonly string[]).includes(String(req.query.provider)) ? (req.query.provider as Provider) : null;
|
|
292
|
-
const main = html`<div class="title-row"><h1
|
|
293
|
+
const main = html`<div class="title-row"><h1>${tx('ml.add_a_mail_service')}</h1></div>
|
|
293
294
|
${provider
|
|
294
|
-
? html`<p class="muted u-mt0">Step 2 of 2: <b>${PROVIDER_LABELS[provider]}</b>. ${PROVIDER_TEXT
|
|
295
|
-
<div class="mail-form-page">${region('
|
|
296
|
-
: html`<p class="muted u-mt0"
|
|
297
|
-
return send(reply, s, shell(s, '
|
|
295
|
+
? html`<p class="muted u-mt0">Step 2 of 2: <b>${PROVIDER_LABELS[provider]}</b>. ${PROVIDER_TEXT(provider)[1]}</p>
|
|
296
|
+
<div class="mail-form-page">${region(tx('ml.settings'), serviceForm({}, provider, `${BASE}/mail`, csrf(s), true))}</div>`
|
|
297
|
+
: html`<p class="muted u-mt0">${tx('ml.step_1_of_2_how')}</p>${providerChoice()}`}`;
|
|
298
|
+
return send(reply, s, shell(s, tx('ml.add_a_mail_service'), [[tx('ml.app_builder'), BASE], [tx('ml.workspace_utilities'), `${BASE}/utilities`], [tx('ml.mail_services'), `${BASE}/mail`], [tx('ml.add')]], main));
|
|
298
299
|
});
|
|
299
300
|
|
|
300
301
|
app.post(`${BASE}/mail`, async (req: Req, reply) => {
|
|
301
302
|
const s = await developer(req, reply);
|
|
302
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
303
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
303
304
|
const b = req.body ?? {};
|
|
304
305
|
try {
|
|
305
306
|
const name = (b.name ?? '').trim().toUpperCase();
|
|
306
|
-
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(name)) throw new Error('
|
|
307
|
+
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(name)) throw new Error(tx('ml.the_name_is_upper_case'));
|
|
307
308
|
const v = formValues(b, false);
|
|
308
309
|
const secret = b.secret ? encryptSecret(b.secret) : null;
|
|
309
310
|
const r = await owner.one(
|
|
310
311
|
`insert into meta.mail_service (name, description, provider, settings, senders, enabled, secret_enc) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
311
312
|
[name, v.description, v.provider, JSON.stringify(v.settings), v.senders, v.enabled, secret]);
|
|
312
|
-
flash(s, '
|
|
313
|
+
flash(s, tx('ml.mail_service_added_choose_the'));
|
|
313
314
|
return back(reply, s, `${BASE}/mail/${r.id}`);
|
|
314
315
|
} catch (e) {
|
|
315
316
|
flash(s, (e as Error).message.includes('mail_service_name_key') ? 'A service with this name exists already.' : (e as Error).message, 'error');
|
|
@@ -319,7 +320,7 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
319
320
|
|
|
320
321
|
app.get(`${BASE}/mail/:id(^\\d+$)`, async (req: Req, reply) => {
|
|
321
322
|
const s = await developer(req, reply);
|
|
322
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
323
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
323
324
|
const x = await owner.one<MailService & { has_secret: boolean }>(
|
|
324
325
|
'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]);
|
|
325
326
|
if (!x) return reply.code(404).send('Not found');
|
|
@@ -334,34 +335,34 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
334
335
|
const main = html`
|
|
335
336
|
<div class="title-row"><h1 class="mail-name">${x.name}</h1></div>
|
|
336
337
|
<div class="mail-form-page">
|
|
337
|
-
${region('
|
|
338
|
-
<form method="get" class="provider-switch">${select('provider', '
|
|
339
|
-
provider !== x.provider ? '
|
|
338
|
+
${region(tx('ml.settings'), html`
|
|
339
|
+
<form method="get" class="provider-switch">${select('provider', tx('ml.provider'), provider, PROVIDERS.map((p): [string, string] => [p, PROVIDER_LABELS[p]]),
|
|
340
|
+
provider !== x.provider ? tx('ml.not_saved_yet_fill_in') : undefined)}<button class="btn btn-sm">${tx('ml.change')}</button></form>
|
|
340
341
|
${serviceForm(x, provider, `${BASE}/mail/${x.id}`, csrf(s), false)}
|
|
341
342
|
<form method="post" action="${BASE}/mail/${x.id}/delete" class="danger-zone">${csrf(s)}
|
|
342
|
-
<button class="btn btn-danger" data-confirm="
|
|
343
|
+
<button class="btn btn-danger" data-confirm="${tx('ml.delete_confirm', { name: x.name })}">${tx('ml.delete_service')}</button></form>`)}
|
|
343
344
|
</div>
|
|
344
|
-
${region('
|
|
345
|
+
${region(tx('ml.applications'), html`<p class="muted u-mt0">${tx('ml.the_applications_that_send_through')}</p>
|
|
345
346
|
<form method="post" action="${BASE}/mail/${x.id}/apps">${csrf(s)}
|
|
346
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
347
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('ml.application')}</th><th>${tx('ml.sends_through_this_service')}</th><th>${tx('ml.from_address')}</th><th>${tx('ml.mails_per_day')}</th></tr></thead>
|
|
347
348
|
<tbody>${apps.length ? apps.map((a) => html`<tr>
|
|
348
|
-
<td data-label="
|
|
349
|
-
<td data-label="
|
|
350
|
-
<td data-label="
|
|
351
|
-
<td data-label="
|
|
352
|
-
: html`<tr><td colspan="4" class="empty"
|
|
353
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
354
|
-
${region('
|
|
355
|
-
return send(reply, s, shell(s, x.name, [['
|
|
349
|
+
<td data-label="${tx('ml.application')}">${a.name} <span class="muted">${a.alias}</span></td>
|
|
350
|
+
<td data-label="${tx('ml.sends_through_this_service')}"><label class="check"><input type="checkbox" name="allow" value="${a.id}"${a.service_id === x.id ? raw(' checked') : ''}> yes</label>${a.other ? html` <span class="muted">now: ${a.other}</span>` : ''}</td>
|
|
351
|
+
<td data-label="${tx('ml.from_address')}"><input type="email" name="from_${a.id}" value="${a.service_id === x.id ? a.from_address ?? '' : ''}" aria-label="${tx('ml.aria_from', { app: a.alias })}"></td>
|
|
352
|
+
<td data-label="${tx('ml.mails_per_day')}"><input type="number" min="0" name="max_${a.id}" value="${a.service_id === x.id ? a.max_per_day ?? '' : ''}" aria-label="${tx('ml.aria_max', { app: a.alias })}"></td></tr>`)
|
|
353
|
+
: html`<tr><td colspan="4" class="empty">${tx('ml.no_applications')}</td></tr>`}</tbody></table></div>
|
|
354
|
+
<div class="buttons"><button class="btn btn-hot">${tx('ml.save')}</button></div></form>`)}
|
|
355
|
+
${region(tx('ml.recent_mail'), mailTable(recent, true))}`;
|
|
356
|
+
return send(reply, s, shell(s, x.name, [[tx('ml.app_builder'), BASE], [tx('ml.workspace_utilities'), `${BASE}/utilities`], [tx('ml.mail_services'), `${BASE}/mail`], [x.name]], main));
|
|
356
357
|
});
|
|
357
358
|
|
|
358
359
|
app.post(`${BASE}/mail/:id(^\\d+$)`, async (req: Req, reply) => {
|
|
359
360
|
const s = await developer(req, reply);
|
|
360
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
361
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
361
362
|
const b = req.body ?? {};
|
|
362
363
|
try {
|
|
363
364
|
const old = await owner.one<{ has_secret: boolean }>('select secret_enc is not null as has_secret from meta.mail_service where id = $1', [req.params.id]);
|
|
364
|
-
if (!old) throw new Error('
|
|
365
|
+
if (!old) throw new Error(tx('ml.not_found'));
|
|
365
366
|
const v = formValues(b, old.has_secret && b.remove_secret !== 'true');
|
|
366
367
|
const secret = b.secret ? encryptSecret(b.secret) : null;
|
|
367
368
|
await owner.query(
|
|
@@ -369,7 +370,7 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
369
370
|
secret_enc = case when $8 then null when $7::text is null then secret_enc else $7 end
|
|
370
371
|
where id = $1`,
|
|
371
372
|
[req.params.id, v.description, v.provider, JSON.stringify(v.settings), v.senders, v.enabled, secret, b.remove_secret === 'true' && !b.secret]);
|
|
372
|
-
flash(s, '
|
|
373
|
+
flash(s, tx('ml.mail_service_saved'));
|
|
373
374
|
} catch (e) {
|
|
374
375
|
flash(s, (e as Error).message, 'error');
|
|
375
376
|
}
|
|
@@ -378,12 +379,12 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
378
379
|
|
|
379
380
|
app.post(`${BASE}/mail/:id(^\\d+$)/apps`, async (req: Req, reply) => {
|
|
380
381
|
const s = await developer(req, reply);
|
|
381
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
382
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
382
383
|
const b = req.body as Record<string, string | string[] | undefined>;
|
|
383
384
|
const allow = new Set([b.allow ?? []].flat().map(Number).filter(Number.isInteger));
|
|
384
385
|
try {
|
|
385
386
|
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]);
|
|
386
|
-
if (!service) throw new Error('
|
|
387
|
+
if (!service) throw new Error(tx('ml.not_found'));
|
|
387
388
|
await owner.tx(async (c) => {
|
|
388
389
|
// (sprint 118) a workspace's own service: that workspace's applications only
|
|
389
390
|
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);
|
|
@@ -392,14 +393,14 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
392
393
|
const from = String(b[`from_${id}`] ?? '').trim() || null;
|
|
393
394
|
if (from && (!ADDRESS.test(from) || !senderAllowed(service.senders, from))) throw new Error(`${from} is not an address this service may send as.`);
|
|
394
395
|
const max = String(b[`max_${id}`] ?? '').trim();
|
|
395
|
-
if (max && !(Number.isInteger(Number(max)) && Number(max) >= 0)) throw new Error('
|
|
396
|
+
if (max && !(Number.isInteger(Number(max)) && Number(max) >= 0)) throw new Error(tx('ml.mails_per_day_a_whole'));
|
|
396
397
|
await c.query(
|
|
397
398
|
`insert into meta.app_mail_service (app_id, service_id, from_address, max_per_day) values ($1, $2, $3, $4)
|
|
398
399
|
on conflict (app_id) do update set service_id = excluded.service_id, from_address = excluded.from_address, max_per_day = excluded.max_per_day`,
|
|
399
400
|
[id, req.params.id, from, max ? Number(max) : null]);
|
|
400
401
|
}
|
|
401
402
|
});
|
|
402
|
-
flash(s, '
|
|
403
|
+
flash(s, tx('ml.applications_saved'));
|
|
403
404
|
} catch (e) {
|
|
404
405
|
flash(s, (e as Error).message, 'error');
|
|
405
406
|
}
|
|
@@ -410,7 +411,7 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
410
411
|
// JSON for builder.js; without JavaScript the form comes back with its values (the secret must be typed again).
|
|
411
412
|
app.post(`${BASE}/mail/check`, async (req: Req, reply) => {
|
|
412
413
|
const s = await developer(req, reply);
|
|
413
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
414
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
414
415
|
const b = (req.body ?? {}) as Record<string, string | undefined>;
|
|
415
416
|
const id = /^\d{1,9}$/.test(b.service_id ?? '') ? Number(b.service_id) : null;
|
|
416
417
|
const stored = id ? await owner.one<MailService>('select * from meta.mail_service where id = $1', [id]) : null;
|
|
@@ -423,16 +424,16 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
423
424
|
if (String(req.headers.accept ?? '').includes('application/json')) return reply.header('cache-control', 'no-store').send(result);
|
|
424
425
|
const x = { id: stored?.id, name: stored?.name, description: typed.description, provider: typed.provider, settings: typed.settings, senders: typed.senders, enabled: typed.enabled,
|
|
425
426
|
has_secret: !!(stored?.secret_enc && stored.provider === typed.provider) };
|
|
426
|
-
const main = html`<div class="title-row"><h1>${stored ? stored.name : '
|
|
427
|
-
<div class="mail-form-page">${region('
|
|
428
|
-
return send(reply, s, shell(s, '
|
|
427
|
+
const main = html`<div class="title-row"><h1>${stored ? stored.name : tx('ml.add_a_mail_service')}</h1></div>
|
|
428
|
+
<div class="mail-form-page">${region(tx('ml.settings'), serviceForm(x, typed.provider, stored ? `${BASE}/mail/${stored.id}` : `${BASE}/mail`, csrf(s), !stored, { ...result, to, from }))}</div>`;
|
|
429
|
+
return send(reply, s, shell(s, tx('ml.mail_service'), [[tx('ml.app_builder'), BASE], [tx('ml.workspace_utilities'), `${BASE}/utilities`], [tx('ml.mail_services'), `${BASE}/mail`], [stored?.name ?? tx('ml.add')]], main));
|
|
429
430
|
});
|
|
430
431
|
|
|
431
432
|
app.post(`${BASE}/mail/:id(^\\d+$)/delete`, async (req: Req, reply) => {
|
|
432
433
|
const s = await developer(req, reply);
|
|
433
|
-
if (!s || !(await adminOnly(s, reply, '
|
|
434
|
+
if (!s || !(await adminOnly(s, reply, tx('ml.mail_services')))) return;
|
|
434
435
|
await owner.query('delete from meta.mail_service where id = $1', [req.params.id]);
|
|
435
|
-
flash(s, '
|
|
436
|
+
flash(s, tx('ml.mail_service_deleted'));
|
|
436
437
|
return back(reply, s, `${BASE}/mail`);
|
|
437
438
|
});
|
|
438
439
|
|
|
@@ -450,16 +451,16 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
450
451
|
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;
|
|
451
452
|
const admin = await isAdmin(s.username);
|
|
452
453
|
const action = (m: any) => m.status === 'error' || m.status === 'cancelled'
|
|
453
|
-
? html`<form method="post" action="${BASE}/apps/${a.id}/mail/${m.id}/retry">${csrf(s)}<button class="btn btn-sm"
|
|
454
|
-
: m.status === 'queued' ? html`<form method="post" action="${BASE}/apps/${a.id}/mail/${m.id}/cancel">${csrf(s)}<button class="btn btn-sm"
|
|
454
|
+
? html`<form method="post" action="${BASE}/apps/${a.id}/mail/${m.id}/retry">${csrf(s)}<button class="btn btn-sm">${tx('ml.send_again')}</button></form>`
|
|
455
|
+
: m.status === 'queued' ? html`<form method="post" action="${BASE}/apps/${a.id}/mail/${m.id}/cancel">${csrf(s)}<button class="btn btn-sm">${tx('ml.cancel')}</button></form>` : '';
|
|
455
456
|
const main = html`
|
|
456
457
|
<div class="title-row"><h1 class="mail-name">Mail · ${a.name}</h1></div>
|
|
457
|
-
<p class="muted u-mt0"
|
|
458
|
-
?
|
|
459
|
-
:
|
|
460
|
-
<form method="get" class="u-mb1">${select('status', '
|
|
461
|
-
${region('
|
|
462
|
-
return send(reply, s, shell(s,
|
|
458
|
+
<p class="muted u-mt0">${tx.code('ml.queue_intro')} ${service
|
|
459
|
+
? tx.rich(service.max_per_day !== null ? 'ml.sends_through_max' : 'ml.sends_through', { service: html`<b>${service.name}</b>`, provider: `${PROVIDER_LABELS[service.provider as Provider]}${service.enabled ? '' : `, ${tx('ml.disabled_l')}`}`, from: service.from_address ?? service.default_from ?? html`<i>${tx('ml.no_from_address')}</i>`, today, max: service.max_per_day ?? '' })
|
|
460
|
+
: admin ? tx.rich('ml.no_service_admin', { none: html`<b>${tx('ml.it_may_not_send_mail')}</b>`, link: html`<a href="${BASE}/mail">${tx('ml.mail_services')}</a>` }) : tx.rich('ml.no_service', { none: html`<b>${tx('ml.it_may_not_send_mail')}</b>` })}</p>
|
|
461
|
+
<form method="get" class="u-mb1">${select('status', tx('ml.status'), status, [['', tx('ml.all')], ['queued', tx('ml.queued')], ['sending', tx('ml.sending')], ['sent', tx('ml.sent')], ['error', tx('ml.error')], ['cancelled', tx('ml.cancelled')]])}<button class="btn btn-sm">${tx('ml.show')}</button></form>
|
|
462
|
+
${region(tx('ml.mail'), mailTable(rows, false, action))}`;
|
|
463
|
+
return send(reply, s, shell(s, tx('ml.mail_of', { app: a.name }), [[tx('ml.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('ml.activity'), `${BASE}/apps/${a.id}/activity`], [tx('ml.mail')]], main));
|
|
463
464
|
});
|
|
464
465
|
|
|
465
466
|
const change = (to: 'queued' | 'cancelled', from: string[]) => async (req: Req, reply: any) => {
|
|
@@ -471,7 +472,7 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
471
472
|
where id = $1 and app_id = $2 and status = any($5::text[])`,
|
|
472
473
|
[req.params.mid, req.params.id, to, s.username, from]);
|
|
473
474
|
if (r.rowCount && to === 'queued') await owner.query(`select pg_notify('pgkiln_mail', $1)`, [String(req.params.mid)]);
|
|
474
|
-
flash(s, r.rowCount ? (to === 'queued' ? '
|
|
475
|
+
flash(s, r.rowCount ? (to === 'queued' ? tx('ml.the_mail_is_queued_again') : tx('ml.the_mail_is_cancelled')) : tx('ml.the_mail_has_changed_meanwhile'), r.rowCount ? 'ok' : 'error');
|
|
475
476
|
return back(reply, s, `${BASE}/apps/${req.params.id}/mail`);
|
|
476
477
|
};
|
|
477
478
|
app.post(`${BASE}/apps/:id(^\\d+$)/mail/:mid(^\\d+$)/retry`, change('queued', ['error', 'cancelled']));
|
|
@@ -481,11 +482,11 @@ export async function mailRoutes(app: FastifyInstance) {
|
|
|
481
482
|
/** (096) Mail services → Builder mail: the service and From address of the builder's own mail. */
|
|
482
483
|
async function builderMailRegion(s: Session, services: { name: string; enabled: boolean }[]) {
|
|
483
484
|
const set = Object.fromEntries((await owner.query<{ name: string; value: string }>(`select name, value from meta.setting where name in ('builder_mail_service', 'builder_mail_from')`)).rows.map((r) => [r.name, r.value]));
|
|
484
|
-
return region('
|
|
485
|
-
<p class="muted u-mt0"
|
|
485
|
+
return region(tx('ml.builder_mail'), html`<form method="post" action="${BASE}/mail/builder">${csrf(s)}
|
|
486
|
+
<p class="muted u-mt0">${tx.rich('ml.builder_intro', { find: html`<b>${tx('ml.find_my_workspace')}</b>` })}</p>
|
|
486
487
|
<div class="form-grid">
|
|
487
|
-
${select('service', '
|
|
488
|
-
${input('from', '
|
|
488
|
+
${select('service', tx('ml.service'), set.builder_mail_service ?? '', [['', tx('ml.none')], ...services.map((x) => [x.name, `${x.name}${x.enabled ? '' : ' (disabled)'}`] as [string, string])])}
|
|
489
|
+
${input('from', tx('ml.from_address'), set.builder_mail_from ?? '', { type: 'email', placeholder: tx('ml.pgkiln_example_com'), help: tx('ml.one_of_the_service_s') })}
|
|
489
490
|
</div>
|
|
490
|
-
<div class="buttons"><button class="btn"
|
|
491
|
+
<div class="buttons"><button class="btn">${tx('ml.save')}</button></div></form>`, '', 'builder-mail');
|
|
491
492
|
}
|