Files
simstudioai--sim/apps/sim/lib/messaging/email/prepare.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

108 lines
3.2 KiB
TypeScript

import { getBaseUrl } from '@/lib/core/utils/urls'
import type { EmailOptions, EmailType, ProcessedEmailData } from '@/lib/messaging/email/types'
import { generateUnsubscribeToken, isUnsubscribed } from '@/lib/messaging/email/unsubscribe'
import { getFromEmailAddress, hasEmailHeaderControlChars } from '@/lib/messaging/email/utils'
function sanitizeEmailSubject(subject: string): string {
return subject.replace(/[\r\n]+/g, ' ').trim()
}
interface UnsubscribeInjection {
headers: Record<string, string>
html?: string
text?: string
}
function buildUnsubscribeInjection(
recipientEmail: string,
emailType: EmailType,
html?: string,
text?: string
): UnsubscribeInjection {
const token = generateUnsubscribeToken(recipientEmail, emailType)
const baseUrl = getBaseUrl()
const encodedEmail = encodeURIComponent(recipientEmail)
const unsubscribeUrl = `${baseUrl}/unsubscribe?token=${token}&email=${encodedEmail}`
return {
headers: {
'List-Unsubscribe': `<${unsubscribeUrl}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
html: html
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, token)
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
text: text
?.replace(/\{\{UNSUBSCRIBE_TOKEN\}\}/g, token)
.replace(/\{\{UNSUBSCRIBE_EMAIL\}\}/g, encodedEmail),
}
}
function validateAndSanitize(options: EmailOptions): {
senderEmail: string
subject: string
replyTo?: string
} {
const senderEmail = options.from || getFromEmailAddress()
const recipients = Array.isArray(options.to) ? options.to : [options.to]
if (recipients.some(hasEmailHeaderControlChars)) {
throw new Error('Invalid recipient email header')
}
if (hasEmailHeaderControlChars(senderEmail)) {
throw new Error('Invalid from email header')
}
if (options.replyTo && hasEmailHeaderControlChars(options.replyTo)) {
throw new Error('Invalid reply-to email header')
}
const subject = sanitizeEmailSubject(options.subject)
if (subject.length === 0) {
throw new Error('Email subject cannot be empty')
}
return { senderEmail, subject, replyTo: options.replyTo }
}
export function processEmailData(options: EmailOptions): ProcessedEmailData {
const { senderEmail, subject, replyTo } = validateAndSanitize(options)
const {
to,
html,
text,
emailType = 'transactional',
includeUnsubscribe = true,
attachments,
} = options
let finalHtml = html
let finalText = text
let headers: Record<string, string> = {}
if (includeUnsubscribe && emailType !== 'transactional') {
const primaryEmail = Array.isArray(to) ? to[0] : to
const injection = buildUnsubscribeInjection(primaryEmail, emailType, html, text)
headers = injection.headers
finalHtml = injection.html
finalText = injection.text
}
return {
to,
subject,
html: finalHtml,
text: finalText,
senderEmail,
headers,
attachments,
replyTo,
}
}
export async function shouldSkipForUnsubscribe(options: EmailOptions): Promise<boolean> {
const { emailType = 'transactional', to } = options
if (emailType === 'transactional') return false
const primaryEmail = Array.isArray(to) ? to[0] : to
return isUnsubscribed(primaryEmail, emailType)
}