6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
224 lines
7.3 KiB
TypeScript
224 lines
7.3 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
|
|
import { processEmailData, shouldSkipForUnsubscribe } from '@/lib/messaging/email/prepare'
|
|
import { activeProviders, emailFallback } from '@/lib/messaging/email/providers'
|
|
import type {
|
|
BatchEmailOptions,
|
|
BatchSendEmailResult,
|
|
EmailOptions,
|
|
ProcessedEmailData,
|
|
SendEmailResult,
|
|
} from '@/lib/messaging/email/types'
|
|
|
|
export type {
|
|
BatchEmailOptions,
|
|
BatchSendEmailResult,
|
|
EmailAttachment,
|
|
EmailOptions,
|
|
EmailType,
|
|
MailProvider,
|
|
MailProviderName,
|
|
ProcessedEmailData,
|
|
SendEmailResult,
|
|
} from '@/lib/messaging/email/types'
|
|
|
|
const logger = createLogger('Mailer')
|
|
|
|
const SKIPPED_UNSUBSCRIBED_RESULT: SendEmailResult = {
|
|
success: true,
|
|
message: 'Email skipped (user unsubscribed)',
|
|
data: { id: 'skipped-unsubscribed' },
|
|
}
|
|
|
|
const MOCK_EMAIL_RESULT: SendEmailResult = {
|
|
success: true,
|
|
message: 'Email logging successful (no email service configured)',
|
|
data: { id: 'mock-email-id' },
|
|
}
|
|
|
|
const SKIPPED_BANNED_RESULT: SendEmailResult = {
|
|
success: true,
|
|
message: 'Email skipped (recipient on access-control ban list)',
|
|
data: { id: 'skipped-banned' },
|
|
}
|
|
|
|
export function hasEmailService(): boolean {
|
|
return activeProviders.length > 0
|
|
}
|
|
|
|
/**
|
|
* Drop recipients that are on the AppConfig access-control ban list. Returns the
|
|
* original options when nothing is banned, options narrowed to the allowed
|
|
* recipients when some are, or `null` when every recipient is banned. Config is
|
|
* cached (~30s TTL) with an env fallback, so a missing/unreachable AppConfig
|
|
* fails open rather than blocking all mail.
|
|
*/
|
|
async function applyBanList(options: EmailOptions): Promise<EmailOptions | null> {
|
|
const recipients = Array.isArray(options.to) ? options.to : [options.to]
|
|
const config = await getAccessControlConfig()
|
|
const allowed = recipients.filter((email) => !isEmailBlockedByAccessControl(email, config))
|
|
if (allowed.length === 0) return null
|
|
if (allowed.length === recipients.length) return options
|
|
return { ...options, to: allowed.length === 1 ? allowed[0] : allowed }
|
|
}
|
|
|
|
export async function sendEmail(options: EmailOptions): Promise<SendEmailResult> {
|
|
try {
|
|
const allowed = await applyBanList(options)
|
|
if (!allowed) {
|
|
logger.info('Email not sent (recipient on access-control ban list):', {
|
|
to: options.to,
|
|
subject: options.subject,
|
|
emailType: options.emailType,
|
|
})
|
|
return SKIPPED_BANNED_RESULT
|
|
}
|
|
|
|
if (await shouldSkipForUnsubscribe(allowed)) {
|
|
logger.info('Email not sent (user unsubscribed):', {
|
|
to: allowed.to,
|
|
subject: allowed.subject,
|
|
emailType: allowed.emailType,
|
|
})
|
|
return SKIPPED_UNSUBSCRIBED_RESULT
|
|
}
|
|
|
|
const data = processEmailData(allowed)
|
|
|
|
if (activeProviders.length === 0) {
|
|
logger.info('Email not sent (no email service configured):', {
|
|
to: data.to,
|
|
subject: data.subject,
|
|
from: data.senderEmail,
|
|
})
|
|
return MOCK_EMAIL_RESULT
|
|
}
|
|
|
|
return await dispatchWithFallback(data)
|
|
} catch (error) {
|
|
logger.error('Error sending email:', error)
|
|
return { success: false, message: 'Failed to send email' }
|
|
}
|
|
}
|
|
|
|
async function dispatchWithFallback(data: ProcessedEmailData): Promise<SendEmailResult> {
|
|
try {
|
|
return await emailFallback.execute((provider) => provider.send(data))
|
|
} catch (error) {
|
|
logger.error('All email providers failed', error)
|
|
return {
|
|
success: false,
|
|
message: getErrorMessage(error, 'All email providers failed'),
|
|
}
|
|
}
|
|
}
|
|
|
|
interface PreparedBatchEntry {
|
|
index: number
|
|
data: ProcessedEmailData | null
|
|
skippedResult: SendEmailResult | null
|
|
}
|
|
|
|
async function prepareBatch(emails: EmailOptions[]): Promise<PreparedBatchEntry[]> {
|
|
return Promise.all(
|
|
emails.map(async (email, index): Promise<PreparedBatchEntry> => {
|
|
try {
|
|
const allowed = await applyBanList(email)
|
|
if (!allowed) {
|
|
return { index, data: null, skippedResult: SKIPPED_BANNED_RESULT }
|
|
}
|
|
if (await shouldSkipForUnsubscribe(allowed)) {
|
|
return { index, data: null, skippedResult: SKIPPED_UNSUBSCRIBED_RESULT }
|
|
}
|
|
return { index, data: processEmailData(allowed), skippedResult: null }
|
|
} catch (error) {
|
|
return {
|
|
index,
|
|
data: null,
|
|
skippedResult: {
|
|
success: false,
|
|
message: getErrorMessage(error, 'Failed to prepare email'),
|
|
},
|
|
}
|
|
}
|
|
})
|
|
)
|
|
}
|
|
|
|
export async function sendBatchEmails(options: BatchEmailOptions): Promise<BatchSendEmailResult> {
|
|
try {
|
|
const entries = await prepareBatch(options.emails)
|
|
const sendable = entries.filter(
|
|
(e): e is PreparedBatchEntry & { data: ProcessedEmailData } => e.data !== null
|
|
)
|
|
|
|
if (sendable.length === 0) {
|
|
const results = entries.map((e) => e.skippedResult ?? SKIPPED_UNSUBSCRIBED_RESULT)
|
|
const allUnsubscribed =
|
|
entries.length > 0 && entries.every((e) => e.skippedResult === SKIPPED_UNSUBSCRIBED_RESULT)
|
|
return {
|
|
success: results.every((r) => r.success),
|
|
message:
|
|
options.emails.length === 0
|
|
? 'No emails to send'
|
|
: allUnsubscribed
|
|
? 'All batch emails skipped (users unsubscribed)'
|
|
: 'No emails sent (all entries skipped or failed validation)',
|
|
results,
|
|
data: { count: 0 },
|
|
}
|
|
}
|
|
|
|
const batchProvider = activeProviders.find((p) => p.sendBatch)
|
|
if (batchProvider) {
|
|
try {
|
|
const batchResult = await batchProvider.sendBatch!(sendable.map((e) => e.data))
|
|
return mergeBatchResults(entries, sendable, batchResult.results)
|
|
} catch (error) {
|
|
logger.warn(`${batchProvider.name} batch failed, falling back to per-message sends`, error)
|
|
}
|
|
}
|
|
|
|
const sentResults = await Promise.all(
|
|
sendable.map((entry) => sendEmail(options.emails[entry.index]))
|
|
)
|
|
return mergeBatchResults(entries, sendable, sentResults)
|
|
} catch (error) {
|
|
logger.error('Error in batch email sending:', error)
|
|
return { success: false, message: 'Failed to send batch emails', results: [] }
|
|
}
|
|
}
|
|
|
|
function mergeBatchResults(
|
|
entries: PreparedBatchEntry[],
|
|
sendable: PreparedBatchEntry[],
|
|
sentResults: SendEmailResult[]
|
|
): BatchSendEmailResult {
|
|
const resultsByIndex = new Map<number, SendEmailResult>()
|
|
sendable.forEach((entry, i) => {
|
|
resultsByIndex.set(entry.index, sentResults[i])
|
|
})
|
|
|
|
const results = entries.map(
|
|
(entry) => resultsByIndex.get(entry.index) ?? entry.skippedResult ?? SKIPPED_UNSUBSCRIBED_RESULT
|
|
)
|
|
|
|
// sentCount excludes both unsubscribe-skipped (success but not delivered)
|
|
// and prepare-failed entries — only counts what actually went out the wire.
|
|
const sentCount = sentResults.filter((r) => r.success).length
|
|
const skippedCount = entries.length - sendable.length
|
|
const allSucceeded = sentCount === sendable.length && skippedCount === 0
|
|
return {
|
|
success: results.every((r) => r.success),
|
|
message:
|
|
skippedCount > 0
|
|
? `${sentCount} emails sent, ${skippedCount} skipped`
|
|
: allSucceeded
|
|
? 'All batch emails sent successfully'
|
|
: `${sentCount}/${sendable.length} emails sent successfully`,
|
|
results,
|
|
data: { count: sentCount },
|
|
}
|
|
}
|