@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.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_resend/acl.js +10 -0
- package/dist/modules/channel_resend/acl.js.map +7 -0
- package/dist/modules/channel_resend/capabilities.js +10 -0
- package/dist/modules/channel_resend/capabilities.js.map +7 -0
- package/dist/modules/channel_resend/di.js +22 -0
- package/dist/modules/channel_resend/di.js.map +7 -0
- package/dist/modules/channel_resend/index.js +13 -0
- package/dist/modules/channel_resend/index.js.map +7 -0
- package/dist/modules/channel_resend/integration.js +61 -0
- package/dist/modules/channel_resend/integration.js.map +7 -0
- package/dist/modules/channel_resend/lib/adapter.js +109 -0
- package/dist/modules/channel_resend/lib/adapter.js.map +7 -0
- package/dist/modules/channel_resend/lib/credentials.js +9 -0
- package/dist/modules/channel_resend/lib/credentials.js.map +7 -0
- package/dist/modules/channel_resend/lib/health.js +43 -0
- package/dist/modules/channel_resend/lib/health.js.map +7 -0
- package/dist/modules/channel_resend/lib/preset.js +61 -0
- package/dist/modules/channel_resend/lib/preset.js.map +7 -0
- package/dist/modules/channel_resend/lib/system-email-config.js +13 -0
- package/dist/modules/channel_resend/lib/system-email-config.js.map +7 -0
- package/dist/modules/channel_resend/setup.js +30 -0
- package/dist/modules/channel_resend/setup.js.map +7 -0
- package/jest.config.cjs +33 -0
- package/package.json +87 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_resend/__tests__/contracts.test.ts +27 -0
- package/src/modules/channel_resend/acl.ts +6 -0
- package/src/modules/channel_resend/capabilities.ts +8 -0
- package/src/modules/channel_resend/di.ts +20 -0
- package/src/modules/channel_resend/index.ts +9 -0
- package/src/modules/channel_resend/integration.ts +56 -0
- package/src/modules/channel_resend/lib/__tests__/adapter.test.ts +70 -0
- package/src/modules/channel_resend/lib/__tests__/health.test.ts +38 -0
- package/src/modules/channel_resend/lib/__tests__/preset.test.ts +179 -0
- package/src/modules/channel_resend/lib/adapter.ts +139 -0
- package/src/modules/channel_resend/lib/credentials.ts +8 -0
- package/src/modules/channel_resend/lib/health.ts +51 -0
- package/src/modules/channel_resend/lib/preset.ts +95 -0
- package/src/modules/channel_resend/lib/system-email-config.ts +10 -0
- package/src/modules/channel_resend/setup.ts +30 -0
- package/tsconfig.json +9 -0
- 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,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
|
+
}
|