diff --git a/README.md b/README.md index 2d942b1..039806c 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,7 @@ export interface SendConfig { content?: string; mimeContent?: Content[]; html?: string; + relatedAttachments?: Attachment[]; inReplyTo?: string; replyTo?: string; references?: string; @@ -302,6 +303,10 @@ base64 is converted to binary and is only present for a better API. So don't encode your binary files as base64, otherwise denomailer can't convert it back to binary. +#### relatedAttachments + +Attachments related to the html content, for example embedded images. + #### internalTag This can be used with preprocessors so you can give mail a type, for example diff --git a/client/basic/client.ts b/client/basic/client.ts index 5ad73f8..1f4fb70 100644 --- a/client/basic/client.ts +++ b/client/basic/client.ts @@ -1,4 +1,6 @@ import type { ResolvedSendConfig } from "../../config/mail/mod.ts"; +import type { ResolvedContent } from "../../config/mail/content.ts"; +import type { ResolvedAttachment } from "../../config/mail/attachments.ts"; import { ResolvedClientOptions } from "../../config/client.ts"; import { SMTPConnection } from "./connection.ts"; import { QUE } from "./QUE.ts"; @@ -53,6 +55,98 @@ export class SMTPClient { return this.#que.idle; } + calcBoundary(content: string, pattern: RegExp): number { + let boundary = 100; + + const matches = content.matchAll(pattern); + for (const match of matches) { + boundary += parseInt(match[1], 10); + } + + return boundary; + } + + encodeContent(content: ResolvedContent) { + this.#connection.writeCmd( + "Content-Type: " + content.mimeType, + ); + if (content.transferEncoding) { + this.#connection.writeCmd( + `Content-Transfer-Encoding: ${content.transferEncoding}\r\n`, + ); + } else { + this.#connection.writeCmd(""); + } + + this.#connection.writeCmd(content.content + "\r\n"); + } + + encodeAttachment(attachment: ResolvedAttachment) { + this.#connection.writeCmd( + `Content-Type: ${attachment.contentType}; name=${attachment.filename}`, + ); + + if (attachment.contentID) { + this.#connection.writeCmd( + `Content-ID: <${attachment.contentID}>`, + ); + } + + this.#connection.writeCmd( + `Content-Disposition: ${attachment.contentDisposition}; filename=${attachment.filename}`, + ); + + if (attachment.encoding === "base64") { + this.#connection.writeCmd( + "Content-Transfer-Encoding: base64\r\n", + ); + + for ( + let line = 0; + line < Math.ceil(attachment.content.length / 75); + line++ + ) { + const lineOfBase64 = attachment.content.slice( + line * 75, + (line + 1) * 75, + ); + + this.#connection.writeCmd(lineOfBase64); + } + + this.#connection.writeCmd("\r\n"); + } else if (attachment.encoding === "text") { + this.#connection.writeCmd( + "Content-Transfer-Encoding: quoted-printable\r\n", + ); + + this.#connection.writeCmd(attachment.content + "\r\n"); + } + } + + encodeRelated(content: ResolvedContent) { + const boundaryAddRel = this.calcBoundary( + content.content + "\n" + + content.relatedAttachments.map((v) => v.content).join("\n"), + new RegExp("--related([0-9]+)", "g"), + ); + + const relatedBoundary = `related${boundaryAddRel}`; + this.#connection.writeCmd( + `Content-Type: multipart/related; boundary=${relatedBoundary}\r\n; type=${content.mimeType}`, + ); + + this.#connection.writeCmd(`--${relatedBoundary}`); + this.encodeContent(content); + + for (let i = 0; i < content.relatedAttachments.length; i++) { + this.#connection.writeCmd(`--${relatedBoundary}`); + this.encodeAttachment(content.relatedAttachments[i]); + } + + this.#connection.writeCmd(`--${relatedBoundary}--\r\n`); + } + async send(config: ResolvedSendConfig) { await this.#ready; let dataMode = false; @@ -144,34 +238,10 @@ export class SMTPClient { this.#connection.writeCmd("MIME-Version: 1.0"); - let boundaryAdditionAtt = 100; - // calc msg boundary - // TODO: replace this with a match or so. - config.mimeContent.map((v) => v.content).join("\n").replace( - new RegExp("--attachment([0-9]+)", "g"), - (_, numb) => { - boundaryAdditionAtt += parseInt(numb, 10); - - return ""; - }, - ); - - // const dec = new TextDecoder(); - - config.attachments.map((v) => { - return v.content; - // if (v.encoding === "text") return v.content; - - // const arr = new Uint8Array(v.content); - - // return dec.decode(arr); - }).join("\n").replace( + const boundaryAdditionAtt = this.calcBoundary( + config.mimeContent.map((v) => v.content).join("\n") + "\n" + + config.attachments.map((v) => v.content).join("\n"), new RegExp("--attachment([0-9]+)", "g"), - (_, numb) => { - boundaryAdditionAtt += parseInt(numb, 10); - - return ""; - }, ); const attachmentBoundary = `attachment${boundaryAdditionAtt}`; @@ -182,19 +252,12 @@ export class SMTPClient { ); this.#connection.writeCmd(`--${attachmentBoundary}`); - let boundaryAddition = 100; - // calc msg boundary - // TODO: replace this with a match or so. - config.mimeContent.map((v) => v.content).join("\n").replace( + const boundaryAdditionMsg = this.calcBoundary( + config.mimeContent.map((v) => v.content).join("\n"), new RegExp("--message([0-9]+)", "g"), - (_, numb) => { - boundaryAddition += parseInt(numb, 10); - - return ""; - }, ); - const messageBoundary = `message${boundaryAddition}`; + const messageBoundary = `message${boundaryAdditionMsg}`; this.#connection.writeCmd( `Content-Type: multipart/alternative; boundary=${messageBoundary}`, @@ -203,83 +266,18 @@ export class SMTPClient { for (let i = 0; i < config.mimeContent.length; i++) { this.#connection.writeCmd(`--${messageBoundary}`); - this.#connection.writeCmd( - "Content-Type: " + config.mimeContent[i].mimeType, - ); - if (config.mimeContent[i].transferEncoding) { - this.#connection.writeCmd( - `Content-Transfer-Encoding: ${ - config.mimeContent[i].transferEncoding - }` + "\r\n", - ); + if (config.mimeContent[i].relatedAttachments.length === 0) { + this.encodeContent(config.mimeContent[i]); } else { - // Send new line - this.#connection.writeCmd(""); + this.encodeRelated(config.mimeContent[i]); } - - this.#connection.writeCmd(config.mimeContent[i].content, "\r\n"); } this.#connection.writeCmd(`--${messageBoundary}--\r\n`); for (let i = 0; i < config.attachments.length; i++) { - const attachment = config.attachments[i]; - this.#connection.writeCmd(`--${attachmentBoundary}`); - this.#connection.writeCmd( - "Content-Type:", - attachment.contentType + ";", - "name=" + attachment.filename, - ); - - if (attachment.contentID) { - this.#connection.writeCmd( - `Content-ID: <${attachment.contentID}>`, - ); - } - - this.#connection.writeCmd( - "Content-Disposition: attachment; filename=" + attachment.filename, - ); - - if (attachment.encoding === "base64") { - this.#connection.writeCmd( - "Content-Transfer-Encoding: base64\r\n", - ); - - for ( - let line = 0; - line < Math.ceil(attachment.content.length / 75); - line++ - ) { - const lineOfBase64 = attachment.content.slice( - line * 75, - (line + 1) * 75, - ); - - this.#connection.writeCmd(lineOfBase64); - } - - // if ( - // attachment.content instanceof ArrayBuffer || - // attachment.content instanceof SharedArrayBuffer - // ) { - // await this.#connection.writeCmdBinary( - // new Uint8Array(attachment.content), - // ); - // } else { - // await this.#connection.writeCmdBinary(attachment.content); - // } - - this.#connection.writeCmd("\r\n"); - } else if (attachment.encoding === "text") { - this.#connection.writeCmd( - "Content-Transfer-Encoding: quoted-printable", - "\r\n", - ); - - this.#connection.writeCmd(attachment.content, "\r\n"); - } + this.encodeAttachment(config.attachments[i]); } this.#connection.writeCmd(`--${attachmentBoundary}--\r\n`); diff --git a/config/mail/attachments.ts b/config/mail/attachments.ts index 95c9ba3..0f488f7 100644 --- a/config/mail/attachments.ts +++ b/config/mail/attachments.ts @@ -12,14 +12,16 @@ export type Attachment = | base64Attachment | arrayBufferLikeAttachment ) - & baseAttachment; + & baseAttachment + & { contentDisposition?: "attachment" | "inline" }; export type ResolvedAttachment = & ( | textAttachment | base64Attachment ) - & baseAttachment; + & baseAttachment + & { contentDisposition: "attachment" | "inline" }; type textAttachment = { encoding: "text"; content: string }; type base64Attachment = { encoding: "base64"; content: string }; @@ -35,8 +37,15 @@ export function resolveAttachment(attachment: Attachment): ResolvedAttachment { contentType: attachment.contentType, encoding: "base64", content: base64Encode(attachment.content), + contentDisposition: attachment.contentDisposition ?? "attachment", }; } else { - return attachment; + return { + filename: attachment.filename, + contentType: attachment.contentType, + encoding: attachment.encoding, + content: attachment.content, + contentDisposition: attachment.contentDisposition ?? "attachment", + }; } } diff --git a/config/mail/content.ts b/config/mail/content.ts index a83cf47..8d3044e 100644 --- a/config/mail/content.ts +++ b/config/mail/content.ts @@ -1,21 +1,47 @@ +import { + Attachment, + resolveAttachment, + ResolvedAttachment, +} from "./attachments.ts"; import { quotedPrintableEncode } from "./encoding.ts"; export interface Content { mimeType: string; content: string; transferEncoding?: string; + relatedAttachments?: Attachment[]; } -export function resolveContent({ +export interface ResolvedContent { + mimeType: string; + content: string; + transferEncoding?: string; + relatedAttachments: ResolvedAttachment[]; +} + +export function resolveContent(content: Content): ResolvedContent { + return { + mimeType: content.mimeType, + content: content.content, + transferEncoding: content.transferEncoding, + relatedAttachments: content.relatedAttachments + ? content.relatedAttachments.map((v) => resolveAttachment(v)) + : [], + }; +} + +export function resolveMessage({ text, html, + relatedAttachments, mimeContent, }: { text?: string; html?: string; + relatedAttachments?: Attachment[]; mimeContent?: Content[]; -}): Content[] { - const newContent = [...mimeContent ?? []]; +}): ResolvedContent[] { + const newContent = [...mimeContent ?? []].map((v) => resolveContent(v)); if (text === "auto" && html) { text = html @@ -29,15 +55,17 @@ export function resolveContent({ mimeType: 'text/plain; charset="utf-8"', content: quotedPrintableEncode(text), transferEncoding: "quoted-printable", + relatedAttachments: [], }); } if (html) { - newContent.push({ + newContent.push(resolveContent({ mimeType: 'text/html; charset="utf-8"', content: quotedPrintableEncode(html), transferEncoding: "quoted-printable", - }); + relatedAttachments, + })); } return newContent; diff --git a/config/mail/mod.ts b/config/mail/mod.ts index 80ad9b5..a050ca5 100644 --- a/config/mail/mod.ts +++ b/config/mail/mod.ts @@ -3,7 +3,7 @@ import { resolveAttachment, ResolvedAttachment, } from "./attachments.ts"; -import { Content, resolveContent } from "./content.ts"; +import { Content, ResolvedContent, resolveMessage } from "./content.ts"; import { isSingleMail, mailList, @@ -28,6 +28,7 @@ export interface SendConfig { content?: string; mimeContent?: Content[]; html?: string; + relatedAttachments?: Attachment[]; inReplyTo?: string; replyTo?: string; references?: string; @@ -48,7 +49,7 @@ export interface ResolvedSendConfig { from: saveMailObject; date: string; subject: string; - mimeContent: Content[]; + mimeContent: ResolvedContent[]; inReplyTo?: string; replyTo?: saveMailObject; references?: string; @@ -69,6 +70,7 @@ export function resolveSendConfig(config: SendConfig): ResolvedSendConfig { content, mimeContent, html, + relatedAttachments, inReplyTo, replyTo, references, @@ -84,9 +86,10 @@ export function resolveSendConfig(config: SendConfig): ResolvedSendConfig { bcc: parseMailList(bcc), from: parseSingleEmail(from), date, - mimeContent: resolveContent({ + mimeContent: resolveMessage({ mimeContent, html, + relatedAttachments, text: content, }), replyTo: replyTo ? parseSingleEmail(replyTo) : undefined,