@things-factory/notification 10.1.247 → 10.1.250
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/dist-server/controllers/notice/deliver-notice.d.ts +1 -1
- package/dist-server/controllers/notice/deliver-notice.js +4 -4
- package/dist-server/controllers/notice/deliver-notice.js.map +1 -1
- package/dist-server/controllers/notice/index.d.ts +0 -2
- package/dist-server/controllers/notice/index.js +0 -2
- package/dist-server/controllers/notice/index.js.map +1 -1
- package/dist-server/controllers/notice/notice-channels.d.ts +2 -6
- package/dist-server/controllers/notice/notice-channels.js +2 -9
- package/dist-server/controllers/notice/notice-channels.js.map +1 -1
- package/dist-server/controllers/notice/send-notice.js +1 -2
- package/dist-server/controllers/notice/send-notice.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/tests/notice-delivery.test.ts +1 -103
- package/dist-server/controllers/notice/app-brand.d.ts +0 -17
- package/dist-server/controllers/notice/app-brand.js +0 -61
- package/dist-server/controllers/notice/app-brand.js.map +0 -1
- package/dist-server/controllers/notice/notice-email.d.ts +0 -32
- package/dist-server/controllers/notice/notice-email.js +0 -129
- package/dist-server/controllers/notice/notice-email.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/notification",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.250",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
"@material/web": "^2.0.0",
|
|
31
31
|
"@operato/layout": "^10.0.0",
|
|
32
32
|
"@operato/styles": "^10.0.0",
|
|
33
|
-
"@things-factory/auth-base": "^10.1.
|
|
34
|
-
"@things-factory/email-base": "^10.1.
|
|
35
|
-
"@things-factory/i18n-base": "^10.1.
|
|
36
|
-
"@things-factory/organization": "^10.1.
|
|
37
|
-
"@things-factory/setting-base": "^10.1.
|
|
33
|
+
"@things-factory/auth-base": "^10.1.250",
|
|
34
|
+
"@things-factory/email-base": "^10.1.250",
|
|
35
|
+
"@things-factory/i18n-base": "^10.1.250",
|
|
36
|
+
"@things-factory/organization": "^10.1.250",
|
|
37
|
+
"@things-factory/setting-base": "^10.1.250",
|
|
38
38
|
"@things-factory/shell": "^10.1.247",
|
|
39
39
|
"clipboard": "^2.0.6",
|
|
40
40
|
"firebase": "^9.14.0",
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"google": "^2.1.0",
|
|
43
43
|
"marked": "^15.0.0"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "4fdb6d24ef24f43d9e158d4c6d6396ad3097bb9e"
|
|
46
46
|
}
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
* The channels are fakes: what each one was handed is what is checked.
|
|
6
6
|
*/
|
|
7
7
|
import { deliverNotice, type Notice, type NoticeChannelFunctions } from '../server/controllers/notice/deliver-notice'
|
|
8
|
-
import {
|
|
9
|
-
import { escapeHtml, noticeEmailForm, renderNoticeMarkdown, safeNoticeHref } from '../server/controllers/notice/notice-email'
|
|
10
|
-
import { brandOf, DEFAULT_BRAND, textColorOn } from '../server/controllers/notice/app-brand'
|
|
8
|
+
import { noticeChannels, NOTICE_EMAIL, NOTICE_TEAMS_CHANNEL, teamsMessage } from '../server/controllers/notice/notice-channels'
|
|
11
9
|
|
|
12
10
|
type Calls = { push: any[]; mail: any[]; teams: { url: string; body: any }[]; reports: string[] }
|
|
13
11
|
|
|
@@ -58,12 +56,6 @@ describe('notice channels a domain turns on', () => {
|
|
|
58
56
|
expect(noticeChannels({ 'Notice::teamsWebhook': 'https://x.example/hook' })).toEqual({ email: false, teamsChannelWebhook: undefined })
|
|
59
57
|
})
|
|
60
58
|
|
|
61
|
-
test('a person reads their language; a person without one reads the default', () => {
|
|
62
|
-
expect(languageOf('ko-KR', 'en')).toBe('ko')
|
|
63
|
-
expect(languageOf('zh_CN', 'en')).toBe('zh')
|
|
64
|
-
expect(languageOf(null, 'ko-KR')).toBe('ko')
|
|
65
|
-
expect(languageOf(undefined, undefined)).toBe('en')
|
|
66
|
-
})
|
|
67
59
|
})
|
|
68
60
|
|
|
69
61
|
describe('delivering a notice', () => {
|
|
@@ -168,22 +160,6 @@ describe('delivering a notice', () => {
|
|
|
168
160
|
describe('what a notice carries', () => {
|
|
169
161
|
const words = { subject: '[결재 요청] <b>A&B</b>', body: 'line "one"\nline two', action: '열기', note: '결재함에서 확인' }
|
|
170
162
|
|
|
171
|
-
test('the mail shows values as text', () => {
|
|
172
|
-
const html = noticeEmailForm({ lang: 'ko', words, link: 'https://x.example/a?b=1&c="2"' })
|
|
173
|
-
|
|
174
|
-
expect(html).toContain('<b>A&B</b>')
|
|
175
|
-
expect(html).not.toContain('<b>A&B</b>')
|
|
176
|
-
expect(html).toContain('href="https://x.example/a?b=1&c="2""')
|
|
177
|
-
expect(escapeHtml(`'`)).toBe(''')
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
test('with no link the mail shows the note instead of a button', () => {
|
|
181
|
-
const html = noticeEmailForm({ lang: 'ko', words })
|
|
182
|
-
|
|
183
|
-
expect(html).not.toContain('href=')
|
|
184
|
-
expect(html).toContain('결재함에서 확인')
|
|
185
|
-
})
|
|
186
|
-
|
|
187
163
|
test('the Teams message is one Adaptive Card for the channel, the link as its action', () => {
|
|
188
164
|
const message = teamsMessage({ words, link: 'https://x.example/a' })
|
|
189
165
|
|
|
@@ -200,48 +176,6 @@ describe('what a notice carries', () => {
|
|
|
200
176
|
})
|
|
201
177
|
|
|
202
178
|
describe('a Markdown body in the mail (an announcement)', () => {
|
|
203
|
-
const BASE = 'https://office.example.com/domain/hatiolab/'
|
|
204
|
-
|
|
205
|
-
test('it is rendered as mail HTML', () => {
|
|
206
|
-
const html = renderNoticeMarkdown('# Title\n\n**bold** and a list:\n\n- one\n- two', BASE)
|
|
207
|
-
|
|
208
|
-
expect(html).toContain('<h1>Title</h1>')
|
|
209
|
-
expect(html).toContain('<strong>bold</strong>')
|
|
210
|
-
expect(html).toContain('<li>one</li>')
|
|
211
|
-
})
|
|
212
|
-
|
|
213
|
-
test('raw HTML is shown as text, never as markup', () => {
|
|
214
|
-
const html = renderNoticeMarkdown('<script>alert(1)</script>\n\nhi <img src=x onerror=alert(1)> <b>b</b>', BASE)
|
|
215
|
-
|
|
216
|
-
expect(html).not.toMatch(/<script|<img|<b>/)
|
|
217
|
-
expect(html).toContain('<script>')
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
test('links go to http(s), mailto and app addresses only', () => {
|
|
221
|
-
expect(safeNoticeHref('https://hatiolab.com/a', BASE)).toBe('https://hatiolab.com/a')
|
|
222
|
-
expect(safeNoticeHref('mailto:hr@hatiolab.com', BASE)).toBe('mailto:hr@hatiolab.com')
|
|
223
|
-
expect(safeNoticeHref('announcement-view?id=1', BASE)).toBe(`${BASE}announcement-view?id=1`)
|
|
224
|
-
|
|
225
|
-
for (const bad of ['javascript:alert(1)', ' JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', 'file:///etc/passwd']) {
|
|
226
|
-
expect(safeNoticeHref(bad, BASE)).toBeUndefined()
|
|
227
|
-
}
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
test('an app address has nowhere to go without the domain’s address', () => {
|
|
231
|
-
expect(safeNoticeHref('announcement-view?id=1')).toBeUndefined()
|
|
232
|
-
expect(safeNoticeHref('https://hatiolab.com/a')).toBe('https://hatiolab.com/a')
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
test('a link that is not allowed keeps its text and loses its address; an image is a link, never loaded', () => {
|
|
236
|
-
const html = renderNoticeMarkdown('[click](javascript:alert(1)) [form](announcement-view?id=1) ', BASE)
|
|
237
|
-
|
|
238
|
-
expect(html).not.toContain('javascript:')
|
|
239
|
-
expect(html).toContain('click')
|
|
240
|
-
expect(html).toContain(`<a href="${BASE}announcement-view?id=1">form</a>`)
|
|
241
|
-
expect(html).not.toContain('<img')
|
|
242
|
-
expect(html).toContain('<a href="https://cdn.example.com/l.png">logo</a>')
|
|
243
|
-
})
|
|
244
|
-
|
|
245
179
|
test('the mail renders a Markdown notice; push shows the summary; the type stays as given', async () => {
|
|
246
180
|
const { channels, calls } = fakeChannels({ [NOTICE_EMAIL]: 'on' })
|
|
247
181
|
await deliverNotice(
|
|
@@ -329,42 +263,6 @@ describe('a notice to everyone in the domain (broadcast)', () => {
|
|
|
329
263
|
})
|
|
330
264
|
|
|
331
265
|
describe('whose mail it is', () => {
|
|
332
|
-
const words = { subject: '[공지] 점검', body: '본문', action: '열기', note: '알림함에서 확인' }
|
|
333
|
-
|
|
334
|
-
test('the brand is the manifest\'s name, tagline and brand colour; the theme colour stands in for a missing brand colour', () => {
|
|
335
|
-
expect(brandOf({ name: 'operato office', tagline: 'Reimagining Business.', brand_color: '#059669', theme_color: '#262e38' })).toEqual({
|
|
336
|
-
name: 'operato office',
|
|
337
|
-
tagline: 'Reimagining Business.',
|
|
338
|
-
color: '#059669'
|
|
339
|
-
})
|
|
340
|
-
expect(brandOf({ name: 'Operato Plant', theme_color: '#0E8A56' })).toEqual({ name: 'Operato Plant', tagline: undefined, color: '#0e8a56' })
|
|
341
|
-
expect(brandOf({ name: ' ', brand_color: 'green', theme_color: '#abc' })).toEqual({ name: DEFAULT_BRAND.name, tagline: undefined, color: '#aabbcc' })
|
|
342
|
-
expect(brandOf(undefined)).toEqual({ name: DEFAULT_BRAND.name, tagline: undefined, color: DEFAULT_BRAND.color })
|
|
343
|
-
})
|
|
344
|
-
|
|
345
|
-
test('the text on the brand colour is white while it keeps 3:1, black below', () => {
|
|
346
|
-
expect(textColorOn('#059669')).toBe('#ffffff')
|
|
347
|
-
expect(textColorOn('#262e38')).toBe('#ffffff')
|
|
348
|
-
expect(textColorOn('#34d399')).toBe('#000000')
|
|
349
|
-
expect(textColorOn('#ffffff')).toBe('#000000')
|
|
350
|
-
})
|
|
351
|
-
|
|
352
|
-
test('the mail names the application and its tagline, and its band and button are the brand colour - no logo image', () => {
|
|
353
|
-
const brand = { name: 'operato <office>', tagline: 'Reimagining Business.', color: '#059669' }
|
|
354
|
-
const html = noticeEmailForm({ lang: 'ko', words, link: 'https://x.example/a', brand })
|
|
355
|
-
|
|
356
|
-
expect(html).toContain('operato <office>')
|
|
357
|
-
expect(html).toContain('Reimagining Business.')
|
|
358
|
-
expect(html).not.toContain('<img')
|
|
359
|
-
expect(html).not.toContain('#22a6a7')
|
|
360
|
-
expect(html.match(/background-color:#059669/g)).toHaveLength(2)
|
|
361
|
-
expect(html).toContain('border-top:2px solid #059669')
|
|
362
|
-
|
|
363
|
-
const plain = noticeEmailForm({ lang: 'ko', words })
|
|
364
|
-
expect(plain).toContain(`>${DEFAULT_BRAND.name}<`)
|
|
365
|
-
expect(plain).toContain(`background-color:${DEFAULT_BRAND.color}`)
|
|
366
|
-
})
|
|
367
|
-
|
|
368
266
|
test('the real channels carry the brand to the mail', async () => {
|
|
369
267
|
const { channels, calls } = fakeChannels({ [NOTICE_EMAIL]: 'on' }, { brand: { name: 'Brand X', tagline: 'Tag Y', color: '#1d4ed8' } })
|
|
370
268
|
await deliverNotice(notice(), channels)
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
export type AppBrand = {
|
|
2
|
-
name: string;
|
|
3
|
-
tagline?: string;
|
|
4
|
-
/** `#rrggbb`. */
|
|
5
|
-
color: string;
|
|
6
|
-
};
|
|
7
|
-
/** What the notice mail had before an application said its own. */
|
|
8
|
-
export declare const DEFAULT_BRAND: AppBrand;
|
|
9
|
-
/** The brand a manifest declares, each part it leaves out the default's. */
|
|
10
|
-
export declare function brandOf(manifest: any): AppBrand;
|
|
11
|
-
/**
|
|
12
|
-
* The text colour on `color`: white while it keeps 3:1 against it - the band and the button carry large bold text
|
|
13
|
-
* (WCAG 1.4.3) - black below that.
|
|
14
|
-
*/
|
|
15
|
-
export declare function textColorOn(color: string): '#000000' | '#ffffff';
|
|
16
|
-
/** The running application's brand; the default when it ships no manifest or one that cannot be read. */
|
|
17
|
-
export declare function appBrand(): AppBrand;
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_BRAND = void 0;
|
|
4
|
-
exports.brandOf = brandOf;
|
|
5
|
-
exports.textColorOn = textColorOn;
|
|
6
|
-
exports.appBrand = appBrand;
|
|
7
|
-
const tslib_1 = require("tslib");
|
|
8
|
-
/*
|
|
9
|
-
* Whose mail it is - the application's brand name, tagline and colour, read from its web manifest
|
|
10
|
-
* (`assets/manifest.json`), the one identity file every application ships and the image keeps. `name` is the brand
|
|
11
|
-
* name; `tagline` and `brand_color` are the application's own members (a browser passes over members it does not
|
|
12
|
-
* know); with no `brand_color` the manifest's `theme_color` stands in. Read once.
|
|
13
|
-
*/
|
|
14
|
-
const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
15
|
-
const path_1 = tslib_1.__importDefault(require("path"));
|
|
16
|
-
const env_1 = require("@things-factory/env");
|
|
17
|
-
/** What the notice mail had before an application said its own. */
|
|
18
|
-
exports.DEFAULT_BRAND = { name: 'Operato', color: '#22a6a7' };
|
|
19
|
-
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
20
|
-
/** A 3-digit colour as its 6 digits; anything not a hex colour is not a colour. */
|
|
21
|
-
function hexOf(value) {
|
|
22
|
-
if (typeof value !== 'string' || !HEX.test(value.trim()))
|
|
23
|
-
return undefined;
|
|
24
|
-
const hex = value.trim().toLowerCase();
|
|
25
|
-
return hex.length === 4 ? `#${[...hex.slice(1)].map(digit => digit + digit).join('')}` : hex;
|
|
26
|
-
}
|
|
27
|
-
const textOf = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined);
|
|
28
|
-
/** The brand a manifest declares, each part it leaves out the default's. */
|
|
29
|
-
function brandOf(manifest) {
|
|
30
|
-
return {
|
|
31
|
-
name: textOf(manifest?.name) ?? exports.DEFAULT_BRAND.name,
|
|
32
|
-
tagline: textOf(manifest?.tagline),
|
|
33
|
-
color: hexOf(manifest?.brand_color) ?? hexOf(manifest?.theme_color) ?? exports.DEFAULT_BRAND.color
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* The text colour on `color`: white while it keeps 3:1 against it - the band and the button carry large bold text
|
|
38
|
-
* (WCAG 1.4.3) - black below that.
|
|
39
|
-
*/
|
|
40
|
-
function textColorOn(color) {
|
|
41
|
-
const channel = (at) => {
|
|
42
|
-
const value = parseInt(color.slice(at, at + 2), 16) / 255;
|
|
43
|
-
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
44
|
-
};
|
|
45
|
-
const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
|
46
|
-
return 1.05 / (luminance + 0.05) >= 3 ? '#ffffff' : '#000000';
|
|
47
|
-
}
|
|
48
|
-
let read;
|
|
49
|
-
/** The running application's brand; the default when it ships no manifest or one that cannot be read. */
|
|
50
|
-
function appBrand() {
|
|
51
|
-
if (!read) {
|
|
52
|
-
try {
|
|
53
|
-
read = brandOf(JSON.parse(fs_1.default.readFileSync(path_1.default.resolve(env_1.appRootPath, 'assets', 'manifest.json'), 'utf8')));
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
read = exports.DEFAULT_BRAND;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return read;
|
|
60
|
-
}
|
|
61
|
-
//# sourceMappingURL=app-brand.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"app-brand.js","sourceRoot":"","sources":["../../../server/controllers/notice/app-brand.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Whose mail it is - the application's brand name, tagline and colour, read from its web manifest\n * (`assets/manifest.json`), the one identity file every application ships and the image keeps. `name` is the brand\n * name; `tagline` and `brand_color` are the application's own members (a browser passes over members it does not\n * know); with no `brand_color` the manifest's `theme_color` stands in. Read once.\n */\nimport fs from 'fs'\nimport path from 'path'\n\nimport { appRootPath } from '@things-factory/env'\n\nexport type AppBrand = {\n name: string\n tagline?: string\n /** `#rrggbb`. */\n color: string\n}\n\n/** What the notice mail had before an application said its own. */\nexport const DEFAULT_BRAND: AppBrand = { name: 'Operato', color: '#22a6a7' }\n\nconst HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i\n\n/** A 3-digit colour as its 6 digits; anything not a hex colour is not a colour. */\nfunction hexOf(value: unknown): string | undefined {\n if (typeof value !== 'string' || !HEX.test(value.trim())) return undefined\n const hex = value.trim().toLowerCase()\n return hex.length === 4 ? `#${[...hex.slice(1)].map(digit => digit + digit).join('')}` : hex\n}\n\nconst textOf = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)\n\n/** The brand a manifest declares, each part it leaves out the default's. */\nexport function brandOf(manifest: any): AppBrand {\n return {\n name: textOf(manifest?.name) ?? DEFAULT_BRAND.name,\n tagline: textOf(manifest?.tagline),\n color: hexOf(manifest?.brand_color) ?? hexOf(manifest?.theme_color) ?? DEFAULT_BRAND.color\n }\n}\n\n/**\n * The text colour on `color`: white while it keeps 3:1 against it - the band and the button carry large bold text\n * (WCAG 1.4.3) - black below that.\n */\nexport function textColorOn(color: string): '#000000' | '#ffffff' {\n const channel = (at: number) => {\n const value = parseInt(color.slice(at, at + 2), 16) / 255\n return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4\n }\n const luminance = 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5)\n return 1.05 / (luminance + 0.05) >= 3 ? '#ffffff' : '#000000'\n}\n\nlet read: AppBrand | undefined\n\n/** The running application's brand; the default when it ships no manifest or one that cannot be read. */\nexport function appBrand(): AppBrand {\n if (!read) {\n try {\n read = brandOf(JSON.parse(fs.readFileSync(path.resolve(appRootPath, 'assets', 'manifest.json'), 'utf8')))\n } catch {\n read = DEFAULT_BRAND\n }\n }\n return read\n}\n"]}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { type AppBrand } from './app-brand.js';
|
|
2
|
-
import type { NoticeWords } from './notice-channels.js';
|
|
3
|
-
/** Every value goes into the HTML through this - a subject like `<b>` or `A&B` stays text. */
|
|
4
|
-
export declare function escapeHtml(value: unknown): string;
|
|
5
|
-
/**
|
|
6
|
-
* Where a link in a notice may go: http(s) and mailto as written, and an address of the app (no scheme) under `base`,
|
|
7
|
-
* the domain's own address. With no base an app address has nowhere to go and is dropped, as is every other scheme -
|
|
8
|
-
* `javascript:` in any spelling the URL parser reads as one.
|
|
9
|
-
*/
|
|
10
|
-
export declare function safeNoticeHref(href: string | null | undefined, base?: string): string | undefined;
|
|
11
|
-
/**
|
|
12
|
-
* Markdown as mail HTML, rendered here on the server. Raw HTML in the text is shown as text, never as markup; a link or
|
|
13
|
-
* an image becomes a link only to an address `safeNoticeHref` allows, and an image is not loaded - it is shown as a
|
|
14
|
-
* link with its alt text, so a mail does not fetch from wherever its writer pointed.
|
|
15
|
-
*/
|
|
16
|
-
export declare function renderNoticeMarkdown(markdown: string, base?: string): string;
|
|
17
|
-
/**
|
|
18
|
-
* The notice mail (ADR-0164) - the head, band, button and foot of the auth mails, so a person knows the sender. The head
|
|
19
|
-
* names the application that sends it with its tagline, and the band and button are in its brand colour (`appBrand`).
|
|
20
|
-
* Without a link (a notice sent with no request to take the address from) the button gives way to the note.
|
|
21
|
-
*/
|
|
22
|
-
export declare function noticeEmailForm({ lang, words, link, format, base, brand }: {
|
|
23
|
-
lang: string;
|
|
24
|
-
words: NoticeWords;
|
|
25
|
-
link?: string;
|
|
26
|
-
/** How the body is written - an announcement's is Markdown. */
|
|
27
|
-
format?: 'text' | 'markdown';
|
|
28
|
-
/** The domain's address, under which an app address in a Markdown body is resolved. */
|
|
29
|
-
base?: string;
|
|
30
|
-
/** The application sending it. */
|
|
31
|
-
brand?: AppBrand;
|
|
32
|
-
}): string;
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.escapeHtml = escapeHtml;
|
|
4
|
-
exports.safeNoticeHref = safeNoticeHref;
|
|
5
|
-
exports.renderNoticeMarkdown = renderNoticeMarkdown;
|
|
6
|
-
exports.noticeEmailForm = noticeEmailForm;
|
|
7
|
-
const marked_1 = require("marked");
|
|
8
|
-
const app_brand_js_1 = require("./app-brand.js");
|
|
9
|
-
/** Every value goes into the HTML through this - a subject like `<b>` or `A&B` stays text. */
|
|
10
|
-
function escapeHtml(value) {
|
|
11
|
-
return String(value ?? '')
|
|
12
|
-
.replace(/&/g, '&')
|
|
13
|
-
.replace(/</g, '<')
|
|
14
|
-
.replace(/>/g, '>')
|
|
15
|
-
.replace(/"/g, '"')
|
|
16
|
-
.replace(/'/g, ''');
|
|
17
|
-
}
|
|
18
|
-
/** The schemes a link in a notice may take; anything else is shown as its text. */
|
|
19
|
-
const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
|
20
|
-
const NO_BASE = 'https://no-base.invalid/';
|
|
21
|
-
/**
|
|
22
|
-
* Where a link in a notice may go: http(s) and mailto as written, and an address of the app (no scheme) under `base`,
|
|
23
|
-
* the domain's own address. With no base an app address has nowhere to go and is dropped, as is every other scheme -
|
|
24
|
-
* `javascript:` in any spelling the URL parser reads as one.
|
|
25
|
-
*/
|
|
26
|
-
function safeNoticeHref(href, base) {
|
|
27
|
-
const raw = String(href ?? '').trim();
|
|
28
|
-
if (!raw)
|
|
29
|
-
return undefined;
|
|
30
|
-
let url;
|
|
31
|
-
try {
|
|
32
|
-
url = new URL(raw, base || NO_BASE);
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
if (!SAFE_PROTOCOLS.has(url.protocol))
|
|
38
|
-
return undefined;
|
|
39
|
-
if (url.origin === new URL(NO_BASE).origin)
|
|
40
|
-
return undefined;
|
|
41
|
-
return url.href;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Markdown as mail HTML, rendered here on the server. Raw HTML in the text is shown as text, never as markup; a link or
|
|
45
|
-
* an image becomes a link only to an address `safeNoticeHref` allows, and an image is not loaded - it is shown as a
|
|
46
|
-
* link with its alt text, so a mail does not fetch from wherever its writer pointed.
|
|
47
|
-
*/
|
|
48
|
-
function renderNoticeMarkdown(markdown, base) {
|
|
49
|
-
const marked = new marked_1.Marked({
|
|
50
|
-
gfm: true,
|
|
51
|
-
breaks: true,
|
|
52
|
-
async: false,
|
|
53
|
-
renderer: {
|
|
54
|
-
html({ text }) {
|
|
55
|
-
return escapeHtml(text);
|
|
56
|
-
},
|
|
57
|
-
link({ href, title, tokens }) {
|
|
58
|
-
const label = this.parser.parseInline(tokens);
|
|
59
|
-
const safe = safeNoticeHref(href, base);
|
|
60
|
-
return safe ? `<a href="${escapeHtml(safe)}"${title ? ` title="${escapeHtml(title)}"` : ''}>${label}</a>` : label;
|
|
61
|
-
},
|
|
62
|
-
image({ href, text }) {
|
|
63
|
-
const safe = safeNoticeHref(href, base);
|
|
64
|
-
return safe ? `<a href="${escapeHtml(safe)}">${escapeHtml(text || safe)}</a>` : escapeHtml(text);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
return marked.parse(String(markdown ?? ''));
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* The notice mail (ADR-0164) - the head, band, button and foot of the auth mails, so a person knows the sender. The head
|
|
72
|
-
* names the application that sends it with its tagline, and the band and button are in its brand colour (`appBrand`).
|
|
73
|
-
* Without a link (a notice sent with no request to take the address from) the button gives way to the note.
|
|
74
|
-
*/
|
|
75
|
-
function noticeEmailForm({ lang, words, link, format = 'text', base, brand = app_brand_js_1.DEFAULT_BRAND }) {
|
|
76
|
-
const color = escapeHtml(brand.color);
|
|
77
|
-
const onColor = (0, app_brand_js_1.textColorOn)(brand.color);
|
|
78
|
-
const action = link
|
|
79
|
-
? `<a
|
|
80
|
-
href="${escapeHtml(link)}"
|
|
81
|
-
style="display:inline-block;margin:10px 5px 5px 0;border-radius:7px;background-color:${color};padding:7px 15px;color:${onColor};font-size:18px;font-weight:bold;text-decoration:none;"
|
|
82
|
-
>${escapeHtml(words.action)}</a
|
|
83
|
-
>`
|
|
84
|
-
: `<span style="display:block;color:#555">${escapeHtml(words.note)}</span>`;
|
|
85
|
-
return `
|
|
86
|
-
<html lang="${escapeHtml(lang)}">
|
|
87
|
-
<head>
|
|
88
|
-
<meta charset="utf-8" />
|
|
89
|
-
<title>${escapeHtml(words.subject)}</title>
|
|
90
|
-
<meta name="author" content="hatiolab" />
|
|
91
|
-
<meta name="google" content="notranslate"/>
|
|
92
|
-
</head>
|
|
93
|
-
|
|
94
|
-
<body>
|
|
95
|
-
<div style="background-color:#f6f6f6">
|
|
96
|
-
<!--header begin-->
|
|
97
|
-
<div style="background-color:#fff;padding:10px 20px;border-top:2px solid ${color};">
|
|
98
|
-
<span style="font-size:20px;font-weight:bold;color:#1f2933">${escapeHtml(brand.name)}</span>${brand.tagline ? `<span style="margin-left:10px;font-size:13px;color:#6b7280">${escapeHtml(brand.tagline)}</span>` : ''}
|
|
99
|
-
</div>
|
|
100
|
-
<!--header end-->
|
|
101
|
-
|
|
102
|
-
<!--title begin-->
|
|
103
|
-
<div style="background-color:${color};padding:12px 10px 10px 10px;min-height:50px;">
|
|
104
|
-
<span style="display:block;color:${onColor};font-size:24px;font-weight:bold;margin-left:40px"
|
|
105
|
-
>${escapeHtml(words.subject)}</span
|
|
106
|
-
>
|
|
107
|
-
</div>
|
|
108
|
-
<!--title end-->
|
|
109
|
-
|
|
110
|
-
<!--body begin-->
|
|
111
|
-
${format === 'markdown'
|
|
112
|
-
? `<div style="padding:10px 20px;line-height:1.5;font-size:16px">${renderNoticeMarkdown(words.body, base)}</div>`
|
|
113
|
-
: `<p style="padding:10px 20px;line-height:1.5;font-size:16px;white-space:pre-line">${escapeHtml(words.body)}</p>`}
|
|
114
|
-
<p style="padding:0 20px 10px 20px;font-size:16px">
|
|
115
|
-
${action}
|
|
116
|
-
</p>
|
|
117
|
-
<!--body end-->
|
|
118
|
-
|
|
119
|
-
<!--footer begin-->
|
|
120
|
-
<div style="background-color:#3d5874;padding:7px 20px 5px 20px;font-size:12px;color:#efefef">
|
|
121
|
-
© Hatio, Lab. Inc. All rights reserved.
|
|
122
|
-
</div>
|
|
123
|
-
<!--footer end-->
|
|
124
|
-
</div>
|
|
125
|
-
</body>
|
|
126
|
-
</html>
|
|
127
|
-
`;
|
|
128
|
-
}
|
|
129
|
-
//# sourceMappingURL=notice-email.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"notice-email.js","sourceRoot":"","sources":["../../../server/controllers/notice/notice-email.ts"],"names":[],"mappings":"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","sourcesContent":["import { Marked } from 'marked'\n\nimport { DEFAULT_BRAND, textColorOn, type AppBrand } from './app-brand.js'\nimport type { NoticeWords } from './notice-channels.js'\n\n/** Every value goes into the HTML through this - a subject like `<b>` or `A&B` stays text. */\nexport function escapeHtml(value: unknown): string {\n return String(value ?? '')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''')\n}\n\n/** The schemes a link in a notice may take; anything else is shown as its text. */\nconst SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])\nconst NO_BASE = 'https://no-base.invalid/'\n\n/**\n * Where a link in a notice may go: http(s) and mailto as written, and an address of the app (no scheme) under `base`,\n * the domain's own address. With no base an app address has nowhere to go and is dropped, as is every other scheme -\n * `javascript:` in any spelling the URL parser reads as one.\n */\nexport function safeNoticeHref(href: string | null | undefined, base?: string): string | undefined {\n const raw = String(href ?? '').trim()\n if (!raw) return undefined\n\n let url: URL\n try {\n url = new URL(raw, base || NO_BASE)\n } catch {\n return undefined\n }\n\n if (!SAFE_PROTOCOLS.has(url.protocol)) return undefined\n if (url.origin === new URL(NO_BASE).origin) return undefined\n return url.href\n}\n\n/**\n * Markdown as mail HTML, rendered here on the server. Raw HTML in the text is shown as text, never as markup; a link or\n * an image becomes a link only to an address `safeNoticeHref` allows, and an image is not loaded - it is shown as a\n * link with its alt text, so a mail does not fetch from wherever its writer pointed.\n */\nexport function renderNoticeMarkdown(markdown: string, base?: string): string {\n const marked = new Marked({\n gfm: true,\n breaks: true,\n async: false,\n renderer: {\n html({ text }) {\n return escapeHtml(text)\n },\n link({ href, title, tokens }) {\n const label = this.parser.parseInline(tokens)\n const safe = safeNoticeHref(href, base)\n return safe ? `<a href=\"${escapeHtml(safe)}\"${title ? ` title=\"${escapeHtml(title)}\"` : ''}>${label}</a>` : label\n },\n image({ href, text }) {\n const safe = safeNoticeHref(href, base)\n return safe ? `<a href=\"${escapeHtml(safe)}\">${escapeHtml(text || safe)}</a>` : escapeHtml(text)\n }\n }\n })\n\n return marked.parse(String(markdown ?? '')) as string\n}\n\n/**\n * The notice mail (ADR-0164) - the head, band, button and foot of the auth mails, so a person knows the sender. The head\n * names the application that sends it with its tagline, and the band and button are in its brand colour (`appBrand`).\n * Without a link (a notice sent with no request to take the address from) the button gives way to the note.\n */\nexport function noticeEmailForm({\n lang,\n words,\n link,\n format = 'text',\n base,\n brand = DEFAULT_BRAND\n}: {\n lang: string\n words: NoticeWords\n link?: string\n /** How the body is written - an announcement's is Markdown. */\n format?: 'text' | 'markdown'\n /** The domain's address, under which an app address in a Markdown body is resolved. */\n base?: string\n /** The application sending it. */\n brand?: AppBrand\n}) {\n const color = escapeHtml(brand.color)\n const onColor = textColorOn(brand.color)\n const action = link\n ? `<a\n href=\"${escapeHtml(link)}\"\n style=\"display:inline-block;margin:10px 5px 5px 0;border-radius:7px;background-color:${color};padding:7px 15px;color:${onColor};font-size:18px;font-weight:bold;text-decoration:none;\"\n >${escapeHtml(words.action)}</a\n >`\n : `<span style=\"display:block;color:#555\">${escapeHtml(words.note)}</span>`\n\n return `\n <html lang=\"${escapeHtml(lang)}\">\n <head>\n <meta charset=\"utf-8\" />\n <title>${escapeHtml(words.subject)}</title>\n <meta name=\"author\" content=\"hatiolab\" />\n <meta name=\"google\" content=\"notranslate\"/>\n </head>\n\n <body>\n <div style=\"background-color:#f6f6f6\">\n <!--header begin-->\n <div style=\"background-color:#fff;padding:10px 20px;border-top:2px solid ${color};\">\n <span style=\"font-size:20px;font-weight:bold;color:#1f2933\">${escapeHtml(brand.name)}</span>${\n brand.tagline ? `<span style=\"margin-left:10px;font-size:13px;color:#6b7280\">${escapeHtml(brand.tagline)}</span>` : ''\n }\n </div>\n <!--header end-->\n\n <!--title begin-->\n <div style=\"background-color:${color};padding:12px 10px 10px 10px;min-height:50px;\">\n <span style=\"display:block;color:${onColor};font-size:24px;font-weight:bold;margin-left:40px\"\n >${escapeHtml(words.subject)}</span\n >\n </div>\n <!--title end-->\n\n <!--body begin-->\n ${\n format === 'markdown'\n ? `<div style=\"padding:10px 20px;line-height:1.5;font-size:16px\">${renderNoticeMarkdown(words.body, base)}</div>`\n : `<p style=\"padding:10px 20px;line-height:1.5;font-size:16px;white-space:pre-line\">${escapeHtml(words.body)}</p>`\n }\n <p style=\"padding:0 20px 10px 20px;font-size:16px\">\n ${action}\n </p>\n <!--body end-->\n\n <!--footer begin-->\n <div style=\"background-color:#3d5874;padding:7px 20px 5px 20px;font-size:12px;color:#efefef\">\n © Hatio, Lab. Inc. All rights reserved.\n </div>\n <!--footer end-->\n </div>\n </body>\n </html>\n `\n}\n"]}
|