chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import type { Transporter } from 'nodemailer'
|
||||
import type {
|
||||
MailProviderName,
|
||||
ProcessedEmailData,
|
||||
SendEmailResult,
|
||||
} from '@/lib/messaging/email/types'
|
||||
|
||||
export async function sendViaNodemailer(
|
||||
transporter: Transporter,
|
||||
data: ProcessedEmailData,
|
||||
provider: MailProviderName
|
||||
): Promise<SendEmailResult> {
|
||||
const info = await transporter.sendMail({
|
||||
from: data.senderEmail,
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
html: data.html,
|
||||
text: data.text,
|
||||
replyTo: data.replyTo,
|
||||
headers: Object.keys(data.headers).length > 0 ? data.headers : undefined,
|
||||
attachments: data.attachments?.map((att) => ({
|
||||
filename: att.filename,
|
||||
content: att.content,
|
||||
contentType: att.contentType,
|
||||
contentDisposition: att.disposition || 'attachment',
|
||||
})),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Email sent successfully via ${provider}`,
|
||||
data: { id: info.messageId },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { EmailClient, type EmailMessage } from '@azure/communication-email'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type { MailProvider, ProcessedEmailData, SendEmailResult } from '@/lib/messaging/email/types'
|
||||
|
||||
function extractBareAddress(addressOrFormatted: string): string {
|
||||
if (!addressOrFormatted.includes('<')) return addressOrFormatted
|
||||
return addressOrFormatted.match(/<(.+)>/)?.[1] ?? addressOrFormatted
|
||||
}
|
||||
|
||||
export function createAzureProvider(): MailProvider | null {
|
||||
const connectionString = env.AZURE_ACS_CONNECTION_STRING
|
||||
if (!connectionString || connectionString.trim() === '') return null
|
||||
const client = new EmailClient(connectionString)
|
||||
|
||||
return {
|
||||
name: 'azure',
|
||||
async send(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
if (!data.html && !data.text) {
|
||||
throw new Error('Azure Communication Services requires either HTML or text content')
|
||||
}
|
||||
|
||||
const message: EmailMessage = {
|
||||
senderAddress: extractBareAddress(data.senderEmail),
|
||||
content: data.html
|
||||
? { subject: data.subject, html: data.html }
|
||||
: { subject: data.subject, plainText: data.text as string },
|
||||
recipients: {
|
||||
to: (Array.isArray(data.to) ? data.to : [data.to]).map((address) => ({ address })),
|
||||
},
|
||||
headers: data.headers,
|
||||
}
|
||||
|
||||
const poller = await client.beginSend(message)
|
||||
const result = await poller.pollUntilDone()
|
||||
if (result.status !== 'Succeeded') {
|
||||
throw new Error(`Azure Communication Services failed with status: ${result.status}`)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully via Azure Communication Services',
|
||||
data: { id: result.id },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { createAzureProvider } from '@/lib/messaging/email/providers/azure'
|
||||
import { createResendProvider } from '@/lib/messaging/email/providers/resend'
|
||||
import { createSesProvider } from '@/lib/messaging/email/providers/ses'
|
||||
import { createSmtpProvider } from '@/lib/messaging/email/providers/smtp'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
const logger = createLogger('MailProviders')
|
||||
|
||||
const factories = [
|
||||
createResendProvider,
|
||||
createSesProvider,
|
||||
createSmtpProvider,
|
||||
createAzureProvider,
|
||||
] as const
|
||||
|
||||
function safeCreate(factory: () => MailProvider | null): MailProvider | null {
|
||||
try {
|
||||
return factory()
|
||||
} catch (error) {
|
||||
logger.error('Mail provider factory threw at startup; skipping', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const activeProviders: readonly MailProvider[] = factories
|
||||
.map((factory) => safeCreate(factory))
|
||||
.filter((provider): provider is MailProvider => provider !== null)
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Resend } from 'resend'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import type {
|
||||
BatchSendEmailResult,
|
||||
MailProvider,
|
||||
ProcessedEmailData,
|
||||
SendEmailResult,
|
||||
} from '@/lib/messaging/email/types'
|
||||
|
||||
function isConfigured(key: string | undefined): key is string {
|
||||
return !!key && key !== 'placeholder' && key.trim() !== ''
|
||||
}
|
||||
|
||||
function toResendPayload(data: ProcessedEmailData) {
|
||||
return {
|
||||
from: data.senderEmail,
|
||||
to: data.to,
|
||||
subject: data.subject,
|
||||
html: data.html,
|
||||
text: data.text,
|
||||
replyTo: data.replyTo,
|
||||
headers: Object.keys(data.headers).length > 0 ? data.headers : undefined,
|
||||
attachments: data.attachments?.map((att) => ({
|
||||
filename: att.filename,
|
||||
content: typeof att.content === 'string' ? att.content : att.content.toString('base64'),
|
||||
contentType: att.contentType,
|
||||
disposition: att.disposition || 'attachment',
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function createResendProvider(): MailProvider | null {
|
||||
if (!isConfigured(env.RESEND_API_KEY)) return null
|
||||
const client = new Resend(env.RESEND_API_KEY)
|
||||
|
||||
return {
|
||||
name: 'resend',
|
||||
async send(data: ProcessedEmailData): Promise<SendEmailResult> {
|
||||
const payload = toResendPayload(data)
|
||||
const { data: responseData, error } = await client.emails.send(payload as never)
|
||||
if (error) {
|
||||
throw new Error(error.message || 'Failed to send email via Resend')
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'Email sent successfully via Resend',
|
||||
data: responseData,
|
||||
}
|
||||
},
|
||||
async sendBatch(emails: ProcessedEmailData[]): Promise<BatchSendEmailResult> {
|
||||
const payloads = emails.map(toResendPayload)
|
||||
const response = await client.batch.send(payloads as never)
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || 'Resend batch API error')
|
||||
}
|
||||
|
||||
const results: SendEmailResult[] = emails.map((_, index) => ({
|
||||
success: true,
|
||||
message: 'Email sent successfully via Resend batch',
|
||||
data: { id: `batch-${index}` },
|
||||
}))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'All batch emails sent successfully via Resend',
|
||||
results,
|
||||
data: { count: emails.length },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'
|
||||
import nodemailer from 'nodemailer'
|
||||
import type SESTransport from 'nodemailer/lib/ses-transport'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { sendViaNodemailer } from '@/lib/messaging/email/providers/_nodemailer'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
/**
|
||||
* AWS SES via nodemailer's SES transport using the AWS SDK v3 client.
|
||||
* Credentials resolve through the SDK's default provider chain (env vars,
|
||||
* shared config, ECS/EKS task role, EC2 instance profile, SSO).
|
||||
*/
|
||||
export function createSesProvider(): MailProvider | null {
|
||||
const region = env.AWS_SES_REGION
|
||||
if (!region) return null
|
||||
|
||||
const sesClient = new SESv2Client({ region })
|
||||
const sesOptions: SESTransport.Options = {
|
||||
// double-cast-allowed: @types/nodemailer bundles a nested @aws-sdk/client-sesv2 whose nominal class types do not unify with the top-level install
|
||||
SES: { sesClient, SendEmailCommand } as unknown as SESTransport.Options['SES'],
|
||||
}
|
||||
const transporter = nodemailer.createTransport(sesOptions)
|
||||
|
||||
return {
|
||||
name: 'ses',
|
||||
send: (data) => sendViaNodemailer(transporter, data, 'ses'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { env, envBoolean, envNumber } from '@/lib/core/config/env'
|
||||
import { sendViaNodemailer } from '@/lib/messaging/email/providers/_nodemailer'
|
||||
import type { MailProvider } from '@/lib/messaging/email/types'
|
||||
|
||||
const logger = createLogger('SmtpMailProvider')
|
||||
|
||||
export function createSmtpProvider(): MailProvider | null {
|
||||
const host = env.SMTP_HOST
|
||||
if (!host) return null
|
||||
|
||||
const port = envNumber(env.SMTP_PORT, 0, { min: 1 })
|
||||
if (port === 0) {
|
||||
logger.warn(
|
||||
'SMTP_HOST is set but SMTP_PORT is missing or invalid; skipping SMTP provider. Set SMTP_PORT to 465 (TLS), 587 (STARTTLS), or 25 (plain).'
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const user = env.SMTP_USER
|
||||
const pass = env.SMTP_PASS
|
||||
if ((user && !pass) || (!user && pass)) {
|
||||
logger.warn(
|
||||
'SMTP_USER and SMTP_PASS must both be set for authenticated relays; proceeding without auth.'
|
||||
)
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port,
|
||||
secure: envBoolean(env.SMTP_SECURE) ?? port === 465,
|
||||
auth: user && pass ? { user, pass } : undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
name: 'smtp',
|
||||
send: (data) => sendViaNodemailer(transporter, data, 'smtp'),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user