@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.
- 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_ses/acl.js +10 -0
- package/dist/modules/channel_ses/acl.js.map +7 -0
- package/dist/modules/channel_ses/capabilities.js +10 -0
- package/dist/modules/channel_ses/capabilities.js.map +7 -0
- package/dist/modules/channel_ses/di.js +22 -0
- package/dist/modules/channel_ses/di.js.map +7 -0
- package/dist/modules/channel_ses/index.js +13 -0
- package/dist/modules/channel_ses/index.js.map +7 -0
- package/dist/modules/channel_ses/integration.js +69 -0
- package/dist/modules/channel_ses/integration.js.map +7 -0
- package/dist/modules/channel_ses/lib/adapter.js +112 -0
- package/dist/modules/channel_ses/lib/adapter.js.map +7 -0
- package/dist/modules/channel_ses/lib/credentials.js +10 -0
- package/dist/modules/channel_ses/lib/credentials.js.map +7 -0
- package/dist/modules/channel_ses/lib/health.js +45 -0
- package/dist/modules/channel_ses/lib/health.js.map +7 -0
- package/dist/modules/channel_ses/lib/preset.js +59 -0
- package/dist/modules/channel_ses/lib/preset.js.map +7 -0
- package/dist/modules/channel_ses/lib/system-email-config.js +13 -0
- package/dist/modules/channel_ses/lib/system-email-config.js.map +7 -0
- package/dist/modules/channel_ses/setup.js +30 -0
- package/dist/modules/channel_ses/setup.js.map +7 -0
- package/jest.config.cjs +33 -0
- package/package.json +89 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_ses/__tests__/contracts.test.ts +27 -0
- package/src/modules/channel_ses/acl.ts +6 -0
- package/src/modules/channel_ses/capabilities.ts +8 -0
- package/src/modules/channel_ses/di.ts +20 -0
- package/src/modules/channel_ses/index.ts +9 -0
- package/src/modules/channel_ses/integration.ts +64 -0
- package/src/modules/channel_ses/lib/__tests__/adapter.test.ts +94 -0
- package/src/modules/channel_ses/lib/__tests__/health.test.ts +51 -0
- package/src/modules/channel_ses/lib/__tests__/preset.test.ts +184 -0
- package/src/modules/channel_ses/lib/adapter.ts +154 -0
- package/src/modules/channel_ses/lib/credentials.ts +9 -0
- package/src/modules/channel_ses/lib/health.ts +53 -0
- package/src/modules/channel_ses/lib/preset.ts +91 -0
- package/src/modules/channel_ses/lib/system-email-config.ts +10 -0
- package/src/modules/channel_ses/setup.ts +30 -0
- package/tsconfig.json +9 -0
- 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