@meith/drivers 0.28.1 → 0.29.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/drivers",
3
- "version": "0.28.1",
3
+ "version": "0.29.1",
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.28.1",
41
- "@meith/core": "0.28.1",
42
- "@meith/db": "0.28.1",
43
- "@meith/i18n": "0.28.1",
44
- "@meith/settings": "0.28.1"
40
+ "@meith/attachments": "0.29.1",
41
+ "@meith/core": "0.29.1",
42
+ "@meith/db": "0.29.1",
43
+ "@meith/i18n": "0.29.1",
44
+ "@meith/settings": "0.29.1"
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(private readonly config: HttpMailConfig) {}
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 controller = new AbortController()
44
- const timeout = setTimeout(() => controller.abort(), 10_000)
53
+ const allowPrivateHosts = mailAllowsPrivateHosts()
45
54
 
46
55
  try {
47
- const response = await fetch(this.config.endpoint, {
48
- method: 'POST',
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
- signal: controller.signal,
72
+ timeoutMs: 10_000,
73
+ allowPrivateHosts,
62
74
  })
63
75
 
64
- if (!response.ok) {
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
- if (response.status >= 400 && response.status < 500 && response.status !== 429) {
69
- throw new ConfigurationError(`Mail provider rejected the message: ${detail}`)
70
- }
71
- throw new Error(`Mail provider error: ${detail}`)
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
- } finally {
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
+ })