diff --git a/README.md b/README.md index 2d942b1..69f5a6b 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,11 @@ export interface ClientOptions { * - ... */ preprocessors?: Preprocessor[]; + /** + * Set the encoding for the mail content + * @default quoted-printable + */ + mimeEncoding?: "quoted-printable" | "base64"; }; } ``` @@ -180,6 +185,9 @@ It takes a preprocessed email config (ResolvedSendConfig) and the preprocessed client options (ResolvedClientOptions) and returns a possibly modified email config. +You can change the encoding of the mail subject and content with `mimeEncoding`. +`mimeEncoding: quoted-printable` (default). + #### pool With a normal SMTP client, emails are sent one after the other so if you have a @@ -234,7 +242,8 @@ const client = new SMTPClient({ }, client: { warning: "log", - preprocessors: [filterBurnerMails], + preprocessors: [filterBurnerMails], + mimeEncoding: "base64", }, debug: { log: false, @@ -263,6 +272,7 @@ export interface SendConfig { subject: string; content?: string; mimeContent?: Content[]; + mimeEncoding?: "quoted-printable" | "base64"; html?: string; inReplyTo?: string; replyTo?: string; @@ -289,6 +299,11 @@ Add custom headers to the email. There may be instances where you want to use your own encoding. This option allows you to specify the content of the mail. +#### mimeEncoding + +Subject and Content are encoded with `quoted-printable` by default. You can change it to `base64`. +this `mimeEncoding` can overwrite Client's default `mimeEncoding`. + #### content & html The content should be a plain-text version of the HTML content. You can set diff --git a/client/mod.ts b/client/mod.ts index add86ed..bc8ef75 100644 --- a/client/mod.ts +++ b/client/mod.ts @@ -135,6 +135,9 @@ export class SMTPHandler { * @returns nothing (for now as this might change in the future!) */ send(config: SendConfig): Promise { + if(config.mimeEncoding == null && this.#clientConfig.client.mimeEncoding != null){ + config.mimeEncoding = this.#clientConfig.client.mimeEncoding; + } let resolvedConfig = resolveSendConfig(config); for (let i = 0; i < this.#clientConfig.client.preprocessors.length; i++) { diff --git a/config/client.ts b/config/client.ts index 6d5143d..108604d 100644 --- a/config/client.ts +++ b/config/client.ts @@ -23,6 +23,7 @@ export interface ResolvedClientOptions { client: { warning: "ignore" | "log" | "error"; preprocessors: Preprocessor[]; + mimeEncoding?: "quoted-printable" | "base64"; }; } @@ -106,6 +107,11 @@ export interface ClientOptions { * - ... */ preprocessors?: Preprocessor[]; + /** + * Set the encoding for the mail content + * @default quoted-printable + */ + mimeEncoding?: "quoted-printable" | "base64"; }; } @@ -144,6 +150,7 @@ export function resolveClientOptions( client: { warning: config.client?.warning ?? "log", preprocessors: config.client?.preprocessors ?? [], + mimeEncoding: config.client?.mimeEncoding ?? "quoted-printable", }, }; } diff --git a/config/mail/content.ts b/config/mail/content.ts index a83cf47..8710802 100644 --- a/config/mail/content.ts +++ b/config/mail/content.ts @@ -1,4 +1,4 @@ -import { quotedPrintableEncode } from "./encoding.ts"; +import {mimeEncode, quotedPrintableEncode} from "./encoding.ts"; export interface Content { mimeType: string; @@ -10,10 +10,12 @@ export function resolveContent({ text, html, mimeContent, + mimeEncoding }: { text?: string; html?: string; mimeContent?: Content[]; + mimeEncoding?: "quoted-printable" | "base64"; }): Content[] { const newContent = [...mimeContent ?? []]; @@ -27,16 +29,16 @@ export function resolveContent({ if (text) { newContent.push({ mimeType: 'text/plain; charset="utf-8"', - content: quotedPrintableEncode(text), - transferEncoding: "quoted-printable", + content: mimeEncode(text, mimeEncoding), + transferEncoding: mimeEncoding, }); } if (html) { newContent.push({ mimeType: 'text/html; charset="utf-8"', - content: quotedPrintableEncode(html), - transferEncoding: "quoted-printable", + content: mimeEncode(html), + transferEncoding: mimeEncoding, }); } diff --git a/config/mail/encoding.ts b/config/mail/encoding.ts index f1a9e8c..195fd5e 100644 --- a/config/mail/encoding.ts +++ b/config/mail/encoding.ts @@ -1,7 +1,24 @@ export { base64Decode, base64Encode } from "../../deps.ts"; +import { base64Encode } from "../../deps.ts"; const encoder = new TextEncoder(); +export function mimeEncode(data: string, encoding?: string) { + if(encoding === "base64"){ + return base64EncodeWrapLine(data); + }else{ + return quotedPrintableEncode(data); + } +} + +export function mimeEncodeInline(data: string, encoding?: string) { + if(encoding === "base64"){ + return base64EncodeInline(data); + }else{ + return quotedPrintableEncodeInline(data); + } +} + /** * Encodes a string as quotedPrintable * @@ -41,16 +58,20 @@ export function quotedPrintableEncode(data: string, encLB = false) { const lines = Math.ceil(encodedData.length / 74) - 1; let offset = 0; + let offsetWrapChars = ""; for (let i = 0; i < lines; i++) { let old = encodedData.slice(i * 74 + offset, (i + 1) * 74); offset = 0; + offsetWrapChars = ""; if (old.at(-1) === "=") { + offsetWrapChars = old.slice(old.length - 1, old.length); old = old.slice(0, old.length - 1); offset = -1; } if (old.at(-2) === "=") { + offsetWrapChars = old.slice(old.length - 2, old.length); old = old.slice(0, old.length - 2); offset = -2; } @@ -62,8 +83,12 @@ export function quotedPrintableEncode(data: string, encLB = false) { } } + if(offsetWrapChars !== "" && !offsetWrapChars.startsWith("=")) { + offsetWrapChars = "=" + offsetWrapChars; + } + // Add rest with no new line - ret += encodedData.slice(lines * 74); + ret += offsetWrapChars + encodedData.slice(lines * 74); return ret; } @@ -74,9 +99,42 @@ function hasNonAsciiCharacters(str: string) { } export function quotedPrintableEncodeInline(data: string) { - if (hasNonAsciiCharacters(data) || data.startsWith("=?")) { + if (data.startsWith("=?")) { return `=?utf-8?Q?${quotedPrintableEncode(data)}?=`; } + if(hasNonAsciiCharacters(data)){ + data = quotedPrintableEncode(data).split("\r\n").map((l, i) => { + if(l.endsWith("=")){ + // strip "=" that was appended by quotedPrintableEncode, but don't need for single line's =??= encoding + l = l.substring(0, l.length - 1); + } + return `${i>0?" ":""}=?utf-8?Q?${l}?=`; + }).join("\r\n"); + } return data; } + +export function base64EncodeWrapLine(data: string) { + const maxlen = 60; + const base64Data = base64Encode(data); + + let encodedLines = []; + for ( + let line = 0; + line < Math.ceil(base64Data.length / maxlen); + line++ + ) { + const lineOfBase64 = base64Data.slice( + line * maxlen, + (line + 1) * maxlen, + ); + encodedLines.push(lineOfBase64); + } + return encodedLines.join("\r\n"); +} + +export function base64EncodeInline(data: string) { + const encodedLines = base64EncodeWrapLine(data); + return encodedLines.split("\r\n").map((l, i) => `${i>0?" ":""}=?utf-8?B?${l}?=`).join("\r\n"); +} diff --git a/config/mail/mod.ts b/config/mail/mod.ts index 80ad9b5..5a87da1 100644 --- a/config/mail/mod.ts +++ b/config/mail/mod.ts @@ -14,7 +14,7 @@ import { } from "./email.ts"; import { ResolvedClientOptions } from "../client.ts"; import { Headers, validateHeaders } from "./headers.ts"; -import { quotedPrintableEncodeInline } from "./encoding.ts"; +import {mimeEncodeInline, quotedPrintableEncodeInline} from "./encoding.ts"; /** * Config for a mail */ @@ -27,6 +27,7 @@ export interface SendConfig { subject: string; content?: string; mimeContent?: Content[]; + mimeEncoding?: "quoted-printable" | "base64"; html?: string; inReplyTo?: string; replyTo?: string; @@ -49,6 +50,7 @@ export interface ResolvedSendConfig { date: string; subject: string; mimeContent: Content[]; + mimeEncoding?: "quoted-printable" | "base64"; inReplyTo?: string; replyTo?: saveMailObject; references?: string; @@ -68,6 +70,7 @@ export function resolveSendConfig(config: SendConfig): ResolvedSendConfig { subject, content, mimeContent, + mimeEncoding, html, inReplyTo, replyTo, @@ -86,12 +89,13 @@ export function resolveSendConfig(config: SendConfig): ResolvedSendConfig { date, mimeContent: resolveContent({ mimeContent, + mimeEncoding, html, text: content, }), replyTo: replyTo ? parseSingleEmail(replyTo) : undefined, inReplyTo, - subject: quotedPrintableEncodeInline(subject), + subject: mimeEncodeInline(subject, mimeEncoding), attachments: attachments ? attachments.map((attachment) => resolveAttachment(attachment)) : [], diff --git a/test/encoding/base64.test.ts b/test/encoding/base64.test.ts new file mode 100644 index 0000000..7452ec8 --- /dev/null +++ b/test/encoding/base64.test.ts @@ -0,0 +1,39 @@ +import { assertEquals } from "https://deno.land/std@0.136.0/testing/asserts.ts"; +import {base64Encode, base64EncodeInline, base64EncodeWrapLine} from "../../config/mail/encoding.ts"; + +Deno.test("test base64 encode ascii", () => { + const encodedString = base64EncodeWrapLine("test"); + assertEquals(encodedString, "dGVzdA=="); +}); + +Deno.test("test base64 encode multibyte('test' meaning in Japanese)", () => { + const encodedString = base64EncodeWrapLine("テスト"); + assertEquals(encodedString, "44OG44K544OI"); +}); + +Deno.test("test base64 encode multibyte multi lines", () => { + const encodedString = base64EncodeWrapLine("これは日本語のメールだよ。QP対象のMultiByteが問題になっていたので日本語以外も同様のはず。"); + assertEquals(encodedString, "44GT44KM44Gv5pel5pys6Kqe44Gu44Oh44O844Or44Gg44KI44CCUVDlr77o\r\n" + + "saHjga5NdWx0aUJ5dGXjgYzllY/poYzjgavjgarjgaPjgabjgYTjgZ/jga7j\r\n" + + "gafml6XmnKzoqp7ku6XlpJbjgoLlkIzmp5jjga7jga/jgZrjgII="); +}); + +Deno.test("test base64 encode inline", () => { + const encodedString = base64EncodeInline("test"); + assertEquals(encodedString, "=?utf-8?B?dGVzdA==?="); +}); + +Deno.test("test base64 encode inline multibytes", () => { + const encodedString = base64EncodeInline("テスト"); + assertEquals(encodedString, "=?utf-8?B?44OG44K544OI?="); +}); + +Deno.test("test base64 encode inline multibytes and multi lines", () => { + const encodedString = base64EncodeInline("これは日本語のメールだよ。QP対象のMultiByteが問題になっていたので日本語以外も同様のはず。"); + assertEquals(encodedString, "=?utf-8?B?44GT44KM44Gv5pel5pys6Kqe44Gu44Oh44O844Or44Gg44KI44CCUVDlr77o?=\r\n" + + " =?utf-8?B?saHjga5NdWx0aUJ5dGXjgYzllY/poYzjgavjgarjgaPjgabjgYTjgZ/jga7j?=\r\n" + + " =?utf-8?B?gafml6XmnKzoqp7ku6XlpJbjgoLlkIzmp5jjga7jga/jgZrjgII=?="); +}); + + + diff --git a/test/encoding/quotedprintable.test.ts b/test/encoding/quotedprintable.test.ts new file mode 100644 index 0000000..62fcae9 --- /dev/null +++ b/test/encoding/quotedprintable.test.ts @@ -0,0 +1,63 @@ +import { assertEquals } from "https://deno.land/std@0.136.0/testing/asserts.ts"; +import {quotedPrintableEncode, quotedPrintableEncodeInline} from "../../config/mail/encoding.ts"; + +Deno.test("test quoted-printable encode ascii", () => { + const encodedString = quotedPrintableEncode("test"); + assertEquals(encodedString, "test"); +}); + +Deno.test("test quoted-printable encode multibyte('test' meaning in Japanese)", () => { + const encodedString = quotedPrintableEncode("テスト"); + assertEquals(encodedString, "=e3=83=86=e3=82=b9=e3=83=88"); +}); + +Deno.test("test quoted-printable encode multibyte('mail' meaning in Japanese)", () => { + const encodedString = quotedPrintableEncode("メール"); + assertEquals(encodedString, "=e3=83=a1=e3=83=bc=e3=83=ab"); +}); + +Deno.test("test quoted-printable encode multibyte bug@1.6.0", () => { + const encodedString = quotedPrintableEncode("これは日本語のメールだよ"); + assertEquals(encodedString, "=e3=81=93=e3=82=8c=e3=81=af=e6=97=a5=e6=9c=ac=e8=aa=9e=e3=81=ae=e3=83=a1=\r\n=e3=83=bc=e3=83=ab=e3=81=a0=e3=82=88"); +}); + +Deno.test("test quoted-printable encode multibyte offset check +1 bug@1.6.0", () => { + const encodedString = quotedPrintableEncode("1これは日本語のメールだよ"); + assertEquals(encodedString, "1=e3=81=93=e3=82=8c=e3=81=af=e6=97=a5=e6=9c=ac=e8=aa=9e=e3=81=ae=e3=83=a1=\r\n=e3=83=bc=e3=83=ab=e3=81=a0=e3=82=88"); +}); + +Deno.test("test quoted-printable encode multibyte offset check +2 bug@1.6.0", () => { + const encodedString = quotedPrintableEncode("12これは日本語のメールだよ"); + assertEquals(encodedString, "12=e3=81=93=e3=82=8c=e3=81=af=e6=97=a5=e6=9c=ac=e8=aa=9e=e3=81=ae=e3=83=a1=\r\n=e3=83=bc=e3=83=ab=e3=81=a0=e3=82=88"); +}); + +Deno.test("test quoted-printable encode multibyte offset check +3 bug@1.6.0", () => { + const encodedString = quotedPrintableEncode("123これは日本語のメールだよ"); + assertEquals(encodedString, "123=e3=81=93=e3=82=8c=e3=81=af=e6=97=a5=e6=9c=ac=e8=aa=9e=e3=81=ae=e3=83=\r\n=a1=e3=83=bc=e3=83=ab=e3=81=a0=e3=82=88"); +}); + +Deno.test("test quoted-printable encode multibyte bug@1.6.0 multi lines", () => { + const encodedString = quotedPrintableEncode("これは日本語のメールだよ。QP対象のMultiByteが問題になっていたので日本語以外も同様のはず。"); + assertEquals(encodedString, "=e3=81=93=e3=82=8c=e3=81=af=e6=97=a5=e6=9c=ac=e8=aa=9e=e3=81=ae=e3=83=a1=\r\n" + + "=e3=83=bc=e3=83=ab=e3=81=a0=e3=82=88=e3=80=82QP=e5=af=be=e8=b1=a1=e3=81=aeMu=\r\n" + + "ltiByte=e3=81=8c=e5=95=8f=e9=a1=8c=e3=81=ab=e3=81=aa=e3=81=a3=e3=81=a6=e3=\r\n" + + "=81=84=e3=81=9f=e3=81=ae=e3=81=a7=e6=97=a5=e6=9c=ac=e8=aa=9e=e4=bb=a5=e5=a4=\r\n" + + "=96=e3=82=82=e5=90=8c=e6=a7=98=e3=81=ae=e3=81=af=e3=81=9a=e3=80=82"); + +}); + +Deno.test("test quoted-printable encode inline", () => { + const encodedString = quotedPrintableEncodeInline("test"); + assertEquals(encodedString, "test"); +}); + +Deno.test("test quoted-printable encode inline multibytes", () => { + const encodedString = quotedPrintableEncodeInline("テスト"); + assertEquals(encodedString, "=?utf-8?Q?=e3=83=86=e3=82=b9=e3=83=88?="); +}); + +Deno.test("test quoted-printable encode inline multibytes and multi lines", () => { + const encodedString = quotedPrintableEncodeInline("これは日本語のメールだよ"); + assertEquals(encodedString, "=?utf-8?Q?=e3=81=93=e3=82=8c=e3=81=af=e6=97=a5=e6=9c=ac=e8=aa=9e=e3=81=ae=e3=83=a1?=\r\n" + + " =?utf-8?Q?=e3=83=bc=e3=83=ab=e3=81=a0=e3=82=88?="); +}); \ No newline at end of file