Skip to content

Commit ea40781

Browse files
committed
add campaign smtp-proxy
1 parent 95dfa6b commit ea40781

File tree

5 files changed

+368
-24
lines changed

5 files changed

+368
-24
lines changed
Binary file not shown.

apps/smtp-server/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515
"@types/mailparser": "^3.4.5",
1616
"@types/smtp-server": "^3.5.10",
1717
"dotenv": "^16.5.0",
18+
"he": "^1.2.0",
19+
"linkedom": "^0.18.12",
1820
"mailparser": "^3.7.2",
1921
"nodemailer": "^6.10.1",
2022
"smtp-server": "^3.13.6"
2123
},
2224
"devDependencies": {
25+
"@types/he": "^1.2.3",
2326
"@types/node": "^22.15.2",
2427
"@types/nodemailer": "^6.4.17",
2528
"tsup": "^8.4.0",

apps/smtp-server/src/server.ts

Lines changed: 305 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Readable } from "stream";
33
import dotenv from "dotenv";
44
import { simpleParser } from "mailparser";
55
import { readFileSync, watch, FSWatcher } from "fs";
6+
import he from "he";
7+
import { parseHTML } from "linkedom";
68

79
dotenv.config();
810

@@ -15,6 +17,115 @@ const SSL_KEY_PATH =
1517
process.env.USESEND_API_KEY_PATH ?? process.env.UNSEND_API_KEY_PATH;
1618
const SSL_CERT_PATH =
1719
process.env.USESEND_API_CERT_PATH ?? process.env.UNSEND_API_CERT_PATH;
20+
const CAMPAIGN_DOMAIN = process.env.USESEND_CAMPAIGN_DOMAIN ?? "usesend.com";
21+
22+
interface ParsedRecipients {
23+
contactBookIds: string[];
24+
emailAddresses: string[];
25+
}
26+
27+
/**
28+
* Parses all recipients from the "to" field.
29+
* - Addresses like "listId@usesend.com" (or configured domain) are contact book IDs
30+
* - All other addresses are treated as individual email recipients
31+
*/
32+
function parseRecipients(to: string | undefined): ParsedRecipients {
33+
const result: ParsedRecipients = {
34+
contactBookIds: [],
35+
emailAddresses: [],
36+
};
37+
38+
if (!to) return result;
39+
40+
const emailRegex = /<?([^<>\s,]+@[^<>\s,]+)>?/g;
41+
let match;
42+
43+
while ((match = emailRegex.exec(to)) !== null) {
44+
const email = match[1].toLowerCase();
45+
const [localPart, domain] = email.split("@");
46+
47+
if (domain === CAMPAIGN_DOMAIN.toLowerCase() && localPart) {
48+
result.contactBookIds.push(localPart);
49+
} else {
50+
result.emailAddresses.push(email);
51+
}
52+
}
53+
54+
return result;
55+
}
56+
57+
interface CampaignData {
58+
name: string;
59+
from: string;
60+
subject: string;
61+
contactBookId: string;
62+
html: string;
63+
replyTo?: string;
64+
}
65+
66+
interface CampaignResponse {
67+
id: string;
68+
name: string;
69+
status: string;
70+
}
71+
72+
/**
73+
* Creates a campaign and schedules it for immediate sending via the UseSend API.
74+
*/
75+
async function sendCampaignToUseSend(
76+
campaignData: CampaignData,
77+
apiKey: string,
78+
): Promise<CampaignResponse> {
79+
try {
80+
const createEndpoint = "/api/v1/campaigns";
81+
const createUrl = new URL(createEndpoint, BASE_URL);
82+
83+
const payload = {
84+
name: campaignData.name,
85+
from: campaignData.from,
86+
subject: campaignData.subject,
87+
contactBookId: campaignData.contactBookId,
88+
html: campaignData.html,
89+
replyTo: campaignData.replyTo,
90+
sendNow: true,
91+
};
92+
93+
const response = await fetch(createUrl.href, {
94+
method: "POST",
95+
headers: {
96+
Authorization: `Bearer ${apiKey}`,
97+
"Content-Type": "application/json",
98+
},
99+
body: JSON.stringify(payload),
100+
});
101+
102+
if (!response.ok) {
103+
const errorText = await response.text();
104+
let errorDisplay: string;
105+
try {
106+
// Try to parse and pretty-print JSON error responses
107+
errorDisplay = JSON.stringify(JSON.parse(errorText), null, 2);
108+
} catch {
109+
errorDisplay = errorText;
110+
}
111+
console.error("useSend Campaign API error response:", errorDisplay);
112+
throw new Error(
113+
`Failed to create campaign: ${errorText || "Unknown error from server"}`,
114+
);
115+
}
116+
117+
const responseData = (await response.json()) as CampaignResponse;
118+
return responseData;
119+
} catch (error) {
120+
if (error instanceof Error) {
121+
console.error("Campaign error message:", error.message);
122+
throw new Error(`Failed to send campaign: ${error.message}`);
123+
} else {
124+
console.error("Unexpected campaign error:", error);
125+
throw new Error("Failed to send campaign: Unexpected error occurred");
126+
}
127+
}
128+
}
18129

19130
async function sendEmailToUseSend(emailData: any, apiKey: string) {
20131
try {
@@ -34,14 +145,21 @@ async function sendEmailToUseSend(emailData: any, apiKey: string) {
34145
});
35146

36147
if (!response.ok) {
37-
const errorData = await response.text();
148+
const errorText = await response.text();
149+
let errorDisplay: string;
150+
try {
151+
// Try to parse and pretty-print JSON error responses
152+
errorDisplay = JSON.stringify(JSON.parse(errorText), null, 2);
153+
} catch {
154+
errorDisplay = errorText;
155+
}
38156
console.error(
39-
"useSend API error response: error:",
40-
JSON.stringify(errorData, null, 4),
157+
"useSend API error response:",
158+
errorDisplay,
41159
`\nemail data: ${emailDataText}`,
42160
);
43161
throw new Error(
44-
`Failed to send email: ${errorData || "Unknown error from server"}`,
162+
`Failed to send email: ${errorText || "Unknown error from server"}`,
45163
);
46164
}
47165

@@ -58,6 +176,93 @@ async function sendEmailToUseSend(emailData: any, apiKey: string) {
58176
}
59177
}
60178

179+
/**
180+
* Converts plain text to a basic HTML document.
181+
*/
182+
function textToHtml(text: string): string {
183+
const escapedText = he.encode(text, { useNamedReferences: true });
184+
// Convert newlines to <br> tags
185+
const htmlText = escapedText.replace(/\n/g, "<br>\n");
186+
return `<!DOCTYPE html><html><body><p>${htmlText}</p></body></html>`;
187+
}
188+
189+
/**
190+
* Creates the unsubscribe footer element.
191+
*/
192+
function createUnsubscribeFooter(document: Document): HTMLElement {
193+
const footer = document.createElement("p");
194+
footer.setAttribute(
195+
"style",
196+
"margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666;",
197+
);
198+
199+
const link = document.createElement("a");
200+
link.setAttribute("href", "{{usesend_unsubscribe_url}}");
201+
link.setAttribute("style", "color: #666;");
202+
link.textContent = "Unsubscribe";
203+
204+
footer.appendChild(link);
205+
return footer;
206+
}
207+
208+
/**
209+
* Checks if the document contains an unsubscribe link placeholder.
210+
*/
211+
function hasUnsubscribeLink(html: string): boolean {
212+
return (
213+
html.includes("{{unsend_unsubscribe_url}}") ||
214+
html.includes("{{usesend_unsubscribe_url}}")
215+
);
216+
}
217+
218+
/**
219+
* Prepares HTML content for campaign sending.
220+
* - Converts plain text to HTML if needed
221+
* - Adds unsubscribe link if not present
222+
* Uses linkedom for proper DOM manipulation.
223+
*/
224+
function prepareCampaignHtml(
225+
html: string | false | undefined,
226+
text: string | undefined,
227+
): string | null {
228+
// Convert plain text to HTML if no HTML provided
229+
let htmlContent: string;
230+
if (!html && text) {
231+
htmlContent = textToHtml(text);
232+
} else if (html) {
233+
htmlContent = html;
234+
} else {
235+
return null;
236+
}
237+
238+
// Check if unsubscribe link already exists
239+
if (hasUnsubscribeLink(htmlContent)) {
240+
return htmlContent;
241+
}
242+
243+
// Parse the HTML and add the unsubscribe footer using DOM APIs
244+
const { document } = parseHTML(htmlContent);
245+
246+
const footer = createUnsubscribeFooter(document);
247+
248+
// Append to body if it exists, otherwise append to document
249+
const body = document.querySelector("body");
250+
if (body) {
251+
body.appendChild(footer);
252+
} else {
253+
// No body tag - wrap content and add footer
254+
const html = document.querySelector("html");
255+
if (html) {
256+
html.appendChild(footer);
257+
} else {
258+
// Minimal HTML - just append
259+
document.appendChild(footer);
260+
}
261+
}
262+
263+
return document.toString();
264+
}
265+
61266
function loadCertificates(): { key?: Buffer; cert?: Buffer } {
62267
return {
63268
key: SSL_KEY_PATH ? readFileSync(SSL_KEY_PATH) : undefined,
@@ -77,7 +282,7 @@ const serverOptions: SMTPServerOptions = {
77282
callback: (error?: Error) => void,
78283
) {
79284
console.log("Receiving email data..."); // Debug statement
80-
simpleParser(stream, (err, parsed) => {
285+
simpleParser(stream, async (err, parsed) => {
81286
if (err) {
82287
console.error("Failed to parse email data:", err.message);
83288
return callback(err);
@@ -88,26 +293,102 @@ const serverOptions: SMTPServerOptions = {
88293
return callback(new Error("No API key found in session"));
89294
}
90295

91-
const emailObject = {
92-
to: Array.isArray(parsed.to)
93-
? parsed.to.map((addr) => addr.text).join(", ")
94-
: parsed.to?.text,
95-
from: Array.isArray(parsed.from)
96-
? parsed.from.map((addr) => addr.text).join(", ")
97-
: parsed.from?.text,
98-
subject: parsed.subject,
99-
text: parsed.text,
100-
html: parsed.html,
101-
replyTo: parsed.replyTo?.text,
102-
};
103-
104-
sendEmailToUseSend(emailObject, session.user)
105-
.then(() => callback())
106-
.then(() => console.log("Email sent successfully to: ", emailObject.to))
107-
.catch((error) => {
108-
console.error("Failed to send email:", error.message);
109-
callback(error);
296+
const toAddress = Array.isArray(parsed.to)
297+
? parsed.to.map((addr) => addr.text).join(", ")
298+
: parsed.to?.text;
299+
300+
const fromAddress = Array.isArray(parsed.from)
301+
? parsed.from.map((addr) => addr.text).join(", ")
302+
: parsed.from?.text;
303+
304+
const sendPromises: Promise<any>[] = [];
305+
const recipients = parseRecipients(toAddress);
306+
const hasCampaigns = recipients.contactBookIds.length > 0;
307+
const hasIndividualEmails = recipients.emailAddresses.length > 0;
308+
309+
// Handle campaign sends (one campaign per contact book)
310+
if (hasCampaigns) {
311+
if (!fromAddress) {
312+
console.error("No from address found for campaign");
313+
return callback(new Error("From address is required for campaigns"));
314+
}
315+
316+
if (!parsed.subject) {
317+
console.error("No subject found for campaign");
318+
return callback(new Error("Subject is required for campaigns"));
319+
}
320+
321+
const htmlContent = prepareCampaignHtml(parsed.html, parsed.text);
322+
if (!htmlContent) {
323+
console.error("No content found for campaign");
324+
return callback(
325+
new Error("HTML or text content is required for campaigns"),
326+
);
327+
}
328+
329+
for (const contactBookId of recipients.contactBookIds) {
330+
const campaignData: CampaignData = {
331+
name: `SMTP Campaign: ${parsed.subject}`,
332+
from: fromAddress,
333+
subject: parsed.subject,
334+
contactBookId,
335+
html: htmlContent,
336+
replyTo: parsed.replyTo?.text,
337+
};
338+
339+
const campaignPromise = sendCampaignToUseSend(
340+
campaignData,
341+
session.user,
342+
).catch((error) => {
343+
console.error(
344+
`Failed to send campaign to ${contactBookId}:`,
345+
error.message,
346+
);
347+
throw error;
348+
});
349+
350+
sendPromises.push(campaignPromise);
351+
}
352+
}
353+
354+
// Handle individual email sends
355+
if (hasIndividualEmails) {
356+
// Send to all individual recipients in one API call
357+
const emailObject = {
358+
to: recipients.emailAddresses,
359+
from: fromAddress,
360+
subject: parsed.subject,
361+
text: parsed.text,
362+
html: parsed.html,
363+
replyTo: parsed.replyTo?.text,
364+
};
365+
366+
const emailPromise = sendEmailToUseSend(
367+
emailObject,
368+
session.user,
369+
).catch((error) => {
370+
console.error("Failed to send individual emails:", error.message);
371+
throw error;
110372
});
373+
374+
sendPromises.push(emailPromise);
375+
}
376+
377+
if (sendPromises.length === 0) {
378+
console.error("No valid recipients found");
379+
return callback(new Error("No valid recipients found"));
380+
}
381+
382+
try {
383+
await Promise.all(sendPromises);
384+
callback();
385+
} catch (error) {
386+
if (error instanceof Error) {
387+
callback(error);
388+
} else {
389+
callback(new Error("One or more sends failed"));
390+
}
391+
}
111392
});
112393
},
113394
onAuth(auth, session: any, callback: (error?: Error, user?: any) => void) {

0 commit comments

Comments
 (0)