@@ -3,6 +3,8 @@ import { Readable } from "stream";
33import dotenv from "dotenv" ;
44import { simpleParser } from "mailparser" ;
55import { readFileSync , watch , FSWatcher } from "fs" ;
6+ import he from "he" ;
7+ import { parseHTML } from "linkedom" ;
68
79dotenv . config ( ) ;
810
@@ -15,6 +17,115 @@ const SSL_KEY_PATH =
1517 process . env . USESEND_API_KEY_PATH ?? process . env . UNSEND_API_KEY_PATH ;
1618const 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
19130async 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+
61266function 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