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,228 @@
|
||||
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 } 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> {
|
||||
let lastError: unknown
|
||||
for (const provider of activeProviders) {
|
||||
try {
|
||||
return await provider.send(data)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
logger.warn(`${provider.name} failed, trying next provider`, error)
|
||||
}
|
||||
}
|
||||
logger.error('All email providers failed', lastError)
|
||||
return {
|
||||
success: false,
|
||||
message: `All email providers failed: ${getErrorMessage(lastError, 'unknown error')}`,
|
||||
}
|
||||
}
|
||||
|
||||
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 },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user