@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
@@ -0,0 +1,154 @@
1
+ import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
2
+ import nodemailer from 'nodemailer'
3
+ import type {
4
+ ChannelAdapter,
5
+ ChannelNativeContent,
6
+ ConvertOutboundInput,
7
+ GetMessageStatusInput,
8
+ InboundMessage,
9
+ MessageStatus,
10
+ NormalizedInboundMessage,
11
+ SendMessageInput,
12
+ SendMessageResult,
13
+ VerifyWebhookInput,
14
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
15
+ import {
16
+ htmlToText,
17
+ sanitizeHeaderValue,
18
+ stringOrUndefined,
19
+ toAddressList,
20
+ } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
21
+ import { sesCapabilities } from '../capabilities'
22
+ import { sesCredentialsSchema } from './credentials'
23
+
24
+ type SesAttachment = {
25
+ filename: string
26
+ content: string
27
+ encoding: 'base64'
28
+ contentType?: string
29
+ }
30
+
31
+ type SesSendInfo = {
32
+ messageId?: string
33
+ response?: string
34
+ }
35
+
36
+ type SesTransportOptions = {
37
+ SES: {
38
+ sesClient: SESv2Client
39
+ SendEmailCommand: typeof SendEmailCommand
40
+ }
41
+ }
42
+
43
+ type SesMailOptions = Parameters<ReturnType<typeof nodemailer.createTransport>['sendMail']>[0] & {
44
+ ses?: {
45
+ ConfigurationSetName?: string
46
+ }
47
+ }
48
+
49
+ function attachmentsFromMeta(value: unknown): SesAttachment[] | undefined {
50
+ if (!Array.isArray(value)) return undefined
51
+ const attachments = value.flatMap((item): SesAttachment[] => {
52
+ if (!item || typeof item !== 'object') return []
53
+ const record = item as Record<string, unknown>
54
+ const filename = stringOrUndefined(record.filename)
55
+ const content = stringOrUndefined(record.content)
56
+ if (!filename || !content) return []
57
+ const contentType = stringOrUndefined(record.contentType)
58
+ return [{
59
+ filename,
60
+ content,
61
+ encoding: 'base64',
62
+ ...(contentType ? { contentType } : {}),
63
+ }]
64
+ })
65
+ return attachments.length ? attachments : undefined
66
+ }
67
+
68
+ class SesChannelAdapter implements ChannelAdapter {
69
+ readonly providerKey = 'ses'
70
+ readonly channelType = 'email'
71
+ readonly capabilities = sesCapabilities
72
+
73
+ async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
74
+ const credentials = sesCredentialsSchema.parse(input.credentials)
75
+ const meta = (input.metadata ?? {}) as Record<string, unknown>
76
+ const to = Array.isArray(meta.to) ? (meta.to as string[]) : []
77
+ if (to.length === 0) {
78
+ return { externalMessageId: '', status: 'failed', error: '[internal] Email send requires at least one recipient' }
79
+ }
80
+ const subject = stringOrUndefined(meta.subject)
81
+ if (!subject) {
82
+ return { externalMessageId: '', status: 'failed', error: '[internal] Email send requires a subject' }
83
+ }
84
+
85
+ const sesClient = new SESv2Client({ region: credentials.region })
86
+ const transporter = nodemailer.createTransport({
87
+ SES: { sesClient, SendEmailCommand },
88
+ } as Parameters<typeof nodemailer.createTransport>[0] & SesTransportOptions)
89
+ const configurationSetName = stringOrUndefined(credentials.configurationSetName)
90
+ const options: SesMailOptions = {
91
+ from: stringOrUndefined(meta.from) ?? credentials.fromAddress,
92
+ to,
93
+ subject,
94
+ ...(input.content.text ? { text: input.content.text } : {}),
95
+ ...(input.content.html ? { html: input.content.html } : {}),
96
+ ...(stringOrUndefined(meta.replyTo) ? { replyTo: stringOrUndefined(meta.replyTo) } : {}),
97
+ ...(attachmentsFromMeta(meta.attachments)?.length ? { attachments: attachmentsFromMeta(meta.attachments) } : {}),
98
+ ...(configurationSetName ? { ses: { ConfigurationSetName: configurationSetName } } : {}),
99
+ }
100
+
101
+ try {
102
+ const info = await transporter.sendMail(options) as SesSendInfo
103
+ return {
104
+ externalMessageId: info.messageId || `ses:${Date.now()}`,
105
+ conversationId: input.conversationId,
106
+ status: 'sent',
107
+ metadata: info.response ? { response: info.response } : undefined,
108
+ }
109
+ } catch (error) {
110
+ const message = error instanceof Error ? error.message : 'Unknown error'
111
+ return { externalMessageId: '', status: 'failed', error: `SES_SEND_FAILED: ${message}` }
112
+ }
113
+ }
114
+
115
+ async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {
116
+ return { raw: {}, eventType: 'other', metadata: { reason: 'ses-system-email-outbound-only' } }
117
+ }
118
+
119
+ async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {
120
+ return { status: 'sent' }
121
+ }
122
+
123
+ async normalizeInbound(_raw: InboundMessage): Promise<NormalizedInboundMessage> {
124
+ throw new Error('[internal] Amazon SES system email adapter is outbound-only')
125
+ }
126
+
127
+ async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {
128
+ const meta = (input.channelMetadata ?? {}) as Record<string, unknown>
129
+ const to = toAddressList(meta.to).map(sanitizeHeaderValue)
130
+ const subject = stringOrUndefined(meta.subject)
131
+ const html = input.bodyFormat === 'html' ? input.body : undefined
132
+ const text = input.bodyFormat === 'html' ? htmlToText(input.body) : input.body
133
+ return {
134
+ content: {
135
+ text,
136
+ html,
137
+ bodyFormat: input.bodyFormat,
138
+ },
139
+ metadata: {
140
+ to,
141
+ subject,
142
+ from: stringOrUndefined(meta.from),
143
+ replyTo: stringOrUndefined(meta.replyTo),
144
+ attachments: attachmentsFromMeta(meta.attachments),
145
+ },
146
+ }
147
+ }
148
+ }
149
+
150
+ const adapter = new SesChannelAdapter()
151
+
152
+ export function getSesChannelAdapter(): ChannelAdapter {
153
+ return adapter
154
+ }
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod'
2
+
3
+ export const sesCredentialsSchema = z.object({
4
+ region: z.string().min(1),
5
+ fromAddress: z.string().email(),
6
+ configurationSetName: z.string().min(1).optional(),
7
+ })
8
+
9
+ export type SesCredentials = z.infer<typeof sesCredentialsSchema>
@@ -0,0 +1,53 @@
1
+ import { GetAccountCommand, SESv2Client } from '@aws-sdk/client-sesv2'
2
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
3
+ import { withTimeout } from '@open-mercato/shared/lib/http/fetchWithTimeout'
4
+ import { sesCredentialsSchema } from './credentials'
5
+
6
+ type HealthCheckResult = {
7
+ status: 'healthy' | 'unhealthy'
8
+ message: string
9
+ details: Record<string, unknown>
10
+ }
11
+
12
+ export const channelSesHealthCheck = {
13
+ async check(
14
+ credentials: Record<string, unknown> | null,
15
+ _scope: IntegrationScope,
16
+ ): Promise<HealthCheckResult> {
17
+ const parsed = sesCredentialsSchema.safeParse(credentials ?? {})
18
+ if (!parsed.success) {
19
+ return {
20
+ status: 'unhealthy',
21
+ message: `Amazon SES credentials invalid: ${parsed.error.issues[0]?.message ?? 'unknown validation error'}`,
22
+ details: { reason: 'invalid_credentials' },
23
+ }
24
+ }
25
+
26
+ const client = new SESv2Client({ region: parsed.data.region })
27
+ try {
28
+ const account = await withTimeout(
29
+ (signal) => client.send(new GetAccountCommand({}), { abortSignal: signal }),
30
+ 8_000,
31
+ 'Amazon SES GetAccount',
32
+ )
33
+ return {
34
+ status: 'healthy',
35
+ message: 'Amazon SES account is reachable',
36
+ details: {
37
+ region: parsed.data.region,
38
+ productionAccessEnabled: account.ProductionAccessEnabled ?? false,
39
+ sendingEnabled: account.SendingEnabled ?? false,
40
+ },
41
+ }
42
+ } catch (error) {
43
+ const message = error instanceof Error ? error.message : 'Amazon SES request failed'
44
+ return {
45
+ status: 'unhealthy',
46
+ message: `Amazon SES health check failed: ${message}`,
47
+ details: { reason: 'request_failed', region: parsed.data.region },
48
+ }
49
+ } finally {
50
+ client.destroy()
51
+ }
52
+ },
53
+ }
@@ -0,0 +1,91 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { normalizeEnvString, resolveDefaultEmailFromAddress } from '@open-mercato/shared/lib/email/config'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
5
+ import { ensureSystemEmailChannel } from '@open-mercato/core/modules/communication_channels/lib/ensure-system-email-channel'
6
+ import { isSelectedSystemEmailProvider } from '@open-mercato/core/modules/communication_channels/lib/system-email-provider-config'
7
+ import { sesCapabilities } from '../capabilities'
8
+
9
+ const logger = createLogger('channel_ses')
10
+
11
+ type PresetScope = {
12
+ em: EntityManager
13
+ container: AppContainer
14
+ tenantId: string
15
+ organizationId: string
16
+ }
17
+
18
+ type CredentialsServiceLike = {
19
+ save: (
20
+ integrationId: string,
21
+ credentials: Record<string, unknown>,
22
+ scope: { organizationId: string; tenantId: string; userId?: string | null },
23
+ ) => Promise<void>
24
+ }
25
+
26
+ type IntegrationStateServiceLike = {
27
+ upsert: (
28
+ integrationId: string,
29
+ input: { isEnabled: boolean },
30
+ scope: { organizationId: string; tenantId: string },
31
+ ) => Promise<unknown>
32
+ }
33
+
34
+ export function readSesEnvPreset(): { region: string; fromAddress: string; configurationSetName?: string } | null {
35
+ const fromAddress = resolveDefaultEmailFromAddress()
36
+ const region = normalizeEnvString(process.env.AWS_SES_REGION) || normalizeEnvString(process.env.AWS_REGION)
37
+ if (!fromAddress || !region) return null
38
+ const configurationSetName = normalizeEnvString(process.env.AWS_SES_CONFIGURATION_SET)
39
+ return {
40
+ fromAddress,
41
+ region,
42
+ ...(configurationSetName ? { configurationSetName } : {}),
43
+ }
44
+ }
45
+
46
+ export async function applySesEnvPreset(ctx: PresetScope): Promise<void> {
47
+ // Only the provider this instance actually selected seeds anything. `AWS_REGION` is not an email
48
+ // variable here — `.env.example` ships it uncommented for vector search, and every AWS runtime
49
+ // injects it — so without this gate a Resend instance would advertise an Enabled SES integration
50
+ // and a `status: 'connected'` SES channel nobody configured. That is the same misleading admin
51
+ // state #5531 was filed for, pointed the other way.
52
+ if (!isSelectedSystemEmailProvider('ses')) return
53
+ const preset = readSesEnvPreset()
54
+ if (!preset) return
55
+
56
+ let credentialsService: CredentialsServiceLike
57
+ try {
58
+ credentialsService = ctx.container.resolve('integrationCredentialsService') as CredentialsServiceLike
59
+ } catch {
60
+ return
61
+ }
62
+
63
+ await credentialsService.save('channel_ses', preset, {
64
+ tenantId: ctx.tenantId,
65
+ organizationId: ctx.organizationId,
66
+ userId: null,
67
+ })
68
+
69
+ try {
70
+ const integrationStateService = ctx.container.resolve('integrationStateService') as IntegrationStateServiceLike
71
+ await integrationStateService.upsert('channel_ses', { isEnabled: true }, {
72
+ tenantId: ctx.tenantId,
73
+ organizationId: ctx.organizationId,
74
+ })
75
+ } catch (err) {
76
+ logger.warn('Failed to enable the SES integration state; Integrations will read Disabled while email is live', {
77
+ err,
78
+ tenantId: ctx.tenantId,
79
+ organizationId: ctx.organizationId,
80
+ })
81
+ }
82
+
83
+ await ensureSystemEmailChannel(ctx.em, {
84
+ tenantId: ctx.tenantId,
85
+ organizationId: ctx.organizationId,
86
+ providerKey: 'ses',
87
+ externalIdentifier: preset.fromAddress,
88
+ displayName: 'Amazon SES system email',
89
+ capabilities: { ...sesCapabilities },
90
+ })
91
+ }
@@ -0,0 +1,10 @@
1
+ import { registerSystemEmailProviderConfigResolver } from '@open-mercato/core/modules/communication_channels/lib/system-email-provider-config'
2
+ import { readSesEnvPreset } from './preset'
3
+
4
+ export function registerSesSystemEmailConfigResolver(): void {
5
+ registerSystemEmailProviderConfigResolver({
6
+ providerKey: 'ses',
7
+ isConfigured: () => Boolean(readSesEnvPreset()),
8
+ resolveCredentials: ({ fromAddress }) => readSesEnvPreset() ?? { fromAddress },
9
+ })
10
+ }
@@ -0,0 +1,30 @@
1
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+ import {
3
+ hasChannelAdapter,
4
+ registerChannelAdapter,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
6
+ import { getSesChannelAdapter } from './lib/adapter'
7
+ import { applySesEnvPreset } from './lib/preset'
8
+ import { registerSesSystemEmailConfigResolver } from './lib/system-email-config'
9
+
10
+ function ensureSesAdapterRegistered(): void {
11
+ if (hasChannelAdapter('ses')) return
12
+ registerChannelAdapter(getSesChannelAdapter())
13
+ }
14
+
15
+ ensureSesAdapterRegistered()
16
+ registerSesSystemEmailConfigResolver()
17
+
18
+ export const setup: ModuleSetupConfig = {
19
+ defaultRoleFeatures: {
20
+ superadmin: ['channel_ses.view', 'channel_ses.configure'],
21
+ admin: ['channel_ses.view', 'channel_ses.configure'],
22
+ },
23
+ async seedDefaults(ctx) {
24
+ ensureSesAdapterRegistered()
25
+ registerSesSystemEmailConfigResolver()
26
+ await applySesEnvPreset(ctx)
27
+ },
28
+ }
29
+
30
+ export default setup
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
9
+ }
package/watch.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { watch } from '../../scripts/watch.mjs'
2
+ import { dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ watch(__dirname)