diff --git a/.devenv/state/minio/data/.minio.sys/tmp/7b08971e-4836-4772-8f90-aa29588a6ef2 b/.devenv/state/minio/data/.minio.sys/tmp/7b08971e-4836-4772-8f90-aa29588a6ef2 new file mode 100644 index 00000000..e36ee446 Binary files /dev/null and b/.devenv/state/minio/data/.minio.sys/tmp/7b08971e-4836-4772-8f90-aa29588a6ef2 differ diff --git a/apps/smtp-server/package.json b/apps/smtp-server/package.json index da662c8a..4f911855 100644 --- a/apps/smtp-server/package.json +++ b/apps/smtp-server/package.json @@ -15,11 +15,14 @@ "@types/mailparser": "^3.4.5", "@types/smtp-server": "^3.5.10", "dotenv": "^16.5.0", + "he": "^1.2.0", + "linkedom": "^0.18.12", "mailparser": "^3.7.2", "nodemailer": "^6.10.1", "smtp-server": "^3.13.6" }, "devDependencies": { + "@types/he": "^1.2.3", "@types/node": "^22.15.2", "@types/nodemailer": "^6.4.17", "tsup": "^8.4.0", diff --git a/apps/smtp-server/src/server.ts b/apps/smtp-server/src/server.ts index 2a83c983..0eacab23 100644 --- a/apps/smtp-server/src/server.ts +++ b/apps/smtp-server/src/server.ts @@ -3,6 +3,8 @@ import { Readable } from "stream"; import dotenv from "dotenv"; import { simpleParser } from "mailparser"; import { readFileSync, watch, FSWatcher } from "fs"; +import he from "he"; +import { parseHTML } from "linkedom"; dotenv.config(); @@ -15,12 +17,128 @@ const SSL_KEY_PATH = process.env.USESEND_API_KEY_PATH ?? process.env.UNSEND_API_KEY_PATH; const SSL_CERT_PATH = process.env.USESEND_API_CERT_PATH ?? process.env.UNSEND_API_CERT_PATH; +const CAMPAIGN_DOMAIN = process.env.USESEND_CAMPAIGN_DOMAIN ?? "usesend.com"; +interface ParsedRecipients { + contactBookIds: string[]; + emailAddresses: string[]; +} + +/** + * Parses all recipients from the "to" field. + * - Addresses like "listId@usesend.com" (or configured domain) are contact book IDs + * - All other addresses are treated as individual email recipients + */ +function parseRecipients(to: string | undefined): ParsedRecipients { + const result: ParsedRecipients = { + contactBookIds: [], + emailAddresses: [], + }; + + if (!to) return result; + + const emailRegex = /\s,]+@[^<>\s,]+)>?/g; + let match; + + while ((match = emailRegex.exec(to)) !== null) { + const email = match[1].toLowerCase(); + const [localPart, domain] = email.split("@"); + + if (domain === CAMPAIGN_DOMAIN.toLowerCase() && localPart) { + result.contactBookIds.push(localPart); + } else { + result.emailAddresses.push(email); + } + } + + return result; +} + +interface CampaignData { + name: string; + from: string; + subject: string; + contactBookId: string; + html: string; + replyTo?: string; +} + +interface CampaignResponse { + id: string; + name: string; + status: string; +} + +/** + * Creates a campaign and schedules it for immediate sending via the UseSend API. + */ +async function sendCampaignToUseSend( + campaignData: CampaignData, + apiKey: string, +): Promise { + try { + const createEndpoint = "/api/v1/campaigns"; + const createUrl = new URL(createEndpoint, BASE_URL); + + const payload = { + name: campaignData.name, + from: campaignData.from, + subject: campaignData.subject, + contactBookId: campaignData.contactBookId, + html: campaignData.html, + replyTo: campaignData.replyTo, + sendNow: true, + }; + + const response = await fetch(createUrl.href, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorDisplay: string; + try { + // Try to parse and pretty-print JSON error responses + errorDisplay = JSON.stringify(JSON.parse(errorText), null, 2); + } catch { + errorDisplay = errorText; + } + console.error("useSend Campaign API error response:", errorDisplay); + throw new Error( + `Failed to create campaign: ${errorText || "Unknown error from server"}`, + ); + } + + const responseData = (await response.json()) as CampaignResponse; + return responseData; + } catch (error) { + if (error instanceof Error) { + console.error("Campaign error message:", error.message); + throw new Error(`Failed to send campaign: ${error.message}`); + } else { + console.error("Unexpected campaign error:", error); + throw new Error("Failed to send campaign: Unexpected error occurred"); + } + } +} + +/** + * Sends an individual email via the UseSend API. + * + * @param emailData - The email data object containing to, from, subject, text, html, and replyTo + * @param apiKey - The API key for authentication + * @throws Error if the API request fails + */ async function sendEmailToUseSend(emailData: any, apiKey: string) { try { const apiEndpoint = "/api/v1/emails"; - const url = new URL(apiEndpoint, BASE_URL); // Combine base URL with endpoint - console.log("Sending email to useSend API at:", url.href); // Debug statement + const url = new URL(apiEndpoint, BASE_URL); + console.log("Sending email to useSend API at:", url.href); const emailDataText = JSON.stringify(emailData); @@ -34,14 +152,21 @@ async function sendEmailToUseSend(emailData: any, apiKey: string) { }); if (!response.ok) { - const errorData = await response.text(); + const errorText = await response.text(); + let errorDisplay: string; + try { + // Try to parse and pretty-print JSON error responses + errorDisplay = JSON.stringify(JSON.parse(errorText), null, 2); + } catch { + errorDisplay = errorText; + } console.error( - "useSend API error response: error:", - JSON.stringify(errorData, null, 4), + "useSend API error response:", + errorDisplay, `\nemail data: ${emailDataText}`, ); throw new Error( - `Failed to send email: ${errorData || "Unknown error from server"}`, + `Failed to send email: ${errorText || "Unknown error from server"}`, ); } @@ -58,6 +183,120 @@ async function sendEmailToUseSend(emailData: any, apiKey: string) { } } +/** + * Converts plain text to a basic HTML document. + * + * Escapes HTML entities using the `he` library and converts newlines to `
` tags. + * Wraps the result in a minimal HTML document structure. + * + * @param text - The plain text content to convert + * @returns A complete HTML document string with the text as body content + */ +function textToHtml(text: string): string { + const escapedText = he.encode(text, { useNamedReferences: true }); + // Convert newlines to
tags + const htmlText = escapedText.replace(/\n/g, "
\n"); + return `

${htmlText}

`; +} + +/** + * Creates an unsubscribe footer element for campaign emails. + * + * Generates a styled paragraph containing an unsubscribe link with the + * `{{usesend_unsubscribe_url}}` placeholder, which will be replaced with + * the actual unsubscribe URL when the campaign is sent. + * + * @param document - The DOM Document to create elements in + * @returns An HTMLElement containing the styled unsubscribe link + */ +function createUnsubscribeFooter(document: Document): HTMLElement { + const footer = document.createElement("p"); + footer.setAttribute( + "style", + "margin-top: 20px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666;", + ); + + const link = document.createElement("a"); + link.setAttribute("href", "{{usesend_unsubscribe_url}}"); + link.setAttribute("style", "color: #666;"); + link.textContent = "Unsubscribe"; + + footer.appendChild(link); + return footer; +} + +/** + * Checks if the HTML content already contains an unsubscribe link placeholder. + * + * Looks for both legacy `{{unsend_unsubscribe_url}}` and current + * `{{usesend_unsubscribe_url}}` placeholders. + * + * @param html - The HTML content to check + * @returns True if an unsubscribe placeholder is found, false otherwise + */ +function hasUnsubscribeLink(html: string): boolean { + return ( + html.includes("{{unsend_unsubscribe_url}}") || + html.includes("{{usesend_unsubscribe_url}}") + ); +} + +/** + * Prepares HTML content for campaign sending. + * + * This function ensures the email content is ready for campaign delivery by: + * 1. Converting plain text to HTML if no HTML content is provided + * 2. Adding an unsubscribe footer if one doesn't already exist + * + * Uses linkedom for proper DOM manipulation rather than string replacement, + * ensuring robust handling of various HTML structures. + * + * @param html - The HTML content from the email, or false/undefined if not provided + * @param text - The plain text content from the email, used as fallback + * @returns The prepared HTML string, or null if no content is available + */ +function prepareCampaignHtml( + html: string | false | undefined, + text: string | undefined, +): string | null { + // Convert plain text to HTML if no HTML provided + let htmlContent: string; + if (!html && text) { + htmlContent = textToHtml(text); + } else if (html) { + htmlContent = html; + } else { + return null; + } + + // Check if unsubscribe link already exists + if (hasUnsubscribeLink(htmlContent)) { + return htmlContent; + } + + // Parse the HTML and add the unsubscribe footer using DOM APIs + const { document } = parseHTML(htmlContent); + + const footer = createUnsubscribeFooter(document); + + // Append to body if it exists, otherwise append to document + const body = document.querySelector("body"); + if (body) { + body.appendChild(footer); + } else { + // No body tag - wrap content and add footer + const html = document.querySelector("html"); + if (html) { + html.appendChild(footer); + } else { + // Minimal HTML - just append + document.appendChild(footer); + } + } + + return document.toString(); +} + function loadCertificates(): { key?: Buffer; cert?: Buffer } { return { key: SSL_KEY_PATH ? readFileSync(SSL_KEY_PATH) : undefined, @@ -77,7 +316,7 @@ const serverOptions: SMTPServerOptions = { callback: (error?: Error) => void, ) { console.log("Receiving email data..."); // Debug statement - simpleParser(stream, (err, parsed) => { + simpleParser(stream, async (err, parsed) => { if (err) { console.error("Failed to parse email data:", err.message); return callback(err); @@ -88,26 +327,102 @@ const serverOptions: SMTPServerOptions = { return callback(new Error("No API key found in session")); } - const emailObject = { - to: Array.isArray(parsed.to) - ? parsed.to.map((addr) => addr.text).join(", ") - : parsed.to?.text, - from: Array.isArray(parsed.from) - ? parsed.from.map((addr) => addr.text).join(", ") - : parsed.from?.text, - subject: parsed.subject, - text: parsed.text, - html: parsed.html, - replyTo: parsed.replyTo?.text, - }; - - sendEmailToUseSend(emailObject, session.user) - .then(() => callback()) - .then(() => console.log("Email sent successfully to: ", emailObject.to)) - .catch((error) => { - console.error("Failed to send email:", error.message); - callback(error); + const toAddress = Array.isArray(parsed.to) + ? parsed.to.map((addr) => addr.text).join(", ") + : parsed.to?.text; + + const fromAddress = Array.isArray(parsed.from) + ? parsed.from.map((addr) => addr.text).join(", ") + : parsed.from?.text; + + const sendPromises: Promise[] = []; + const recipients = parseRecipients(toAddress); + const hasCampaigns = recipients.contactBookIds.length > 0; + const hasIndividualEmails = recipients.emailAddresses.length > 0; + + // Handle campaign sends (one campaign per contact book) + if (hasCampaigns) { + if (!fromAddress) { + console.error("No from address found for campaign"); + return callback(new Error("From address is required for campaigns")); + } + + if (!parsed.subject) { + console.error("No subject found for campaign"); + return callback(new Error("Subject is required for campaigns")); + } + + const htmlContent = prepareCampaignHtml(parsed.html, parsed.text); + if (!htmlContent) { + console.error("No content found for campaign"); + return callback( + new Error("HTML or text content is required for campaigns"), + ); + } + + for (const contactBookId of recipients.contactBookIds) { + const campaignData: CampaignData = { + name: `SMTP Campaign: ${parsed.subject}`, + from: fromAddress, + subject: parsed.subject, + contactBookId, + html: htmlContent, + replyTo: parsed.replyTo?.text, + }; + + const campaignPromise = sendCampaignToUseSend( + campaignData, + session.user, + ).catch((error) => { + console.error( + `Failed to send campaign to ${contactBookId}:`, + error.message, + ); + throw error; + }); + + sendPromises.push(campaignPromise); + } + } + + // Handle individual email sends + if (hasIndividualEmails) { + // Send to all individual recipients in one API call + const emailObject = { + to: recipients.emailAddresses, + from: fromAddress, + subject: parsed.subject, + text: parsed.text, + html: parsed.html, + replyTo: parsed.replyTo?.text, + }; + + const emailPromise = sendEmailToUseSend( + emailObject, + session.user, + ).catch((error) => { + console.error("Failed to send individual emails:", error.message); + throw error; }); + + sendPromises.push(emailPromise); + } + + if (sendPromises.length === 0) { + console.error("No valid recipients found"); + return callback(new Error("No valid recipients found")); + } + + try { + await Promise.all(sendPromises); + callback(); + } catch (error) { + if (error instanceof Error) { + callback(error); + } else { + callback(new Error("One or more sends failed")); + } + } }); }, onAuth(auth, session: any, callback: (error?: Error, user?: any) => void) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88867c2a..48b1de47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,12 @@ importers: dotenv: specifier: ^16.5.0 version: 16.5.0 + he: + specifier: ^1.2.0 + version: 1.2.0 + linkedom: + specifier: ^0.18.12 + version: 0.18.12 mailparser: specifier: ^3.7.2 version: 3.7.2 @@ -137,6 +143,9 @@ importers: specifier: ^3.13.6 version: 3.13.6 devDependencies: + '@types/he': + specifier: ^1.2.3 + version: 1.2.3 '@types/node': specifier: ^22.15.2 version: 22.15.2 @@ -8224,6 +8233,10 @@ packages: dependencies: '@types/unist': 3.0.3 + /@types/he@1.2.3: + resolution: {integrity: sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==} + dev: true + /@types/html-to-text@9.0.4: resolution: {integrity: sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==} dev: true @@ -10066,6 +10079,16 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + dev: false + /css-selector-parser@3.1.3: resolution: {integrity: sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg==} dev: false @@ -10080,6 +10103,10 @@ packages: engines: {node: '>=4'} hasBin: true + /cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + dev: false + /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -12426,6 +12453,10 @@ packages: resolution: {integrity: sha512-fxfswuADQ6N6RmCUYoCEIw09Zbk/h8GJSJsbiQ3Uw3mkQegJ5b7Eu5Tpxl2xDUq9meWmivHe0GFieG2qHl2j4A==} dev: false + /html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + dev: false + /html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -12440,6 +12471,15 @@ packages: /html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + /htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.0 + dev: false + /htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} dependencies: @@ -13364,6 +13404,22 @@ packages: /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + /linkedom@0.18.12: + resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==} + engines: {node: '>=16'} + peerDependencies: + canvas: '>= 2' + peerDependenciesMeta: + canvas: + optional: true + dependencies: + css-select: 5.2.2 + cssom: 0.5.0 + html-escaper: 3.0.3 + htmlparser2: 10.0.0 + uhyphen: 0.2.0 + dev: false + /linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} dependencies: @@ -18027,6 +18083,10 @@ packages: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} dev: false + /uhyphen@0.2.0: + resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} + dev: false + /unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} diff --git a/temp b/temp new file mode 100644 index 00000000..e69de29b