@open-mercato/channel-resend 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.
Files changed (46) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/channel_resend/acl.js +10 -0
  6. package/dist/modules/channel_resend/acl.js.map +7 -0
  7. package/dist/modules/channel_resend/capabilities.js +10 -0
  8. package/dist/modules/channel_resend/capabilities.js.map +7 -0
  9. package/dist/modules/channel_resend/di.js +22 -0
  10. package/dist/modules/channel_resend/di.js.map +7 -0
  11. package/dist/modules/channel_resend/index.js +13 -0
  12. package/dist/modules/channel_resend/index.js.map +7 -0
  13. package/dist/modules/channel_resend/integration.js +61 -0
  14. package/dist/modules/channel_resend/integration.js.map +7 -0
  15. package/dist/modules/channel_resend/lib/adapter.js +109 -0
  16. package/dist/modules/channel_resend/lib/adapter.js.map +7 -0
  17. package/dist/modules/channel_resend/lib/credentials.js +9 -0
  18. package/dist/modules/channel_resend/lib/credentials.js.map +7 -0
  19. package/dist/modules/channel_resend/lib/health.js +43 -0
  20. package/dist/modules/channel_resend/lib/health.js.map +7 -0
  21. package/dist/modules/channel_resend/lib/preset.js +61 -0
  22. package/dist/modules/channel_resend/lib/preset.js.map +7 -0
  23. package/dist/modules/channel_resend/lib/system-email-config.js +13 -0
  24. package/dist/modules/channel_resend/lib/system-email-config.js.map +7 -0
  25. package/dist/modules/channel_resend/setup.js +30 -0
  26. package/dist/modules/channel_resend/setup.js.map +7 -0
  27. package/jest.config.cjs +33 -0
  28. package/package.json +87 -0
  29. package/src/index.ts +1 -0
  30. package/src/modules/channel_resend/__tests__/contracts.test.ts +27 -0
  31. package/src/modules/channel_resend/acl.ts +6 -0
  32. package/src/modules/channel_resend/capabilities.ts +8 -0
  33. package/src/modules/channel_resend/di.ts +20 -0
  34. package/src/modules/channel_resend/index.ts +9 -0
  35. package/src/modules/channel_resend/integration.ts +56 -0
  36. package/src/modules/channel_resend/lib/__tests__/adapter.test.ts +70 -0
  37. package/src/modules/channel_resend/lib/__tests__/health.test.ts +38 -0
  38. package/src/modules/channel_resend/lib/__tests__/preset.test.ts +179 -0
  39. package/src/modules/channel_resend/lib/adapter.ts +139 -0
  40. package/src/modules/channel_resend/lib/credentials.ts +8 -0
  41. package/src/modules/channel_resend/lib/health.ts +51 -0
  42. package/src/modules/channel_resend/lib/preset.ts +95 -0
  43. package/src/modules/channel_resend/lib/system-email-config.ts +10 -0
  44. package/src/modules/channel_resend/setup.ts +30 -0
  45. package/tsconfig.json +9 -0
  46. package/watch.mjs +7 -0
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@open-mercato/channel-resend",
3
+ "version": "0.7.1-develop.7151.1.00d0391847",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "build": "node build.mjs",
8
+ "watch": "node watch.mjs",
9
+ "test": "jest --config jest.config.cjs",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./*.ts": {
15
+ "types": "./src/*.ts",
16
+ "default": "./dist/*.js"
17
+ },
18
+ "./*.tsx": {
19
+ "types": "./src/*.tsx",
20
+ "default": "./dist/*.js"
21
+ },
22
+ "./*": {
23
+ "types": [
24
+ "./src/*.ts",
25
+ "./src/*.tsx"
26
+ ],
27
+ "default": "./dist/*.js"
28
+ },
29
+ "./*/*": {
30
+ "types": [
31
+ "./src/*/*.ts",
32
+ "./src/*/*.tsx"
33
+ ],
34
+ "default": "./dist/*/*.js"
35
+ },
36
+ "./*/*/*": {
37
+ "types": [
38
+ "./src/*/*/*.ts",
39
+ "./src/*/*/*.tsx"
40
+ ],
41
+ "default": "./dist/*/*/*.js"
42
+ },
43
+ "./*/*/*/*": {
44
+ "types": [
45
+ "./src/*/*/*/*.ts",
46
+ "./src/*/*/*/*.tsx"
47
+ ],
48
+ "default": "./dist/*/*/*/*.js"
49
+ }
50
+ },
51
+ "dependencies": {
52
+ "@open-mercato/core": "0.7.1-develop.7151.1.00d0391847",
53
+ "resend": "^6.12.4"
54
+ },
55
+ "peerDependencies": {
56
+ "@mikro-orm/postgresql": "^7.0.14",
57
+ "@open-mercato/ai-assistant": "0.7.1-develop.7151.1.00d0391847",
58
+ "@open-mercato/shared": "0.7.1-develop.7151.1.00d0391847",
59
+ "@open-mercato/ui": "0.7.1-develop.7151.1.00d0391847",
60
+ "react": "^19.0.0",
61
+ "react-dom": "^19.0.0"
62
+ },
63
+ "devDependencies": {
64
+ "@open-mercato/ai-assistant": "0.7.1-develop.7151.1.00d0391847",
65
+ "@open-mercato/shared": "0.7.1-develop.7151.1.00d0391847",
66
+ "@open-mercato/ui": "0.7.1-develop.7151.1.00d0391847",
67
+ "@types/jest": "^30.0.0",
68
+ "@types/react": "^19.2.17",
69
+ "@types/react-dom": "^19.2.3",
70
+ "esbuild": "^0.28.0",
71
+ "glob": "^13.0.6",
72
+ "jest": "^30.4.2",
73
+ "react": "19.2.7",
74
+ "react-dom": "19.2.7",
75
+ "ts-jest": "^29.4.11",
76
+ "typescript": "7.0.2"
77
+ },
78
+ "publishConfig": {
79
+ "access": "public"
80
+ },
81
+ "repository": {
82
+ "type": "git",
83
+ "url": "https://github.com/open-mercato/open-mercato",
84
+ "directory": "packages/channel-resend"
85
+ },
86
+ "stableVersion": "0.7.0"
87
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { metadata } from './modules/channel_resend/index'
@@ -0,0 +1,27 @@
1
+ import { features } from '../acl'
2
+ import { resendCapabilities } from '../capabilities'
3
+ import { integration } from '../integration'
4
+ import { metadata } from '../index'
5
+
6
+ describe('channel_resend contracts', () => {
7
+ it('declares supported module metadata and coordinated versions', () => {
8
+ expect(metadata).toEqual(expect.objectContaining({
9
+ id: 'channel_resend',
10
+ version: '0.1.0',
11
+ requires: ['communication_channels', 'integrations'],
12
+ }))
13
+ expect(integration).toEqual(expect.objectContaining({
14
+ version: '0.1.0',
15
+ healthCheck: { service: 'channelResendHealthCheck' },
16
+ }))
17
+ expect(metadata).not.toHaveProperty('dependencies')
18
+ })
19
+
20
+ it('exports catalog-compatible ACL features and honest capabilities', () => {
21
+ expect(features).toEqual([
22
+ { id: 'channel_resend.view', title: expect.any(String), module: 'channel_resend' },
23
+ { id: 'channel_resend.configure', title: expect.any(String), module: 'channel_resend' },
24
+ ])
25
+ expect(resendCapabilities.fileSharing).toBe(false)
26
+ })
27
+ })
@@ -0,0 +1,6 @@
1
+ export const features = [
2
+ { id: 'channel_resend.view', title: 'View Resend channel configuration', module: 'channel_resend' },
3
+ { id: 'channel_resend.configure', title: 'Configure Resend channel defaults', module: 'channel_resend' },
4
+ ]
5
+
6
+ export default features
@@ -0,0 +1,8 @@
1
+ import { baseEmailCapabilities } from '@open-mercato/core/modules/communication_channels/lib/email-capabilities'
2
+ import type { ChannelCapabilities } from '@open-mercato/core/modules/communication_channels/lib/adapter'
3
+
4
+ export const resendCapabilities: ChannelCapabilities = {
5
+ ...baseEmailCapabilities,
6
+ fileSharing: false,
7
+ conversationHistory: false,
8
+ }
@@ -0,0 +1,20 @@
1
+ import { asValue } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import {
4
+ hasChannelAdapter,
5
+ registerChannelAdapter,
6
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
7
+ import { getResendChannelAdapter } from './lib/adapter'
8
+ import { registerResendSystemEmailConfigResolver } from './lib/system-email-config'
9
+ import { channelResendHealthCheck } from './lib/health'
10
+
11
+ export function register(container: AppContainer): void {
12
+ registerResendSystemEmailConfigResolver()
13
+ if (!hasChannelAdapter('resend')) {
14
+ registerChannelAdapter(getResendChannelAdapter())
15
+ }
16
+ container.register({
17
+ channelResendAdapter: asValue(getResendChannelAdapter()),
18
+ channelResendHealthCheck: asValue(channelResendHealthCheck),
19
+ })
20
+ }
@@ -0,0 +1,9 @@
1
+ export const metadata = {
2
+ id: 'channel_resend',
3
+ name: 'Resend Email Channel',
4
+ description: 'Outbound Resend email channel provider for the Communications Hub.',
5
+ version: '0.1.0',
6
+ requires: ['communication_channels', 'integrations'],
7
+ }
8
+
9
+ export default metadata
@@ -0,0 +1,56 @@
1
+ import { buildIntegrationDetailWidgetSpotId, type IntegrationBundle, type IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'
2
+
3
+ export const channelResendDetailWidgetSpotId = buildIntegrationDetailWidgetSpotId('channel_resend')
4
+
5
+ export const integration: IntegrationDefinition = {
6
+ id: 'channel_resend',
7
+ title: 'Resend Email',
8
+ description: 'Send transactional email through Resend using the Communications Hub.',
9
+ category: 'communication',
10
+ hub: 'communication_channels',
11
+ providerKey: 'resend',
12
+ icon: 'mail',
13
+ docsUrl: 'https://resend.com/docs',
14
+ package: '@open-mercato/channel-resend',
15
+ version: '0.1.0',
16
+ author: 'Open Mercato Team',
17
+ company: 'Open Mercato',
18
+ license: 'MIT',
19
+ tags: ['email', 'resend', 'transactional', 'communication'],
20
+ detailPage: {
21
+ widgetSpotId: channelResendDetailWidgetSpotId,
22
+ },
23
+ apiVersions: [
24
+ {
25
+ id: 'resend-v2',
26
+ label: 'Resend Email API',
27
+ status: 'stable',
28
+ default: true,
29
+ changelog: 'Initial outbound transactional email adapter.',
30
+ },
31
+ ],
32
+ healthCheck: { service: 'channelResendHealthCheck' },
33
+ credentials: {
34
+ fields: [
35
+ {
36
+ key: 'apiKey',
37
+ label: 'API key',
38
+ type: 'secret',
39
+ required: true,
40
+ helpText: 'Resend API key used for outbound transactional email.',
41
+ },
42
+ {
43
+ key: 'fromAddress',
44
+ label: 'From address',
45
+ type: 'text',
46
+ required: true,
47
+ placeholder: 'no-reply@example.com',
48
+ helpText: 'Verified sender address or domain identity in Resend.',
49
+ },
50
+ ],
51
+ },
52
+ }
53
+
54
+ export const integrations: IntegrationDefinition[] = [integration]
55
+ export const bundles: IntegrationBundle[] = []
56
+ export const bundle: IntegrationBundle | undefined = undefined
@@ -0,0 +1,70 @@
1
+ import { getResendChannelAdapter } from '../adapter'
2
+
3
+ var sendMock: jest.Mock
4
+ var ResendMock: jest.Mock
5
+
6
+ jest.mock('resend', () => {
7
+ sendMock = jest.fn().mockResolvedValue({ data: { id: 'email-1' } })
8
+ ResendMock = jest.fn().mockImplementation(() => ({
9
+ emails: { send: sendMock },
10
+ }))
11
+ return { Resend: ResendMock }
12
+ })
13
+
14
+ describe('ResendChannelAdapter', () => {
15
+ beforeEach(() => {
16
+ sendMock.mockClear()
17
+ ResendMock.mockClear()
18
+ })
19
+
20
+ it('sends html email with replyTo and attachments', async () => {
21
+ const adapter = getResendChannelAdapter()
22
+ const converted = await adapter.convertOutbound({
23
+ body: '<p>Hello</p>',
24
+ bodyFormat: 'html',
25
+ channelMetadata: {
26
+ to: 'user@example.com',
27
+ subject: 'Hello',
28
+ from: 'from@example.com',
29
+ replyTo: 'reply@example.com',
30
+ attachments: [{ filename: 'a.txt', content: 'dGVzdA==', contentType: 'text/plain' }],
31
+ },
32
+ })
33
+
34
+ const result = await adapter.sendMessage({
35
+ content: converted.content,
36
+ credentials: { apiKey: 'key', fromAddress: 'fallback@example.com' },
37
+ scope: { tenantId: 'tenant', organizationId: 'org' },
38
+ metadata: converted.metadata,
39
+ })
40
+
41
+ expect(result).toEqual(expect.objectContaining({ status: 'sent', externalMessageId: 'email-1' }))
42
+ expect(ResendMock).toHaveBeenCalledWith('key')
43
+ expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({
44
+ from: 'from@example.com',
45
+ to: ['user@example.com'],
46
+ subject: 'Hello',
47
+ html: '<p>Hello</p>',
48
+ text: 'Hello',
49
+ replyTo: 'reply@example.com',
50
+ attachments: [{ filename: 'a.txt', content: 'dGVzdA==', contentType: 'text/plain' }],
51
+ }))
52
+ })
53
+
54
+ it('returns a failed result when Resend reports an error', async () => {
55
+ sendMock.mockResolvedValueOnce({ error: { message: 'bad domain' } })
56
+ const adapter = getResendChannelAdapter()
57
+
58
+ const result = await adapter.sendMessage({
59
+ content: { text: 'Hello' },
60
+ credentials: { apiKey: 'key', fromAddress: 'from@example.com' },
61
+ scope: { tenantId: 'tenant', organizationId: 'org' },
62
+ metadata: { to: ['user@example.com'], subject: 'Hello' },
63
+ })
64
+
65
+ expect(result).toEqual(expect.objectContaining({
66
+ status: 'failed',
67
+ error: 'RESEND_SEND_FAILED: bad domain',
68
+ }))
69
+ })
70
+ })
@@ -0,0 +1,38 @@
1
+ import { channelResendHealthCheck } from '../health'
2
+
3
+ describe('channelResendHealthCheck', () => {
4
+ const originalFetch = global.fetch
5
+
6
+ afterEach(() => {
7
+ global.fetch = originalFetch
8
+ })
9
+
10
+ it('performs a bounded provider API probe', async () => {
11
+ global.fetch = jest.fn().mockResolvedValue(new Response('{}', { status: 200 }))
12
+
13
+ const result = await channelResendHealthCheck.check(
14
+ { apiKey: 're_test', fromAddress: 'from@example.com' },
15
+ { tenantId: 'tenant-1', organizationId: 'organization-1' },
16
+ )
17
+
18
+ expect(result.status).toBe('healthy')
19
+ expect(global.fetch).toHaveBeenCalledWith(
20
+ 'https://api.resend.com/domains?limit=1',
21
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
22
+ )
23
+ })
24
+
25
+ it('reports rejected credentials as unhealthy', async () => {
26
+ global.fetch = jest.fn().mockResolvedValue(new Response('{}', { status: 401 }))
27
+
28
+ const result = await channelResendHealthCheck.check(
29
+ { apiKey: 're_bad', fromAddress: 'from@example.com' },
30
+ { tenantId: 'tenant-1', organizationId: 'organization-1' },
31
+ )
32
+
33
+ expect(result).toEqual(expect.objectContaining({
34
+ status: 'unhealthy',
35
+ details: expect.objectContaining({ status: 401 }),
36
+ }))
37
+ })
38
+ })
@@ -0,0 +1,179 @@
1
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
2
+ import { applyResendEnvPreset, readResendEnvPreset } from '../preset'
3
+
4
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
5
+ findOneWithDecryption: jest.fn(),
6
+ }))
7
+
8
+ const mockedFindOneWithDecryption = findOneWithDecryption as jest.MockedFunction<typeof findOneWithDecryption>
9
+
10
+ function createContainer(overrides: { save?: jest.Mock; upsert?: jest.Mock } = {}) {
11
+ const save = overrides.save ?? jest.fn().mockResolvedValue(undefined)
12
+ const upsert = overrides.upsert ?? jest.fn().mockResolvedValue(undefined)
13
+ const container = {
14
+ resolve: (key: string) => (key === 'integrationStateService' ? { upsert } : { save }),
15
+ } as never
16
+ return { container, save, upsert }
17
+ }
18
+
19
+ describe('channel_resend env preset', () => {
20
+ const originalEnv = process.env
21
+
22
+ beforeEach(() => {
23
+ process.env = {
24
+ ...originalEnv,
25
+ RESEND_API_KEY: 're_test',
26
+ EMAIL_FROM: 'from@example.com',
27
+ }
28
+ mockedFindOneWithDecryption.mockReset()
29
+ })
30
+
31
+ afterEach(() => {
32
+ process.env = originalEnv
33
+ })
34
+
35
+ it('creates a tenant-scoped credential row and system channel', async () => {
36
+ mockedFindOneWithDecryption.mockResolvedValue(null)
37
+ const channel = { id: 'channel-1' }
38
+ const flush = jest.fn().mockResolvedValue(undefined)
39
+ const persist = jest.fn().mockReturnValue({ flush })
40
+ const em = { create: jest.fn().mockReturnValue(channel), persist }
41
+ const { container, save } = createContainer()
42
+
43
+ await applyResendEnvPreset({
44
+ em: em as never,
45
+ container,
46
+ tenantId: 'tenant-1',
47
+ organizationId: 'organization-1',
48
+ })
49
+
50
+ expect(save).toHaveBeenCalledWith(
51
+ 'channel_resend',
52
+ { apiKey: 're_test', fromAddress: 'from@example.com' },
53
+ { tenantId: 'tenant-1', organizationId: 'organization-1', userId: null },
54
+ )
55
+ expect(em.create).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
56
+ tenantId: 'tenant-1',
57
+ organizationId: 'organization-1',
58
+ userId: null,
59
+ providerKey: 'resend',
60
+ }))
61
+ expect(persist).toHaveBeenCalledWith(channel)
62
+ })
63
+
64
+ it('enables the integration state so Integrations does not read Disabled while email is live', async () => {
65
+ mockedFindOneWithDecryption.mockResolvedValue(null)
66
+ const flush = jest.fn().mockResolvedValue(undefined)
67
+ const em = { create: jest.fn().mockReturnValue({ id: 'channel-1' }), persist: jest.fn().mockReturnValue({ flush }) }
68
+ const { container, upsert } = createContainer()
69
+
70
+ await applyResendEnvPreset({
71
+ em: em as never,
72
+ container,
73
+ tenantId: 'tenant-1',
74
+ organizationId: 'organization-1',
75
+ })
76
+
77
+ expect(upsert).toHaveBeenCalledWith(
78
+ 'channel_resend',
79
+ { isEnabled: true },
80
+ { tenantId: 'tenant-1', organizationId: 'organization-1' },
81
+ )
82
+ })
83
+
84
+ it('still seeds the system channel when the integration state cannot be written', async () => {
85
+ mockedFindOneWithDecryption.mockResolvedValue(null)
86
+ const channel = { id: 'channel-1' }
87
+ const flush = jest.fn().mockResolvedValue(undefined)
88
+ const persist = jest.fn().mockReturnValue({ flush })
89
+ const em = { create: jest.fn().mockReturnValue(channel), persist }
90
+ const { container } = createContainer({ upsert: jest.fn().mockRejectedValue(new Error('connection terminated')) })
91
+
92
+ await expect(applyResendEnvPreset({
93
+ em: em as never,
94
+ container,
95
+ tenantId: 'tenant-1',
96
+ organizationId: 'organization-1',
97
+ })).resolves.toBeUndefined()
98
+
99
+ expect(persist).toHaveBeenCalledWith(channel)
100
+ })
101
+
102
+ it('reactivates the exactly scoped existing system channel', async () => {
103
+ const existing = { isActive: false, status: 'error', lastError: 'failed' }
104
+ mockedFindOneWithDecryption.mockResolvedValue(existing as never)
105
+ const flush = jest.fn().mockResolvedValue(undefined)
106
+ const em = { flush }
107
+
108
+ await applyResendEnvPreset({
109
+ em: em as never,
110
+ container: createContainer().container,
111
+ tenantId: 'tenant-1',
112
+ organizationId: 'organization-1',
113
+ })
114
+
115
+ expect(mockedFindOneWithDecryption).toHaveBeenCalledWith(
116
+ em,
117
+ expect.anything(),
118
+ expect.objectContaining({ tenantId: 'tenant-1', organizationId: 'organization-1', userId: null }),
119
+ undefined,
120
+ { tenantId: 'tenant-1', organizationId: 'organization-1' },
121
+ )
122
+ expect(existing).toEqual(expect.objectContaining({ isActive: true, status: 'connected', lastError: null }))
123
+ expect(flush).toHaveBeenCalledTimes(1)
124
+ })
125
+
126
+ it('still completes tenant seeding when the channel row cannot be written', async () => {
127
+ mockedFindOneWithDecryption.mockRejectedValue(new Error('connection terminated'))
128
+ const { container, save } = createContainer()
129
+
130
+ await expect(applyResendEnvPreset({
131
+ em: { flush: jest.fn() } as never,
132
+ container,
133
+ tenantId: 'tenant-1',
134
+ organizationId: 'organization-1',
135
+ })).resolves.toBeUndefined()
136
+
137
+ expect(save).toHaveBeenCalledTimes(1)
138
+ })
139
+
140
+ it('requires both an API key and sender address', () => {
141
+ delete process.env.RESEND_API_KEY
142
+ expect(readResendEnvPreset()).toBeNull()
143
+ })
144
+
145
+ it('still seeds when no provider is selected, because Resend is the documented default', async () => {
146
+ delete process.env.SYSTEM_EMAIL_PROVIDER
147
+ mockedFindOneWithDecryption.mockResolvedValue(null)
148
+ const flush = jest.fn().mockResolvedValue(undefined)
149
+ const em = { create: jest.fn().mockReturnValue({ id: 'channel-1' }), persist: jest.fn().mockReturnValue({ flush }) }
150
+ const { container, save } = createContainer()
151
+
152
+ await applyResendEnvPreset({
153
+ em: em as never,
154
+ container,
155
+ tenantId: 'tenant-1',
156
+ organizationId: 'organization-1',
157
+ })
158
+
159
+ expect(save).toHaveBeenCalledTimes(1)
160
+ })
161
+
162
+ it('seeds nothing when SES is the selected provider, even with a leftover RESEND_API_KEY', async () => {
163
+ process.env.SYSTEM_EMAIL_PROVIDER = 'ses'
164
+ const em = { create: jest.fn(), persist: jest.fn(), flush: jest.fn() }
165
+ const { container, save, upsert } = createContainer()
166
+
167
+ await applyResendEnvPreset({
168
+ em: em as never,
169
+ container,
170
+ tenantId: 'tenant-1',
171
+ organizationId: 'organization-1',
172
+ })
173
+
174
+ expect(save).not.toHaveBeenCalled()
175
+ expect(upsert).not.toHaveBeenCalled()
176
+ expect(em.create).not.toHaveBeenCalled()
177
+ expect(mockedFindOneWithDecryption).not.toHaveBeenCalled()
178
+ })
179
+ })
@@ -0,0 +1,139 @@
1
+ import { Resend } from 'resend'
2
+ import type {
3
+ ChannelAdapter,
4
+ ChannelNativeContent,
5
+ ConvertOutboundInput,
6
+ GetMessageStatusInput,
7
+ InboundMessage,
8
+ MessageStatus,
9
+ NormalizedInboundMessage,
10
+ SendMessageInput,
11
+ SendMessageResult,
12
+ VerifyWebhookInput,
13
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
14
+ import {
15
+ htmlToText,
16
+ sanitizeHeaderValue,
17
+ stringOrUndefined,
18
+ toAddressList,
19
+ } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
20
+ import { resendCapabilities } from '../capabilities'
21
+ import { resendCredentialsSchema } from './credentials'
22
+
23
+ type ResendAttachment = {
24
+ filename: string
25
+ content: string
26
+ contentType?: string
27
+ }
28
+
29
+ type ResendSendResult = {
30
+ data?: { id?: string | null } | null
31
+ error?: unknown
32
+ }
33
+
34
+ function resolveResendErrorMessage(result: unknown): string | null {
35
+ const value = result as ResendSendResult
36
+ if (typeof value.error === 'string') return value.error
37
+ if (value.error && typeof value.error === 'object' && 'message' in value.error) {
38
+ const message = (value.error as { message?: unknown }).message
39
+ return typeof message === 'string' ? message : null
40
+ }
41
+ return null
42
+ }
43
+
44
+ function attachmentsFromMeta(value: unknown): ResendAttachment[] | undefined {
45
+ if (!Array.isArray(value)) return undefined
46
+ const attachments = value.flatMap((item): ResendAttachment[] => {
47
+ if (!item || typeof item !== 'object') return []
48
+ const record = item as Record<string, unknown>
49
+ const filename = stringOrUndefined(record.filename)
50
+ const content = stringOrUndefined(record.content)
51
+ if (!filename || !content) return []
52
+ const contentType = stringOrUndefined(record.contentType)
53
+ return [{ filename, content, ...(contentType ? { contentType } : {}) }]
54
+ })
55
+ return attachments.length ? attachments : undefined
56
+ }
57
+
58
+ class ResendChannelAdapter implements ChannelAdapter {
59
+ readonly providerKey = 'resend'
60
+ readonly channelType = 'email'
61
+ readonly capabilities = resendCapabilities
62
+
63
+ async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
64
+ const credentials = resendCredentialsSchema.parse(input.credentials)
65
+ const meta = (input.metadata ?? {}) as Record<string, unknown>
66
+ const to = Array.isArray(meta.to) ? (meta.to as string[]) : []
67
+ if (to.length === 0) {
68
+ return { externalMessageId: '', status: 'failed', error: '[internal] Email send requires at least one recipient' }
69
+ }
70
+ const subject = stringOrUndefined(meta.subject)
71
+ if (!subject) {
72
+ return { externalMessageId: '', status: 'failed', error: '[internal] Email send requires a subject' }
73
+ }
74
+
75
+ const client = new Resend(credentials.apiKey)
76
+ const resendAttachments = attachmentsFromMeta(meta.attachments)
77
+ const basePayload = {
78
+ from: stringOrUndefined(meta.from) ?? credentials.fromAddress,
79
+ to,
80
+ subject,
81
+ ...(stringOrUndefined(meta.replyTo) ? { replyTo: stringOrUndefined(meta.replyTo) } : {}),
82
+ ...(resendAttachments?.length ? { attachments: resendAttachments } : {}),
83
+ }
84
+ const payload: Parameters<typeof client.emails.send>[0] = input.content.html
85
+ ? { ...basePayload, html: input.content.html, ...(input.content.text ? { text: input.content.text } : {}) }
86
+ : { ...basePayload, text: input.content.text ?? '' }
87
+ const result = await client.emails.send(payload)
88
+ const errorMessage = resolveResendErrorMessage(result)
89
+ if (errorMessage) {
90
+ return { externalMessageId: '', status: 'failed', error: `RESEND_SEND_FAILED: ${errorMessage}` }
91
+ }
92
+ const sentId = (result as ResendSendResult).data?.id
93
+ return {
94
+ externalMessageId: sentId || `resend:${Date.now()}`,
95
+ conversationId: input.conversationId,
96
+ status: 'sent',
97
+ }
98
+ }
99
+
100
+ async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {
101
+ return { raw: {}, eventType: 'other', metadata: { reason: 'resend-system-email-outbound-only' } }
102
+ }
103
+
104
+ async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {
105
+ return { status: 'sent' }
106
+ }
107
+
108
+ async normalizeInbound(_raw: InboundMessage): Promise<NormalizedInboundMessage> {
109
+ throw new Error('[internal] Resend system email adapter is outbound-only')
110
+ }
111
+
112
+ async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {
113
+ const meta = (input.channelMetadata ?? {}) as Record<string, unknown>
114
+ const to = toAddressList(meta.to).map(sanitizeHeaderValue)
115
+ const subject = stringOrUndefined(meta.subject)
116
+ const html = input.bodyFormat === 'html' ? input.body : undefined
117
+ const text = input.bodyFormat === 'html' ? htmlToText(input.body) : input.body
118
+ return {
119
+ content: {
120
+ text,
121
+ html,
122
+ bodyFormat: input.bodyFormat,
123
+ },
124
+ metadata: {
125
+ to,
126
+ subject,
127
+ from: stringOrUndefined(meta.from),
128
+ replyTo: stringOrUndefined(meta.replyTo),
129
+ attachments: attachmentsFromMeta(meta.attachments),
130
+ },
131
+ }
132
+ }
133
+ }
134
+
135
+ const adapter = new ResendChannelAdapter()
136
+
137
+ export function getResendChannelAdapter(): ChannelAdapter {
138
+ return adapter
139
+ }
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod'
2
+
3
+ export const resendCredentialsSchema = z.object({
4
+ apiKey: z.string().min(1),
5
+ fromAddress: z.string().email(),
6
+ })
7
+
8
+ export type ResendCredentials = z.infer<typeof resendCredentialsSchema>