Skip to content

Commit 1a305d4

Browse files
authored
Merge pull request #2623 from ankaboot-source/fix/campaign-inline-cid-images
fix: render campaign base64 images via CID attachments
2 parents c063a00 + f5d0cd1 commit 1a305d4

File tree

3 files changed

+116
-3
lines changed

3 files changed

+116
-3
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
2+
import { inlineDataImagesAsCid } from "./inline-images.ts";
3+
4+
Deno.test(
5+
"inlineDataImagesAsCid converts data image sources to cid attachments",
6+
() => {
7+
const html = '<p><img src="data:image/png;base64,QUJDRA==" alt="x" /></p>';
8+
const result = inlineDataImagesAsCid(html);
9+
10+
assertEquals(result.attachments.length, 1);
11+
assertEquals(result.attachments[0].encoding, "base64");
12+
assertEquals(result.attachments[0].contentType, "image/png");
13+
assertEquals(
14+
result.html.includes('src="cid:inline-image-1@leadminer"'),
15+
true,
16+
);
17+
},
18+
);
19+
20+
Deno.test("inlineDataImagesAsCid reuses same cid for duplicate payload", () => {
21+
const dataUri = "data:image/jpeg;base64,QUJDREVGRw==";
22+
const html = `<img src="${dataUri}" /><img src="${dataUri}" />`;
23+
const result = inlineDataImagesAsCid(html);
24+
25+
assertEquals(result.attachments.length, 1);
26+
assertEquals(result.html.match(/cid:inline-image-1@leadminer/g)?.length, 2);
27+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
export type InlineImageAttachment = {
2+
filename: string;
3+
content: string;
4+
encoding: "base64";
5+
cid: string;
6+
contentType: string;
7+
disposition: "inline";
8+
};
9+
10+
type InlineImageTransformResult = {
11+
html: string;
12+
attachments: InlineImageAttachment[];
13+
};
14+
15+
const DATA_IMAGE_SRC_REGEX =
16+
/src\s*=\s*(["'])(data:image\/([a-zA-Z0-9.+-]+);base64,([a-zA-Z0-9+/=\r\n]+))\1/gi;
17+
18+
function extensionFromMime(mimeSubtype: string): string {
19+
const normalized = mimeSubtype.toLowerCase();
20+
if (normalized === "jpeg") return "jpg";
21+
if (normalized === "svg+xml") return "svg";
22+
return normalized;
23+
}
24+
25+
export function inlineDataImagesAsCid(
26+
html: string,
27+
): InlineImageTransformResult {
28+
if (!html.includes("data:image/")) {
29+
return { html, attachments: [] };
30+
}
31+
32+
let index = 0;
33+
const attachments: InlineImageAttachment[] = [];
34+
const cidByPayload = new Map<string, string>();
35+
36+
const rewrittenHtml = html.replace(
37+
DATA_IMAGE_SRC_REGEX,
38+
(
39+
full,
40+
quote: string,
41+
_dataUri: string,
42+
mimeSubtype: string,
43+
payload: string,
44+
) => {
45+
const normalizedPayload = payload.replace(/\s+/g, "");
46+
if (!normalizedPayload) {
47+
return full;
48+
}
49+
50+
let cid = cidByPayload.get(normalizedPayload);
51+
if (!cid) {
52+
index += 1;
53+
cid = `inline-image-${index}@leadminer`;
54+
cidByPayload.set(normalizedPayload, cid);
55+
attachments.push({
56+
filename: `inline-image-${index}.${extensionFromMime(mimeSubtype)}`,
57+
content: normalizedPayload,
58+
encoding: "base64",
59+
cid,
60+
contentType: `image/${mimeSubtype.toLowerCase()}`,
61+
disposition: "inline",
62+
});
63+
}
64+
65+
return `src=${quote}cid:${cid}${quote}`;
66+
},
67+
);
68+
69+
return {
70+
html: rewrittenHtml,
71+
attachments,
72+
};
73+
}

supabase/functions/email-campaigns/email.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import nodemailer from "nodemailer";
2+
import {
3+
inlineDataImagesAsCid,
4+
type InlineImageAttachment,
5+
} from "../_shared/inline-images.ts";
26

37
const host = Deno.env.get("SMTP_HOST");
48
const port = Deno.env.get("SMTP_PORT");
@@ -10,6 +14,7 @@ type SendEmailOptions = {
1014
from?: string;
1115
replyTo?: string;
1216
text?: string;
17+
attachments?: InlineImageAttachment[];
1318
transport?: {
1419
host: string;
1520
port: number;
@@ -35,7 +40,9 @@ function getDefaultTransport() {
3540
};
3641
}
3742

38-
export async function verifyTransport(transport?: SendEmailOptions["transport"]) {
43+
export async function verifyTransport(
44+
transport?: SendEmailOptions["transport"],
45+
) {
3946
const transporter = nodemailer.createTransport(
4047
transport ?? getDefaultTransport(),
4148
);
@@ -51,16 +58,22 @@ export async function sendEmail(
5158
const transporter = nodemailer.createTransport(
5259
options.transport ?? getDefaultTransport(),
5360
);
61+
const inlinePayload = inlineDataImagesAsCid(html);
62+
const attachments = [
63+
...(options.attachments ?? []),
64+
...inlinePayload.attachments,
65+
];
5466

5567
const info = await transporter.sendMail({
5668
from: options.from ?? defaultFrom,
5769
to,
5870
subject,
59-
html,
71+
html: inlinePayload.html,
6072
text: options.text,
6173
replyTo: options.replyTo,
74+
attachments,
6275
});
6376

6477
console.log("Email sent:", { to, messageId: info.messageId });
6578
// console.log("Preview URL: %s", nodemailer.getTestMessageUrl(info)); // For local testing only
66-
}
79+
}

0 commit comments

Comments
 (0)