@open-mercato/channel-ses 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_ses/acl.js +10 -0
  6. package/dist/modules/channel_ses/acl.js.map +7 -0
  7. package/dist/modules/channel_ses/capabilities.js +10 -0
  8. package/dist/modules/channel_ses/capabilities.js.map +7 -0
  9. package/dist/modules/channel_ses/di.js +22 -0
  10. package/dist/modules/channel_ses/di.js.map +7 -0
  11. package/dist/modules/channel_ses/index.js +13 -0
  12. package/dist/modules/channel_ses/index.js.map +7 -0
  13. package/dist/modules/channel_ses/integration.js +69 -0
  14. package/dist/modules/channel_ses/integration.js.map +7 -0
  15. package/dist/modules/channel_ses/lib/adapter.js +112 -0
  16. package/dist/modules/channel_ses/lib/adapter.js.map +7 -0
  17. package/dist/modules/channel_ses/lib/credentials.js +10 -0
  18. package/dist/modules/channel_ses/lib/credentials.js.map +7 -0
  19. package/dist/modules/channel_ses/lib/health.js +45 -0
  20. package/dist/modules/channel_ses/lib/health.js.map +7 -0
  21. package/dist/modules/channel_ses/lib/preset.js +59 -0
  22. package/dist/modules/channel_ses/lib/preset.js.map +7 -0
  23. package/dist/modules/channel_ses/lib/system-email-config.js +13 -0
  24. package/dist/modules/channel_ses/lib/system-email-config.js.map +7 -0
  25. package/dist/modules/channel_ses/setup.js +30 -0
  26. package/dist/modules/channel_ses/setup.js.map +7 -0
  27. package/jest.config.cjs +33 -0
  28. package/package.json +89 -0
  29. package/src/index.ts +1 -0
  30. package/src/modules/channel_ses/__tests__/contracts.test.ts +27 -0
  31. package/src/modules/channel_ses/acl.ts +6 -0
  32. package/src/modules/channel_ses/capabilities.ts +8 -0
  33. package/src/modules/channel_ses/di.ts +20 -0
  34. package/src/modules/channel_ses/index.ts +9 -0
  35. package/src/modules/channel_ses/integration.ts +64 -0
  36. package/src/modules/channel_ses/lib/__tests__/adapter.test.ts +94 -0
  37. package/src/modules/channel_ses/lib/__tests__/health.test.ts +51 -0
  38. package/src/modules/channel_ses/lib/__tests__/preset.test.ts +184 -0
  39. package/src/modules/channel_ses/lib/adapter.ts +154 -0
  40. package/src/modules/channel_ses/lib/credentials.ts +9 -0
  41. package/src/modules/channel_ses/lib/health.ts +53 -0
  42. package/src/modules/channel_ses/lib/preset.ts +91 -0
  43. package/src/modules/channel_ses/lib/system-email-config.ts +10 -0
  44. package/src/modules/channel_ses/setup.ts +30 -0
  45. package/tsconfig.json +9 -0
  46. package/watch.mjs +7 -0
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@open-mercato/channel-ses",
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
+ "@aws-sdk/client-sesv2": "^3.946.0",
53
+ "@open-mercato/core": "0.7.1-develop.7151.1.00d0391847",
54
+ "@types/nodemailer": "^8.0.0",
55
+ "nodemailer": "^9.0.0"
56
+ },
57
+ "peerDependencies": {
58
+ "@mikro-orm/postgresql": "^7.0.14",
59
+ "@open-mercato/ai-assistant": "0.7.1-develop.7151.1.00d0391847",
60
+ "@open-mercato/shared": "0.7.1-develop.7151.1.00d0391847",
61
+ "@open-mercato/ui": "0.7.1-develop.7151.1.00d0391847",
62
+ "react": "^19.0.0",
63
+ "react-dom": "^19.0.0"
64
+ },
65
+ "devDependencies": {
66
+ "@open-mercato/ai-assistant": "0.7.1-develop.7151.1.00d0391847",
67
+ "@open-mercato/shared": "0.7.1-develop.7151.1.00d0391847",
68
+ "@open-mercato/ui": "0.7.1-develop.7151.1.00d0391847",
69
+ "@types/jest": "^30.0.0",
70
+ "@types/react": "^19.2.17",
71
+ "@types/react-dom": "^19.2.3",
72
+ "esbuild": "^0.28.1",
73
+ "glob": "^13.0.6",
74
+ "jest": "^30.4.2",
75
+ "react": "19.2.7",
76
+ "react-dom": "19.2.7",
77
+ "ts-jest": "^29.4.11",
78
+ "typescript": "7.0.2"
79
+ },
80
+ "publishConfig": {
81
+ "access": "public"
82
+ },
83
+ "repository": {
84
+ "type": "git",
85
+ "url": "https://github.com/open-mercato/open-mercato",
86
+ "directory": "packages/channel-ses"
87
+ },
88
+ "stableVersion": "0.7.0"
89
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { metadata } from './modules/channel_ses/index'
@@ -0,0 +1,27 @@
1
+ import { features } from '../acl'
2
+ import { sesCapabilities } from '../capabilities'
3
+ import { integration } from '../integration'
4
+ import { metadata } from '../index'
5
+
6
+ describe('channel_ses contracts', () => {
7
+ it('declares supported module metadata and coordinated versions', () => {
8
+ expect(metadata).toEqual(expect.objectContaining({
9
+ id: 'channel_ses',
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: 'channelSesHealthCheck' },
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_ses.view', title: expect.any(String), module: 'channel_ses' },
23
+ { id: 'channel_ses.configure', title: expect.any(String), module: 'channel_ses' },
24
+ ])
25
+ expect(sesCapabilities.fileSharing).toBe(false)
26
+ })
27
+ })
@@ -0,0 +1,6 @@
1
+ export const features = [
2
+ { id: 'channel_ses.view', title: 'View Amazon SES channel configuration', module: 'channel_ses' },
3
+ { id: 'channel_ses.configure', title: 'Configure Amazon SES channel defaults', module: 'channel_ses' },
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 sesCapabilities: 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 { getSesChannelAdapter } from './lib/adapter'
8
+ import { registerSesSystemEmailConfigResolver } from './lib/system-email-config'
9
+ import { channelSesHealthCheck } from './lib/health'
10
+
11
+ export function register(container: AppContainer): void {
12
+ registerSesSystemEmailConfigResolver()
13
+ if (!hasChannelAdapter('ses')) {
14
+ registerChannelAdapter(getSesChannelAdapter())
15
+ }
16
+ container.register({
17
+ channelSesAdapter: asValue(getSesChannelAdapter()),
18
+ channelSesHealthCheck: asValue(channelSesHealthCheck),
19
+ })
20
+ }
@@ -0,0 +1,9 @@
1
+ export const metadata = {
2
+ id: 'channel_ses',
3
+ name: 'Amazon SES Email Channel',
4
+ description: 'Outbound Amazon SES 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,64 @@
1
+ import { buildIntegrationDetailWidgetSpotId, type IntegrationBundle, type IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'
2
+
3
+ export const channelSesDetailWidgetSpotId = buildIntegrationDetailWidgetSpotId('channel_ses')
4
+
5
+ export const integration: IntegrationDefinition = {
6
+ id: 'channel_ses',
7
+ title: 'Amazon SES',
8
+ description: 'Send transactional email through Amazon SES using the Communications Hub.',
9
+ category: 'communication',
10
+ hub: 'communication_channels',
11
+ providerKey: 'ses',
12
+ icon: 'mail',
13
+ docsUrl: 'https://docs.aws.amazon.com/ses/',
14
+ package: '@open-mercato/channel-ses',
15
+ version: '0.1.0',
16
+ author: 'Open Mercato Team',
17
+ company: 'Open Mercato',
18
+ license: 'MIT',
19
+ tags: ['email', 'aws', 'ses', 'transactional', 'communication'],
20
+ detailPage: {
21
+ widgetSpotId: channelSesDetailWidgetSpotId,
22
+ },
23
+ apiVersions: [
24
+ {
25
+ id: 'sesv2',
26
+ label: 'Amazon SES v2',
27
+ status: 'stable',
28
+ default: true,
29
+ changelog: 'Initial outbound transactional email adapter.',
30
+ },
31
+ ],
32
+ healthCheck: { service: 'channelSesHealthCheck' },
33
+ credentials: {
34
+ fields: [
35
+ {
36
+ key: 'region',
37
+ label: 'AWS region',
38
+ type: 'text',
39
+ required: true,
40
+ placeholder: 'eu-west-2',
41
+ helpText: 'SES region used for this tenant connection.',
42
+ },
43
+ {
44
+ key: 'fromAddress',
45
+ label: 'From address',
46
+ type: 'text',
47
+ required: true,
48
+ placeholder: 'no-reply@example.com',
49
+ helpText: 'Verified SES sender address or domain identity.',
50
+ },
51
+ {
52
+ key: 'configurationSetName',
53
+ label: 'Configuration set',
54
+ type: 'text',
55
+ required: false,
56
+ helpText: 'Optional SES configuration set name.',
57
+ },
58
+ ],
59
+ },
60
+ }
61
+
62
+ export const integrations: IntegrationDefinition[] = [integration]
63
+ export const bundles: IntegrationBundle[] = []
64
+ export const bundle: IntegrationBundle | undefined = undefined
@@ -0,0 +1,94 @@
1
+ import { getSesChannelAdapter } from '../adapter'
2
+
3
+ var sendMailMock: jest.Mock
4
+ var createTransportMock: jest.Mock
5
+ var SESv2ClientMock: jest.Mock
6
+ var SendEmailCommandMock: jest.Mock
7
+
8
+ jest.mock('nodemailer', () => {
9
+ sendMailMock = jest.fn().mockResolvedValue({ messageId: 'ses-1', response: 'ok' })
10
+ createTransportMock = jest.fn().mockReturnValue({ sendMail: sendMailMock })
11
+ return { __esModule: true, default: { createTransport: createTransportMock } }
12
+ })
13
+
14
+ jest.mock('@aws-sdk/client-sesv2', () => {
15
+ SESv2ClientMock = jest.fn()
16
+ SendEmailCommandMock = jest.fn()
17
+ return {
18
+ SESv2Client: SESv2ClientMock,
19
+ SendEmailCommand: SendEmailCommandMock,
20
+ }
21
+ })
22
+
23
+ describe('SesChannelAdapter', () => {
24
+ const originalEnv = process.env
25
+
26
+ beforeEach(() => {
27
+ process.env = { ...originalEnv }
28
+ sendMailMock.mockClear()
29
+ createTransportMock.mockClear()
30
+ SESv2ClientMock.mockClear()
31
+ SendEmailCommandMock.mockClear()
32
+ })
33
+
34
+ afterEach(() => {
35
+ process.env = originalEnv
36
+ })
37
+
38
+ it('sends html email through Nodemailer SES transport', async () => {
39
+ const adapter = getSesChannelAdapter()
40
+ const converted = await adapter.convertOutbound({
41
+ body: '<p>Hello</p>',
42
+ bodyFormat: 'html',
43
+ channelMetadata: {
44
+ to: 'user@example.com',
45
+ subject: 'Hello',
46
+ from: 'from@example.com',
47
+ replyTo: 'reply@example.com',
48
+ attachments: [{ filename: 'a.txt', content: 'dGVzdA==', contentType: 'text/plain' }],
49
+ },
50
+ })
51
+
52
+ const result = await adapter.sendMessage({
53
+ content: converted.content,
54
+ credentials: {
55
+ region: 'eu-west-2',
56
+ fromAddress: 'fallback@example.com',
57
+ configurationSetName: 'default',
58
+ },
59
+ scope: { tenantId: 'tenant', organizationId: 'org' },
60
+ metadata: converted.metadata,
61
+ })
62
+
63
+ expect(result).toEqual(expect.objectContaining({ status: 'sent', externalMessageId: 'ses-1' }))
64
+ expect(SESv2ClientMock).toHaveBeenCalledWith({ region: 'eu-west-2' })
65
+ expect(createTransportMock).toHaveBeenCalledWith(expect.objectContaining({ SES: expect.any(Object) }))
66
+ expect(sendMailMock).toHaveBeenCalledWith(expect.objectContaining({
67
+ from: 'from@example.com',
68
+ to: ['user@example.com'],
69
+ subject: 'Hello',
70
+ html: '<p>Hello</p>',
71
+ text: 'Hello',
72
+ replyTo: 'reply@example.com',
73
+ attachments: [{ filename: 'a.txt', content: 'dGVzdA==', encoding: 'base64', contentType: 'text/plain' }],
74
+ ses: { ConfigurationSetName: 'default' },
75
+ }))
76
+ })
77
+
78
+ it('returns a failed result when the SES transport rejects', async () => {
79
+ sendMailMock.mockRejectedValueOnce(new Error('temporary outage'))
80
+ const adapter = getSesChannelAdapter()
81
+
82
+ const result = await adapter.sendMessage({
83
+ content: { text: 'Hello' },
84
+ credentials: { region: 'eu-west-2', fromAddress: 'from@example.com' },
85
+ scope: { tenantId: 'tenant', organizationId: 'org' },
86
+ metadata: { to: ['user@example.com'], subject: 'Hello' },
87
+ })
88
+
89
+ expect(result).toEqual(expect.objectContaining({
90
+ status: 'failed',
91
+ error: 'SES_SEND_FAILED: temporary outage',
92
+ }))
93
+ })
94
+ })
@@ -0,0 +1,51 @@
1
+ import { channelSesHealthCheck } from '../health'
2
+
3
+ var sendMock: jest.Mock
4
+ var destroyMock: jest.Mock
5
+ var SESv2ClientMock: jest.Mock
6
+ var GetAccountCommandMock: jest.Mock
7
+
8
+ jest.mock('@aws-sdk/client-sesv2', () => {
9
+ sendMock = jest.fn().mockResolvedValue({ ProductionAccessEnabled: true, SendingEnabled: true })
10
+ destroyMock = jest.fn()
11
+ SESv2ClientMock = jest.fn().mockImplementation(() => ({ send: sendMock, destroy: destroyMock }))
12
+ GetAccountCommandMock = jest.fn()
13
+ return { SESv2Client: SESv2ClientMock, GetAccountCommand: GetAccountCommandMock }
14
+ })
15
+
16
+ describe('channelSesHealthCheck', () => {
17
+ beforeEach(() => {
18
+ sendMock.mockClear()
19
+ destroyMock.mockClear()
20
+ SESv2ClientMock.mockClear()
21
+ GetAccountCommandMock.mockClear()
22
+ })
23
+
24
+ it('performs a bounded GetAccount probe and closes the client', async () => {
25
+ const result = await channelSesHealthCheck.check(
26
+ { region: 'eu-west-2', fromAddress: 'from@example.com' },
27
+ { tenantId: 'tenant-1', organizationId: 'organization-1' },
28
+ )
29
+
30
+ expect(result).toEqual(expect.objectContaining({ status: 'healthy' }))
31
+ expect(SESv2ClientMock).toHaveBeenCalledWith({ region: 'eu-west-2' })
32
+ expect(sendMock).toHaveBeenCalledWith(expect.anything(), {
33
+ abortSignal: expect.any(AbortSignal),
34
+ })
35
+ expect(destroyMock).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ it('reports provider failures as unhealthy', async () => {
39
+ sendMock.mockRejectedValueOnce(new Error('access denied'))
40
+
41
+ const result = await channelSesHealthCheck.check(
42
+ { region: 'eu-west-2', fromAddress: 'from@example.com' },
43
+ { tenantId: 'tenant-1', organizationId: 'organization-1' },
44
+ )
45
+
46
+ expect(result).toEqual(expect.objectContaining({
47
+ status: 'unhealthy',
48
+ message: expect.stringContaining('access denied'),
49
+ }))
50
+ })
51
+ })
@@ -0,0 +1,184 @@
1
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
2
+ import { applySesEnvPreset, readSesEnvPreset } 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_ses env preset', () => {
20
+ const originalEnv = process.env
21
+
22
+ beforeEach(() => {
23
+ process.env = {
24
+ ...originalEnv,
25
+ SYSTEM_EMAIL_PROVIDER: 'ses',
26
+ AWS_SES_REGION: 'eu-west-2',
27
+ EMAIL_FROM: 'from@example.com',
28
+ }
29
+ mockedFindOneWithDecryption.mockReset()
30
+ })
31
+
32
+ afterEach(() => {
33
+ process.env = originalEnv
34
+ })
35
+
36
+ it('creates a tenant-scoped credential row and system channel', async () => {
37
+ mockedFindOneWithDecryption.mockResolvedValue(null)
38
+ const channel = { id: 'channel-1' }
39
+ const flush = jest.fn().mockResolvedValue(undefined)
40
+ const persist = jest.fn().mockReturnValue({ flush })
41
+ const em = { create: jest.fn().mockReturnValue(channel), persist }
42
+ const { container, save } = createContainer()
43
+
44
+ await applySesEnvPreset({
45
+ em: em as never,
46
+ container,
47
+ tenantId: 'tenant-1',
48
+ organizationId: 'organization-1',
49
+ })
50
+
51
+ expect(save).toHaveBeenCalledWith(
52
+ 'channel_ses',
53
+ { region: 'eu-west-2', fromAddress: 'from@example.com' },
54
+ { tenantId: 'tenant-1', organizationId: 'organization-1', userId: null },
55
+ )
56
+ expect(em.create).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
57
+ tenantId: 'tenant-1',
58
+ organizationId: 'organization-1',
59
+ userId: null,
60
+ providerKey: 'ses',
61
+ }))
62
+ expect(persist).toHaveBeenCalledWith(channel)
63
+ })
64
+
65
+ it('enables the integration state so Integrations does not read Disabled while email is live', async () => {
66
+ mockedFindOneWithDecryption.mockResolvedValue(null)
67
+ const flush = jest.fn().mockResolvedValue(undefined)
68
+ const em = { create: jest.fn().mockReturnValue({ id: 'channel-1' }), persist: jest.fn().mockReturnValue({ flush }) }
69
+ const { container, upsert } = createContainer()
70
+
71
+ await applySesEnvPreset({
72
+ em: em as never,
73
+ container,
74
+ tenantId: 'tenant-1',
75
+ organizationId: 'organization-1',
76
+ })
77
+
78
+ expect(upsert).toHaveBeenCalledWith(
79
+ 'channel_ses',
80
+ { isEnabled: true },
81
+ { tenantId: 'tenant-1', organizationId: 'organization-1' },
82
+ )
83
+ })
84
+
85
+ it('still seeds the system channel when the integration state cannot be written', async () => {
86
+ mockedFindOneWithDecryption.mockResolvedValue(null)
87
+ const channel = { id: 'channel-1' }
88
+ const flush = jest.fn().mockResolvedValue(undefined)
89
+ const persist = jest.fn().mockReturnValue({ flush })
90
+ const em = { create: jest.fn().mockReturnValue(channel), persist }
91
+ const { container } = createContainer({ upsert: jest.fn().mockRejectedValue(new Error('connection terminated')) })
92
+
93
+ await expect(applySesEnvPreset({
94
+ em: em as never,
95
+ container,
96
+ tenantId: 'tenant-1',
97
+ organizationId: 'organization-1',
98
+ })).resolves.toBeUndefined()
99
+
100
+ expect(persist).toHaveBeenCalledWith(channel)
101
+ })
102
+
103
+ it('reactivates the exactly scoped existing system channel', async () => {
104
+ const existing = { isActive: false, status: 'error', lastError: 'failed' }
105
+ mockedFindOneWithDecryption.mockResolvedValue(existing as never)
106
+ const flush = jest.fn().mockResolvedValue(undefined)
107
+ const em = { flush }
108
+
109
+ await applySesEnvPreset({
110
+ em: em as never,
111
+ container: createContainer().container,
112
+ tenantId: 'tenant-1',
113
+ organizationId: 'organization-1',
114
+ })
115
+
116
+ expect(mockedFindOneWithDecryption).toHaveBeenCalledWith(
117
+ em,
118
+ expect.anything(),
119
+ expect.objectContaining({ tenantId: 'tenant-1', organizationId: 'organization-1', userId: null }),
120
+ undefined,
121
+ { tenantId: 'tenant-1', organizationId: 'organization-1' },
122
+ )
123
+ expect(existing).toEqual(expect.objectContaining({ isActive: true, status: 'connected', lastError: null }))
124
+ expect(flush).toHaveBeenCalledTimes(1)
125
+ })
126
+
127
+ it('still completes tenant seeding when the channel row cannot be written', async () => {
128
+ mockedFindOneWithDecryption.mockRejectedValue(new Error('connection terminated'))
129
+ const { container, save } = createContainer()
130
+
131
+ await expect(applySesEnvPreset({
132
+ em: { flush: jest.fn() } as never,
133
+ container,
134
+ tenantId: 'tenant-1',
135
+ organizationId: 'organization-1',
136
+ })).resolves.toBeUndefined()
137
+
138
+ expect(save).toHaveBeenCalledTimes(1)
139
+ })
140
+
141
+ it('requires an explicit region and sender address', () => {
142
+ delete process.env.AWS_SES_REGION
143
+ delete process.env.AWS_REGION
144
+ expect(readSesEnvPreset()).toBeNull()
145
+ })
146
+
147
+ it('seeds nothing when another provider is selected, even with AWS_REGION set', async () => {
148
+ // `AWS_REGION` is set by every AWS runtime and ships uncommented in `.env.example` for vector
149
+ // search, so without the provider gate a Resend instance would get an Enabled SES integration
150
+ // and a connected SES channel it never configured.
151
+ process.env.SYSTEM_EMAIL_PROVIDER = 'resend'
152
+ delete process.env.AWS_SES_REGION
153
+ process.env.AWS_REGION = 'eu-central-1'
154
+ const em = { create: jest.fn(), persist: jest.fn(), flush: jest.fn() }
155
+ const { container, save, upsert } = createContainer()
156
+
157
+ await applySesEnvPreset({
158
+ em: em as never,
159
+ container,
160
+ tenantId: 'tenant-1',
161
+ organizationId: 'organization-1',
162
+ })
163
+
164
+ expect(save).not.toHaveBeenCalled()
165
+ expect(upsert).not.toHaveBeenCalled()
166
+ expect(em.create).not.toHaveBeenCalled()
167
+ expect(mockedFindOneWithDecryption).not.toHaveBeenCalled()
168
+ })
169
+
170
+ it('seeds nothing when no provider is selected, because the default is Resend', async () => {
171
+ delete process.env.SYSTEM_EMAIL_PROVIDER
172
+ const em = { create: jest.fn(), persist: jest.fn(), flush: jest.fn() }
173
+ const { container, save } = createContainer()
174
+
175
+ await applySesEnvPreset({
176
+ em: em as never,
177
+ container,
178
+ tenantId: 'tenant-1',
179
+ organizationId: 'organization-1',
180
+ })
181
+
182
+ expect(save).not.toHaveBeenCalled()
183
+ })
184
+ })