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
108 lines
3.2 KiB
TypeScript
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)
|
|
}
|