@open-mercato/shared 0.7.1-develop.7149.1.7efa6e1612 → 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.
@@ -64,6 +64,19 @@ type QueuedCrudSideEffect = {
64
64
  indexer?: CrudIndexerConfig<unknown>
65
65
  }
66
66
 
67
+ /**
68
+ * A `makeCrudRoute` route-level `indexer:` declaration, handed to the data engine for the
69
+ * duration of one command-bus write. Command handlers own the side-effect mark on the
70
+ * `actions.*` path, and most of them mark `events:` only — without this the route's
71
+ * declaration would reach no code at all. `entityClass` scopes the default to the route's
72
+ * own ORM entity so a handler that also marks a sibling entity in the same request (a tag
73
+ * assignment alongside a tag, say) is never indexed under the route's `entityType`.
74
+ */
75
+ export type DefaultCrudIndexerConfig = {
76
+ indexer: CrudIndexerConfig<unknown>
77
+ entityClass: abstract new (...args: never[]) => object
78
+ }
79
+
67
80
  export interface DataEngine {
68
81
  setCustomFields(opts: {
69
82
  entityId: string
@@ -149,6 +162,20 @@ export interface DataEngine {
149
162
  * is responsible for rebuilding the `query_index` afterwards.
150
163
  */
151
164
  flushOrmEntityChanges(suppress?: BulkImportSuppression): Promise<void>
165
+
166
+ /**
167
+ * Declare the indexer a CRUD route configured, for marks made during one command-bus
168
+ * write that do not carry an indexer of their own. Pass `null` to clear it. Optional so
169
+ * third-party `DataEngine` implementations stay valid; callers invoke it with `?.`.
170
+ */
171
+ setDefaultIndexerConfig?(config: DefaultCrudIndexerConfig | null): void
172
+
173
+ /**
174
+ * Whether any side effect drained since the current default was declared carried an
175
+ * indexer for that default's entity class — false means the declared query-index
176
+ * obligation was discharged by nobody. Optional for the same reason as the setter.
177
+ */
178
+ hasIndexedDefaultEntityClass?(): boolean
152
179
  }
153
180
 
154
181
  export const SYSTEM_ENTITY_RECORDS_BLOCKED_CODE = 'system_entity_records_blocked'
@@ -188,8 +215,43 @@ export function assertCustomEntityStorageEntityId(em: EntityManager, entityId: s
188
215
 
189
216
  export class DefaultDataEngine implements DataEngine {
190
217
  private pendingSideEffects = new Map<string, QueuedCrudSideEffect>()
218
+ private defaultIndexer: DefaultCrudIndexerConfig | null = null
219
+ private indexedDefaultEntityClass = false
191
220
  constructor(private em: EntityManager, private container: AwilixContainer) {}
192
221
 
222
+ /**
223
+ * Per-command state, deliberately held on the engine instance rather than threaded through
224
+ * `CommandRuntimeContext` the way the bulk-import flags are. That is sound only because
225
+ * `createRequestContainer()` registers `dataEngine` per request (`lib/di/container.ts`), so
226
+ * one engine instance never spans two requests, and no `makeCrudRoute` verb runs two commands
227
+ * concurrently against it. An application that re-registers `dataEngine` as a transient would
228
+ * break both assumptions: the command would mark on a different instance than the route
229
+ * declared on, so nothing is indexed and every write logs the undischarged-declaration warning.
230
+ */
231
+ setDefaultIndexerConfig(config: DefaultCrudIndexerConfig | null): void {
232
+ this.defaultIndexer = config
233
+ this.indexedDefaultEntityClass = false
234
+ }
235
+
236
+ hasIndexedDefaultEntityClass(): boolean {
237
+ return this.indexedDefaultEntityClass
238
+ }
239
+
240
+ private matchesDefaultEntityClass(entity: unknown): boolean {
241
+ const declared = this.defaultIndexer?.entityClass
242
+ // `OrmEntityConfig.entity` is `any` and this repository treats `EntitySchema` instances as a
243
+ // first-class entity shape (`lib/bootstrap/types.ts`). An `EntitySchema` is an object rather
244
+ // than a constructor, so `instanceof` against it throws — and it would throw inside
245
+ // `markOrmEntityChange`, outside the best-effort try/catch that guards the flush, turning
246
+ // every write on such a route into a 500.
247
+ if (typeof declared !== 'function') return false
248
+ return entity instanceof declared
249
+ }
250
+
251
+ private resolveDefaultIndexer(entity: unknown): CrudIndexerConfig<unknown> | undefined {
252
+ return this.matchesDefaultEntityClass(entity) ? this.defaultIndexer?.indexer : undefined
253
+ }
254
+
193
255
  async setCustomFields(opts: Parameters<DataEngine['setCustomFields']>[0]): Promise<void> {
194
256
  const { entityId, recordId, organizationId = null, tenantId = null, values } = opts
195
257
  const sanitizedValues = await sanitizeCustomFieldHtmlRichTextValuesServer(this.em, {
@@ -728,6 +790,10 @@ export class DefaultDataEngine implements DataEngine {
728
790
  const { entity, identifiers } = opts
729
791
  if (!entity) return
730
792
  if (!identifiers?.id) return
793
+ // A command handler that marks `events:` only still discharges the route's declared
794
+ // query-index obligation — the route hands its `indexer:` down as the default so the
795
+ // handler's own entity and identifiers (the accurate ones) drive the projection write.
796
+ const indexer = opts.indexer ?? this.resolveDefaultIndexer(entity)
731
797
  const key = this.buildSideEffectKey(opts.action, identifiers)
732
798
  const existing = this.pendingSideEffects.get(key)
733
799
  if (existing) {
@@ -740,7 +806,11 @@ export class DefaultDataEngine implements DataEngine {
740
806
  existing.syncOrigin = opts.syncOrigin ?? null
741
807
  existing.actorUserId = opts.actorUserId ?? null
742
808
  if (opts.events) existing.events = opts.events as CrudEventsConfig<unknown>
809
+ // Explicit always wins, on the merge branch too: a second `events:`-only mark on the same
810
+ // key must not let the route default overwrite the `indexer:` an earlier mark installed,
811
+ // which would silently drop that handler's own `buildUpsertPayload`.
743
812
  if (opts.indexer) existing.indexer = opts.indexer as CrudIndexerConfig<unknown>
813
+ else if (!existing.indexer && indexer) existing.indexer = indexer
744
814
  this.pendingSideEffects.set(key, existing)
745
815
  return
746
816
  }
@@ -756,7 +826,7 @@ export class DefaultDataEngine implements DataEngine {
756
826
  actorUserId: opts.actorUserId ?? null,
757
827
  }
758
828
  if (opts.events) entry.events = opts.events as CrudEventsConfig<unknown>
759
- if (opts.indexer) entry.indexer = opts.indexer as CrudIndexerConfig<unknown>
829
+ if (indexer) entry.indexer = indexer
760
830
  this.pendingSideEffects.set(key, entry)
761
831
  }
762
832
 
@@ -765,6 +835,9 @@ export class DefaultDataEngine implements DataEngine {
765
835
  const entries = Array.from(this.pendingSideEffects.values())
766
836
  this.pendingSideEffects.clear()
767
837
  for (const entry of entries) {
838
+ if (entry.indexer && !suppress?.skipReindex && this.matchesDefaultEntityClass(entry.entity)) {
839
+ this.indexedDefaultEntityClass = true
840
+ }
768
841
  try {
769
842
  await this.emitOrmEntityEvent({
770
843
  action: entry.action,
@@ -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
+ }