@open-mercato/shared 0.7.1-develop.7150.1.c1941e0c22 → 0.7.1-develop.7151.1.00d0391847
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/email/config.js +20 -0
- package/dist/lib/email/config.js.map +2 -2
- package/dist/lib/email/send.js +25 -22
- package/dist/lib/email/send.js.map +2 -2
- package/dist/lib/email/transport.js +19 -0
- package/dist/lib/email/transport.js.map +7 -0
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/email/__tests__/send.test.ts +140 -69
- package/src/lib/email/config.ts +26 -1
- package/src/lib/email/send.ts +59 -37
- package/src/lib/email/transport.ts +29 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:shared] found
|
|
1
|
+
[build:shared] found 272 entry points
|
|
2
2
|
[build:shared] built successfully
|
package/dist/lib/email/config.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseBooleanWithDefault } from "../boolean.js";
|
|
2
|
+
import { getRegisteredEmailTransport } from "./transport.js";
|
|
1
3
|
function normalizeEnvString(value) {
|
|
2
4
|
if (typeof value !== "string") return void 0;
|
|
3
5
|
const trimmed = value.trim();
|
|
@@ -6,7 +8,25 @@ function normalizeEnvString(value) {
|
|
|
6
8
|
function resolveDefaultEmailFromAddress() {
|
|
7
9
|
return normalizeEnvString(process.env.NOTIFICATIONS_EMAIL_FROM) || normalizeEnvString(process.env.EMAIL_FROM) || normalizeEnvString(process.env.ADMIN_EMAIL);
|
|
8
10
|
}
|
|
11
|
+
function isEmailDeliveryDisabled() {
|
|
12
|
+
const explicitlyDisabled = parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false);
|
|
13
|
+
if (explicitlyDisabled) return true;
|
|
14
|
+
const testMode = parseBooleanWithDefault(process.env.OM_TEST_MODE, false);
|
|
15
|
+
if (!testMode) return false;
|
|
16
|
+
const testCaptureDeliveryEnabled = process.env.SYSTEM_EMAIL_PROVIDER === "__test_seed__" && parseBooleanWithDefault(process.env.OM_ENABLE_TEST_CHANNEL_SEEDING, false) && parseBooleanWithDefault(process.env.OM_ENABLE_TEST_EMAIL_CAPTURE_DELIVERY, false);
|
|
17
|
+
return !testCaptureDeliveryEnabled;
|
|
18
|
+
}
|
|
19
|
+
function isEmailDeliveryConfigured() {
|
|
20
|
+
if (isEmailDeliveryDisabled()) return false;
|
|
21
|
+
if (!resolveDefaultEmailFromAddress()) return false;
|
|
22
|
+
const transport = getRegisteredEmailTransport();
|
|
23
|
+
if (!transport) return false;
|
|
24
|
+
return transport.isConfigured ? transport.isConfigured() : true;
|
|
25
|
+
}
|
|
9
26
|
export {
|
|
27
|
+
isEmailDeliveryConfigured,
|
|
28
|
+
isEmailDeliveryDisabled,
|
|
29
|
+
normalizeEnvString,
|
|
10
30
|
resolveDefaultEmailFromAddress
|
|
11
31
|
};
|
|
12
32
|
//# sourceMappingURL=config.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/email/config.ts"],
|
|
4
|
-
"sourcesContent": ["function normalizeEnvString(value: string | null | undefined): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport function resolveDefaultEmailFromAddress(): string | undefined {\n return (\n normalizeEnvString(process.env.NOTIFICATIONS_EMAIL_FROM) ||\n normalizeEnvString(process.env.EMAIL_FROM) ||\n normalizeEnvString(process.env.ADMIN_EMAIL)\n )\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,mBAAmB,OAAsD;
|
|
4
|
+
"sourcesContent": ["import { parseBooleanWithDefault } from '../boolean'\nimport { getRegisteredEmailTransport } from './transport'\n\nexport function normalizeEnvString(value: string | null | undefined): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport function resolveDefaultEmailFromAddress(): string | undefined {\n return (\n normalizeEnvString(process.env.NOTIFICATIONS_EMAIL_FROM) ||\n normalizeEnvString(process.env.EMAIL_FROM) ||\n normalizeEnvString(process.env.ADMIN_EMAIL)\n )\n}\n\nexport function isEmailDeliveryDisabled(): boolean {\n const explicitlyDisabled = parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false)\n if (explicitlyDisabled) return true\n\n const testMode = parseBooleanWithDefault(process.env.OM_TEST_MODE, false)\n if (!testMode) return false\n\n const testCaptureDeliveryEnabled =\n process.env.SYSTEM_EMAIL_PROVIDER === '__test_seed__'\n && parseBooleanWithDefault(process.env.OM_ENABLE_TEST_CHANNEL_SEEDING, false)\n && parseBooleanWithDefault(process.env.OM_ENABLE_TEST_EMAIL_CAPTURE_DELIVERY, false)\n return !testCaptureDeliveryEnabled\n}\n\nexport function isEmailDeliveryConfigured(): boolean {\n if (isEmailDeliveryDisabled()) return false\n if (!resolveDefaultEmailFromAddress()) return false\n const transport = getRegisteredEmailTransport()\n if (!transport) return false\n return transport.isConfigured ? transport.isConfigured() : true\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,mCAAmC;AAErC,SAAS,mBAAmB,OAAsD;AACvF,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAEO,SAAS,iCAAqD;AACnE,SACE,mBAAmB,QAAQ,IAAI,wBAAwB,KACvD,mBAAmB,QAAQ,IAAI,UAAU,KACzC,mBAAmB,QAAQ,IAAI,WAAW;AAE9C;AAEO,SAAS,0BAAmC;AACjD,QAAM,qBAAqB,wBAAwB,QAAQ,IAAI,2BAA2B,KAAK;AAC/F,MAAI,mBAAoB,QAAO;AAE/B,QAAM,WAAW,wBAAwB,QAAQ,IAAI,cAAc,KAAK;AACxE,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,6BACJ,QAAQ,IAAI,0BAA0B,mBACnC,wBAAwB,QAAQ,IAAI,gCAAgC,KAAK,KACzE,wBAAwB,QAAQ,IAAI,uCAAuC,KAAK;AACrF,SAAO,CAAC;AACV;AAEO,SAAS,4BAAqC;AACnD,MAAI,wBAAwB,EAAG,QAAO;AACtC,MAAI,CAAC,+BAA+B,EAAG,QAAO;AAC9C,QAAM,YAAY,4BAA4B;AAC9C,MAAI,CAAC,UAAW,QAAO;AACvB,SAAO,UAAU,eAAe,UAAU,aAAa,IAAI;AAC7D;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/email/send.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { Resend } from "resend";
|
|
2
1
|
import React from "react";
|
|
3
2
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
4
3
|
import { dirname, join } from "node:path";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
6
5
|
import { parseBooleanWithDefault } from "../boolean.js";
|
|
7
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isEmailDeliveryDisabled,
|
|
8
|
+
resolveDefaultEmailFromAddress
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import { getRegisteredEmailTransport } from "./transport.js";
|
|
8
11
|
const DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), "open-mercato-email-capture.jsonl");
|
|
9
12
|
function resolveTestEmailCapturePath() {
|
|
10
13
|
return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH;
|
|
@@ -56,30 +59,30 @@ async function captureEmailForTests(options) {
|
|
|
56
59
|
await appendFile(capturePath, `${JSON.stringify(record)}
|
|
57
60
|
`, "utf8");
|
|
58
61
|
}
|
|
59
|
-
async function sendEmail(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const apiKey = process.env.RESEND_API_KEY;
|
|
64
|
-
if (!apiKey) throw new Error("RESEND_API_KEY is not set");
|
|
65
|
-
const resend = new Resend(apiKey);
|
|
66
|
-
const fromAddr = from || resolveDefaultEmailFromAddress();
|
|
62
|
+
async function sendEmail(options) {
|
|
63
|
+
await captureEmailForTests(options);
|
|
64
|
+
if (isEmailDeliveryDisabled()) return;
|
|
65
|
+
const fromAddr = options.from || resolveDefaultEmailFromAddress();
|
|
67
66
|
if (!fromAddr) {
|
|
68
67
|
throw new Error("EMAIL_FROM_NOT_CONFIGURED: set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL");
|
|
69
68
|
}
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
from: fromAddr,
|
|
74
|
-
react,
|
|
75
|
-
...replyTo ? { reply_to: replyTo } : {},
|
|
76
|
-
...attachments?.length ? { attachments } : {}
|
|
77
|
-
};
|
|
78
|
-
const result = await resend.emails.send(payload);
|
|
79
|
-
const errorMessage = typeof result?.error === "string" ? result.error : typeof result?.error?.message === "string" ? result.error.message : null;
|
|
80
|
-
if (errorMessage) {
|
|
81
|
-
throw new Error(`RESEND_SEND_FAILED: ${errorMessage}`);
|
|
69
|
+
const transport = getRegisteredEmailTransport();
|
|
70
|
+
if (!transport) {
|
|
71
|
+
throw new Error("EMAIL_TRANSPORT_NOT_CONFIGURED: enable an outbound email provider module");
|
|
82
72
|
}
|
|
73
|
+
await transport.send({
|
|
74
|
+
to: options.to,
|
|
75
|
+
subject: options.subject,
|
|
76
|
+
react: options.react,
|
|
77
|
+
html: options.html,
|
|
78
|
+
text: options.text,
|
|
79
|
+
from: fromAddr,
|
|
80
|
+
fromIsInstanceDefault: !options.from,
|
|
81
|
+
replyTo: options.replyTo,
|
|
82
|
+
attachments: options.attachments,
|
|
83
|
+
tenantId: options.tenantId,
|
|
84
|
+
organizationId: options.organizationId
|
|
85
|
+
});
|
|
83
86
|
}
|
|
84
87
|
export {
|
|
85
88
|
sendEmail
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/email/send.ts"],
|
|
4
|
-
"sourcesContent": ["import
|
|
5
|
-
"mappings": "AAAA,
|
|
4
|
+
"sourcesContent": ["import React from 'react'\nimport { appendFile, mkdir } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { parseBooleanWithDefault } from '../boolean'\nimport {\n isEmailDeliveryDisabled,\n resolveDefaultEmailFromAddress,\n} from './config'\nimport { getRegisteredEmailTransport } from './transport'\n\nexport type EmailAttachment = {\n filename: string\n content: string\n contentType?: string\n}\n\nexport type SendEmailOptions = {\n to: string\n subject: string\n react?: React.ReactElement\n html?: string\n text?: string\n from?: string\n replyTo?: string\n attachments?: EmailAttachment[]\n tenantId?: string\n organizationId?: string | null\n}\n\nexport type ResolvedEmailPayload = {\n to: string\n subject: string\n react?: React.ReactElement\n html?: string\n text?: string\n from: string\n /**\n * True when `from` was filled in from the instance-wide environment defaults rather than chosen by\n * the caller. Transports use this to decide whether a tenant's own configured sender may take\n * precedence: `from` is never empty by the time it reaches a transport, so without this flag a\n * per-tenant sender is unreachable. Absent means \"caller chose it\" for older transports.\n */\n fromIsInstanceDefault?: boolean\n replyTo?: string\n attachments?: EmailAttachment[]\n tenantId?: string\n organizationId?: string | null\n}\n\ntype CapturedEmail = {\n to: string\n subject: string\n from: string | null\n replyTo: string | null\n links: string[]\n text: string\n capturedAt: string\n}\n\ntype ReactElementProps = {\n href?: unknown\n children?: unknown\n}\n\nconst DEFAULT_TEST_EMAIL_CAPTURE_PATH = join(tmpdir(), 'open-mercato-email-capture.jsonl')\n\nfunction resolveTestEmailCapturePath(): string {\n return process.env.OM_TEST_EMAIL_CAPTURE_PATH?.trim() || DEFAULT_TEST_EMAIL_CAPTURE_PATH\n}\n\nfunction readElementProps(node: React.ReactElement): ReactElementProps {\n return node.props as ReactElementProps\n}\n\nfunction collectEmailLinks(node: unknown, links: string[] = []): string[] {\n if (node == null || typeof node === 'boolean') return links\n if (Array.isArray(node)) {\n for (const child of node) collectEmailLinks(child, links)\n return links\n }\n if (React.isValidElement(node)) {\n const props = readElementProps(node)\n if (typeof props.href === 'string' && props.href.length > 0) links.push(props.href)\n collectEmailLinks(props.children, links)\n }\n return links\n}\n\nfunction collectEmailText(node: unknown, parts: string[] = []): string[] {\n if (node == null || typeof node === 'boolean') return parts\n if (typeof node === 'string' || typeof node === 'number') {\n parts.push(String(node))\n return parts\n }\n if (Array.isArray(node)) {\n for (const child of node) collectEmailText(child, parts)\n return parts\n }\n if (React.isValidElement(node)) {\n collectEmailText(readElementProps(node).children, parts)\n }\n return parts\n}\n\nasync function captureEmailForTests(options: SendEmailOptions): Promise<void> {\n if (!parseBooleanWithDefault(process.env.OM_TEST_MODE, false)) return\n\n const capturePath = resolveTestEmailCapturePath()\n const record: CapturedEmail = {\n to: options.to,\n subject: options.subject,\n from: options.from ?? resolveDefaultEmailFromAddress() ?? null,\n replyTo: options.replyTo ?? null,\n links: collectEmailLinks(options.react),\n text: collectEmailText(options.react).join(' ').replace(/\\s+/g, ' ').trim(),\n capturedAt: new Date().toISOString(),\n }\n\n await mkdir(dirname(capturePath), { recursive: true })\n await appendFile(capturePath, `${JSON.stringify(record)}\\n`, 'utf8')\n}\n\nexport async function sendEmail(options: SendEmailOptions): Promise<void> {\n await captureEmailForTests(options)\n if (isEmailDeliveryDisabled()) return\n\n const fromAddr = options.from || resolveDefaultEmailFromAddress()\n if (!fromAddr) {\n throw new Error('EMAIL_FROM_NOT_CONFIGURED: set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL')\n }\n\n const transport = getRegisteredEmailTransport()\n if (!transport) {\n throw new Error('EMAIL_TRANSPORT_NOT_CONFIGURED: enable an outbound email provider module')\n }\n\n await transport.send({\n to: options.to,\n subject: options.subject,\n react: options.react,\n html: options.html,\n text: options.text,\n from: fromAddr,\n fromIsInstanceDefault: !options.from,\n replyTo: options.replyTo,\n attachments: options.attachments,\n tenantId: options.tenantId,\n organizationId: options.organizationId,\n })\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const EMAIL_TRANSPORT_REGISTRY = /* @__PURE__ */ Symbol.for("open-mercato.email.transport");
|
|
2
|
+
function emailTransportRoot() {
|
|
3
|
+
return globalThis;
|
|
4
|
+
}
|
|
5
|
+
function registerEmailTransport(transport) {
|
|
6
|
+
emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = transport;
|
|
7
|
+
}
|
|
8
|
+
function getRegisteredEmailTransport() {
|
|
9
|
+
return emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] ?? null;
|
|
10
|
+
}
|
|
11
|
+
function clearRegisteredEmailTransportForTests() {
|
|
12
|
+
emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = null;
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
clearRegisteredEmailTransportForTests,
|
|
16
|
+
getRegisteredEmailTransport,
|
|
17
|
+
registerEmailTransport
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/lib/email/transport.ts"],
|
|
4
|
+
"sourcesContent": ["import type { ResolvedEmailPayload } from './send'\n\nexport type EmailTransport = {\n id: string\n send: (payload: ResolvedEmailPayload) => Promise<void>\n isConfigured?: () => boolean\n}\n\nconst EMAIL_TRANSPORT_REGISTRY = Symbol.for('open-mercato.email.transport')\n\ntype EmailTransportRegistryGlobal = typeof globalThis & {\n [EMAIL_TRANSPORT_REGISTRY]?: EmailTransport | null\n}\n\nfunction emailTransportRoot(): EmailTransportRegistryGlobal {\n return globalThis as EmailTransportRegistryGlobal\n}\n\nexport function registerEmailTransport(transport: EmailTransport): void {\n emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = transport\n}\n\nexport function getRegisteredEmailTransport(): EmailTransport | null {\n return emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] ?? null\n}\n\nexport function clearRegisteredEmailTransportForTests(): void {\n emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = null\n}\n"],
|
|
5
|
+
"mappings": "AAQA,MAAM,2BAA2B,uBAAO,IAAI,8BAA8B;AAM1E,SAAS,qBAAmD;AAC1D,SAAO;AACT;AAEO,SAAS,uBAAuB,WAAiC;AACtE,qBAAmB,EAAE,wBAAwB,IAAI;AACnD;AAEO,SAAS,8BAAqD;AACnE,SAAO,mBAAmB,EAAE,wBAAwB,KAAK;AAC3D;AAEO,SAAS,wCAA8C;AAC5D,qBAAmB,EAAE,wBAAwB,IAAI;AACnD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.7.1-develop.7151.1.00d0391847';\nexport const appVersion = APP_VERSION;\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7151.1.00d0391847",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"@mikro-orm/core": "^7.1.8",
|
|
114
114
|
"@mikro-orm/decorators": "^7.1.8",
|
|
115
115
|
"@mikro-orm/postgresql": "^7.1.8",
|
|
116
|
-
"@open-mercato/cache": "0.7.1-develop.
|
|
116
|
+
"@open-mercato/cache": "0.7.1-develop.7151.1.00d0391847",
|
|
117
117
|
"@types/html-to-text": "^9.0.4",
|
|
118
118
|
"@types/sanitize-html": "^2.16.1",
|
|
119
119
|
"dotenv": "^17.4.2",
|
|
@@ -2,32 +2,25 @@ import React from 'react'
|
|
|
2
2
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
|
+
import { isEmailDeliveryConfigured } from '../config'
|
|
5
6
|
import { sendEmail } from '../send'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
jest.mock('resend', () => {
|
|
11
|
-
sendMock = jest.fn().mockResolvedValue({ data: { id: 'email-1' } })
|
|
12
|
-
ResendMock = jest.fn().mockImplementation(() => ({
|
|
13
|
-
emails: { send: sendMock },
|
|
14
|
-
}))
|
|
15
|
-
|
|
16
|
-
return { Resend: ResendMock }
|
|
17
|
-
})
|
|
7
|
+
import {
|
|
8
|
+
clearRegisteredEmailTransportForTests,
|
|
9
|
+
registerEmailTransport,
|
|
10
|
+
} from '../transport'
|
|
18
11
|
|
|
19
12
|
describe('sendEmail', () => {
|
|
20
13
|
const originalEnv = process.env
|
|
14
|
+
let sendMock: jest.Mock
|
|
21
15
|
let tempDir: string | null = null
|
|
22
16
|
|
|
23
17
|
beforeEach(() => {
|
|
24
18
|
process.env = {
|
|
25
19
|
...originalEnv,
|
|
26
|
-
RESEND_API_KEY: 'test-key',
|
|
27
20
|
EMAIL_FROM: 'from@example.com',
|
|
28
21
|
}
|
|
29
|
-
sendMock.
|
|
30
|
-
|
|
22
|
+
sendMock = jest.fn().mockResolvedValue(undefined)
|
|
23
|
+
clearRegisteredEmailTransportForTests()
|
|
31
24
|
})
|
|
32
25
|
|
|
33
26
|
afterEach(async () => {
|
|
@@ -36,79 +29,99 @@ describe('sendEmail', () => {
|
|
|
36
29
|
tempDir = null
|
|
37
30
|
}
|
|
38
31
|
process.env = originalEnv
|
|
32
|
+
clearRegisteredEmailTransportForTests()
|
|
39
33
|
})
|
|
40
34
|
|
|
41
|
-
it('
|
|
35
|
+
it('delegates normalized payloads to the registered transport', async () => {
|
|
36
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
37
|
+
|
|
42
38
|
await sendEmail({
|
|
43
39
|
to: 'user@example.com',
|
|
44
40
|
subject: 'Hello',
|
|
45
41
|
react: React.createElement('div', null, 'Hi'),
|
|
46
42
|
replyTo: 'reply@example.com',
|
|
43
|
+
tenantId: 'tenant-1',
|
|
44
|
+
organizationId: 'org-1',
|
|
45
|
+
attachments: [
|
|
46
|
+
{
|
|
47
|
+
filename: 'invoice.pdf',
|
|
48
|
+
content: 'dGVzdA==',
|
|
49
|
+
contentType: 'application/pdf',
|
|
50
|
+
},
|
|
51
|
+
],
|
|
47
52
|
})
|
|
48
53
|
|
|
49
|
-
expect(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
expect(sendMock).toHaveBeenCalledWith({
|
|
55
|
+
to: 'user@example.com',
|
|
56
|
+
subject: 'Hello',
|
|
57
|
+
from: 'from@example.com',
|
|
58
|
+
fromIsInstanceDefault: true,
|
|
59
|
+
react: expect.any(Object),
|
|
60
|
+
html: undefined,
|
|
61
|
+
text: undefined,
|
|
62
|
+
replyTo: 'reply@example.com',
|
|
63
|
+
tenantId: 'tenant-1',
|
|
64
|
+
organizationId: 'org-1',
|
|
65
|
+
attachments: [
|
|
66
|
+
{
|
|
67
|
+
filename: 'invoice.pdf',
|
|
68
|
+
content: 'dGVzdA==',
|
|
69
|
+
contentType: 'application/pdf',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
})
|
|
58
73
|
})
|
|
59
74
|
|
|
60
|
-
it('
|
|
75
|
+
it('delegates html and text bodies without provider-specific rendering', async () => {
|
|
76
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
77
|
+
|
|
61
78
|
await sendEmail({
|
|
62
79
|
to: 'user@example.com',
|
|
63
80
|
subject: 'Hello',
|
|
64
|
-
|
|
81
|
+
html: '<p>Hello</p>',
|
|
82
|
+
text: 'Hello',
|
|
65
83
|
})
|
|
66
84
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
86
|
+
html: '<p>Hello</p>',
|
|
87
|
+
text: 'Hello',
|
|
88
|
+
}))
|
|
70
89
|
})
|
|
71
90
|
|
|
72
|
-
it('
|
|
91
|
+
it('marks an inherited sender so transports can prefer a tenant-configured one', async () => {
|
|
92
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
93
|
+
|
|
73
94
|
await sendEmail({
|
|
74
95
|
to: 'user@example.com',
|
|
75
96
|
subject: 'Hello',
|
|
76
97
|
react: React.createElement('div', null, 'Hi'),
|
|
77
|
-
attachments: [
|
|
78
|
-
{
|
|
79
|
-
filename: 'invoice.pdf',
|
|
80
|
-
content: 'dGVzdA==',
|
|
81
|
-
contentType: 'application/pdf',
|
|
82
|
-
},
|
|
83
|
-
],
|
|
84
98
|
})
|
|
85
99
|
|
|
86
|
-
expect(sendMock).toHaveBeenCalledWith(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
{
|
|
90
|
-
filename: 'invoice.pdf',
|
|
91
|
-
content: 'dGVzdA==',
|
|
92
|
-
contentType: 'application/pdf',
|
|
93
|
-
},
|
|
94
|
-
],
|
|
95
|
-
})
|
|
96
|
-
)
|
|
100
|
+
expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
101
|
+
fromIsInstanceDefault: true,
|
|
102
|
+
}))
|
|
97
103
|
})
|
|
98
104
|
|
|
99
|
-
it('
|
|
100
|
-
|
|
105
|
+
it('does not mark a sender the caller passed explicitly', async () => {
|
|
106
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
101
107
|
|
|
102
|
-
await
|
|
108
|
+
await sendEmail({
|
|
103
109
|
to: 'user@example.com',
|
|
104
110
|
subject: 'Hello',
|
|
111
|
+
from: 'chosen@example.com',
|
|
105
112
|
react: React.createElement('div', null, 'Hi'),
|
|
106
|
-
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
116
|
+
from: 'chosen@example.com',
|
|
117
|
+
fromIsInstanceDefault: false,
|
|
118
|
+
}))
|
|
107
119
|
})
|
|
108
120
|
|
|
109
121
|
it('falls back to NOTIFICATIONS_EMAIL_FROM when EMAIL_FROM is not set', async () => {
|
|
110
122
|
delete process.env.EMAIL_FROM
|
|
111
123
|
process.env.NOTIFICATIONS_EMAIL_FROM = 'notifications@example.com'
|
|
124
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
112
125
|
|
|
113
126
|
await sendEmail({
|
|
114
127
|
to: 'user@example.com',
|
|
@@ -116,17 +129,16 @@ describe('sendEmail', () => {
|
|
|
116
129
|
react: React.createElement('div', null, 'Hi'),
|
|
117
130
|
})
|
|
118
131
|
|
|
119
|
-
expect(sendMock).toHaveBeenCalledWith(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
})
|
|
123
|
-
)
|
|
132
|
+
expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
133
|
+
from: 'notifications@example.com',
|
|
134
|
+
}))
|
|
124
135
|
})
|
|
125
136
|
|
|
126
137
|
it('falls back to ADMIN_EMAIL when sender-specific env vars are not set', async () => {
|
|
127
138
|
delete process.env.EMAIL_FROM
|
|
128
139
|
delete process.env.NOTIFICATIONS_EMAIL_FROM
|
|
129
140
|
process.env.ADMIN_EMAIL = 'admin@example.com'
|
|
141
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
130
142
|
|
|
131
143
|
await sendEmail({
|
|
132
144
|
to: 'user@example.com',
|
|
@@ -134,17 +146,16 @@ describe('sendEmail', () => {
|
|
|
134
146
|
react: React.createElement('div', null, 'Hi'),
|
|
135
147
|
})
|
|
136
148
|
|
|
137
|
-
expect(sendMock).toHaveBeenCalledWith(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
})
|
|
141
|
-
)
|
|
149
|
+
expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
150
|
+
from: 'admin@example.com',
|
|
151
|
+
}))
|
|
142
152
|
})
|
|
143
153
|
|
|
144
154
|
it('throws a clear error when no sender address is configured', async () => {
|
|
145
155
|
delete process.env.EMAIL_FROM
|
|
146
156
|
delete process.env.NOTIFICATIONS_EMAIL_FROM
|
|
147
157
|
delete process.env.ADMIN_EMAIL
|
|
158
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
148
159
|
|
|
149
160
|
await expect(sendEmail({
|
|
150
161
|
to: 'user@example.com',
|
|
@@ -153,9 +164,17 @@ describe('sendEmail', () => {
|
|
|
153
164
|
})).rejects.toThrow('EMAIL_FROM_NOT_CONFIGURED')
|
|
154
165
|
})
|
|
155
166
|
|
|
156
|
-
it('
|
|
157
|
-
|
|
158
|
-
|
|
167
|
+
it('throws a clear error when no transport is registered', async () => {
|
|
168
|
+
await expect(sendEmail({
|
|
169
|
+
to: 'user@example.com',
|
|
170
|
+
subject: 'Hello',
|
|
171
|
+
react: React.createElement('div', null, 'Hi'),
|
|
172
|
+
})).rejects.toThrow('EMAIL_TRANSPORT_NOT_CONFIGURED')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('skips transport delivery when email delivery is disabled', async () => {
|
|
176
|
+
process.env.OM_DISABLE_EMAIL_DELIVERY = 'yes'
|
|
177
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
159
178
|
|
|
160
179
|
await sendEmail({
|
|
161
180
|
to: 'user@example.com',
|
|
@@ -163,16 +182,59 @@ describe('sendEmail', () => {
|
|
|
163
182
|
react: React.createElement('div', null, 'Hi'),
|
|
164
183
|
})
|
|
165
184
|
|
|
166
|
-
expect(ResendMock).not.toHaveBeenCalled()
|
|
167
185
|
expect(sendMock).not.toHaveBeenCalled()
|
|
168
186
|
})
|
|
169
187
|
|
|
188
|
+
it('keeps the established boolean tokens for test-mode delivery suppression', async () => {
|
|
189
|
+
process.env.OM_TEST_MODE = 'on'
|
|
190
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
191
|
+
|
|
192
|
+
await sendEmail({
|
|
193
|
+
to: 'user@example.com',
|
|
194
|
+
subject: 'Hello',
|
|
195
|
+
react: React.createElement('div', null, 'Hi'),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
expect(sendMock).not.toHaveBeenCalled()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('does not let OM_DISABLE_EMAIL_DELIVERY=0 override test-mode delivery suppression', async () => {
|
|
202
|
+
process.env.OM_TEST_MODE = '1'
|
|
203
|
+
process.env.OM_DISABLE_EMAIL_DELIVERY = '0'
|
|
204
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
205
|
+
|
|
206
|
+
await sendEmail({
|
|
207
|
+
to: 'user@example.com',
|
|
208
|
+
subject: 'Hello',
|
|
209
|
+
react: React.createElement('div', null, 'Hi'),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
expect(sendMock).not.toHaveBeenCalled()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('allows test-mode delivery only for the explicitly enabled capture adapter', async () => {
|
|
216
|
+
process.env.OM_TEST_MODE = '1'
|
|
217
|
+
process.env.OM_DISABLE_EMAIL_DELIVERY = '0'
|
|
218
|
+
process.env.OM_ENABLE_TEST_CHANNEL_SEEDING = 'true'
|
|
219
|
+
process.env.OM_ENABLE_TEST_EMAIL_CAPTURE_DELIVERY = 'true'
|
|
220
|
+
process.env.SYSTEM_EMAIL_PROVIDER = '__test_seed__'
|
|
221
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
222
|
+
|
|
223
|
+
await sendEmail({
|
|
224
|
+
to: 'user@example.com',
|
|
225
|
+
subject: 'Hello',
|
|
226
|
+
react: React.createElement('div', null, 'Hi'),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
expect(sendMock).toHaveBeenCalledTimes(1)
|
|
230
|
+
})
|
|
231
|
+
|
|
170
232
|
it('captures email links in OM_TEST_MODE without external delivery', async () => {
|
|
171
233
|
tempDir = await mkdtemp(join(tmpdir(), 'om-email-capture-'))
|
|
172
234
|
const capturePath = join(tempDir, 'emails.jsonl')
|
|
173
235
|
process.env.OM_TEST_MODE = '1'
|
|
174
236
|
process.env.OM_TEST_EMAIL_CAPTURE_PATH = capturePath
|
|
175
|
-
|
|
237
|
+
registerEmailTransport({ id: 'test', send: sendMock })
|
|
176
238
|
|
|
177
239
|
await sendEmail({
|
|
178
240
|
to: 'user@example.com',
|
|
@@ -191,7 +253,16 @@ describe('sendEmail', () => {
|
|
|
191
253
|
links: ['https://example.com/portal/invite?token=raw'],
|
|
192
254
|
text: 'Accept your invite Accept',
|
|
193
255
|
}))
|
|
194
|
-
expect(ResendMock).not.toHaveBeenCalled()
|
|
195
256
|
expect(sendMock).not.toHaveBeenCalled()
|
|
196
257
|
})
|
|
258
|
+
|
|
259
|
+
it('reports configured only when a sender and configured transport are present', () => {
|
|
260
|
+
expect(isEmailDeliveryConfigured()).toBe(false)
|
|
261
|
+
|
|
262
|
+
registerEmailTransport({ id: 'test', send: sendMock, isConfigured: () => false })
|
|
263
|
+
expect(isEmailDeliveryConfigured()).toBe(false)
|
|
264
|
+
|
|
265
|
+
registerEmailTransport({ id: 'test', send: sendMock, isConfigured: () => true })
|
|
266
|
+
expect(isEmailDeliveryConfigured()).toBe(true)
|
|
267
|
+
})
|
|
197
268
|
})
|
package/src/lib/email/config.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
import { parseBooleanWithDefault } from '../boolean'
|
|
2
|
+
import { getRegisteredEmailTransport } from './transport'
|
|
3
|
+
|
|
4
|
+
export function normalizeEnvString(value: string | null | undefined): string | undefined {
|
|
2
5
|
if (typeof value !== 'string') return undefined
|
|
3
6
|
const trimmed = value.trim()
|
|
4
7
|
return trimmed.length > 0 ? trimmed : undefined
|
|
@@ -11,3 +14,25 @@ export function resolveDefaultEmailFromAddress(): string | undefined {
|
|
|
11
14
|
normalizeEnvString(process.env.ADMIN_EMAIL)
|
|
12
15
|
)
|
|
13
16
|
}
|
|
17
|
+
|
|
18
|
+
export function isEmailDeliveryDisabled(): boolean {
|
|
19
|
+
const explicitlyDisabled = parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false)
|
|
20
|
+
if (explicitlyDisabled) return true
|
|
21
|
+
|
|
22
|
+
const testMode = parseBooleanWithDefault(process.env.OM_TEST_MODE, false)
|
|
23
|
+
if (!testMode) return false
|
|
24
|
+
|
|
25
|
+
const testCaptureDeliveryEnabled =
|
|
26
|
+
process.env.SYSTEM_EMAIL_PROVIDER === '__test_seed__'
|
|
27
|
+
&& parseBooleanWithDefault(process.env.OM_ENABLE_TEST_CHANNEL_SEEDING, false)
|
|
28
|
+
&& parseBooleanWithDefault(process.env.OM_ENABLE_TEST_EMAIL_CAPTURE_DELIVERY, false)
|
|
29
|
+
return !testCaptureDeliveryEnabled
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isEmailDeliveryConfigured(): boolean {
|
|
33
|
+
if (isEmailDeliveryDisabled()) return false
|
|
34
|
+
if (!resolveDefaultEmailFromAddress()) return false
|
|
35
|
+
const transport = getRegisteredEmailTransport()
|
|
36
|
+
if (!transport) return false
|
|
37
|
+
return transport.isConfigured ? transport.isConfigured() : true
|
|
38
|
+
}
|
package/src/lib/email/send.ts
CHANGED
|
@@ -1,22 +1,51 @@
|
|
|
1
|
-
import { Resend } from 'resend'
|
|
2
1
|
import React from 'react'
|
|
3
2
|
import { appendFile, mkdir } from 'node:fs/promises'
|
|
4
3
|
import { dirname, join } from 'node:path'
|
|
5
4
|
import { tmpdir } from 'node:os'
|
|
6
5
|
import { parseBooleanWithDefault } from '../boolean'
|
|
7
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isEmailDeliveryDisabled,
|
|
8
|
+
resolveDefaultEmailFromAddress,
|
|
9
|
+
} from './config'
|
|
10
|
+
import { getRegisteredEmailTransport } from './transport'
|
|
11
|
+
|
|
12
|
+
export type EmailAttachment = {
|
|
13
|
+
filename: string
|
|
14
|
+
content: string
|
|
15
|
+
contentType?: string
|
|
16
|
+
}
|
|
8
17
|
|
|
9
18
|
export type SendEmailOptions = {
|
|
10
19
|
to: string
|
|
11
20
|
subject: string
|
|
12
|
-
react
|
|
21
|
+
react?: React.ReactElement
|
|
22
|
+
html?: string
|
|
23
|
+
text?: string
|
|
13
24
|
from?: string
|
|
14
25
|
replyTo?: string
|
|
15
|
-
attachments?:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
attachments?: EmailAttachment[]
|
|
27
|
+
tenantId?: string
|
|
28
|
+
organizationId?: string | null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ResolvedEmailPayload = {
|
|
32
|
+
to: string
|
|
33
|
+
subject: string
|
|
34
|
+
react?: React.ReactElement
|
|
35
|
+
html?: string
|
|
36
|
+
text?: string
|
|
37
|
+
from: string
|
|
38
|
+
/**
|
|
39
|
+
* True when `from` was filled in from the instance-wide environment defaults rather than chosen by
|
|
40
|
+
* the caller. Transports use this to decide whether a tenant's own configured sender may take
|
|
41
|
+
* precedence: `from` is never empty by the time it reaches a transport, so without this flag a
|
|
42
|
+
* per-tenant sender is unreachable. Absent means "caller chose it" for older transports.
|
|
43
|
+
*/
|
|
44
|
+
fromIsInstanceDefault?: boolean
|
|
45
|
+
replyTo?: string
|
|
46
|
+
attachments?: EmailAttachment[]
|
|
47
|
+
tenantId?: string
|
|
48
|
+
organizationId?: string | null
|
|
20
49
|
}
|
|
21
50
|
|
|
22
51
|
type CapturedEmail = {
|
|
@@ -92,38 +121,31 @@ async function captureEmailForTests(options: SendEmailOptions): Promise<void> {
|
|
|
92
121
|
await appendFile(capturePath, `${JSON.stringify(record)}\n`, 'utf8')
|
|
93
122
|
}
|
|
94
123
|
|
|
95
|
-
export async function sendEmail(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
parseBooleanWithDefault(process.env.OM_TEST_MODE, false)
|
|
99
|
-
|
|
100
|
-
await captureEmailForTests({ to, subject, react, from, replyTo, attachments })
|
|
124
|
+
export async function sendEmail(options: SendEmailOptions): Promise<void> {
|
|
125
|
+
await captureEmailForTests(options)
|
|
126
|
+
if (isEmailDeliveryDisabled()) return
|
|
101
127
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const apiKey = process.env.RESEND_API_KEY
|
|
105
|
-
if (!apiKey) throw new Error('RESEND_API_KEY is not set')
|
|
106
|
-
const resend = new Resend(apiKey)
|
|
107
|
-
const fromAddr = from || resolveDefaultEmailFromAddress()
|
|
128
|
+
const fromAddr = options.from || resolveDefaultEmailFromAddress()
|
|
108
129
|
if (!fromAddr) {
|
|
109
130
|
throw new Error('EMAIL_FROM_NOT_CONFIGURED: set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL')
|
|
110
131
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
react,
|
|
116
|
-
...(replyTo ? { reply_to: replyTo } : {}),
|
|
117
|
-
...(attachments?.length ? { attachments } : {}),
|
|
118
|
-
}
|
|
119
|
-
const result = await resend.emails.send(payload)
|
|
120
|
-
const errorMessage =
|
|
121
|
-
typeof (result as any)?.error === 'string'
|
|
122
|
-
? (result as any).error
|
|
123
|
-
: typeof (result as any)?.error?.message === 'string'
|
|
124
|
-
? (result as any).error.message
|
|
125
|
-
: null
|
|
126
|
-
if (errorMessage) {
|
|
127
|
-
throw new Error(`RESEND_SEND_FAILED: ${errorMessage}`)
|
|
132
|
+
|
|
133
|
+
const transport = getRegisteredEmailTransport()
|
|
134
|
+
if (!transport) {
|
|
135
|
+
throw new Error('EMAIL_TRANSPORT_NOT_CONFIGURED: enable an outbound email provider module')
|
|
128
136
|
}
|
|
137
|
+
|
|
138
|
+
await transport.send({
|
|
139
|
+
to: options.to,
|
|
140
|
+
subject: options.subject,
|
|
141
|
+
react: options.react,
|
|
142
|
+
html: options.html,
|
|
143
|
+
text: options.text,
|
|
144
|
+
from: fromAddr,
|
|
145
|
+
fromIsInstanceDefault: !options.from,
|
|
146
|
+
replyTo: options.replyTo,
|
|
147
|
+
attachments: options.attachments,
|
|
148
|
+
tenantId: options.tenantId,
|
|
149
|
+
organizationId: options.organizationId,
|
|
150
|
+
})
|
|
129
151
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ResolvedEmailPayload } from './send'
|
|
2
|
+
|
|
3
|
+
export type EmailTransport = {
|
|
4
|
+
id: string
|
|
5
|
+
send: (payload: ResolvedEmailPayload) => Promise<void>
|
|
6
|
+
isConfigured?: () => boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const EMAIL_TRANSPORT_REGISTRY = Symbol.for('open-mercato.email.transport')
|
|
10
|
+
|
|
11
|
+
type EmailTransportRegistryGlobal = typeof globalThis & {
|
|
12
|
+
[EMAIL_TRANSPORT_REGISTRY]?: EmailTransport | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function emailTransportRoot(): EmailTransportRegistryGlobal {
|
|
16
|
+
return globalThis as EmailTransportRegistryGlobal
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerEmailTransport(transport: EmailTransport): void {
|
|
20
|
+
emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = transport
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getRegisteredEmailTransport(): EmailTransport | null {
|
|
24
|
+
return emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] ?? null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clearRegisteredEmailTransportForTests(): void {
|
|
28
|
+
emailTransportRoot()[EMAIL_TRANSPORT_REGISTRY] = null
|
|
29
|
+
}
|