mailgun-optin-cli
Version:
CLI tool for sending opt-in confirmation emails via Mailgun
164 lines (142 loc) • 4.99 kB
JavaScript
import { createReadStream } from 'fs';
import { pipeline } from 'stream/promises';
import csvParser from 'csv-parser';
/**
* Validates an email address using a comprehensive regex pattern
* @param {string} email - The email address to validate
* @returns {boolean} - True if email is valid, false otherwise
*/
export const validateEmail = email => {
if (!email || typeof email !== 'string') {
return false;
}
// Comprehensive email validation regex
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
// Additional checks for edge cases
if (email.includes('..') || email.startsWith('.') || email.endsWith('.')) {
return false;
}
if (email.includes(' ')) {
return false;
}
return emailRegex.test(email.trim());
};
/**
* Validates the structure of parsed CSV data and filters out invalid emails
* @param {Array<Object>} data - Array of CSV row objects
* @returns {Object} - Object containing valid data and skipped emails info
* @throws {Error} - If validation fails
*/
export const validateCSVStructure = data => {
if (!Array.isArray(data) || data.length === 0) {
throw new Error('CSV file is empty');
}
// Check if email column exists (support both formats)
const firstRow = data[0];
const emailField = firstRow.Email || firstRow.email;
if (!emailField && !Object.prototype.hasOwnProperty.call(firstRow, 'Email') && !Object.prototype.hasOwnProperty.call(firstRow, 'email')) {
throw new Error('CSV must contain an "Email" or "email" column');
}
// Filter out invalid email addresses and collect skipped emails
const validData = [];
const skippedEmails = [];
for (let i = 0; i < data.length; i++) {
const row = data[i];
const email = (row.Email || row.email)?.trim();
if (!validateEmail(email)) {
skippedEmails.push({
rowNumber: i + 1,
email: email || 'empty',
reason: 'Invalid email format'
});
} else {
validData.push(row);
}
}
return {
validData,
skippedEmails,
totalRows: data.length,
validRows: validData.length,
skippedRows: skippedEmails.length
};
};
/**
* Parses a CSV file and returns validated subscriber data
* @param {string} filePath - Path to the CSV file
* @returns {Promise<Object>} - Object containing valid subscribers and skipped emails info
* @throws {Error} - If file cannot be read or data is invalid
*/
export const parseCSV = async filePath => {
const results = [];
try {
await pipeline(
createReadStream(filePath),
csvParser({
skipEmptyLines: true,
trim: true,
}),
async function* (source) {
for await (const chunk of source) {
// Trim all string values and normalize the data
const normalizedChunk = {};
for (const [key, value] of Object.entries(chunk)) {
normalizedChunk[key] = typeof value === 'string' ? value.trim() : value;
}
// Normalize field names for template compatibility
// Map new format (FirstName, LastName, Email) to template placeholders
if (normalizedChunk.FirstName !== undefined) {
normalizedChunk.firstName = normalizedChunk.FirstName;
}
if (normalizedChunk.LastName !== undefined) {
normalizedChunk.lastName = normalizedChunk.LastName;
}
if (normalizedChunk.Email !== undefined) {
normalizedChunk.email = normalizedChunk.Email;
}
yield normalizedChunk;
}
},
async (source) => {
for await (const chunk of source) {
results.push(chunk);
}
},
);
// Validate the parsed data structure and filter invalid emails
const validationResult = validateCSVStructure(results);
return validationResult;
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`CSV file not found: ${filePath}`);
}
if (
error.message.includes('CSV must contain') ||
error.message.includes('CSV file is empty')
) {
throw error;
}
throw new Error(`Failed to parse CSV file: ${error.message}`);
}
};
/**
* Gets statistics about the parsed CSV data
* @param {Array<Object>} data - Array of subscriber objects
* @returns {Object} - Statistics object
*/
export const getCSVStats = data => {
const totalSubscribers = data.length;
const withFirstName = data.filter(row => (row.FirstName || row.first_name)?.trim()).length;
const withLastName = data.filter(row => (row.LastName || row.last_name)?.trim()).length;
const withFullName = data.filter(row =>
(row.FirstName || row.first_name)?.trim() && (row.LastName || row.last_name)?.trim()
).length;
return {
totalSubscribers,
withFirstName,
withLastName,
withFullName,
emailOnly: totalSubscribers - withFirstName,
};
};