@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/lib/crud/factory.js +43 -3
- package/dist/lib/crud/factory.js.map +2 -2
- package/dist/lib/crud/types.js.map +1 -1
- package/dist/lib/data/engine.js +32 -1
- package/dist/lib/data/engine.js.map +2 -2
- 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/crud/__tests__/crud-factory.test.ts +173 -1
- package/src/lib/crud/factory.ts +67 -12
- package/src/lib/crud/types.ts +23 -0
- package/src/lib/data/__tests__/engine.default-indexer.test.ts +188 -0
- package/src/lib/data/engine.ts +74 -1
- 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/src/lib/data/engine.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
+
}
|