EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
email.js
Go to the documentation of this file.
1/**
2 * @file Email Service - Multi-Provider Email Delivery
3 * @module services/email
4 * @description
5 * Unified email sending service supporting multiple providers (SMTP, Mailgun). Provides
6 * automatic fallback to console logging when no transport is configured (development mode).
7 * Supports per-tenant email configuration and environment variable defaults.
8 *
9 * **Supported Providers:**
10 * - **SMTP**: Universal email protocol via nodemailer (default)
11 * - **Mailgun**: Transactional email API for high deliverability
12 *
13 * **Configuration Priority:**
14 * 1. Runtime settings parameter (tenant-specific)
15 * 2. Environment variables (global defaults)
16 * 3. Console fallback (no configuration)
17 *
18 * **SMTP Environment Variables:**
19 * - `SMTP_HOST` - SMTP server hostname
20 * - `SMTP_PORT` - SMTP server port (25, 465, 587)
21 * - `SMTP_USER` - SMTP authentication username
22 * - `SMTP_PASS` - SMTP authentication password
23 * - `SMTP_SECURE` - Use TLS (true for port 465)
24 * - `FROM_EMAIL` - Default sender address
25 *
26 * **Mailgun Configuration:**
27 * - `settings.mailgun.apiKey` - Mailgun API key
28 * - `settings.mailgun.domain` - Mailgun domain
29 *
30 * **Use Cases:**
31 * - Password reset emails
32 * - User verification
33 * - Alert notifications
34 * - Invoice delivery
35 * - Ticket notifications
36 * @example
37 * // Send email using SMTP (env vars)
38 * const { sendEmail } = require('./services/email');
39 * await sendEmail({
40 * to: 'user@example.com',
41 * subject: 'Welcome to RMM',
42 * html: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
43 * text: 'Welcome! Thanks for signing up.'
44 * });
45 * @example
46 * // Send via Mailgun with custom settings
47 * await sendEmail({
48 * to: 'customer@example.com',
49 * subject: 'Invoice #12345',
50 * html: invoiceHtml,
51 * provider: 'mailgun',
52 * settings: {
53 * mailgun: {
54 * apiKey: 'key-abc123',
55 * domain: 'mg.example.com'
56 * },
57 * from: 'billing@example.com'
58 * }
59 * });
60 * @example
61 * // Development fallback (no SMTP configured)
62 * // Logs email to console instead of sending
63 * await sendEmail({
64 * to: 'test@test.com',
65 * subject: 'Test',
66 * text: 'Testing'
67 * });
68 * // Output:
69 * // [Email:FALLBACK] To: test@test.com
70 * // [Email:FALLBACK] Subject: Test
71 * // [Email:FALLBACK] Text: Testing
72 * @requires nodemailer - SMTP email client
73 * @requires @mailgun-js/mailgun.js - Mailgun API client
74 * @requires form-data - Multipart form data for Mailgun
75 */
76
77const nodemailer = require('nodemailer');
78const formData = require('form-data');
79const Mailgun = require('@mailgun-js/mailgun.js');
80const pool = require('./db');
81const crypto = require('crypto');
82
83
84/**
85 * Check if email configuration is valid for a tenant
86 * @description Validates that tenant has complete email configuration (SMTP or Mailgun)
87 * before attempting to send. Prevents connection errors and logs issues.
88 * @param {number} tenantId - Tenant ID to check configuration for
89 * @param {object} [settings] - Runtime settings to validate (optional, checks DB if not provided)
90 * @returns {Promise<object>} {isConfigured: boolean, provider: string, error: string}
91 */
92async function checkEmailConfig(tenantId, settings = null) {
93 try {
94 // If settings provided, validate them
95 if (settings) {
96 if (settings.provider === 'mailgun') {
97 const valid = settings.mailgun?.apiKey && settings.mailgun?.domain;
98 return {
99 isConfigured: valid,
100 provider: 'mailgun',
101 error: valid ? null : 'Missing Mailgun API key or domain'
102 };
103 }
104 // SMTP validation
105 const smtp = settings.smtp || {};
106 const host = smtp.host || process.env.SMTP_HOST;
107 const port = smtp.port || process.env.SMTP_PORT;
108 const user = smtp.user || process.env.SMTP_USER;
109 const pass = smtp.pass || process.env.SMTP_PASS;
110
111 if (!host || !port) {
112 return {
113 isConfigured: false,
114 provider: 'smtp',
115 error: 'Missing SMTP host or port'
116 };
117 }
118
119 return { isConfigured: true, provider: 'smtp', error: null };
120 }
121
122 // Check database for tenant config
123 const result = await pool.query(
124 'SELECT provider, is_configured, smtp_host, smtp_port, mailgun_api_key, mailgun_domain FROM tenant_email_config WHERE tenant_id = $1',
125 [tenantId]
126 );
127
128 if (!result.rows.length) {
129 // No tenant config, check global env vars
130 const hasGlobalSMTP = process.env.SMTP_HOST && process.env.SMTP_PORT;
131 return {
132 isConfigured: hasGlobalSMTP,
133 provider: 'smtp',
134 error: hasGlobalSMTP ? null : 'No email configuration found (tenant or global)'
135 };
136 }
137
138 const config = result.rows[0];
139 return {
140 isConfigured: config.is_configured,
141 provider: config.provider,
142 error: config.is_configured ? null : 'Email configuration incomplete or not tested'
143 };
144 } catch (error) {
145 console.error('[Email] Config check error:', error);
146 return { isConfigured: false, provider: 'unknown', error: error.message };
147 }
148}
149
150
151/**
152 * Create email tracking record in database
153 * @description Generates tracking pixel ID and stores email metadata for open tracking
154 * @param {object} params - Tracking parameters
155 * @param {number} params.tenantId - Tenant ID
156 * @param {string} params.to - Recipient email
157 * @param {string} params.subject - Email subject
158 * @param {string} [params.emailType] - Email type (invoice, ticket, notification, etc.)
159 * @param {string} [params.referenceType] - Reference type (invoice, ticket, user, etc.)
160 * @param {number} [params.referenceId] - Reference ID
161 * @param {string} [params.provider] - Email provider (smtp, mailgun)
162 * @returns {Promise<string>} Tracking ID (UUID)
163 */
164async function createEmailTracking({ tenantId, to, subject, emailType, referenceType, referenceId, provider = 'smtp' }) {
165 try {
166 const result = await pool.query(
167 `INSERT INTO email_tracking
168 (tenant_id, recipient_email, subject, email_type, reference_type, reference_id, provider)
169 VALUES ($1, $2, $3, $4, $5, $6, $7)
170 RETURNING tracking_id`,
171 [tenantId, to, subject, emailType, referenceType, referenceId, provider]
172 );
173 return result.rows[0].tracking_id;
174 } catch (error) {
175 console.error('[Email] Failed to create tracking record:', error);
176 return null; // Non-fatal, email can still send without tracking
177 }
178}
179
180
181/**
182 * Inject tracking pixel into HTML email
183 * @description Adds 1x1 transparent tracking pixel at end of HTML body for open tracking
184 * @param {string} html - HTML email content
185 * @param {string} trackingId - Tracking UUID to embed in pixel URL
186 * @param {string} [backendUrl] - Backend URL (defaults to env var or relative)
187 * @returns {string} HTML with tracking pixel injected
188 */
189function injectTrackingPixel(html, trackingId, backendUrl = null) {
190 if (!html || !trackingId) return html;
191
192 const baseUrl = backendUrl || process.env.BACKEND_URL || 'https://rmm-psa-backend-t9f7k.ondigitalocean.app';
193 const pixelUrl = `${baseUrl}/api/email/track/${trackingId}/pixel.gif`;
194
195 // Inject pixel before closing body tag (or at end if no body tag)
196 const pixel = `<img src="${pixelUrl}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
197
198 if (html.toLowerCase().includes('</body>')) {
199 return html.replace(/<\/body>/i, `${pixel}</body>`);
200 } else {
201 return html + pixel;
202 }
203}
204
205
206/**
207 * Build email transport client for SMTP or Mailgun
208 * @description Creates nodemailer transport for SMTP or Mailgun client based on settings.
209 * Returns null if SMTP not configured (triggers console fallback). Supports both
210 * runtime settings and environment variable configuration.
211 * @param {object} [settings] - Transport configuration object
212 * @param {string} [settings.provider] - Transport provider: 'smtp' (default) or 'mailgun'
213 * @param {object} [settings.smtp] - SMTP configuration
214 * @param {string} [settings.smtp.host] - SMTP server hostname
215 * @param {number} [settings.smtp.port] - SMTP server port (25, 465, 587)
216 * @param {string} [settings.smtp.user] - SMTP authentication username
217 * @param {string} [settings.smtp.pass] - SMTP authentication password
218 * @param {boolean} [settings.smtp.secure] - Use TLS (true for port 465)
219 * @param {object} [settings.mailgun] - Mailgun configuration
220 * @param {string} [settings.mailgun.apiKey] - Mailgun API key
221 * @param {string} [settings.mailgun.domain] - Mailgun sending domain
222 * @returns {object|null} Nodemailer transport, Mailgun client, or null if unconfigured
223 */
224function buildTransport(settings) {
225 // settings: { smtp: {...}, mailgun: {...}, provider: 'smtp'|'mailgun' }
226 if (settings && settings.provider === 'mailgun' && settings.mailgun) {
227 // Mailgun transport (new SDK)
228 const mg = new Mailgun(formData);
229 return mg.client({
230 username: 'api',
231 key: settings.mailgun.apiKey
232 });
233 }
234 // Default to SMTP
235 const smtp = settings && settings.smtp ? settings.smtp : {};
236 const host = smtp.host || process.env.SMTP_HOST;
237 const port = smtp.port || process.env.SMTP_PORT;
238 const user = smtp.user || process.env.SMTP_USER;
239 const pass = smtp.pass || process.env.SMTP_PASS;
240 const secure = smtp.secure !== undefined ? smtp.secure : (process.env.SMTP_SECURE === 'true');
241 if (!host || !port) {
242 return null; // No SMTP configured; fallback to console
243 }
244 return nodemailer.createTransport({
245 host,
246 port: Number(port),
247 secure,
248 auth: user && pass ? { user, pass } : undefined,
249 });
250}
251
252
253/**
254 * Send email via SMTP or Mailgun with automatic fallback
255 * @description Sends email using configured provider (SMTP by default). Falls back to
256 * console logging in development if SMTP is not configured. Supports HTML and plain text
257 * content with automatic multipart handling. Now includes tracking pixel injection and
258 * configuration validation to prevent connection errors.
259 * @param {object} param0 - Email sending options
260 * @param {string} param0.to - Recipient email address
261 * @param {string} param0.subject - Email subject line
262 * @param {string} [param0.html] - HTML email content
263 * @param {string} [param0.text] - Plain text email content
264 * @param {string} [param0.provider] - Email provider: 'smtp' (default) or 'mailgun'
265 * @param {object} [param0.settings] - Provider configuration with from/smtp/mailgun properties
266 * @param {number} [param0.tenantId] - Tenant ID for tracking and config validation
267 * @param {string} [param0.emailType] - Email type for tracking (invoice, ticket, notification, etc.)
268 * @param {string} [param0.referenceType] - Reference type (invoice, ticket, user, etc.)
269 * @param {number} [param0.referenceId] - Reference ID for tracking
270 * @param {boolean} [param0.skipTracking] - Skip tracking pixel injection (default: false)
271 * @returns {Promise<object>} Email send result with tracking info: {success, trackingId, messageId, fallback}
272 * @throws {Error} Throws if email sending fails (network, auth, etc.) or if config is invalid
273 */
274async function sendEmail({
275 to,
276 subject,
277 html,
278 text,
279 provider = 'smtp',
280 settings = {},
281 tenantId = null,
282 emailType = null,
283 referenceType = null,
284 referenceId = null,
285 skipTracking = false
286}) {
287 const from = (settings.from || process.env.FROM_EMAIL) || 'no-reply@ibg.local';
288
289 // Validate email configuration before attempting to send
290 if (tenantId) {
291 const configCheck = await checkEmailConfig(tenantId, settings);
292 if (!configCheck.isConfigured) {
293 console.warn(`[Email] Tenant ${tenantId}: Email not configured - ${configCheck.error}`);
294 console.log('[Email:FALLBACK] To:', to);
295 console.log('[Email:FALLBACK] Subject:', subject);
296 console.log('[Email:FALLBACK] Reason: Email not configured for tenant');
297 return {
298 success: false,
299 fallback: true,
300 error: configCheck.error,
301 message: 'Email configuration incomplete - email not sent'
302 };
303 }
304 }
305
306 // Create tracking record and inject pixel (if enabled)
307 let trackingId = null;
308 if (tenantId && !skipTracking) {
309 trackingId = await createEmailTracking({
310 tenantId,
311 to,
312 subject,
313 emailType,
314 referenceType,
315 referenceId,
316 provider
317 });
318
319 // Inject tracking pixel into HTML
320 if (trackingId && html) {
321 html = injectTrackingPixel(html, trackingId);
322 }
323 }
324
325 // Send via provider
326 if (provider === 'mailgun' && settings.mailgun) {
327 // Use Mailgun
328 const mg = buildTransport({ provider: 'mailgun', mailgun: settings.mailgun });
329 const data = {
330 from,
331 to,
332 subject,
333 text: text || undefined,
334 html: html || undefined
335 };
336 try {
337 const result = await mg.messages.create(settings.mailgun.domain, data);
338 console.log(`[Email:Mailgun] Sent to ${to} - Message ID: ${result.id}`);
339
340 // Update tracking with provider message ID
341 if (trackingId) {
342 await pool.query(
343 'UPDATE email_tracking SET provider_message_id = $1, delivery_status = $2 WHERE tracking_id = $3',
344 [result.id, 'sent', trackingId]
345 );
346 }
347
348 return {
349 success: true,
350 messageId: result.id,
351 trackingId,
352 provider: 'mailgun'
353 };
354 } catch (error) {
355 console.error('[Mailgun] Error:', error);
356
357 // Update tracking with error
358 if (trackingId) {
359 await pool.query(
360 'UPDATE email_tracking SET delivery_status = $1, delivery_error = $2 WHERE tracking_id = $3',
361 ['failed', error.message, trackingId]
362 );
363 }
364
365 throw error;
366 }
367 } else {
368 // Use SMTP
369 const transporter = buildTransport({ provider: 'smtp', smtp: settings.smtp });
370 if (!transporter) {
371 console.log('[Email:FALLBACK] To:', to);
372 console.log('[Email:FALLBACK] Subject:', subject);
373 console.log('[Email:FALLBACK] Text:', text || '');
374 console.log('[Email:FALLBACK] HTML length:', html ? html.length : 0);
375 console.log('[Email:FALLBACK] Reason: SMTP not configured (missing SMTP_HOST or SMTP_PORT)');
376 return {
377 success: false,
378 fallback: true,
379 trackingId: null,
380 message: 'SMTP not configured - email logged to console only'
381 };
382 }
383
384 try {
385 const info = await transporter.sendMail({ from, to, subject, text, html });
386 console.log(`[Email:SMTP] Sent to ${to} - Message ID: ${info.messageId}`);
387
388 // Update tracking with provider message ID
389 if (trackingId) {
390 await pool.query(
391 'UPDATE email_tracking SET provider_message_id = $1, delivery_status = $2 WHERE tracking_id = $3',
392 [info.messageId, 'sent', trackingId]
393 );
394 }
395
396 return {
397 success: true,
398 messageId: info.messageId,
399 trackingId,
400 provider: 'smtp'
401 };
402 } catch (error) {
403 console.error('[SMTP] Error:', error);
404
405 // Update tracking with error
406 if (trackingId) {
407 await pool.query(
408 'UPDATE email_tracking SET delivery_status = $1, delivery_error = $2 WHERE tracking_id = $3',
409 ['failed', error.message, trackingId]
410 );
411 }
412
413 throw error;
414 }
415 }
416}
417
418module.exports = { sendEmail, checkEmailConfig, createEmailTracking, injectTrackingPixel };