@meith/drivers 0.29.0 → 0.30.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/package.json +6 -6
- package/src/mail/index.ts +32 -15
- package/src/mail/smtp.ts +10 -0
- package/src/net/outbound.ts +61 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/drivers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,11 +37,11 @@
|
|
|
37
37
|
"nodemailer": "^9.0.5",
|
|
38
38
|
"redis": "^6.2.1",
|
|
39
39
|
"shiki": "^4.4.3",
|
|
40
|
-
"@meith/attachments": "0.
|
|
41
|
-
"@meith/core": "0.
|
|
42
|
-
"@meith/db": "0.
|
|
43
|
-
"@meith/i18n": "0.
|
|
44
|
-
"@meith/settings": "0.
|
|
40
|
+
"@meith/attachments": "0.30.0",
|
|
41
|
+
"@meith/core": "0.30.0",
|
|
42
|
+
"@meith/db": "0.30.0",
|
|
43
|
+
"@meith/i18n": "0.30.0",
|
|
44
|
+
"@meith/settings": "0.30.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"next": "16.3.1"
|
package/src/mail/index.ts
CHANGED
|
@@ -7,6 +7,13 @@ import {
|
|
|
7
7
|
mailConfigProblems,
|
|
8
8
|
} from '@meith/settings'
|
|
9
9
|
|
|
10
|
+
import {
|
|
11
|
+
assertSafeMailEndpoint,
|
|
12
|
+
BlockedOutboundError,
|
|
13
|
+
guardedMailTransport,
|
|
14
|
+
type HttpMailTransport,
|
|
15
|
+
mailAllowsPrivateHosts,
|
|
16
|
+
} from '../net/outbound'
|
|
10
17
|
import { formatSender } from './sender'
|
|
11
18
|
import { SmtpMailDriver } from './smtp'
|
|
12
19
|
|
|
@@ -37,15 +44,19 @@ export class MemoryMailDriver implements MailDriver {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export class HttpMailDriver implements MailDriver {
|
|
40
|
-
constructor(
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly config: HttpMailConfig,
|
|
49
|
+
private readonly transport: HttpMailTransport = guardedMailTransport,
|
|
50
|
+
) {}
|
|
41
51
|
|
|
42
52
|
async send(mail: OutgoingMail): Promise<void> {
|
|
43
|
-
const
|
|
44
|
-
const timeout = setTimeout(() => controller.abort(), 10_000)
|
|
53
|
+
const allowPrivateHosts = mailAllowsPrivateHosts()
|
|
45
54
|
|
|
46
55
|
try {
|
|
47
|
-
const
|
|
48
|
-
|
|
56
|
+
const url = assertSafeMailEndpoint(this.config.endpoint, allowPrivateHosts)
|
|
57
|
+
|
|
58
|
+
const result = await this.transport({
|
|
59
|
+
url,
|
|
49
60
|
headers: {
|
|
50
61
|
'content-type': 'application/json',
|
|
51
62
|
authorization: `Bearer ${this.config.token}`,
|
|
@@ -58,20 +69,26 @@ export class HttpMailDriver implements MailDriver {
|
|
|
58
69
|
...(mail.html ? { html: mail.html } : {}),
|
|
59
70
|
...(mail.replyTo ? { reply_to: mail.replyTo } : {}),
|
|
60
71
|
}),
|
|
61
|
-
|
|
72
|
+
timeoutMs: 10_000,
|
|
73
|
+
allowPrivateHosts,
|
|
62
74
|
})
|
|
63
75
|
|
|
64
|
-
if (
|
|
65
|
-
const body = await response.text().catch(() => '')
|
|
66
|
-
const detail = `${response.status} ${body.slice(0, 200)}`
|
|
76
|
+
if (result.status >= 200 && result.status < 300) return
|
|
67
77
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
logger({ driver: 'http', host: url.host }).warn(
|
|
79
|
+
{ status: result.status, sample: result.diagnostic },
|
|
80
|
+
'mail provider returned a non-success status',
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if (result.status >= 400 && result.status < 500 && result.status !== 429) {
|
|
84
|
+
throw new ConfigurationError(`Mail provider rejected the message (HTTP ${result.status}).`)
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`Mail provider error (HTTP ${result.status}).`)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error instanceof BlockedOutboundError) {
|
|
89
|
+
throw new ConfigurationError(error.message, { cause: error })
|
|
72
90
|
}
|
|
73
|
-
|
|
74
|
-
clearTimeout(timeout)
|
|
91
|
+
throw error
|
|
75
92
|
}
|
|
76
93
|
}
|
|
77
94
|
}
|
package/src/mail/smtp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import nodemailer, { type Transporter } from 'nodemailer'
|
|
|
3
3
|
import { ConfigurationError, logger, type MailDriver, type OutgoingMail } from '@meith/core'
|
|
4
4
|
import type { SmtpMailConfig } from '@meith/settings'
|
|
5
5
|
|
|
6
|
+
import { assertSafeSmtpHost, BlockedOutboundError, mailAllowsPrivateHosts } from '../net/outbound'
|
|
6
7
|
import { formatSender } from './sender'
|
|
7
8
|
|
|
8
9
|
const CONNECTION_TIMEOUT_MS = 10_000
|
|
@@ -52,6 +53,15 @@ export class SmtpMailDriver implements MailDriver {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
async send(mail: OutgoingMail): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
await assertSafeSmtpHost(this.config.host, mailAllowsPrivateHosts())
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof BlockedOutboundError) {
|
|
60
|
+
throw new ConfigurationError(error.message, { cause: error })
|
|
61
|
+
}
|
|
62
|
+
throw error
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
try {
|
|
56
66
|
await this.transport.sendMail({
|
|
57
67
|
from: formatSender(this.config.from, mail.fromName),
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { lookup as dnsLookupAsync } from 'node:dns/promises'
|
|
2
|
+
import { isIP } from 'node:net'
|
|
3
|
+
|
|
4
|
+
import { env, isProduction } from '@meith/core'
|
|
5
|
+
import {
|
|
6
|
+
assertAllowedUrl,
|
|
7
|
+
BlockedOutboundError,
|
|
8
|
+
guardedRequest,
|
|
9
|
+
isBlockedAddress,
|
|
10
|
+
} from '@meith/core/outbound'
|
|
11
|
+
|
|
12
|
+
export { BlockedOutboundError } from '@meith/core/outbound'
|
|
13
|
+
|
|
14
|
+
export function mailAllowsPrivateHosts(): boolean {
|
|
15
|
+
return env.MAIL_ALLOW_PRIVATE_HOSTS || !isProduction()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function assertSafeMailEndpoint(rawUrl: string, allowPrivateHosts: boolean): URL {
|
|
19
|
+
return assertAllowedUrl(rawUrl, { allowPrivateHosts })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function assertSafeSmtpHost(host: string, allowPrivateHosts: boolean): Promise<void> {
|
|
23
|
+
if (allowPrivateHosts) return
|
|
24
|
+
|
|
25
|
+
if (isIP(host) !== 0) {
|
|
26
|
+
if (isBlockedAddress(host)) {
|
|
27
|
+
throw new BlockedOutboundError('The SMTP host is a private or internal address.')
|
|
28
|
+
}
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const addresses = await dnsLookupAsync(host, { all: true })
|
|
33
|
+
if (addresses.some((candidate) => isBlockedAddress(candidate.address))) {
|
|
34
|
+
throw new BlockedOutboundError('The SMTP host resolves to a private or internal address.')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MailRequest {
|
|
39
|
+
readonly url: URL
|
|
40
|
+
readonly headers: Readonly<Record<string, string>>
|
|
41
|
+
readonly body: string
|
|
42
|
+
readonly timeoutMs: number
|
|
43
|
+
readonly allowPrivateHosts: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MailTransportResult {
|
|
47
|
+
readonly status: number
|
|
48
|
+
readonly diagnostic: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type HttpMailTransport = (request: MailRequest) => Promise<MailTransportResult>
|
|
52
|
+
|
|
53
|
+
export const guardedMailTransport: HttpMailTransport = (request) =>
|
|
54
|
+
guardedRequest({
|
|
55
|
+
url: request.url,
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: request.headers,
|
|
58
|
+
body: request.body,
|
|
59
|
+
timeoutMs: request.timeoutMs,
|
|
60
|
+
allowPrivateHosts: request.allowPrivateHosts,
|
|
61
|
+
})
|