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.
Files changed (81) hide show
  1. package/docs/apex-feature-parity.md +2 -2
  2. package/docs/guide/03-builder.md +14 -5
  3. package/docs/guide/12-development.md +6 -1
  4. package/package.json +1 -1
  5. package/src/app.ts +9 -0
  6. package/src/builder/ai-agents.ts +86 -85
  7. package/src/builder/ai-builder.ts +30 -30
  8. package/src/builder/ai.ts +93 -92
  9. package/src/builder/api.ts +47 -47
  10. package/src/builder/app-groups.ts +33 -32
  11. package/src/builder/appfromfile.ts +57 -58
  12. package/src/builder/appsheets.ts +33 -32
  13. package/src/builder/appwizard.ts +65 -65
  14. package/src/builder/arrange.ts +23 -22
  15. package/src/builder/assistant.ts +39 -38
  16. package/src/builder/automations.ts +32 -31
  17. package/src/builder/availability.ts +24 -24
  18. package/src/builder/backups.ts +30 -31
  19. package/src/builder/blueprints.ts +62 -61
  20. package/src/builder/create-page.ts +52 -50
  21. package/src/builder/cross-app.ts +37 -35
  22. package/src/builder/dataload.ts +2 -2
  23. package/src/builder/datasets.ts +107 -106
  24. package/src/builder/dependencies.ts +20 -19
  25. package/src/builder/designer.ts +36 -36
  26. package/src/builder/diagnostics.ts +58 -57
  27. package/src/builder/features.ts +39 -35
  28. package/src/builder/feedback.ts +23 -20
  29. package/src/builder/find-workspace.ts +28 -24
  30. package/src/builder/form-designer.ts +36 -35
  31. package/src/builder/gallery.ts +25 -29
  32. package/src/builder/globalization.ts +50 -49
  33. package/src/builder/i18n/de.ts +3453 -0
  34. package/src/builder/i18n/en.ts +3453 -0
  35. package/src/builder/i18n/es.ts +3453 -0
  36. package/src/builder/i18n/fr.ts +3453 -0
  37. package/src/builder/i18n/it.ts +3453 -0
  38. package/src/builder/i18n/ja.ts +3453 -0
  39. package/src/builder/i18n/ko.ts +3453 -0
  40. package/src/builder/i18n/nl.ts +3453 -0
  41. package/src/builder/i18n/pt.ts +3453 -0
  42. package/src/builder/i18n/zh-cn.ts +3453 -0
  43. package/src/builder/i18n/zh-tw.ts +3453 -0
  44. package/src/builder/i18n.ts +17 -3
  45. package/src/builder/json-sources.ts +63 -62
  46. package/src/builder/layouts.ts +12 -11
  47. package/src/builder/locks.ts +29 -28
  48. package/src/builder/login-page.ts +26 -25
  49. package/src/builder/mail.ts +148 -147
  50. package/src/builder/newapp.ts +15 -12
  51. package/src/builder/plugins.ts +47 -44
  52. package/src/builder/process-jobs.ts +11 -10
  53. package/src/builder/pwa.ts +22 -21
  54. package/src/builder/remote-databases.ts +55 -56
  55. package/src/builder/report-settings.ts +1 -1
  56. package/src/builder/reporter.ts +43 -42
  57. package/src/builder/rest-catalogs.ts +87 -86
  58. package/src/builder/rest.ts +9 -8
  59. package/src/builder/routes.ts +27 -27
  60. package/src/builder/search.ts +14 -13
  61. package/src/builder/static-files.ts +31 -32
  62. package/src/builder/subscriptions.ts +47 -45
  63. package/src/builder/supporting.ts +23 -18
  64. package/src/builder/team.ts +179 -179
  65. package/src/builder/telemetry.ts +59 -62
  66. package/src/builder/templates.ts +66 -65
  67. package/src/builder/themeroller.ts +60 -63
  68. package/src/builder/ui-defaults.ts +21 -20
  69. package/src/builder/ui.ts +2 -1
  70. package/src/builder/upgrade.ts +25 -24
  71. package/src/builder/utilities.ts +48 -47
  72. package/src/builder/vector-providers.ts +67 -66
  73. package/src/builder/websources.ts +43 -42
  74. package/src/builder/wizards.ts +95 -89
  75. package/src/builder/workflow-migrate.ts +30 -31
  76. package/src/builder/workflows.ts +40 -39
  77. package/src/builder/workingcopies.ts +41 -49
  78. package/src/builder/workspace-backups.ts +21 -20
  79. package/src/builder/workspace-mail.ts +17 -16
  80. package/src/builder/workspace-resources.ts +104 -112
  81. package/src/builder/workspace-transfer.ts +34 -26
@@ -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: Record<Provider, string> = {
29
- smtp: 'Password, or the client secret (OAuth2)', graph: 'Client secret', ses: 'Secret access key', resend: 'API key',
30
- postmark: 'Server token', sendgrid: 'API key', mailgun: 'API key', log: 'Secret (not used)',
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: Record<Provider, [icon: string, text: string]> = {
35
- smtp: ['send', 'Any SMTP server or relay. Sign in with a password, or with OAuth2 (Exchange Online, Google Workspace).'],
36
- graph: ['building', 'Microsoft 365 through the Graph API, as an Entra application with Mail.Send.'],
37
- ses: ['database', 'Amazon Simple Email Service (API v2), with an IAM access key.'],
38
- resend: ['send', 'Resend\'s HTTP API, with an API key.'],
39
- postmark: ['send', 'Postmark\'s HTTP API, with a server token.'],
40
- sendgrid: ['send', 'SendGrid\'s HTTP API, with an API key.'],
41
- mailgun: ['send', 'Mailgun\'s HTTP API, with an API key and a sending domain.'],
42
- log: ['list', 'For development and demos: the server writes each mail to its log and sends nothing.'],
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[p][0])}</span><strong>${PROVIDER_LABELS[p]}</strong><span>${PROVIDER_TEXT[p][1]}</span></a>`)}</div>`;
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', 'Tenant ID (Microsoft Entra)', s.tenant_id, { placeholder: 'contoso.onmicrosoft.com or its GUID' })}
64
- ${input('client_id', 'Client (application) ID', s.client_id)}
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.' })}
66
- ${input('scope', 'Scope', s.scope, { placeholder: `empty: ${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('SMTP server', html`<div class="form-grid">
71
- ${input('host', 'Host', s.host, { required: true, placeholder: 'smtp.office365.com' })}
72
- ${input('port', 'Port', s.port ?? '', { type: 'number', placeholder: '587', help: 'Empty: 587 (STARTTLS) or 465 (TLS).' })}
73
- ${select('security', 'Security', s.security ?? 'starttls', [['starttls', 'STARTTLS (required)'], ['tls', 'TLS from the start (port 465)'], ['none', 'None (a local relay only)']])}
74
- ${select('auth', 'Sign-in', s.auth ?? 'password', [['password', 'User name and password'], ['oauth2', 'OAuth2 (XOAUTH2, client credentials)'], ['none', 'None']],
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', 'User name', s.username, { placeholder: 'the mailbox, e.g. noreply@example.com' })}
77
- </div>${check('tls_verify', 'Check the server\'s certificate', s.tls_verify !== false, 'Switch off only for a relay with a self-signed certificate on your own network.')}`)}
78
- <details class="prop-group prop-group-more"${s.auth === 'oauth2' ? raw(' open') : ''}><summary>OAuth2 sign-in <span class="muted">(only with Sign-in: OAuth2)</span></summary>${oauth('https://outlook.office365.com/.default')}</details>`
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('Microsoft Entra application', html`${oauth('https://graph.microsoft.com/.default')}
81
- ${check('save_to_sent_items', 'Keep a copy in the mailbox\'s Sent Items', s.save_to_sent_items === true)}`,
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('Amazon SES', html`<div class="form-grid">
85
- ${input('region', 'AWS region', s.region, { required: true, placeholder: 'eu-west-1' })}
86
- ${input('access_key_id', 'Access key ID', s.access_key_id, { required: true })}</div>`,
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('Mailgun', html`<div class="form-grid">
90
- ${input('domain', 'Sending domain', s.domain, { required: true, placeholder: 'mg.example.com' })}
91
- ${select('mailgun_region', 'Region', s.mailgun_region ?? 'us', [['us', 'US'], ['eu', 'EU']])}</div>`)
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('Postmark', html`<div class="form-grid">${input('message_stream', 'Message stream', s.message_stream, { placeholder: 'outbound' })}</div>`)
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[provider], html`
96
- ${input('secret', SECRET_LABEL[provider], '', { type: 'password', auto: 'new-password',
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.' })}
98
- ${x.has_secret ? check('remove_secret', 'Remove the stored secret', false) : ''}
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>`}`);
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('Service', html`<div class="form-grid">
104
- ${isNew && !ws ? input('name', 'Name', '', { required: true, placeholder: 'e.g. OFFICE365', help: 'Upper case letters, digits and _.' }) : ''}
105
- ${input('description', 'Description', x.description)}
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.')}`)}
107
- ${group('Senders', html`<div class="form-grid">
108
- ${input('senders', 'Allowed senders', (x.senders ?? []).join(', '), { required: true, placeholder: 'noreply@example.com, @example.com',
109
- help: 'The From addresses this service may send as: an address, or @domain for every address of a domain (the provider must accept them too).' })}
110
- ${input('default_from', 'Default From address', s.default_from, { placeholder: 'noreply@example.com', help: 'For applications that set none.' })}
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>Advanced</summary><div class="form-grid">
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>` : ''}
116
- ${group('Check before saving', html`
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')} Test connection</button></div>
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', 'Send a test mail to', tried?.to ?? '', { type: 'email', placeholder: 'you@example.com' })}
121
- ${input('test_from', 'From', tried?.from ?? s.default_from ?? (x.senders ?? []).find((a) => !a.startsWith('@')) ?? '', { type: 'email', help: 'One of the allowed senders.' })}
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')} Send test mail</button></div>
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
- `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.`)}
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>
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('Name at least one allowed sender (an address, or @domain).');
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('The default From address must be an address the service may send as.');
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>Queued (UTC)</th>${withApp ? html`<th>Application</th>` : ''}<th>To</th><th>Subject</th><th>From</th><th class="num">Attempts</th><th>Status</th>${actions ? html`<th>Actions</th>` : ''}</tr></thead>
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="Queued">${when(m.created_at)}</td>${withApp ? html`<td data-label="Application">${m.app ?? ''}</td>` : ''}
181
- <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>
182
- <td data-label="Subject" class="u-break">${m.subject}${m.template ? html` <span class="muted">(${m.template})</span>` : ''}</td>
183
- <td data-label="From" class="u-break">${m.from_address ?? ''}</td>
184
- <td class="num" data-label="Attempts">${m.attempts}</td>
185
- <td data-label="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>
186
- ${actions ? html`<td data-label="Actions">${actions(m)}</td>` : ''}</tr>`)
187
- : html`<tr><td colspan="${7 + (withApp ? 1 : 0) + (actions ? 1 : 0)}" class="empty">No mail yet.</td></tr>`}</tbody></table></div>`;
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">Only administrators configure mail services.</div>`;
192
- await send(reply.code(403), s, shell(s, title, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [title]], main));
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(`The stored secret can't be read: ${(e as Error).message}`, false);
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('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);
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: `The test mail was handed to ${PROVIDER_LABELS[v.provider]}${pid ? ` (its id: ${pid})` : ''}. Check the mailbox of ${to}.` };
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 : 'The check failed.' };
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, 'Mail services'))) return;
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>Mail services</h1></div>
244
- <p class="muted u-mt0">How the applications of this installation send e-mail (<code>meta.send_mail()</code> from SQL, the <b>Send e-mail</b> process, e-mail templates). Mail is queued in the application's transaction and sent by the server after it commits. Choose the service of each application on the service's page.</p>
245
- ${region('Services', html`<div class="table-wrap"><table class="report report-reflow">
246
- <thead><tr><th>Service</th><th>Provider</th><th>Senders</th><th>Secret</th><th>Applications</th><th>Status</th></tr></thead>
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="Service"><a href="${BASE}/mail/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
249
- <td data-label="Provider">${PROVIDER_LABELS[x.provider as Provider]}</td>
250
- <td data-label="Senders" class="u-break">${x.senders.join(', ')}</td>
251
- <td data-label="Secret">${x.has_secret ? 'stored (encrypted)' : x.provider === 'log' ? '–' : html`<b>missing</b>`}</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>
253
- <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
254
- : html`<tr><td colspan="6" class="empty">No mail services yet.</td></tr>`}</tbody></table></div>`,
255
- html`<a class="btn btn-sm btn-hot" href="${BASE}/mail/new">${icon('plus')} Add service</a>`)}
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('Recent mail', mailTable(recent, true))}`;
258
- return send(reply, s, shell(s, 'Mail services', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Mail services']], main));
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, 'Mail services'))) return;
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('Choose one of the mail services.');
272
- if (!/^[^@\s<>"]+@[^@\s<>"]+$/.test(from)) throw new Error('The From address is an e-mail address, e.g. pgkiln@example.com.');
273
- if (!senderAllowed(svc.senders, from)) throw new Error(`The service ${name} may not send as ${from}: add it to its senders first.`);
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 ? 'Builder mail saved: "Find my workspace" sends through it.' : 'The builder sends no mail now.');
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, 'Mail services'))) return;
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>Add a mail service</h1></div>
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[provider][1]}</p>
295
- <div class="mail-form-page">${region('Settings', serviceForm({}, provider, `${BASE}/mail`, csrf(s), true))}</div>`
296
- : html`<p class="muted u-mt0">Step 1 of 2: how should the applications send mail?</p>${providerChoice()}`}`;
297
- return send(reply, s, shell(s, 'Add a mail service', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Mail services', `${BASE}/mail`], ['Add']], main));
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, 'Mail services'))) return;
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('The name is upper case letters, digits and _ (e.g. OFFICE365).');
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, 'Mail service added. Choose the applications that send through it below, and test it.');
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, 'Mail services'))) return;
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('Settings', html`
338
- <form method="get" class="provider-switch">${select('provider', 'Provider', provider, PROVIDERS.map((p): [string, string] => [p, PROVIDER_LABELS[p]]),
339
- provider !== x.provider ? 'Not saved yet: fill in the settings below and save to switch.' : undefined)}<button class="btn btn-sm">Change</button></form>
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="Delete mail service ${x.name}? Its applications can't send mail until they get another one.">Delete service</button></form>`)}
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('Applications', html`<p class="muted u-mt0">The applications that send through this service (each application has one), their From address (empty: the service's default) and a limit per day (UTC; empty: none). An application with another service shows it.</p>
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>Application</th><th>Sends through this service</th><th>From address</th><th>Mails per day</th></tr></thead>
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="Application">${a.name} <span class="muted">${a.alias}</span></td>
349
- <td data-label="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>
350
- <td data-label="From address"><input type="email" name="from_${a.id}" value="${a.service_id === x.id ? a.from_address ?? '' : ''}" aria-label="From address for ${a.alias}"></td>
351
- <td data-label="Mails per day"><input type="number" min="0" name="max_${a.id}" value="${a.service_id === x.id ? a.max_per_day ?? '' : ''}" aria-label="Mails per day for ${a.alias}"></td></tr>`)
352
- : html`<tr><td colspan="4" class="empty">No applications.</td></tr>`}</tbody></table></div>
353
- <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`)}
354
- ${region('Recent mail', mailTable(recent, true))}`;
355
- return send(reply, s, shell(s, x.name, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Mail services', `${BASE}/mail`], [x.name]], main));
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, 'Mail services'))) return;
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('Not found.');
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, 'Mail service saved.');
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, 'Mail services'))) return;
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('Not found.');
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('Mails per day: a whole number of 0 or more (empty: no limit).');
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, 'Applications saved.');
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, 'Mail services'))) return;
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 : 'Add a mail service'}</h1></div>
427
- <div class="mail-form-page">${region('Settings', serviceForm(x, typed.provider, stored ? `${BASE}/mail/${stored.id}` : `${BASE}/mail`, csrf(s), !stored, { ...result, to, from }))}</div>`;
428
- return send(reply, s, shell(s, 'Mail service', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Mail services', `${BASE}/mail`], [stored?.name ?? 'Add']], main));
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, 'Mail services'))) return;
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, 'Mail service deleted.');
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">Send again</button></form>`
454
- : m.status === 'queued' ? html`<form method="post" action="${BASE}/apps/${a.id}/mail/${m.id}/cancel">${csrf(s)}<button class="btn btn-sm">Cancel</button></form>` : '';
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">The mail this application queued (<code>meta.send_mail()</code>, the Send e-mail process), kept 30 days. ${service
458
- ? html`It sends through <b>${service.name}</b> (${PROVIDER_LABELS[service.provider as Provider]}${service.enabled ? '' : ', disabled'}) as ${service.from_address ?? service.default_from ?? html`<i>no From address</i>`}; today: ${today}${service.max_per_day !== null ? ` of ${service.max_per_day}` : ''}.`
459
- : html`<b>It may not send mail yet</b>: an administrator chooses its mail service${admin ? html` under <a href="${BASE}/mail">Mail services</a>` : ''}.`}</p>
460
- <form method="get" class="u-mb1">${select('status', 'Status', status, [['', 'All'], ['queued', 'Queued'], ['sending', 'Sending'], ['sent', 'Sent'], ['error', 'Error'], ['cancelled', 'Cancelled']])}<button class="btn btn-sm">Show</button></form>
461
- ${region('Mail', mailTable(rows, false, action))}`;
462
- return send(reply, s, shell(s, `Mail · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['Mail']], main));
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' ? 'The mail is queued again.' : 'The mail is cancelled.') : 'The mail has changed meanwhile.', r.rowCount ? 'ok' : 'error');
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('Builder mail', html`<form method="post" action="${BASE}/mail/builder">${csrf(s)}
485
- <p class="muted u-mt0">The service the builder itself sends through: <b>Find my workspace</b> on the sign-in page mails developers their workspaces (to the address on their account). None: the builder sends no mail.</p>
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', 'Service', set.builder_mail_service ?? '', [['', 'None'], ...services.map((x) => [x.name, `${x.name}${x.enabled ? '' : ' (disabled)'}`] as [string, string])])}
488
- ${input('from', 'From address', set.builder_mail_from ?? '', { type: 'email', placeholder: 'pgkiln@example.com', help: 'One of the service\'s senders.' })}
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">Save</button></div></form>`, '', 'builder-mail');
491
+ <div class="buttons"><button class="btn">${tx('ml.save')}</button></div></form>`, '', 'builder-mail');
491
492
  }