@meith/settings 0.16.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/LICENSE.md +165 -0
- package/package.json +26 -0
- package/src/board-url.ts +26 -0
- package/src/branding.ts +21 -0
- package/src/definitions.ts +1088 -0
- package/src/fields.ts +58 -0
- package/src/index.ts +69 -0
- package/src/legal.ts +117 -0
- package/src/mail.ts +281 -0
- package/src/origin.ts +52 -0
- package/src/push.ts +44 -0
- package/src/store.ts +135 -0
package/src/fields.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { SettingDefinition } from './definitions'
|
|
2
|
+
|
|
3
|
+
export interface SettingOption {
|
|
4
|
+
readonly value: string
|
|
5
|
+
readonly label: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type SettingField =
|
|
9
|
+
| { readonly kind: 'text' }
|
|
10
|
+
| { readonly kind: 'textarea' }
|
|
11
|
+
| { readonly kind: 'boolean' }
|
|
12
|
+
| { readonly kind: 'number'; readonly min?: number; readonly max?: number }
|
|
13
|
+
| { readonly kind: 'select'; readonly options: readonly SettingOption[] }
|
|
14
|
+
| { readonly kind: 'secret' }
|
|
15
|
+
|
|
16
|
+
export function settingField(definition: SettingDefinition): SettingField {
|
|
17
|
+
if (definition.secret === true) return { kind: 'secret' }
|
|
18
|
+
|
|
19
|
+
const ui = definition.ui
|
|
20
|
+
if (ui?.options !== undefined) return { kind: 'select', options: ui.options }
|
|
21
|
+
|
|
22
|
+
switch (typeof definition.default) {
|
|
23
|
+
case 'boolean':
|
|
24
|
+
return { kind: 'boolean' }
|
|
25
|
+
case 'number':
|
|
26
|
+
return {
|
|
27
|
+
kind: 'number',
|
|
28
|
+
...(ui?.min === undefined ? {} : { min: ui.min }),
|
|
29
|
+
...(ui?.max === undefined ? {} : { max: ui.max }),
|
|
30
|
+
}
|
|
31
|
+
default:
|
|
32
|
+
return ui?.multiline === true ? { kind: 'textarea' } : { kind: 'text' }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function secretClearField(key: string): string {
|
|
37
|
+
return `${key}__clear`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function coerceFormValue(
|
|
41
|
+
definition: SettingDefinition,
|
|
42
|
+
raw: string | undefined,
|
|
43
|
+
options: { readonly clear?: boolean } = {},
|
|
44
|
+
): unknown {
|
|
45
|
+
if (typeof definition.default === 'boolean') return raw !== undefined && raw !== ''
|
|
46
|
+
if (definition.secret === true) {
|
|
47
|
+
if (options.clear === true) return definition.default
|
|
48
|
+
if (raw === undefined || raw === '') return undefined
|
|
49
|
+
}
|
|
50
|
+
if (raw === undefined) return undefined
|
|
51
|
+
|
|
52
|
+
if (typeof definition.default === 'number') {
|
|
53
|
+
const value = Number(raw)
|
|
54
|
+
return raw.trim() === '' || Number.isNaN(value) ? raw : value
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return raw
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type BoardUrlEnvironment,
|
|
3
|
+
type BoardUrlResolution,
|
|
4
|
+
type BoardUrlSource,
|
|
5
|
+
isUsableOrigin,
|
|
6
|
+
normaliseOrigin,
|
|
7
|
+
resolveBoardUrl,
|
|
8
|
+
} from './board-url'
|
|
9
|
+
export {
|
|
10
|
+
isLogoKey,
|
|
11
|
+
isLogoScheme,
|
|
12
|
+
LOGO_SCHEMES,
|
|
13
|
+
type LogoScheme,
|
|
14
|
+
logoFormat,
|
|
15
|
+
logoPath,
|
|
16
|
+
} from './branding'
|
|
17
|
+
export {
|
|
18
|
+
SETTING_DEFINITION_BY_KEY,
|
|
19
|
+
SETTING_DEFINITIONS,
|
|
20
|
+
type SettingDefinition,
|
|
21
|
+
type SettingGroup,
|
|
22
|
+
type SettingKey,
|
|
23
|
+
type SettingValue,
|
|
24
|
+
} from './definitions'
|
|
25
|
+
export {
|
|
26
|
+
coerceFormValue,
|
|
27
|
+
type SettingField,
|
|
28
|
+
type SettingOption,
|
|
29
|
+
secretClearField,
|
|
30
|
+
settingField,
|
|
31
|
+
} from './fields'
|
|
32
|
+
export { DEFAULT_PRIVACY_POLICY, DEFAULT_TERMS_OF_SERVICE } from './legal'
|
|
33
|
+
export {
|
|
34
|
+
canSendMail,
|
|
35
|
+
defaultPort,
|
|
36
|
+
describeMailConfig,
|
|
37
|
+
type HttpMailConfig,
|
|
38
|
+
type LogMailConfig,
|
|
39
|
+
MAIL_PRESET_BY_ID,
|
|
40
|
+
MAIL_PRESETS,
|
|
41
|
+
type MailConfig,
|
|
42
|
+
type MailEnvironment,
|
|
43
|
+
type MailPreset,
|
|
44
|
+
type MailResolution,
|
|
45
|
+
type MailSecurity,
|
|
46
|
+
type MailSource,
|
|
47
|
+
type MailTransport,
|
|
48
|
+
mailConfigFromEnvironment,
|
|
49
|
+
mailConfigFromSettings,
|
|
50
|
+
mailConfigProblems,
|
|
51
|
+
NO_MAIL,
|
|
52
|
+
resolveMailConfig,
|
|
53
|
+
type SmtpMailConfig,
|
|
54
|
+
} from './mail'
|
|
55
|
+
export { isUsableFeedUrl, isUsableIssuer } from './origin'
|
|
56
|
+
export {
|
|
57
|
+
type PushConfig,
|
|
58
|
+
type PushProblem,
|
|
59
|
+
type PushResolution,
|
|
60
|
+
pushContact,
|
|
61
|
+
resolvePushConfig,
|
|
62
|
+
} from './push'
|
|
63
|
+
export {
|
|
64
|
+
type SaveResult,
|
|
65
|
+
type SettingsRepository,
|
|
66
|
+
SettingsSnapshot,
|
|
67
|
+
type SettingsSnapshotOptions,
|
|
68
|
+
saveSettings,
|
|
69
|
+
} from './store'
|
package/src/legal.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export const DEFAULT_TERMS_OF_SERVICE = `## The short version
|
|
2
|
+
|
|
3
|
+
This community is run by its administrators. These terms are the agreement
|
|
4
|
+
between them and you. By creating an account, and by posting, you accept them.
|
|
5
|
+
|
|
6
|
+
## Your account
|
|
7
|
+
|
|
8
|
+
- Give a working e-mail address. It is how the board reaches you about your own
|
|
9
|
+
account, and how you get back in when you forget your password.
|
|
10
|
+
- Keep your password to yourself. Anything posted from your account is treated
|
|
11
|
+
as posted by you.
|
|
12
|
+
- One account per person, unless an administrator has agreed otherwise.
|
|
13
|
+
- Do not register on behalf of someone else, or claim to be someone you are not.
|
|
14
|
+
|
|
15
|
+
## What you post
|
|
16
|
+
|
|
17
|
+
You keep the rights to what you write. By posting it here you allow this
|
|
18
|
+
community to store it and to show it to whoever can read the forum it is in,
|
|
19
|
+
for as long as the community keeps running.
|
|
20
|
+
|
|
21
|
+
Do not post anything that is unlawful where this board operates, that harasses
|
|
22
|
+
or threatens another person, that infringes someone else's copyright, that
|
|
23
|
+
exists to advertise, or that deliberately disrupts other people's discussions.
|
|
24
|
+
|
|
25
|
+
## Moderation
|
|
26
|
+
|
|
27
|
+
Moderators may edit, move, hide or delete any post, lock any thread, and warn,
|
|
28
|
+
suspend or remove any account. They do not have to ask first, and a decision may
|
|
29
|
+
be made about the effect of a post rather than its intent.
|
|
30
|
+
|
|
31
|
+
If you think a decision was wrong, say so to the staff rather than in the
|
|
32
|
+
thread — that is the route that can actually change it.
|
|
33
|
+
|
|
34
|
+
## Ending your membership
|
|
35
|
+
|
|
36
|
+
You may stop using the board at any time and may ask the administrators to close
|
|
37
|
+
your account. Closing an account does not automatically delete what you have
|
|
38
|
+
already posted: the discussions other members took part in stay readable. If you
|
|
39
|
+
need something specific removed, ask, and the administrators will decide.
|
|
40
|
+
|
|
41
|
+
## Availability
|
|
42
|
+
|
|
43
|
+
The board is provided as it is. There is no guarantee that it stays up, that
|
|
44
|
+
nothing is lost, or that it will keep running indefinitely. Keep your own copy
|
|
45
|
+
of anything you cannot afford to lose.
|
|
46
|
+
|
|
47
|
+
## Changes
|
|
48
|
+
|
|
49
|
+
These terms can change. Continuing to use the board after a change means you
|
|
50
|
+
accept the version in force at the time you use it.
|
|
51
|
+
|
|
52
|
+
## Contact
|
|
53
|
+
|
|
54
|
+
Questions about these terms go to the board's administrators.`
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_PRIVACY_POLICY = `## What this covers
|
|
57
|
+
|
|
58
|
+
How this community handles information about the people who use it. The
|
|
59
|
+
administrators of this board decide what happens to that information.
|
|
60
|
+
|
|
61
|
+
## What is collected
|
|
62
|
+
|
|
63
|
+
- **What you give us.** Your username, e-mail address, password (stored only as
|
|
64
|
+
a hash, never as text anyone can read), and anything you choose to put in your
|
|
65
|
+
profile or your posts.
|
|
66
|
+
- **What using the board records.** The IP address a request came from, the time
|
|
67
|
+
of it, and the browser it announced itself as — kept so that abuse can be
|
|
68
|
+
traced and spam refused.
|
|
69
|
+
- **Cookies.** One for your session, so the board knows you are signed in, and
|
|
70
|
+
ones remembering your preferences. Signing out clears the session cookie.
|
|
71
|
+
|
|
72
|
+
## What it is used for
|
|
73
|
+
|
|
74
|
+
Running the board: showing your posts to other members, sending the mail you
|
|
75
|
+
asked for, keeping accounts secure, and dealing with spam and abuse. Nothing
|
|
76
|
+
here is sold, and nothing is handed to advertisers.
|
|
77
|
+
|
|
78
|
+
## What other people can see
|
|
79
|
+
|
|
80
|
+
Your username, your profile, your posts, and when you were last active are
|
|
81
|
+
visible to anyone who can read the forum in question — which, on a public forum,
|
|
82
|
+
means anyone at all, including search engines. Your e-mail address and your IP
|
|
83
|
+
address are not shown to other members; administrators and moderators can see
|
|
84
|
+
them as part of moderating.
|
|
85
|
+
|
|
86
|
+
## Who else is involved
|
|
87
|
+
|
|
88
|
+
The board runs on servers rented from a hosting provider, and it may send mail
|
|
89
|
+
through a mail provider. Those providers handle the data needed to do their job,
|
|
90
|
+
and nothing further.
|
|
91
|
+
|
|
92
|
+
If this board offers a "sign in with…" button, choosing it tells that provider
|
|
93
|
+
you have an account here and hands this board the address and display name they
|
|
94
|
+
hold for you. Nothing else passes in either direction, and no member has to use
|
|
95
|
+
one: a password works the same as it always did. A passkey involves nobody but
|
|
96
|
+
you and this board — the key never leaves your device.
|
|
97
|
+
|
|
98
|
+
## How long it is kept
|
|
99
|
+
|
|
100
|
+
Your account and your posts are kept while your account exists. Technical logs,
|
|
101
|
+
including IP addresses, are kept only as long as they are useful for security
|
|
102
|
+
and moderation, and then discarded.
|
|
103
|
+
|
|
104
|
+
## What you can ask for
|
|
105
|
+
|
|
106
|
+
You can ask the administrators for a copy of what is held about you, for a
|
|
107
|
+
correction to it, or for your account to be closed. Closing an account does not
|
|
108
|
+
by itself delete posts other members replied to; ask if you need something
|
|
109
|
+
specific removed, and it will be considered.
|
|
110
|
+
|
|
111
|
+
## Changes
|
|
112
|
+
|
|
113
|
+
This policy can change. The version on this page is the one in force.
|
|
114
|
+
|
|
115
|
+
## Contact
|
|
116
|
+
|
|
117
|
+
Questions about your data go to the board's administrators.`
|
package/src/mail.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { SettingsSnapshot } from './store'
|
|
2
|
+
|
|
3
|
+
export type MailSecurity = 'tls' | 'starttls' | 'none'
|
|
4
|
+
|
|
5
|
+
export type MailTransport = 'log' | 'http' | 'smtp'
|
|
6
|
+
|
|
7
|
+
export interface LogMailConfig {
|
|
8
|
+
readonly transport: 'log'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HttpMailConfig {
|
|
12
|
+
readonly transport: 'http'
|
|
13
|
+
readonly from: string
|
|
14
|
+
readonly endpoint: string
|
|
15
|
+
readonly token: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SmtpMailConfig {
|
|
19
|
+
readonly transport: 'smtp'
|
|
20
|
+
readonly from: string
|
|
21
|
+
readonly host: string
|
|
22
|
+
readonly port: number
|
|
23
|
+
readonly security: MailSecurity
|
|
24
|
+
readonly username: string
|
|
25
|
+
readonly password: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type MailConfig = LogMailConfig | HttpMailConfig | SmtpMailConfig
|
|
29
|
+
|
|
30
|
+
export const NO_MAIL: LogMailConfig = { transport: 'log' }
|
|
31
|
+
|
|
32
|
+
export interface MailEnvironment {
|
|
33
|
+
readonly MAIL_DRIVER?: MailTransport | undefined
|
|
34
|
+
readonly MAIL_FROM?: string | undefined
|
|
35
|
+
readonly MAIL_HTTP_ENDPOINT?: string | undefined
|
|
36
|
+
readonly MAIL_HTTP_TOKEN?: string | undefined
|
|
37
|
+
readonly MAIL_SMTP_HOST?: string | undefined
|
|
38
|
+
readonly MAIL_SMTP_PORT?: number | undefined
|
|
39
|
+
readonly MAIL_SMTP_SECURITY?: MailSecurity | undefined
|
|
40
|
+
readonly MAIL_SMTP_USERNAME?: string | undefined
|
|
41
|
+
readonly MAIL_SMTP_PASSWORD?: string | undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type MailSource = 'environment' | 'board'
|
|
45
|
+
|
|
46
|
+
export interface MailResolution {
|
|
47
|
+
readonly config: MailConfig
|
|
48
|
+
readonly source: MailSource
|
|
49
|
+
readonly problems: readonly string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function text(value: string | undefined): string {
|
|
53
|
+
return (value ?? '').trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function mailConfigFromEnvironment(source: MailEnvironment): MailConfig | null {
|
|
57
|
+
switch (source.MAIL_DRIVER) {
|
|
58
|
+
case 'http':
|
|
59
|
+
return {
|
|
60
|
+
transport: 'http',
|
|
61
|
+
from: text(source.MAIL_FROM),
|
|
62
|
+
endpoint: text(source.MAIL_HTTP_ENDPOINT),
|
|
63
|
+
token: text(source.MAIL_HTTP_TOKEN),
|
|
64
|
+
}
|
|
65
|
+
case 'smtp':
|
|
66
|
+
return {
|
|
67
|
+
transport: 'smtp',
|
|
68
|
+
from: text(source.MAIL_FROM),
|
|
69
|
+
host: text(source.MAIL_SMTP_HOST),
|
|
70
|
+
port: source.MAIL_SMTP_PORT ?? defaultPort(source.MAIL_SMTP_SECURITY ?? 'starttls'),
|
|
71
|
+
security: source.MAIL_SMTP_SECURITY ?? 'starttls',
|
|
72
|
+
username: text(source.MAIL_SMTP_USERNAME),
|
|
73
|
+
password: text(source.MAIL_SMTP_PASSWORD),
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function mailConfigFromSettings(settings: SettingsSnapshot): MailConfig {
|
|
81
|
+
switch (settings.get('mail.transport')) {
|
|
82
|
+
case 'http':
|
|
83
|
+
return {
|
|
84
|
+
transport: 'http',
|
|
85
|
+
from: text(settings.get('mail.from')),
|
|
86
|
+
endpoint: text(settings.get('mail.http_endpoint')),
|
|
87
|
+
token: text(settings.get('mail.http_token')),
|
|
88
|
+
}
|
|
89
|
+
case 'smtp':
|
|
90
|
+
return {
|
|
91
|
+
transport: 'smtp',
|
|
92
|
+
from: text(settings.get('mail.from')),
|
|
93
|
+
host: text(settings.get('mail.smtp_host')),
|
|
94
|
+
port: settings.get('mail.smtp_port'),
|
|
95
|
+
security: settings.get('mail.smtp_security') as MailSecurity,
|
|
96
|
+
username: text(settings.get('mail.smtp_username')),
|
|
97
|
+
password: text(settings.get('mail.smtp_password')),
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
return NO_MAIL
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function defaultPort(security: MailSecurity): number {
|
|
105
|
+
return security === 'tls' ? 465 : 587
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function resolveMailConfig(input: {
|
|
109
|
+
readonly environment: MailEnvironment
|
|
110
|
+
readonly settings: SettingsSnapshot
|
|
111
|
+
}): MailResolution {
|
|
112
|
+
const fromEnvironment = mailConfigFromEnvironment(input.environment)
|
|
113
|
+
const config = fromEnvironment ?? mailConfigFromSettings(input.settings)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
config,
|
|
117
|
+
source: fromEnvironment === null ? 'board' : 'environment',
|
|
118
|
+
problems: mailConfigProblems(config),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function mailConfigProblems(config: MailConfig): readonly string[] {
|
|
123
|
+
const problems: string[] = []
|
|
124
|
+
|
|
125
|
+
if (config.transport === 'log') return problems
|
|
126
|
+
|
|
127
|
+
if (config.from === '') {
|
|
128
|
+
problems.push('No sender address. Mail needs an address to come from.')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (config.transport === 'http') {
|
|
132
|
+
if (config.endpoint === '') problems.push('No API endpoint.')
|
|
133
|
+
if (config.token === '') problems.push('No API key.')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (config.transport === 'smtp') {
|
|
137
|
+
if (config.host === '') problems.push('No SMTP host.')
|
|
138
|
+
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
|
|
139
|
+
problems.push('The SMTP port must be between 1 and 65535.')
|
|
140
|
+
}
|
|
141
|
+
if (config.username !== '' && config.password === '') {
|
|
142
|
+
problems.push('An SMTP username was given with no password.')
|
|
143
|
+
}
|
|
144
|
+
if (config.username === '' && config.password !== '') {
|
|
145
|
+
problems.push('An SMTP password was given with no username.')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return problems
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function canSendMail(config: MailConfig): boolean {
|
|
153
|
+
return config.transport !== 'log' && mailConfigProblems(config).length === 0
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function describeMailConfig(config: MailConfig): string {
|
|
157
|
+
switch (config.transport) {
|
|
158
|
+
case 'log':
|
|
159
|
+
return 'Not sending — messages are written to the server log'
|
|
160
|
+
case 'http':
|
|
161
|
+
return `HTTP API at ${hostOf(config.endpoint)}, from ${config.from || '(no address)'}`
|
|
162
|
+
case 'smtp':
|
|
163
|
+
return (
|
|
164
|
+
`SMTP to ${config.host || '(no host)'}:${config.port} (${config.security}), ` +
|
|
165
|
+
`from ${config.from || '(no address)'}`
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hostOf(endpoint: string): string {
|
|
171
|
+
try {
|
|
172
|
+
return new URL(endpoint).host
|
|
173
|
+
} catch {
|
|
174
|
+
return endpoint === '' ? '(no endpoint)' : endpoint
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface MailPreset {
|
|
179
|
+
readonly id: string
|
|
180
|
+
readonly label: string
|
|
181
|
+
readonly transport: 'http' | 'smtp'
|
|
182
|
+
readonly note: string
|
|
183
|
+
readonly endpoint?: string
|
|
184
|
+
readonly host?: string
|
|
185
|
+
readonly port?: number
|
|
186
|
+
readonly security?: MailSecurity
|
|
187
|
+
readonly username?: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const MAIL_PRESETS: readonly MailPreset[] = [
|
|
191
|
+
{
|
|
192
|
+
id: 'mailbox',
|
|
193
|
+
label: 'A mailbox I already have (SMTP)',
|
|
194
|
+
transport: 'smtp',
|
|
195
|
+
note:
|
|
196
|
+
'The least work by a distance, if you already receive mail on this domain: ' +
|
|
197
|
+
'SPF and DKIM are set up already, so there are no DNS records to add. Use ' +
|
|
198
|
+
'your provider’s SMTP host and an app password — never your login password.',
|
|
199
|
+
port: 465,
|
|
200
|
+
security: 'tls',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'resend-http',
|
|
204
|
+
label: 'Resend (API)',
|
|
205
|
+
transport: 'http',
|
|
206
|
+
endpoint: 'https://api.resend.com/emails',
|
|
207
|
+
note:
|
|
208
|
+
'Free for 3,000 messages a month. You must verify your sending domain with ' +
|
|
209
|
+
'Resend first — until you do, a new account can only mail the address you ' +
|
|
210
|
+
'signed up with, and every other message is rejected.',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: 'resend-smtp',
|
|
214
|
+
label: 'Resend (SMTP)',
|
|
215
|
+
transport: 'smtp',
|
|
216
|
+
host: 'smtp.resend.com',
|
|
217
|
+
port: 465,
|
|
218
|
+
security: 'tls',
|
|
219
|
+
username: 'resend',
|
|
220
|
+
note:
|
|
221
|
+
'The same service as the row above, over SMTP. The username is the literal ' +
|
|
222
|
+
'word “resend” and the password is your API key. Verify your domain first.',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: 'brevo',
|
|
226
|
+
label: 'Brevo (SMTP)',
|
|
227
|
+
transport: 'smtp',
|
|
228
|
+
host: 'smtp-relay.brevo.com',
|
|
229
|
+
port: 587,
|
|
230
|
+
security: 'starttls',
|
|
231
|
+
note:
|
|
232
|
+
'The most generous free tier — around 300 messages a day. Independent ' +
|
|
233
|
+
'deliverability testing puts it below Postmark and Resend, which matters ' +
|
|
234
|
+
'most for password resets. Verify your domain first.',
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: 'postmark',
|
|
238
|
+
label: 'Postmark (SMTP)',
|
|
239
|
+
transport: 'smtp',
|
|
240
|
+
host: 'smtp.postmarkapp.com',
|
|
241
|
+
port: 587,
|
|
242
|
+
security: 'starttls',
|
|
243
|
+
note:
|
|
244
|
+
'The best-delivering of these and the stingiest — 100 messages a month free, ' +
|
|
245
|
+
'and an approval step before you can send at all. Username and password are ' +
|
|
246
|
+
'both the server API token. Verify your domain first.',
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: 'ses',
|
|
250
|
+
label: 'Amazon SES (SMTP)',
|
|
251
|
+
transport: 'smtp',
|
|
252
|
+
port: 587,
|
|
253
|
+
security: 'starttls',
|
|
254
|
+
note:
|
|
255
|
+
'The cheapest at volume and the most work to start: a new account is in a ' +
|
|
256
|
+
'sandbox that can only mail verified addresses, and leaving it needs a ' +
|
|
257
|
+
'support request. The host is email-smtp.<your-region>.amazonaws.com, and ' +
|
|
258
|
+
'the credentials are SMTP credentials — not your AWS access keys.',
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
id: 'smtp',
|
|
262
|
+
label: 'Any other SMTP server',
|
|
263
|
+
transport: 'smtp',
|
|
264
|
+
port: 587,
|
|
265
|
+
security: 'starttls',
|
|
266
|
+
note: 'Anything that speaks SMTP, including a relay you run yourself.',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: 'http',
|
|
270
|
+
label: 'Any other JSON API',
|
|
271
|
+
transport: 'http',
|
|
272
|
+
note:
|
|
273
|
+
'Only works for a provider whose API takes Resend’s exact field names — ' +
|
|
274
|
+
'from, to, subject, text, html, reply_to — with a Bearer token. Postmark and ' +
|
|
275
|
+
'Mailgun do not; use their SMTP hosts instead.',
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
export const MAIL_PRESET_BY_ID: ReadonlyMap<string, MailPreset> = new Map(
|
|
280
|
+
MAIL_PRESETS.map((preset) => [preset.id, preset]),
|
|
281
|
+
)
|
package/src/origin.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function normaliseOrigin(value: string): string {
|
|
2
|
+
return value.trim().replace(/\/+$/, '')
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isUsableIssuer(value: string): boolean {
|
|
6
|
+
let parsed: URL
|
|
7
|
+
try {
|
|
8
|
+
parsed = new URL(normaliseOrigin(value))
|
|
9
|
+
} catch {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost') return false
|
|
14
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
|
15
|
+
return parsed.hostname !== '' && parsed.search === '' && parsed.hash === ''
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* https, or plain http to a loopback address — the same allowance
|
|
20
|
+
* `allowedRedirectHosts` gives a plugin route, for the same reason: a test
|
|
21
|
+
* double never gets real TLS, and self-hosting a marketplace mirror should
|
|
22
|
+
* not have to fake a certificate to be pointed at from a dev or e2e board.
|
|
23
|
+
*/
|
|
24
|
+
export function isUsableFeedUrl(value: string): boolean {
|
|
25
|
+
let parsed: URL
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(value)
|
|
28
|
+
} catch {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hostname = parsed.hostname.toLowerCase()
|
|
33
|
+
const loopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
|
|
34
|
+
return parsed.protocol === 'https:' || (parsed.protocol === 'http:' && loopback)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isUsableOrigin(value: string): boolean {
|
|
38
|
+
let parsed: URL
|
|
39
|
+
try {
|
|
40
|
+
parsed = new URL(normaliseOrigin(value))
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
|
46
|
+
if (parsed.hostname === '') return false
|
|
47
|
+
return (
|
|
48
|
+
(parsed.pathname === '' || parsed.pathname === '/') &&
|
|
49
|
+
parsed.search === '' &&
|
|
50
|
+
parsed.hash === ''
|
|
51
|
+
)
|
|
52
|
+
}
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type BoardUrlEnvironment, resolveBoardUrl } from './board-url'
|
|
2
|
+
import type { SettingsSnapshot } from './store'
|
|
3
|
+
|
|
4
|
+
export interface PushConfig {
|
|
5
|
+
readonly publicKey: string
|
|
6
|
+
readonly privateKey: string
|
|
7
|
+
readonly subject: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type PushProblem = 'disabled' | 'no-keys' | 'no-subject'
|
|
11
|
+
|
|
12
|
+
export type PushResolution =
|
|
13
|
+
| { readonly config: PushConfig; readonly problem: null }
|
|
14
|
+
| { readonly config: null; readonly problem: PushProblem }
|
|
15
|
+
|
|
16
|
+
export function pushContact(input: {
|
|
17
|
+
readonly environment: BoardUrlEnvironment
|
|
18
|
+
readonly settings: SettingsSnapshot
|
|
19
|
+
}): string {
|
|
20
|
+
const stated = input.settings.get('push.contact').trim()
|
|
21
|
+
if (stated !== '') return stated
|
|
22
|
+
|
|
23
|
+
const from = input.settings.get('mail.from').trim()
|
|
24
|
+
if (from !== '') return `mailto:${from}`
|
|
25
|
+
|
|
26
|
+
const { url } = resolveBoardUrl(input)
|
|
27
|
+
return url.startsWith('https://') ? url : ''
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolvePushConfig(input: {
|
|
31
|
+
readonly environment: BoardUrlEnvironment
|
|
32
|
+
readonly settings: SettingsSnapshot
|
|
33
|
+
}): PushResolution {
|
|
34
|
+
if (!input.settings.get('push.enabled')) return { config: null, problem: 'disabled' }
|
|
35
|
+
|
|
36
|
+
const publicKey = input.settings.get('push.vapid_public_key').trim()
|
|
37
|
+
const privateKey = input.settings.get('push.vapid_private_key').trim()
|
|
38
|
+
if (publicKey === '' || privateKey === '') return { config: null, problem: 'no-keys' }
|
|
39
|
+
|
|
40
|
+
const subject = pushContact(input)
|
|
41
|
+
if (subject === '') return { config: null, problem: 'no-subject' }
|
|
42
|
+
|
|
43
|
+
return { config: { publicKey, privateKey, subject }, problem: null }
|
|
44
|
+
}
|