@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.
@@ -1,2 +1,2 @@
1
- [build:shared] found 271 entry points
1
+ [build:shared] found 272 entry points
2
2
  [build:shared] built successfully
@@ -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;AAChF,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;",
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
  }
@@ -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 { resolveDefaultEmailFromAddress } from "./config.js";
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({ to, subject, react, from, replyTo, attachments }) {
60
- const emailDisabled = parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) || parseBooleanWithDefault(process.env.OM_TEST_MODE, false);
61
- await captureEmailForTests({ to, subject, react, from, replyTo, attachments });
62
- if (emailDisabled) return;
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 payload = {
71
- to,
72
- subject,
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 { Resend } from 'resend'\nimport 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 { resolveDefaultEmailFromAddress } from './config'\n\nexport type SendEmailOptions = {\n to: string\n subject: string\n react: React.ReactElement\n from?: string\n replyTo?: string\n attachments?: Array<{\n filename: string\n content: string\n contentType?: string\n }>\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({ to, subject, react, from, replyTo, attachments }: SendEmailOptions) {\n const emailDisabled =\n parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||\n parseBooleanWithDefault(process.env.OM_TEST_MODE, false)\n\n await captureEmailForTests({ to, subject, react, from, replyTo, attachments })\n\n if (emailDisabled) return\n\n const apiKey = process.env.RESEND_API_KEY\n if (!apiKey) throw new Error('RESEND_API_KEY is not set')\n const resend = new Resend(apiKey)\n const fromAddr = from || resolveDefaultEmailFromAddress()\n if (!fromAddr) {\n throw new Error('EMAIL_FROM_NOT_CONFIGURED: set NOTIFICATIONS_EMAIL_FROM, EMAIL_FROM, or ADMIN_EMAIL')\n }\n const payload = {\n to,\n subject,\n from: fromAddr,\n react,\n ...(replyTo ? { reply_to: replyTo } : {}),\n ...(attachments?.length ? { attachments } : {}),\n }\n const result = await resend.emails.send(payload)\n const errorMessage =\n typeof (result as any)?.error === 'string'\n ? (result as any).error\n : typeof (result as any)?.error?.message === 'string'\n ? (result as any).error.message\n : null\n if (errorMessage) {\n throw new Error(`RESEND_SEND_FAILED: ${errorMessage}`)\n }\n}\n"],
5
- "mappings": "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",
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
+ }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.7.1-develop.7150.1.c1941e0c22";
1
+ const APP_VERSION = "0.7.1-develop.7151.1.00d0391847";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.7150.1.c1941e0c22';\nexport const appVersion = APP_VERSION;\n"],
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.7150.1.c1941e0c22",
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.7150.1.c1941e0c22",
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
- var sendMock: jest.Mock
8
- var ResendMock: jest.Mock
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.mockClear()
30
- ResendMock.mockClear()
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('maps replyTo to reply_to in Resend payload', async () => {
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(ResendMock).toHaveBeenCalledWith('test-key')
50
- expect(sendMock).toHaveBeenCalledWith(
51
- expect.objectContaining({
52
- to: 'user@example.com',
53
- subject: 'Hello',
54
- from: 'from@example.com',
55
- reply_to: 'reply@example.com',
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('omits reply_to when replyTo is not provided', async () => {
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
- react: React.createElement('div', null, 'Hi'),
81
+ html: '<p>Hello</p>',
82
+ text: 'Hello',
65
83
  })
66
84
 
67
- const payload = sendMock.mock.calls[0]?.[0] as Record<string, unknown>
68
- expect(payload).toBeDefined()
69
- expect(payload.reply_to).toBeUndefined()
85
+ expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({
86
+ html: '<p>Hello</p>',
87
+ text: 'Hello',
88
+ }))
70
89
  })
71
90
 
72
- it('passes attachments to Resend payload when provided', async () => {
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
- expect.objectContaining({
88
- attachments: [
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('throws when Resend returns an error', async () => {
100
- sendMock.mockResolvedValueOnce({ error: { message: 'invalid domain' } })
105
+ it('does not mark a sender the caller passed explicitly', async () => {
106
+ registerEmailTransport({ id: 'test', send: sendMock })
101
107
 
102
- await expect(sendEmail({
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
- })).rejects.toThrow('RESEND_SEND_FAILED: invalid domain')
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
- expect.objectContaining({
121
- from: 'notifications@example.com',
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
- expect.objectContaining({
139
- from: 'admin@example.com',
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('skips external delivery in test mode when email delivery is disabled', async () => {
157
- process.env.OM_DISABLE_EMAIL_DELIVERY = '1'
158
- delete process.env.RESEND_API_KEY
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
- delete process.env.RESEND_API_KEY
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
  })
@@ -1,4 +1,7 @@
1
- function normalizeEnvString(value: string | null | undefined): string | undefined {
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
+ }
@@ -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 { resolveDefaultEmailFromAddress } from './config'
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: React.ReactElement
21
+ react?: React.ReactElement
22
+ html?: string
23
+ text?: string
13
24
  from?: string
14
25
  replyTo?: string
15
- attachments?: Array<{
16
- filename: string
17
- content: string
18
- contentType?: string
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({ to, subject, react, from, replyTo, attachments }: SendEmailOptions) {
96
- const emailDisabled =
97
- parseBooleanWithDefault(process.env.OM_DISABLE_EMAIL_DELIVERY, false) ||
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
- if (emailDisabled) return
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
- const payload = {
112
- to,
113
- subject,
114
- from: fromAddr,
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
+ }