Monthly Market Update Email Manager
Add contacts manually or import your Mailchimp CSV export, create your monthly market update, and manage opt-outs so unsubscribed addresses are excluded from future sends.
Add a New Recipient
Add one contact at a time, or import a Mailchimp CSV export. Contacts and unsubscribes are stored in your browser on this device.
Import CSV Files
Supports Mailchimp exports with headers like Email Address, First Name, and Last Name.
Manage Opt-Outs
Add an email here when someone replies asking to unsubscribe.
Create This Month’s Market Update
Write your monthly market update below.
Search
<script>
document.addEventListener(‘DOMContentLoaded’, function () {
const STORAGE_KEY = ‘monthlyMarketUpdateRecipients’;
const UNSUB_KEY = ‘monthlyMarketUpdateUnsubscribed’;
const SETTINGS_KEY = ‘monthlyMarketUpdateSettings’;
const IMPORT_STATS_KEY = ‘monthlyMarketUpdateImportStats’;
const contactForm = document.getElementById(‘mmu-contactForm’);
if (!contactForm) return;
const firstNameInput = document.getElementById(‘mmu-firstName’);
const lastNameInput = document.getElementById(‘mmu-lastName’);
const emailInput = document.getElementById(‘mmu-email’);
const csvFileInput = document.getElementById(‘mmu-csvFile’);
const contactStatus = document.getElementById(‘mmu-contactStatus’);
const importSummary = document.getElementById(‘mmu-importSummary’);
const recipientList = document.getElementById(‘mmu-recipientList’);
const unsubscribedList = document.getElementById(‘mmu-unsubscribedList’);
const totalRecipientsEl = document.getElementById(‘mmu-totalRecipients’);
const unsubscribedCountEl = document.getElementById(‘mmu-unsubscribedCount’);
const uniqueDomainsEl = document.getElementById(‘mmu-uniqueDomains’);
const importedFilesEl = document.getElementById(‘mmu-importedFiles’);
const unsubscribeEmailInput = document.getElementById(‘mmu-unsubscribeEmail’);
const unsubscribeNameInput = document.getElementById(‘mmu-unsubscribeName’);
const unsubscribeStatus = document.getElementById(‘mmu-unsubscribeStatus’);
const fromNameInput = document.getElementById(‘mmu-fromName’);
const replyToEmailInput = document.getElementById(‘mmu-replyToEmail’);
const emailSubjectInput = document.getElementById(‘mmu-emailSubject’);
const monthlyBodyInput = document.getElementById(‘mmu-monthlyBody’);
const includeOptOutInput = document.getElementById(‘mmu-includeOptOut’);
const optOutTextInput = document.getElementById(‘mmu-optOutText’);
const sendStatus = document.getElementById(‘mmu-sendStatus’);
const previewBox = document.getElementById(‘mmu-previewBox’);
function normalizeEmail(email) {
return String(email || ”).trim().toLowerCase();
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function escapeHtml(str) {
return String(str || ”)
.replace(/&/g, ‘&’)
.replace(/</g, ‘<’)
.replace(/>/g, ‘>’)
.replace(/”/g, ‘"’)
.replace(/’/g, ‘'’);
}
function setStatus(el, message, type) {
el.className = ‘mmu-status ‘ + (type || ‘info’);
el.textContent = message;
}
function getRecipients() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; }
catch(e) { return []; }
}
function saveRecipients(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
renderAll();
}
function getUnsubscribed() {
try { return JSON.parse(localStorage.getItem(UNSUB_KEY)) || []; }
catch(e) { return []; }
}
function saveUnsubscribed(data) {
localStorage.setItem(UNSUB_KEY, JSON.stringify(data));
renderAll();
}
function getImportStats() {
try { return JSON.parse(localStorage.getItem(IMPORT_STATS_KEY)) || { files: 0 }; }
catch(e) { return { files: 0 }; }
}
function saveImportStats(stats) {
localStorage.setItem(IMPORT_STATS_KEY, JSON.stringify(stats));
}
function getSettings() {
try { return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {}; }
catch(e) { return {}; }
}
function saveSettings() {
const settings = {
fromName: fromNameInput.value.trim(),
replyToEmail: replyToEmailInput.value.trim(),
emailSubject: emailSubjectInput.value.trim(),
monthlyBody: monthlyBodyInput.value,
includeOptOut: includeOptOutInput.checked,
optOutText: optOutTextInput.value
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
updatePreview();
}
function loadSettings() {
const s = getSettings();
if (s.fromName) fromNameInput.value = s.fromName;
if (s.replyToEmail) replyToEmailInput.value = s.replyToEmail;
if (s.emailSubject) emailSubjectInput.value = s.emailSubject;
if (s.monthlyBody) monthlyBodyInput.value = s.monthlyBody;
if (typeof s.includeOptOut === ‘boolean’) includeOptOutInput.checked = s.includeOptOut;
if (s.optOutText) optOutTextInput.value = s.optOutText;
}
function renderRecipients() {
const recipients = getRecipients();
recipientList.innerHTML = ”;
if (!recipients.length) {
recipientList.innerHTML = ‘<div class=”mmu-list-row”><div><span class=”mmu-mobile-label”>Status:</span>No active recipients added yet.</div><div></div><div></div><div></div></div>’;
return;
}
recipients.forEach((r, index) => {
const row = document.createElement(‘div’);
row.className = ‘mmu-list-row’;
row.innerHTML = `
<div><span class=”mmu-mobile-label”>First Name:</span>${escapeHtml(r.firstName)}</div>
<div><span class=”mmu-mobile-label”>Last Name:</span>${escapeHtml(r.lastName)}</div>
<div><span class=”mmu-mobile-label”>Email:</span>${escapeHtml(r.email)}</div>
<div><button type=”button” class=”mmu-btn-danger” data-delete-recipient=”${index}” style=”padding:10px 12px;font-size:.9rem;”>Delete</button></div>
`;
recipientList.appendChild(row);
});
}
function renderUnsubscribed() {
const list = getUnsubscribed();
unsubscribedList.innerHTML = ”;
if (!list.length) {
unsubscribedList.innerHTML = ‘<div class=”mmu-list-row”><div><span class=”mmu-mobile-label”>Status:</span>No unsubscribed emails saved yet.</div><div></div><div></div><div></div></div>’;
return;
}
list.forEach((r, index) => {
const row = document.createElement(‘div’);
row.className = ‘mmu-list-row’;
row.innerHTML = `
<div><span class=”mmu-mobile-label”>Name:</span>${escapeHtml(r.name)}</div>
<div></div>
<div><span class=”mmu-mobile-label”>Email:</span>${escapeHtml(r.email)}</div>
<div><button type=”button” class=”mmu-btn-danger” data-delete-unsub=”${index}” style=”padding:10px 12px;font-size:.9rem;”>Delete</button></div>
`;
unsubscribedList.appendChild(row);
});
}
function renderStats() {
const recipients = getRecipients();
const unsubscribed = getUnsubscribed();
totalRecipientsEl.textContent = recipients.length;
unsubscribedCountEl.textContent = unsubscribed.length;
importedFilesEl.textContent = getImportStats().files || 0;
const domains = new Set(
recipients.map(r => (r.email.split(‘@’)[1] || ”).toLowerCase()).filter(Boolean)
);
uniqueDomainsEl.textContent = domains.size;
}
function getComposedBody() {
let body = monthlyBodyInput.value.trim();
if (includeOptOutInput.checked && optOutTextInput.value.trim()) {
body += ‘\n\n—\n’ + optOutTextInput.value.trim();
}
return body;
}
function updatePreview() {
previewBox.textContent =
`Preview
————————-
Active recipients in BCC: ${getRecipients().length}
Reply-To:
${replyToEmailInput.value.trim() || ‘(none)’}
Subject:
${emailSubjectInput.value.trim() || ‘(No subject)’}
Message:
${getComposedBody()}`;
}
function renderAll() {
renderRecipients();
renderUnsubscribed();
renderStats();
updatePreview();
}
function parseCSV(text) {
text = String(text || ”).replace(/^\uFEFF/, ”).replace(/\r\n/g, ‘\n’).replace(/\r/g, ‘\n’);
const firstLine = text.split(‘\n’)[0] || ”;
const counts = {
‘,’: (firstLine.match(/,/g) || []).length,
‘;’: (firstLine.match(/;/g) || []).length,
‘\t’: (firstLine.match(/\t/g) || []).length
};
let delimiter = ‘,’;
if (counts[‘;’] > counts[‘,’]) delimiter = ‘;’;
if (counts[‘\t’] > counts[delimiter]) delimiter = ‘\t’;
const rows = [];
let row = [];
let field = ”;
let insideQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (ch === ‘”‘) {
if (insideQuotes && next === ‘”‘) {
field += ‘”‘;
i++;
} else {
insideQuotes = !insideQuotes;
}
} else if (ch === delimiter && !insideQuotes) {
row.push(field.trim());
field = ”;
} else if (ch === ‘\n’ && !insideQuotes) {
row.push(field.trim());
if (row.some(v => String(v).trim() !== ”)) rows.push(row);
row = [];
field = ”;
} else {
field += ch;
}
}
if (field.length || row.length) {
row.push(field.trim());
if (row.some(v => String(v).trim() !== ”)) rows.push(row);
}
if (!rows.length) return { headers: [], rows: [] };
const headers = rows.shift();
return { headers, rows };
}
function normalizeHeader(v) {
return String(v || ”)
.trim().toLowerCase()
.replace(/^”+|”+$/g, ”)
.replace(/\s+/g, ”)
.replace(/[^a-z0-9@_-]/g, ”);
}
function findHeaderIndex(headers, candidates) {
const normalized = headers.map(normalizeHeader);
for (const c of candidates) {
const i = normalized.indexOf(c);
if (i > -1) return i;
}
return -1;
}
function downloadCsv(rows, base) {
const csv = rows.map(row =>
row.map(v => `”${String(v || ”).replace(/”/g, ‘””‘)}”`).join(‘,’)
).join(‘\n’);
const blob = new Blob([csv], { type: ‘text/csv;charset=utf-8;’ });
const url = URL.createObjectURL(blob);
const a = document.createElement(‘a’);
a.href = url;
a.download = `${base}-${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function moveRecipientToUnsubscribed(email, name) {
const normalized = normalizeEmail(email);
if (!isValidEmail(normalized)) return false;
const unsub = getUnsubscribed();
const map = new Map(unsub.map(x => [normalizeEmail(x.email), x]));
if (!map.has(normalized)) map.set(normalized, { email: normalized, name: name || ” });
const recipients = getRecipients().filter(r => normalizeEmail(r.email) !== normalized);
localStorage.setItem(STORAGE_KEY, JSON.stringify(recipients));
localStorage.setItem(UNSUB_KEY, JSON.stringify(Array.from(map.values())));
renderAll();
return true;
}
contactForm.addEventListener(‘submit’, function(e) {
e.preventDefault();
const firstName = firstNameInput.value.trim();
const lastName = lastNameInput.value.trim();
const email = normalizeEmail(emailInput.value);
if (!firstName || !lastName || !email) {
setStatus(contactStatus, ‘Please complete all recipient fields.’, ‘error’);
return;
}
if (!isValidEmail(email)) {
setStatus(contactStatus, ‘Please enter a valid email address.’, ‘error’);
return;
}
const unsubSet = new Set(getUnsubscribed().map(x => normalizeEmail(x.email)));
if (unsubSet.has(email)) {
setStatus(contactStatus, ‘That email is on the unsubscribed list.’, ‘warn’);
return;
}
const recipients = getRecipients();
if (recipients.some(r => normalizeEmail(r.email) === email)) {
setStatus(contactStatus, ‘That email is already in your active list.’, ‘warn’);
return;
}
recipients.push({ firstName, lastName, email });
recipients.sort((a,b) => (a.lastName || ”).localeCompare(b.lastName || ”) || (a.firstName || ”).localeCompare(b.firstName || ”));
saveRecipients(recipients);
contactForm.reset();
setStatus(contactStatus, ‘Recipient added successfully.’, ‘success’);
});
document.getElementById(‘mmu-clearFormBtn’).addEventListener(‘click’, function() {
contactForm.reset();
contactStatus.textContent = ”;
});
document.getElementById(‘mmu-clearAllBtn’).addEventListener(‘click’, function() {
if (!confirm(‘Delete all active recipients from this device?’)) return;
localStorage.removeItem(STORAGE_KEY);
renderAll();
setStatus(contactStatus, ‘All active recipients deleted.’, ‘success’);
});
document.getElementById(‘mmu-addUnsubscribeBtn’).addEventListener(‘click’, function() {
const email = normalizeEmail(unsubscribeEmailInput.value);
const name = unsubscribeNameInput.value.trim();
if (!email) {
setStatus(unsubscribeStatus, ‘Please enter an email address.’, ‘error’);
return;
}
if (!isValidEmail(email)) {
setStatus(unsubscribeStatus, ‘Please enter a valid email address.’, ‘error’);
return;
}
moveRecipientToUnsubscribed(email, name);
unsubscribeEmailInput.value = ”;
unsubscribeNameInput.value = ”;
setStatus(unsubscribeStatus, ‘Email added to unsubscribed list.’, ‘success’);
});
document.getElementById(‘mmu-clearUnsubscribedBtn’).addEventListener(‘click’, function() {
if (!confirm(‘Delete all unsubscribed emails from this device?’)) return;
localStorage.removeItem(UNSUB_KEY);
renderAll();
setStatus(unsubscribeStatus, ‘All unsubscribed emails deleted.’, ‘success’);
});
document.addEventListener(‘click’, function(e) {
const delRec = e.target.getAttribute(‘data-delete-recipient’);
const delUnsub = e.target.getAttribute(‘data-delete-unsub’);
if (delRec !== null) {
const recipients = getRecipients();
recipients.splice(Number(delRec), 1);
saveRecipients(recipients);
setStatus(contactStatus, ‘Recipient deleted.’, ‘success’);
}
if (delUnsub !== null) {
const list = getUnsubscribed();
list.splice(Number(delUnsub), 1);
saveUnsubscribed(list);
setStatus(unsubscribeStatus, ‘Unsubscribed email deleted.’, ‘success’);
}
});
document.getElementById(‘mmu-exportCsvBtn’).addEventListener(‘click’, function() {
const recipients = getRecipients();
if (!recipients.length) {
setStatus(importSummary, ‘There are no active recipients to export.’, ‘error’);
return;
}
downloadCsv([[‘First Name’,’Last Name’,’Email’], …recipients.map(r => [r.firstName, r.lastName, r.email])], ‘active-market-update-recipients’);
setStatus(importSummary, ‘Active recipient CSV exported successfully.’, ‘success’);
});
document.getElementById(‘mmu-exportUnsubscribedBtn’).addEventListener(‘click’, function() {
const unsub = getUnsubscribed();
if (!unsub.length) {
setStatus(unsubscribeStatus, ‘There are no unsubscribed emails to export.’, ‘error’);
return;
}
downloadCsv([[‘Name’,’Email’], …unsub.map(r => [r.name, r.email])], ‘unsubscribed-market-update-emails’);
setStatus(unsubscribeStatus, ‘Unsubscribed CSV exported successfully.’, ‘success’);
});
csvFileInput.addEventListener(‘change’, async function(e) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
let totalImported = 0;
let totalDuplicates = 0;
let totalSkipped = 0;
let skippedFiles = [];
let importedFileCount = 0;
const unsubSet = new Set(getUnsubscribed().map(x => normalizeEmail(x.email)));
const recipientMap = new Map(getRecipients().map(x => [normalizeEmail(x.email), x]));
for (const file of files) {
const name = (file.name || ”).toLowerCase();
if (name.includes(‘unsubscribed’) || name.includes(‘cleaned’) || name.includes(‘nonsubscribed’)) {
skippedFiles.push(file.name + ‘ skipped’);
continue;
}
try {
const text = await file.text();
const parsed = parseCSV(text);
if (!parsed.headers.length || !parsed.rows.length) {
skippedFiles.push(file.name + ‘ empty or unreadable’);
continue;
}
const emailIndex = findHeaderIndex(parsed.headers, [’emailaddress’,’email’,’e-mail’]);
const firstIndex = findHeaderIndex(parsed.headers, [‘firstname’,’first’,’givenname’,’fname’]);
const lastIndex = findHeaderIndex(parsed.headers, [‘lastname’,’last’,’surname’,’familyname’,’lname’]);
if (emailIndex === -1) {
skippedFiles.push(file.name + ‘ missing email column’);
continue;
}
importedFileCount++;
parsed.rows.forEach(row => {
const email = normalizeEmail(row[emailIndex] || ”);
const firstName = ((firstIndex > -1 ? row[firstIndex] : ”) || ”).trim();
const lastName = ((lastIndex > -1 ? row[lastIndex] : ”) || ”).trim();
if (!email || !isValidEmail(email) || unsubSet.has(email)) {
totalSkipped++;
return;
}
if (recipientMap.has(email)) {
totalDuplicates++;
return;
}
recipientMap.set(email, { firstName, lastName, email });
totalImported++;
});
} catch (err) {
skippedFiles.push(file.name + ‘ could not be read’);
}
}
const combined = Array.from(recipientMap.values()).sort((a,b) =>
(a.lastName || ”).localeCompare(b.lastName || ”) || (a.firstName || ”).localeCompare(b.firstName || ”)
);
saveRecipients(combined);
const prior = getImportStats();
saveImportStats({ files: (prior.files || 0) + importedFileCount });
renderAll();
let msg = `Imported: ${totalImported}\nDuplicates skipped: ${totalDuplicates}\nInvalid or excluded rows skipped: ${totalSkipped}`;
if (skippedFiles.length) msg += `\nSkipped files: ${skippedFiles.join(‘, ‘)}`;
setStatus(importSummary, msg, totalImported > 0 ? ‘success’ : ‘warn’);
csvFileInput.value = ”;
});
document.getElementById(‘mmu-copyBccBtn’).addEventListener(‘click’, async function() {
const bcc = getRecipients().map(r => r.email).filter(Boolean).join(‘,’);
if (!bcc) {
setStatus(sendStatus, ‘No active recipients found.’, ‘error’);
return;
}
await navigator.clipboard.writeText(bcc);
setStatus(sendStatus, ‘BCC list copied to clipboard.’, ‘success’);
});
document.getElementById(‘mmu-copyBodyBtn’).addEventListener(‘click’, async function() {
await navigator.clipboard.writeText(getComposedBody());
setStatus(sendStatus, ‘Email body copied to clipboard.’, ‘success’);
});
document.getElementById(‘mmu-openBccBtn’).addEventListener(‘click’, function() {
const bcc = getRecipients().map(r => r.email).filter(Boolean).join(‘,’);
const subject = emailSubjectInput.value.trim();
const body = getComposedBody();
if (!bcc) {
setStatus(sendStatus, ‘No active recipients found.’, ‘error’);
return;
}
if (!subject || !body.trim()) {
setStatus(sendStatus, ‘Please add both a subject and message.’, ‘error’);
return;
}
const mailto = `mailto:?bcc=${encodeURIComponent(bcc)}&subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
if (mailto.length > 1800) {
setStatus(sendStatus, ‘Your BCC list is too large for one mailto draft.’, ‘warn’);
return;
}
window.location.href = mailto;
setStatus(sendStatus, ‘Your email app should open with all active recipients in BCC.’, ‘success’);
});
[fromNameInput, replyToEmailInput, emailSubjectInput, monthlyBodyInput, includeOptOutInput, optOutTextInput].forEach(el => {
el.addEventListener(‘input’, saveSettings);
el.addEventListener(‘change’, saveSettings);
});
loadSettings();
renderAll();
});
</script>
Proudly powered by WordPress
<script>
document.addEventListener(‘DOMContentLoaded’, function () {
const STORAGE_KEY = ‘monthlyMarketUpdateRecipients’;
const UNSUB_KEY = ‘monthlyMarketUpdateUnsubscribed’;
const SETTINGS_KEY = ‘monthlyMarketUpdateSettings’;
const IMPORT_STATS_KEY = ‘monthlyMarketUpdateImportStats’;
const contactForm = document.getElementById(‘mmu-contactForm’);
if (!contactForm) return;
const firstNameInput = document.getElementById(‘mmu-firstName’);
const lastNameInput = document.getElementById(‘mmu-lastName’);
const emailInput = document.getElementById(‘mmu-email’);
const csvFileInput = document.getElementById(‘mmu-csvFile’);
const contactStatus = document.getElementById(‘mmu-contactStatus’);
const importSummary = document.getElementById(‘mmu-importSummary’);
const recipientList = document.getElementById(‘mmu-recipientList’);
const unsubscribedList = document.getElementById(‘mmu-unsubscribedList’);
const totalRecipientsEl = document.getElementById(‘mmu-totalRecipients’);
const unsubscribedCountEl = document.getElementById(‘mmu-unsubscribedCount’);
const uniqueDomainsEl = document.getElementById(‘mmu-uniqueDomains’);
const importedFilesEl = document.getElementById(‘mmu-importedFiles’);
const unsubscribeEmailInput = document.getElementById(‘mmu-unsubscribeEmail’);
const unsubscribeNameInput = document.getElementById(‘mmu-unsubscribeName’);
const unsubscribeStatus = document.getElementById(‘mmu-unsubscribeStatus’);
const fromNameInput = document.getElementById(‘mmu-fromName’);
const replyToEmailInput = document.getElementById(‘mmu-replyToEmail’);
const emailSubjectInput = document.getElementById(‘mmu-emailSubject’);
const monthlyBodyInput = document.getElementById(‘mmu-monthlyBody’);
const includeOptOutInput = document.getElementById(‘mmu-includeOptOut’);
const optOutTextInput = document.getElementById(‘mmu-optOutText’);
const sendStatus = document.getElementById(‘mmu-sendStatus’);
const previewBox = document.getElementById(‘mmu-previewBox’);
function normalizeEmail(email) {
return String(email || ”).trim().toLowerCase();
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function escapeHtml(str) {
return String(str || ”)
.replace(/&/g, ‘&’)
.replace(/</g, ‘<’)
.replace(/>/g, ‘>’)
.replace(/”/g, ‘"’)
.replace(/’/g, ‘'’);
}
function setStatus(el, message, type) {
el.className = ‘mmu-status ‘ + (type || ‘info’);
el.textContent = message;
}
function getRecipients() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; }
catch(e) { return []; }
}
function saveRecipients(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
renderAll();
}
function getUnsubscribed() {
try { return JSON.parse(localStorage.getItem(UNSUB_KEY)) || []; }
catch(e) { return []; }
}
function saveUnsubscribed(data) {
localStorage.setItem(UNSUB_KEY, JSON.stringify(data));
renderAll();
}
function getImportStats() {
try { return JSON.parse(localStorage.getItem(IMPORT_STATS_KEY)) || { files: 0 }; }
catch(e) { return { files: 0 }; }
}
function saveImportStats(stats) {
localStorage.setItem(IMPORT_STATS_KEY, JSON.stringify(stats));
}
function getSettings() {
try { return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {}; }
catch(e) { return {}; }
}
function saveSettings() {
const settings = {
fromName: fromNameInput.value.trim(),
replyToEmail: replyToEmailInput.value.trim(),
emailSubject: emailSubjectInput.value.trim(),
monthlyBody: monthlyBodyInput.value,
includeOptOut: includeOptOutInput.checked,
optOutText: optOutTextInput.value
};
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
updatePreview();
}
function loadSettings() {
const s = getSettings();
if (s.fromName) fromNameInput.value = s.fromName;
if (s.replyToEmail) replyToEmailInput.value = s.replyToEmail;
if (s.emailSubject) emailSubjectInput.value = s.emailSubject;
if (s.monthlyBody) monthlyBodyInput.value = s.monthlyBody;
if (typeof s.includeOptOut === ‘boolean’) includeOptOutInput.checked = s.includeOptOut;
if (s.optOutText) optOutTextInput.value = s.optOutText;
}
function renderRecipients() {
const recipients = getRecipients();
recipientList.innerHTML = ”;
if (!recipients.length) {
recipientList.innerHTML = ‘<div class=”mmu-list-row”><div><span class=”mmu-mobile-label”>Status:</span>No active recipients added yet.</div><div></div><div></div><div></div></div>’;
return;
}
recipients.forEach((r, index) => {
const row = document.createElement(‘div’);
row.className = ‘mmu-list-row’;
row.innerHTML = `
<div><span class=”mmu-mobile-label”>First Name:</span>${escapeHtml(r.firstName)}</div>
<div><span class=”mmu-mobile-label”>Last Name:</span>${escapeHtml(r.lastName)}</div>
<div><span class=”mmu-mobile-label”>Email:</span>${escapeHtml(r.email)}</div>
<div><button type=”button” class=”mmu-btn-danger” data-delete-recipient=”${index}” style=”padding:10px 12px;font-size:.9rem;”>Delete</button></div>
`;
recipientList.appendChild(row);
});
}
function renderUnsubscribed() {
const list = getUnsubscribed();
unsubscribedList.innerHTML = ”;
if (!list.length) {
unsubscribedList.innerHTML = ‘<div class=”mmu-list-row”><div><span class=”mmu-mobile-label”>Status:</span>No unsubscribed emails saved yet.</div><div></div><div></div><div></div></div>’;
return;
}
list.forEach((r, index) => {
const row = document.createElement(‘div’);
row.className = ‘mmu-list-row’;
row.innerHTML = `
<div><span class=”mmu-mobile-label”>Name:</span>${escapeHtml(r.name)}</div>
<div></div>
<div><span class=”mmu-mobile-label”>Email:</span>${escapeHtml(r.email)}</div>
<div><button type=”button” class=”mmu-btn-danger” data-delete-unsub=”${index}” style=”padding:10px 12px;font-size:.9rem;”>Delete</button></div>
`;
unsubscribedList.appendChild(row);
});
}
function renderStats() {
const recipients = getRecipients();
const unsubscribed = getUnsubscribed();
totalRecipientsEl.textContent = recipients.length;
unsubscribedCountEl.textContent = unsubscribed.length;
importedFilesEl.textContent = getImportStats().files || 0;
const domains = new Set(
recipients.map(r => (r.email.split(‘@’)[1] || ”).toLowerCase()).filter(Boolean)
);
uniqueDomainsEl.textContent = domains.size;
}
function getComposedBody() {
let body = monthlyBodyInput.value.trim();
if (includeOptOutInput.checked && optOutTextInput.value.trim()) {
body += ‘\n\n—\n’ + optOutTextInput.value.trim();
}
return body;
}
function updatePreview() {
previewBox.textContent =
`Preview
————————-
Active recipients in BCC: ${getRecipients().length}
Reply-To:
${replyToEmailInput.value.trim() || ‘(none)’}
Subject:
${emailSubjectInput.value.trim() || ‘(No subject)’}
Message:
${getComposedBody()}`;
}
function renderAll() {
renderRecipients();
renderUnsubscribed();
renderStats();
updatePreview();
}
function parseCSV(text) {
text = String(text || ”).replace(/^\uFEFF/, ”).replace(/\r\n/g, ‘\n’).replace(/\r/g, ‘\n’);
const firstLine = text.split(‘\n’)[0] || ”;
const counts = {
‘,’: (firstLine.match(/,/g) || []).length,
‘;’: (firstLine.match(/;/g) || []).length,
‘\t’: (firstLine.match(/\t/g) || []).length
};
let delimiter = ‘,’;
if (counts[‘;’] > counts[‘,’]) delimiter = ‘;’;
if (counts[‘\t’] > counts[delimiter]) delimiter = ‘\t’;
const rows = [];
let row = [];
let field = ”;
let insideQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
const next = text[i + 1];
if (ch === ‘”‘) {
if (insideQuotes && next === ‘”‘) {
field += ‘”‘;
i++;
} else {
insideQuotes = !insideQuotes;
}
} else if (ch === delimiter && !insideQuotes) {
row.push(field.trim());
field = ”;
} else if (ch === ‘\n’ && !insideQuotes) {
row.push(field.trim());
if (row.some(v => String(v).trim() !== ”)) rows.push(row);
row = [];
field = ”;
} else {
field += ch;
}
}
if (field.length || row.length) {
row.push(field.trim());
if (row.some(v => String(v).trim() !== ”)) rows.push(row);
}
if (!rows.length) return { headers: [], rows: [] };
const headers = rows.shift();
return { headers, rows };
}
function normalizeHeader(v) {
return String(v || ”)
.trim().toLowerCase()
.replace(/^”+|”+$/g, ”)
.replace(/\s+/g, ”)
.replace(/[^a-z0-9@_-]/g, ”);
}
function findHeaderIndex(headers, candidates) {
const normalized = headers.map(normalizeHeader);
for (const c of candidates) {
const i = normalized.indexOf(c);
if (i > -1) return i;
}
return -1;
}
function downloadCsv(rows, base) {
const csv = rows.map(row =>
row.map(v => `”${String(v || ”).replace(/”/g, ‘””‘)}”`).join(‘,’)
).join(‘\n’);
const blob = new Blob([csv], { type: ‘text/csv;charset=utf-8;’ });
const url = URL.createObjectURL(blob);
const a = document.createElement(‘a’);
a.href = url;
a.download = `${base}-${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function moveRecipientToUnsubscribed(email, name) {
const normalized = normalizeEmail(email);
if (!isValidEmail(normalized)) return false;
const unsub = getUnsubscribed();
const map = new Map(unsub.map(x => [normalizeEmail(x.email), x]));
if (!map.has(normalized)) map.set(normalized, { email: normalized, name: name || ” });
const recipients = getRecipients().filter(r => normalizeEmail(r.email) !== normalized);
localStorage.setItem(STORAGE_KEY, JSON.stringify(recipients));
localStorage.setItem(UNSUB_KEY, JSON.stringify(Array.from(map.values())));
renderAll();
return true;
}
contactForm.addEventListener(‘submit’, function(e) {
e.preventDefault();
const firstName = firstNameInput.value.trim();
const lastName = lastNameInput.value.trim();
const email = normalizeEmail(emailInput.value);
if (!firstName || !lastName || !email) {
setStatus(contactStatus, ‘Please complete all recipient fields.’, ‘error’);
return;
}
if (!isValidEmail(email)) {
setStatus(contactStatus, ‘Please enter a valid email address.’, ‘error’);
return;
}
const unsubSet = new Set(getUnsubscribed().map(x => normalizeEmail(x.email)));
if (unsubSet.has(email)) {
setStatus(contactStatus, ‘That email is on the unsubscribed list.’, ‘warn’);
return;
}
const recipients = getRecipients();
if (recipients.some(r => normalizeEmail(r.email) === email)) {
setStatus(contactStatus, ‘That email is already in your active list.’, ‘warn’);
return;
}
recipients.push({ firstName, lastName, email });
recipients.sort((a,b) => (a.lastName || ”).localeCompare(b.lastName || ”) || (a.firstName || ”).localeCompare(b.firstName || ”));
saveRecipients(recipients);
contactForm.reset();
setStatus(contactStatus, ‘Recipient added successfully.’, ‘success’);
});
document.getElementById(‘mmu-clearFormBtn’).addEventListener(‘click’, function() {
contactForm.reset();
contactStatus.textContent = ”;
});
document.getElementById(‘mmu-clearAllBtn’).addEventListener(‘click’, function() {
if (!confirm(‘Delete all active recipients from this device?’)) return;
localStorage.removeItem(STORAGE_KEY);
renderAll();
setStatus(contactStatus, ‘All active recipients deleted.’, ‘success’);
});
document.getElementById(‘mmu-addUnsubscribeBtn’).addEventListener(‘click’, function() {
const email = normalizeEmail(unsubscribeEmailInput.value);
const name = unsubscribeNameInput.value.trim();
if (!email) {
setStatus(unsubscribeStatus, ‘Please enter an email address.’, ‘error’);
return;
}
if (!isValidEmail(email)) {
setStatus(unsubscribeStatus, ‘Please enter a valid email address.’, ‘error’);
return;
}
moveRecipientToUnsubscribed(email, name);
unsubscribeEmailInput.value = ”;
unsubscribeNameInput.value = ”;
setStatus(unsubscribeStatus, ‘Email added to unsubscribed list.’, ‘success’);
});
document.getElementById(‘mmu-clearUnsubscribedBtn’).addEventListener(‘click’, function() {
if (!confirm(‘Delete all unsubscribed emails from this device?’)) return;
localStorage.removeItem(UNSUB_KEY);
renderAll();
setStatus(unsubscribeStatus, ‘All unsubscribed emails deleted.’, ‘success’);
});
document.addEventListener(‘click’, function(e) {
const delRec = e.target.getAttribute(‘data-delete-recipient’);
const delUnsub = e.target.getAttribute(‘data-delete-unsub’);
if (delRec !== null) {
const recipients = getRecipients();
recipients.splice(Number(delRec), 1);
saveRecipients(recipients);
setStatus(contactStatus, ‘Recipient deleted.’, ‘success’);
}
if (delUnsub !== null) {
const list = getUnsubscribed();
list.splice(Number(delUnsub), 1);
saveUnsubscribed(list);
setStatus(unsubscribeStatus, ‘Unsubscribed email deleted.’, ‘success’);
}
});
document.getElementById(‘mmu-exportCsvBtn’).addEventListener(‘click’, function() {
const recipients = getRecipients();
if (!recipients.length) {
setStatus(importSummary, ‘There are no active recipients to export.’, ‘error’);
return;
}
downloadCsv([[‘First Name’,’Last Name’,’Email’], …recipients.map(r => [r.firstName, r.lastName, r.email])], ‘active-market-update-recipients’);
setStatus(importSummary, ‘Active recipient CSV exported successfully.’, ‘success’);
});
document.getElementById(‘mmu-exportUnsubscribedBtn’).addEventListener(‘click’, function() {
const unsub = getUnsubscribed();
if (!unsub.length) {
setStatus(unsubscribeStatus, ‘There are no unsubscribed emails to export.’, ‘error’);
return;
}
downloadCsv([[‘Name’,’Email’], …unsub.map(r => [r.name, r.email])], ‘unsubscribed-market-update-emails’);
setStatus(unsubscribeStatus, ‘Unsubscribed CSV exported successfully.’, ‘success’);
});
csvFileInput.addEventListener(‘change’, async function(e) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
let totalImported = 0;
let totalDuplicates = 0;
let totalSkipped = 0;
let skippedFiles = [];
let importedFileCount = 0;
const unsubSet = new Set(getUnsubscribed().map(x => normalizeEmail(x.email)));
const recipientMap = new Map(getRecipients().map(x => [normalizeEmail(x.email), x]));
for (const file of files) {
const name = (file.name || ”).toLowerCase();
if (name.includes(‘unsubscribed’) || name.includes(‘cleaned’) || name.includes(‘nonsubscribed’)) {
skippedFiles.push(file.name + ‘ skipped’);
continue;
}
try {
const text = await file.text();
const parsed = parseCSV(text);
if (!parsed.headers.length || !parsed.rows.length) {
skippedFiles.push(file.name + ‘ empty or unreadable’);
continue;
}
const emailIndex = findHeaderIndex(parsed.headers, [’emailaddress’,’email’,’e-mail’]);
const firstIndex = findHeaderIndex(parsed.headers, [‘firstname’,’first’,’givenname’,’fname’]);
const lastIndex = findHeaderIndex(parsed.headers, [‘lastname’,’last’,’surname’,’familyname’,’lname’]);
if (emailIndex === -1) {
skippedFiles.push(file.name + ‘ missing email column’);
continue;
}
importedFileCount++;
parsed.rows.forEach(row => {
const email = normalizeEmail(row[emailIndex] || ”);
const firstName = ((firstIndex > -1 ? row[firstIndex] : ”) || ”).trim();
const lastName = ((lastIndex > -1 ? row[lastIndex] : ”) || ”).trim();
if (!email || !isValidEmail(email) || unsubSet.has(email)) {
totalSkipped++;
return;
}
if (recipientMap.has(email)) {
totalDuplicates++;
return;
}
recipientMap.set(email, { firstName, lastName, email });
totalImported++;
});
} catch (err) {
skippedFiles.push(file.name + ‘ could not be read’);
}
}
const combined = Array.from(recipientMap.values()).sort((a,b) =>
(a.lastName || ”).localeCompare(b.lastName || ”) || (a.firstName || ”).localeCompare(b.firstName || ”)
);
saveRecipients(combined);
const prior = getImportStats();
saveImportStats({ files: (prior.files || 0) + importedFileCount });
renderAll();
let msg = `Imported: ${totalImported}\nDuplicates skipped: ${totalDuplicates}\nInvalid or excluded rows skipped: ${totalSkipped}`;
if (skippedFiles.length) msg += `\nSkipped files: ${skippedFiles.join(‘, ‘)}`;
setStatus(importSummary, msg, totalImported > 0 ? ‘success’ : ‘warn’);
csvFileInput.value = ”;
});
document.getElementById(‘mmu-copyBccBtn’).addEventListener(‘click’, async function() {
const bcc = getRecipients().map(r => r.email).filter(Boolean).join(‘,’);
if (!bcc) {
setStatus(sendStatus, ‘No active recipients found.’, ‘error’);
return;
}
await navigator.clipboard.writeText(bcc);
setStatus(sendStatus, ‘BCC list copied to clipboard.’, ‘success’);
});
document.getElementById(‘mmu-copyBodyBtn’).addEventListener(‘click’, async function() {
await navigator.clipboard.writeText(getComposedBody());
setStatus(sendStatus, ‘Email body copied to clipboard.’, ‘success’);
});
document.getElementById(‘mmu-openBccBtn’).addEventListener(‘click’, function() {
const bcc = getRecipients().map(r => r.email).filter(Boolean).join(‘,’);
const subject = emailSubjectInput.value.trim();
const body = getComposedBody();
if (!bcc) {
setStatus(sendStatus, ‘No active recipients found.’, ‘error’);
return;
}
if (!subject || !body.trim()) {
setStatus(sendStatus, ‘Please add both a subject and message.’, ‘error’);
return;
}
const mailto = `mailto:?bcc=${encodeURIComponent(bcc)}&subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
if (mailto.length > 1800) {
setStatus(sendStatus, ‘Your BCC list is too large for one mailto draft.’, ‘warn’);
return;
}
window.location.href = mailto;
setStatus(sendStatus, ‘Your email app should open with all active recipients in BCC.’, ‘success’);
});
[fromNameInput, replyToEmailInput, emailSubjectInput, monthlyBodyInput, includeOptOutInput, optOutTextInput].forEach(el => {
el.addEventListener(‘input’, saveSettings);
el.addEventListener(‘change’, saveSettings);
});
loadSettings();
renderAll();
});
</script>