UNPKG

mimetext

Version:

RFC-5322 compliant, fully typed and documented email message generator for javascript runtimes.

351 lines (288 loc) 13.2 kB
import type { MailboxAddrObject, MailboxConfig } from './Mailbox.js' import { MIMETextError } from './MIMETextError.js' import { type HeadersObject, MIMEMessageHeader } from './MIMEMessageHeader.js' import { Mailbox } from './Mailbox.js' import { MIMEMessageContent } from './MIMEMessageContent.js' export class MIMEMessage { envctx: EnvironmentContext headers: MIMEMessageHeader boundaries: Boundaries = { mixed: '', alt: '', related: '' } validTypes = ['text/html', 'text/plain'] validContentTransferEncodings = ['7bit', '8bit', 'binary', 'quoted-printable', 'base64'] messages: MIMEMessageContent[] = [] constructor (envctx: EnvironmentContext) { this.envctx = envctx this.headers = new MIMEMessageHeader(this.envctx) this.messages = [] this.generateBoundaries() } asRaw (): string { const eol = this.envctx.eol const lines = this.headers.dump() const plaintext = this.getMessageByType('text/plain') const html = this.getMessageByType('text/html') const primaryMessage = html ?? (plaintext ?? undefined) if (primaryMessage === undefined) { throw new MIMETextError('MIMETEXT_MISSING_BODY', 'No content added to the message.') } const hasAttachments = this.hasAttachments() const hasInlineAttachments = this.hasInlineAttachments() const structure = hasInlineAttachments && hasAttachments ? 'mixed+related' : hasAttachments ? 'mixed' : hasInlineAttachments ? 'related' : plaintext && html ? 'alternative' : '' if (structure === 'mixed+related') { const attachments = this.getAttachments() .map((a) => '--' + this.boundaries.mixed + eol + a.dump() + eol + eol) .join('') .slice(0, -1 * eol.length) const inlineAttachments = this.getInlineAttachments() .map((a) => '--' + this.boundaries.related + eol + a.dump() + eol + eol) .join('') .slice(0, -1 * eol.length) return lines + eol + 'Content-Type: multipart/mixed; boundary=' + this.boundaries.mixed + eol + eol + '--' + this.boundaries.mixed + eol + 'Content-Type: multipart/related; boundary=' + this.boundaries.related + eol + eol + this.dumpTextContent(plaintext, html, this.boundaries.related) + eol + eol + inlineAttachments + '--' + this.boundaries.related + '--' + eol + attachments + '--' + this.boundaries.mixed + '--' } else if (structure === 'mixed') { const attachments = this.getAttachments() .map((a) => '--' + this.boundaries.mixed + eol + a.dump() + eol + eol) .join('') .slice(0, -1 * eol.length) return lines + eol + 'Content-Type: multipart/mixed; boundary=' + this.boundaries.mixed + eol + eol + this.dumpTextContent(plaintext, html, this.boundaries.mixed) + eol + (plaintext && html ? '' : eol) + attachments + '--' + this.boundaries.mixed + '--' } else if (structure === 'related') { const inlineAttachments = this.getInlineAttachments() .map((a) => '--' + this.boundaries.related + eol + a.dump() + eol + eol) .join('') .slice(0, -1 * eol.length) return lines + eol + 'Content-Type: multipart/related; boundary=' + this.boundaries.related + eol + eol + this.dumpTextContent(plaintext, html, this.boundaries.related) + eol + eol + inlineAttachments + '--' + this.boundaries.related + '--' } else if (structure === 'alternative') { return lines + eol + 'Content-Type: multipart/alternative; boundary=' + this.boundaries.alt + eol + eol + this.dumpTextContent(plaintext, html, this.boundaries.alt) + eol + eol + '--' + this.boundaries.alt + '--' } else { return lines + eol + primaryMessage.dump() } } asEncoded (): string { return this.envctx.toBase64WebSafe(this.asRaw()) } dumpTextContent (plaintext: MIMEMessageContent | undefined, html: MIMEMessageContent | undefined, boundary: string): string { const eol = this.envctx.eol const primaryMessage = html ?? plaintext let data = '' if (plaintext && html && (this.hasInlineAttachments() || this.hasAttachments())) { data = '--' + boundary + eol + 'Content-Type: multipart/alternative; boundary=' + this.boundaries.alt + eol + eol + '--' + this.boundaries.alt + eol + plaintext.dump() + eol + eol + '--' + this.boundaries.alt + eol + html.dump() + eol + eol + '--' + this.boundaries.alt + '--' } else if (plaintext && html) { data = '--' + boundary + eol + plaintext.dump() + eol + eol + '--' + boundary + eol + html.dump() } else { data = '--' + boundary + eol + (primaryMessage!).dump() } return data } hasInlineAttachments (): boolean { return this.messages.some((msg) => msg.isInlineAttachment()) } hasAttachments (): boolean { return this.messages.some((msg) => msg.isAttachment()) } getAttachments (): MIMEMessageContent[] | [] { const matcher = (msg: MIMEMessageContent): boolean => msg.isAttachment() return this.messages.some(matcher) ? this.messages.filter(matcher) : [] } getInlineAttachments (): MIMEMessageContent[] | [] { const matcher = (msg: MIMEMessageContent): boolean => msg.isInlineAttachment() return this.messages.some(matcher) ? this.messages.filter(matcher) : [] } getMessageByType (type: string): MIMEMessageContent | undefined { const matcher = (msg: MIMEMessageContent): boolean => !msg.isAttachment() && !msg.isInlineAttachment() && (msg.getHeader('Content-Type') as string || '').includes(type) return this.messages.some(matcher) ? this.messages.filter(matcher)[0] : undefined } addAttachment (opts: AttachmentOptions): MIMEMessageContent { if (!this.isObject(opts.headers)) opts.headers = {} if (typeof opts.filename !== 'string') { throw new MIMETextError('MIMETEXT_MISSING_FILENAME', 'The property "filename" must exist while adding attachments.') } let type = (opts.headers['Content-Type'] ?? opts.contentType) || 'none' if (this.envctx.validateContentType(type) === false) { throw new MIMETextError('MIMETEXT_INVALID_MESSAGE_TYPE', `You specified an invalid content type "${type}".`) } const encoding = (opts.headers['Content-Transfer-Encoding'] ?? opts.encoding) ?? 'base64' if (!this.validContentTransferEncodings.includes(encoding)) { type = 'application/octet-stream' } const contentId = opts.headers['Content-ID'] if (typeof contentId === 'string' && contentId.length > 2 && !contentId.startsWith('<') && !contentId.endsWith('>')) { opts.headers['Content-ID'] = '<' + opts.headers['Content-ID'] + '>' } const disposition = opts.inline ? 'inline' : 'attachment' opts.headers = Object.assign({}, opts.headers, { 'Content-Type': `${type}; name="${opts.filename}"`, 'Content-Transfer-Encoding': encoding, 'Content-Disposition': `${disposition}; filename="${opts.filename}"` }) return this._addMessage({ data: opts.data, headers: opts.headers }) } addMessage (opts: ContentOptions): MIMEMessageContent { if (!this.isObject(opts.headers)) opts.headers = {} let type = (opts.headers['Content-Type'] ?? opts.contentType) || 'none' if (!this.validTypes.includes(type)) { throw new MIMETextError('MIMETEXT_INVALID_MESSAGE_TYPE', `Valid content types are ${this.validTypes.join(', ')} but you specified "${type}".`) } const encoding = (opts.headers['Content-Transfer-Encoding'] ?? opts.encoding) ?? '7bit' if (!this.validContentTransferEncodings.includes(encoding)) { type = 'application/octet-stream' } const charset = opts.charset ?? 'UTF-8' opts.headers = Object.assign({}, opts.headers, { 'Content-Type': `${type}; charset=${charset}`, 'Content-Transfer-Encoding': encoding }) return this._addMessage({ data: opts.data, headers: opts.headers }) } private _addMessage (opts: { data: string, headers: ContentHeaders }): MIMEMessageContent { const msg = new MIMEMessageContent(this.envctx, opts.data, opts.headers) this.messages.push(msg) return msg } setSender (input: MailboxAddrObject | string, config: MailboxConfig = { type: 'From' }): Mailbox { const mailbox = new Mailbox(input, config) this.setHeader('From', mailbox) return mailbox } getSender (): Mailbox | undefined { return this.getHeader('From') as Mailbox } setRecipients (input: MailboxAddrObject | string | MailboxAddrObject[] | string[], config: MailboxConfig = { type: 'To' }): Mailbox[] { const arr = !this.isArray(input) ? [input] : input const recs = arr.map((_input) => new Mailbox(_input, config)) this.setHeader(config.type, recs) return recs } getRecipients (config: MailboxConfig = { type: 'To' }): Mailbox | Mailbox[] | undefined { return this.getHeader(config.type) as Mailbox | Mailbox[] | undefined } setRecipient (input: MailboxAddrObject | string | MailboxAddrObject[] | string[], config: MailboxConfig = { type: 'To' }): Mailbox[] { return this.setRecipients(input, config) } setTo (input: MailboxAddrObject | string | MailboxAddrObject[] | string[], config: MailboxConfig = { type: 'To' }): Mailbox[] { return this.setRecipients(input, config) } setCc (input: MailboxAddrObject | string | MailboxAddrObject[] | string[], config: MailboxConfig = { type: 'Cc' }): Mailbox[] { return this.setRecipients(input, config) } setBcc (input: MailboxAddrObject | string | MailboxAddrObject[] | string[], config: MailboxConfig = { type: 'Bcc' }): Mailbox[] { return this.setRecipients(input, config) } setSubject (value: string): string { this.setHeader('subject', value) return value } getSubject (): string | undefined { return this.getHeader('subject') as string } setHeader (name: string, value: string | Mailbox | Mailbox[]): string { this.headers.set(name, value) return name } getHeader (name: string): string | Mailbox | Mailbox[] | undefined { return this.headers.get(name) } setHeaders (obj: Record<string, string | Mailbox | Mailbox[]>): string[] { return Object.keys(obj).map((prop) => this.setHeader(prop, obj[prop]!)) } getHeaders (): HeadersObject { return this.headers.toObject() } toBase64 (v: string): string { return this.envctx.toBase64(v) } toBase64WebSafe (v: string): string { return this.envctx.toBase64WebSafe(v) } generateBoundaries (): void { this.boundaries = { mixed: Math.random().toString(36).slice(2), alt: Math.random().toString(36).slice(2), related: Math.random().toString(36).slice(2) } } isArray (v: unknown): v is unknown[] { return (!!v) && (v.constructor === Array) } isObject (v: unknown): v is object { return (!!v) && (v.constructor === Object) } } export interface EnvironmentContext { toBase64: (v: string) => string toBase64WebSafe: (v: string) => string eol: string validateContentType: (v: string) => string | false } export interface Boundaries { mixed: string alt: string related: string } export type ContentTransferEncoding = '7bit' | '8bit' | 'binary' | 'quoted-printable' | 'base64' export interface ContentHeaders { 'Content-Type'?: string 'Content-Transfer-Encoding'?: ContentTransferEncoding 'Content-Disposition'?: string 'Content-ID'?: string [index: string]: string | undefined } export interface ContentOptions { data: string encoding?: ContentTransferEncoding contentType: string headers?: ContentHeaders charset?: string } export interface AttachmentOptions extends ContentOptions { inline?: boolean filename: string }