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,559 @@
|
||||
import { generateRandomString } from '@sim/utils/random'
|
||||
import { convert } from 'html-to-text'
|
||||
import type {
|
||||
GmailAttachment,
|
||||
GmailMessage,
|
||||
GmailReadParams,
|
||||
GmailToolResponse,
|
||||
} from '@/tools/gmail/types'
|
||||
|
||||
export const GMAIL_API_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me'
|
||||
|
||||
/**
|
||||
* Fetch original message headers for threading
|
||||
* @param messageId Gmail message ID to fetch headers from
|
||||
* @param accessToken Gmail access token
|
||||
* @returns Object containing threading headers (messageId, references, subject)
|
||||
*/
|
||||
export async function fetchThreadingHeaders(
|
||||
messageId: string,
|
||||
accessToken: string
|
||||
): Promise<{
|
||||
messageId?: string
|
||||
references?: string
|
||||
subject?: string
|
||||
}> {
|
||||
try {
|
||||
const messageResponse = await fetch(
|
||||
`${GMAIL_API_BASE}/messages/${messageId}?format=metadata&metadataHeaders=Message-ID&metadataHeaders=References&metadataHeaders=Subject`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (messageResponse.ok) {
|
||||
const messageData = await messageResponse.json()
|
||||
const headers = messageData.payload?.headers || []
|
||||
|
||||
return {
|
||||
messageId: headers.find((h: any) => h.name.toLowerCase() === 'message-id')?.value,
|
||||
references: headers.find((h: any) => h.name.toLowerCase() === 'references')?.value,
|
||||
subject: headers.find((h: any) => h.name.toLowerCase() === 'subject')?.value,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue without threading headers rather than failing
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// Helper function to process a Gmail message
|
||||
export async function processMessage(
|
||||
message: GmailMessage,
|
||||
params?: GmailReadParams
|
||||
): Promise<GmailToolResponse> {
|
||||
// Check if message and payload exist
|
||||
if (!message || !message.payload) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
content: 'Unable to process email: Invalid message format',
|
||||
metadata: {
|
||||
id: message?.id || '',
|
||||
threadId: message?.threadId || '',
|
||||
labelIds: message?.labelIds || [],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const headers = message.payload.headers || []
|
||||
const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || ''
|
||||
const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || ''
|
||||
const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value || ''
|
||||
const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || ''
|
||||
|
||||
// Extract the message body
|
||||
const body = extractMessageBody(message.payload)
|
||||
|
||||
// Check for attachments
|
||||
const attachmentInfo = extractAttachmentInfo(message.payload)
|
||||
const hasAttachments = attachmentInfo.length > 0
|
||||
|
||||
// Download attachments if requested
|
||||
let attachments: GmailAttachment[] | undefined
|
||||
if (params?.includeAttachments && hasAttachments && params.accessToken) {
|
||||
try {
|
||||
attachments = await downloadAttachments(message.id, attachmentInfo, params.accessToken)
|
||||
} catch (error) {
|
||||
// Continue without attachments rather than failing the entire request
|
||||
}
|
||||
}
|
||||
|
||||
const result: GmailToolResponse = {
|
||||
success: true,
|
||||
output: {
|
||||
content: body || 'No content found in email',
|
||||
metadata: {
|
||||
id: message.id || '',
|
||||
threadId: message.threadId || '',
|
||||
labelIds: message.labelIds || [],
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
date,
|
||||
hasAttachments,
|
||||
attachmentCount: attachmentInfo.length,
|
||||
},
|
||||
// Always include attachments array (empty if none downloaded)
|
||||
attachments: attachments || [],
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Helper function to process a message for summary (without full content)
|
||||
export function processMessageForSummary(message: GmailMessage): any {
|
||||
if (!message || !message.payload) {
|
||||
return {
|
||||
id: message?.id || '',
|
||||
threadId: message?.threadId || '',
|
||||
subject: 'Unknown Subject',
|
||||
from: 'Unknown Sender',
|
||||
to: '',
|
||||
date: '',
|
||||
snippet: message?.snippet || '',
|
||||
}
|
||||
}
|
||||
|
||||
const headers = message.payload.headers || []
|
||||
const subject = headers.find((h) => h.name.toLowerCase() === 'subject')?.value || 'No Subject'
|
||||
const from = headers.find((h) => h.name.toLowerCase() === 'from')?.value || 'Unknown Sender'
|
||||
const to = headers.find((h) => h.name.toLowerCase() === 'to')?.value || ''
|
||||
const date = headers.find((h) => h.name.toLowerCase() === 'date')?.value || ''
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
threadId: message.threadId,
|
||||
subject,
|
||||
from,
|
||||
to,
|
||||
date,
|
||||
snippet: message.snippet || '',
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to recursively extract message body from MIME parts
|
||||
export function extractMessageBody(payload: any): string {
|
||||
// If the payload has a body with data, decode it
|
||||
if (payload.body?.data) {
|
||||
return Buffer.from(payload.body.data, 'base64').toString()
|
||||
}
|
||||
|
||||
// If there are no parts, return empty string
|
||||
if (!payload.parts || !Array.isArray(payload.parts) || payload.parts.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// First try to find a text/plain part
|
||||
const textPart = payload.parts.find((part: any) => part.mimeType === 'text/plain')
|
||||
if (textPart?.body?.data) {
|
||||
return Buffer.from(textPart.body.data, 'base64').toString()
|
||||
}
|
||||
|
||||
// If no text/plain, try to find text/html
|
||||
const htmlPart = payload.parts.find((part: any) => part.mimeType === 'text/html')
|
||||
if (htmlPart?.body?.data) {
|
||||
return Buffer.from(htmlPart.body.data, 'base64').toString()
|
||||
}
|
||||
|
||||
// If we have multipart/alternative or other complex types, recursively check parts
|
||||
for (const part of payload.parts) {
|
||||
if (part.parts) {
|
||||
const nestedBody = extractMessageBody(part)
|
||||
if (nestedBody) {
|
||||
return nestedBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find any text content, return empty string
|
||||
return ''
|
||||
}
|
||||
|
||||
// Helper function to extract attachment information from message payload
|
||||
export function extractAttachmentInfo(
|
||||
payload: any
|
||||
): Array<{ attachmentId: string; filename: string; mimeType: string; size: number }> {
|
||||
const attachments: Array<{
|
||||
attachmentId: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
}> = []
|
||||
|
||||
function processPayloadPart(part: any) {
|
||||
// Check if this part has an attachment
|
||||
if (part.body?.attachmentId && part.filename) {
|
||||
attachments.push({
|
||||
attachmentId: part.body.attachmentId,
|
||||
filename: part.filename,
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
size: part.body.size || 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Recursively process nested parts
|
||||
if (part.parts && Array.isArray(part.parts)) {
|
||||
part.parts.forEach(processPayloadPart)
|
||||
}
|
||||
}
|
||||
|
||||
// Process the main payload
|
||||
processPayloadPart(payload)
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
// Helper function to download attachments from Gmail API
|
||||
export async function downloadAttachments(
|
||||
messageId: string,
|
||||
attachmentInfo: Array<{ attachmentId: string; filename: string; mimeType: string; size: number }>,
|
||||
accessToken: string
|
||||
): Promise<GmailAttachment[]> {
|
||||
const downloadedAttachments: GmailAttachment[] = []
|
||||
|
||||
for (const attachment of attachmentInfo) {
|
||||
try {
|
||||
// Download attachment from Gmail API
|
||||
const attachmentResponse = await fetch(
|
||||
`${GMAIL_API_BASE}/messages/${messageId}/attachments/${attachment.attachmentId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!attachmentResponse.ok) {
|
||||
await attachmentResponse.body?.cancel().catch(() => {})
|
||||
continue
|
||||
}
|
||||
|
||||
const attachmentData = (await attachmentResponse.json()) as { data: string; size: number }
|
||||
|
||||
// Decode base64url data to buffer
|
||||
// Gmail API returns data in base64url format (URL-safe base64)
|
||||
const base64Data = attachmentData.data.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const buffer = Buffer.from(base64Data, 'base64')
|
||||
|
||||
downloadedAttachments.push({
|
||||
name: attachment.filename,
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: attachment.mimeType,
|
||||
size: attachment.size,
|
||||
})
|
||||
} catch (error) {
|
||||
// Continue with other attachments
|
||||
}
|
||||
}
|
||||
|
||||
return downloadedAttachments
|
||||
}
|
||||
|
||||
// Helper function to create a summary of multiple messages
|
||||
export function createMessagesSummary(messages: any[]): string {
|
||||
if (messages.length === 0) {
|
||||
return 'No messages found.'
|
||||
}
|
||||
|
||||
let summary = `Found ${messages.length} messages:\n\n`
|
||||
|
||||
messages.forEach((msg, index) => {
|
||||
summary += `${index + 1}. Subject: ${msg.subject}\n`
|
||||
summary += ` From: ${msg.from}\n`
|
||||
summary += ` To: ${msg.to}\n`
|
||||
summary += ` Date: ${msg.date}\n`
|
||||
summary += ` ID: ${msg.id}\n`
|
||||
summary += ` Thread ID: ${msg.threadId}\n`
|
||||
summary += ` Preview: ${msg.snippet}\n\n`
|
||||
})
|
||||
|
||||
summary += `To read full content of a specific message, use the gmail_read tool with messageId: ${messages.map((m) => m.id).join(', ')}`
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique MIME boundary string
|
||||
*/
|
||||
function generateBoundary(): string {
|
||||
return `----=_Part_${Date.now()}_${generateRandomString(13)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a header value using RFC 2047 Base64 encoding if it contains non-ASCII characters.
|
||||
* This matches Google's own Gmail API sample: `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`
|
||||
* @see https://github.com/googleapis/google-api-nodejs-client/blob/main/samples/gmail/send.js
|
||||
*/
|
||||
export function encodeRfc2047(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^[\x00-\x7F]*$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips CR/LF so a value can't introduce extra lines when placed into a MIME header.
|
||||
*/
|
||||
export function sanitizeHeaderValue(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode string or buffer to base64url format (URL-safe base64)
|
||||
* Gmail API requires base64url encoding for the raw message field
|
||||
*/
|
||||
export function base64UrlEncode(data: string | Buffer): string {
|
||||
const base64 = Buffer.from(data).toString('base64')
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters so user-supplied text renders safely inside an HTML body.
|
||||
*/
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a plain-text body to an HTML body that flows naturally in Gmail.
|
||||
* Newlines become `<br>` rather than per-paragraph `<p>` tags or a CSS
|
||||
* `white-space` rule — `<p>` margins are what Gmail's "Remove formatting"
|
||||
* button strips, and `white-space` has inconsistent support across email
|
||||
* clients (including Gmail for non-Google accounts). Plain `<br>` line
|
||||
* breaks are the standard, client-agnostic way to preserve plain-text
|
||||
* formatting in an HTML alternative part.
|
||||
* This avoids the narrow hard-wrapped rendering Gmail uses for `text/plain`.
|
||||
*/
|
||||
export function plainTextToHtml(body: string): string {
|
||||
const normalized = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
const escaped = escapeHtml(normalized).replace(/\n/g, '<br>')
|
||||
return `<!DOCTYPE html><html><body>${escaped}</body></html>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort conversion of an HTML body to a plain-text fallback. Used so we
|
||||
* always include a plain-text part alongside HTML for clients that don't render
|
||||
* HTML. Delegates to the `html-to-text` library for robust tag stripping and
|
||||
* entity decoding (also used elsewhere in the repo for the same purpose).
|
||||
*/
|
||||
export function htmlToPlainText(html: string): string {
|
||||
return convert(html, {
|
||||
wordwrap: false,
|
||||
selectors: [
|
||||
{ selector: 'a', options: { hideLinkHrefIfSameAsText: true, noAnchorUrl: true } },
|
||||
{ selector: 'img', format: 'skip' },
|
||||
{ selector: 'script', format: 'skip' },
|
||||
{ selector: 'style', format: 'skip' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the plain-text and HTML representations of a body so we can emit a
|
||||
* `multipart/alternative` section. Gmail renders the HTML version full-width
|
||||
* (matching how a manually-composed email looks); the plain-text part is the
|
||||
* fallback for clients that don't render HTML.
|
||||
*/
|
||||
export function buildBodyAlternatives(
|
||||
body: string,
|
||||
contentType: 'text' | 'html' | undefined
|
||||
): { plain: string; html: string } {
|
||||
if (contentType === 'html') {
|
||||
return { plain: htmlToPlainText(body) || body, html: body }
|
||||
}
|
||||
return { plain: body, html: plainTextToHtml(body) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a text body as base64 with RFC 2045 line wrapping (max 76 chars).
|
||||
* Using base64 lets us safely transport arbitrary UTF-8 (emoji, accented
|
||||
* characters, etc.) — `7bit` is only valid for strict 7-bit ASCII.
|
||||
*/
|
||||
function encodeBodyBase64(content: string): string[] {
|
||||
const base64 = Buffer.from(content, 'utf-8').toString('base64')
|
||||
return base64.match(/.{1,76}/g) || ['']
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the inner part of a `multipart/alternative` section (text/plain
|
||||
* followed by text/html, per RFC 2046 — clients pick the last format they
|
||||
* understand).
|
||||
*/
|
||||
function renderAlternativeParts(plain: string, html: string, boundary: string): string[] {
|
||||
return [
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
...encodeBodyBase64(plain),
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
...encodeBodyBase64(html),
|
||||
'',
|
||||
`--${boundary}--`,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `multipart/alternative` email (without attachments). Always emits
|
||||
* both text/plain and text/html parts so Gmail renders messages full-width
|
||||
* like a hand-composed email.
|
||||
* @returns Base64url encoded raw message
|
||||
*/
|
||||
export function buildSimpleEmailMessage(params: {
|
||||
to: string
|
||||
cc?: string | null
|
||||
bcc?: string | null
|
||||
subject?: string | null
|
||||
body: string
|
||||
contentType?: 'text' | 'html'
|
||||
inReplyTo?: string
|
||||
references?: string
|
||||
}): string {
|
||||
const { to, cc, bcc, subject, body, contentType, inReplyTo, references } = params
|
||||
const boundary = generateBoundary()
|
||||
const { plain, html } = buildBodyAlternatives(body, contentType)
|
||||
|
||||
const emailHeaders = ['MIME-Version: 1.0', `To: ${sanitizeHeaderValue(to)}`]
|
||||
|
||||
if (cc) {
|
||||
emailHeaders.push(`Cc: ${sanitizeHeaderValue(cc)}`)
|
||||
}
|
||||
if (bcc) {
|
||||
emailHeaders.push(`Bcc: ${sanitizeHeaderValue(bcc)}`)
|
||||
}
|
||||
|
||||
emailHeaders.push(`Subject: ${encodeRfc2047(sanitizeHeaderValue(subject || ''))}`)
|
||||
|
||||
if (inReplyTo) {
|
||||
const sanitizedInReplyTo = sanitizeHeaderValue(inReplyTo)
|
||||
emailHeaders.push(`In-Reply-To: ${sanitizedInReplyTo}`)
|
||||
const referencesChain = references
|
||||
? `${sanitizeHeaderValue(references)} ${sanitizedInReplyTo}`
|
||||
: sanitizedInReplyTo
|
||||
emailHeaders.push(`References: ${referencesChain}`)
|
||||
}
|
||||
|
||||
emailHeaders.push(`Content-Type: multipart/alternative; boundary="${boundary}"`)
|
||||
emailHeaders.push('')
|
||||
emailHeaders.push(...renderAlternativeParts(plain, html, boundary))
|
||||
|
||||
const email = emailHeaders.join('\n')
|
||||
return Buffer.from(email).toString('base64url')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a MIME multipart message with optional attachments
|
||||
* @param params Message parameters including recipients, subject, body, and attachments
|
||||
* @returns Complete MIME message string ready to be base64url encoded
|
||||
*/
|
||||
export interface BuildMimeMessageParams {
|
||||
to: string
|
||||
cc?: string
|
||||
bcc?: string
|
||||
subject?: string
|
||||
body: string
|
||||
contentType?: 'text' | 'html'
|
||||
inReplyTo?: string
|
||||
references?: string
|
||||
attachments?: Array<{
|
||||
filename: string
|
||||
mimeType: string
|
||||
content: Buffer
|
||||
}>
|
||||
}
|
||||
|
||||
export function buildMimeMessage(params: BuildMimeMessageParams): string {
|
||||
const { to, cc, bcc, subject, body, contentType, inReplyTo, references, attachments } = params
|
||||
const messageParts: string[] = []
|
||||
const { plain, html } = buildBodyAlternatives(body, contentType)
|
||||
|
||||
messageParts.push(`To: ${sanitizeHeaderValue(to)}`)
|
||||
if (cc) {
|
||||
messageParts.push(`Cc: ${sanitizeHeaderValue(cc)}`)
|
||||
}
|
||||
if (bcc) {
|
||||
messageParts.push(`Bcc: ${sanitizeHeaderValue(bcc)}`)
|
||||
}
|
||||
messageParts.push(`Subject: ${encodeRfc2047(sanitizeHeaderValue(subject || ''))}`)
|
||||
|
||||
const sanitizedInReplyTo = inReplyTo ? sanitizeHeaderValue(inReplyTo) : undefined
|
||||
const sanitizedReferences = references ? sanitizeHeaderValue(references) : undefined
|
||||
|
||||
if (sanitizedInReplyTo) {
|
||||
messageParts.push(`In-Reply-To: ${sanitizedInReplyTo}`)
|
||||
}
|
||||
if (sanitizedReferences) {
|
||||
const referencesChain = sanitizedInReplyTo
|
||||
? `${sanitizedReferences} ${sanitizedInReplyTo}`
|
||||
: sanitizedReferences
|
||||
messageParts.push(`References: ${referencesChain}`)
|
||||
} else if (sanitizedInReplyTo) {
|
||||
messageParts.push(`References: ${sanitizedInReplyTo}`)
|
||||
}
|
||||
|
||||
messageParts.push('MIME-Version: 1.0')
|
||||
|
||||
if (attachments && attachments.length > 0) {
|
||||
const mixedBoundary = generateBoundary()
|
||||
const altBoundary = generateBoundary()
|
||||
|
||||
messageParts.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`)
|
||||
messageParts.push('')
|
||||
messageParts.push(`--${mixedBoundary}`)
|
||||
messageParts.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`)
|
||||
messageParts.push('')
|
||||
messageParts.push(...renderAlternativeParts(plain, html, altBoundary))
|
||||
messageParts.push('')
|
||||
|
||||
for (const attachment of attachments) {
|
||||
messageParts.push(`--${mixedBoundary}`)
|
||||
messageParts.push(`Content-Type: ${sanitizeHeaderValue(attachment.mimeType)}`)
|
||||
messageParts.push(
|
||||
`Content-Disposition: attachment; filename="${sanitizeHeaderValue(attachment.filename)}"`
|
||||
)
|
||||
messageParts.push('Content-Transfer-Encoding: base64')
|
||||
messageParts.push('')
|
||||
|
||||
const base64Content = attachment.content.toString('base64')
|
||||
const lines = base64Content.match(/.{1,76}/g) || []
|
||||
messageParts.push(...lines)
|
||||
messageParts.push('')
|
||||
}
|
||||
|
||||
messageParts.push(`--${mixedBoundary}--`)
|
||||
} else {
|
||||
const altBoundary = generateBoundary()
|
||||
messageParts.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`)
|
||||
messageParts.push('')
|
||||
messageParts.push(...renderAlternativeParts(plain, html, altBoundary))
|
||||
}
|
||||
|
||||
return messageParts.join('\n')
|
||||
}
|
||||
Reference in New Issue
Block a user