- Add subscribe_batch endpoint for subscribing multiple members from CSV - Add list whitelist: MAILMAN_LIST_ID supports multiple comma-separated lists - Add IP whitelist: REQUESTOR_IP supports multiple comma-separated IPs - Add optional list_id override for targeting specific lists - Add test-subscribe-members2.sh utility script - Update README with comprehensive documentation and examples
412 lines
13 KiB
JavaScript
412 lines
13 KiB
JavaScript
const http = require('http');
|
|
const querystring = require('querystring');
|
|
const secret_password = process.env.SECRET_PASSWORD;
|
|
|
|
const username = process.env.MAILMAN_USERNAME;
|
|
const password = process.env.MAILMAN_PASSWORD;
|
|
const hostname = process.env.HOSTNAME;
|
|
const port = process.env.PORT
|
|
const buf = Buffer.from(username + ":" + password);
|
|
const auth = "Basic " + buf.toString("base64");
|
|
const mailman = require('http');
|
|
// Parse REQUESTOR_IP - support single IP or comma-separated list of IPs
|
|
const requestor = process.env.REQUESTOR_IP;
|
|
const requestor_ips = requestor ? requestor.split(',').map(ip => ip.trim()) : [];
|
|
|
|
// Parse MAILMAN_LIST_ID - support single or multiple comma-separated lists (whitelist)
|
|
const mailman_list_ids = process.env.MAILMAN_LIST_ID
|
|
? process.env.MAILMAN_LIST_ID.split(',').map(id => id.trim().toLowerCase())
|
|
: [];
|
|
const default_list_id = mailman_list_ids[0] || null;
|
|
|
|
// Helper function to validate and get allowed list_id
|
|
function get_allowed_list_id(requested_list_id) {
|
|
// If no lists configured, reject
|
|
if (!mailman_list_ids.length) {
|
|
throw new Error("No MAILMAN_LIST_ID configured");
|
|
}
|
|
|
|
// If client didn't specify, use default (first in list)
|
|
if (!requested_list_id) {
|
|
return default_list_id;
|
|
}
|
|
|
|
// Check if requested list is in allowed list (case-insensitive)
|
|
const normalized_request = requested_list_id.toLowerCase();
|
|
if (mailman_list_ids.includes(normalized_request)) {
|
|
return normalized_request;
|
|
}
|
|
|
|
// Not allowed
|
|
return null;
|
|
}
|
|
|
|
// Server loop
|
|
const server = http.createServer(function (request, response) {
|
|
|
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
|
|
let body = '';
|
|
let receivedBytes = 0;
|
|
const MAX_BODY_SIZE = 1e6; // 1MB limit to prevent DoS attacks
|
|
|
|
request.on('data', function (chunk) {
|
|
receivedBytes += chunk.length;
|
|
// console.log("Received chunk:", chunk.toString()); // Log each received chunk
|
|
|
|
if (receivedBytes > MAX_BODY_SIZE) {
|
|
console.warn("Request body too large, potential DoS attack detected.");
|
|
response.writeHead(413, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Request entity too large" }));
|
|
request.connection.destroy();
|
|
return;
|
|
}
|
|
body += chunk;
|
|
});
|
|
|
|
request.on('end', function () {
|
|
// console.log("Final accumulated body before parsing:", body);
|
|
|
|
try {
|
|
let data = JSON.parse(body);
|
|
let realIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
|
|
|
|
// Check if IP is allowed (no restriction, or matches any IP in the list)
|
|
const allowed = requestor_ips.length === 0 || requestor_ips.some(ip => realIp.includes(ip));
|
|
if (allowed) {
|
|
if (data.subscribe_batch === 'subscribe_batch') {
|
|
console.log("From IP:", realIp);
|
|
console.log("Batch subscribe request");
|
|
handleSubscribeBatch(data, response);
|
|
} else if (data.subscribe === 'subscribe') {
|
|
console.log("From IP:", realIp);
|
|
console.log("Data", data);
|
|
handleSubscribe(data, response);
|
|
} else {
|
|
console.log("From IP:", realIp);
|
|
console.log("Data", data);
|
|
handleUnsubscribe(data, response);
|
|
}
|
|
} else {
|
|
// Handle the case where the `requestor` does not match or is empty
|
|
console.log("Requestor does not match the real IP or is empty.");
|
|
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Requestor does not match IP" }));
|
|
}
|
|
} catch (error) {
|
|
console.error("Invalid JSON received:", body);
|
|
let realIp = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
|
|
console.warn("Potential malicious request detected from IP:", realIp);
|
|
response.writeHead(400, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Invalid JSON format" }));
|
|
}
|
|
});
|
|
});
|
|
|
|
server.on('error', function (e) {
|
|
console.error(e);
|
|
});
|
|
|
|
server.listen(Number(process.argv[2]));
|
|
|
|
// Handle subscription requests
|
|
function handleSubscribe(data, response) {
|
|
if (secret_password !== data.password) {
|
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Unauthorized" }));
|
|
return;
|
|
}
|
|
|
|
// Validate list_id against whitelist
|
|
let list_id = get_allowed_list_id(data.list_id);
|
|
if (!list_id) {
|
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "List not allowed" }));
|
|
return;
|
|
}
|
|
|
|
let url_object = {
|
|
list_id: list_id,
|
|
subscriber: data.email,
|
|
display_name: data.first_name + ' ' + data.last_name,
|
|
pre_verified: 'True',
|
|
pre_confirmed: 'True',
|
|
pre_approved: 'True'
|
|
};
|
|
|
|
let mailman_options = {
|
|
hostname: hostname,
|
|
port: port,
|
|
path: '/3.1/members?' + querystring.stringify(url_object),
|
|
method: 'POST',
|
|
headers: { "Authorization": auth }
|
|
};
|
|
|
|
let req = mailman.request(mailman_options, function (res) {
|
|
let body = '';
|
|
res.on('data', (chunk) => { body += chunk; });
|
|
res.on('end', () => {
|
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
response.end(body);
|
|
});
|
|
});
|
|
req.on('error', (err) => {
|
|
console.error(err);
|
|
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Internal server error" }));
|
|
});
|
|
req.end();
|
|
}
|
|
|
|
// Handle unsubscription requests
|
|
async function handleUnsubscribe(data, response) {
|
|
if (secret_password !== data.password) {
|
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Unauthorized" }));
|
|
return;
|
|
}
|
|
try {
|
|
// Validate list_id against whitelist
|
|
let list_id = get_allowed_list_id(data.list_id);
|
|
if (!list_id) {
|
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "List not allowed" }));
|
|
return;
|
|
}
|
|
let member_id = await find_member_id(data.email, list_id);
|
|
if (member_id) {
|
|
await unsubscribe_member_id(member_id, response);
|
|
} else {
|
|
response.writeHead(404, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Member not found" }));
|
|
}
|
|
} catch (error) {
|
|
console.error("Error handling unsubscribe:", error);
|
|
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Internal server error" }));
|
|
}
|
|
}
|
|
|
|
// Find member in mailing list
|
|
function find_member_id(email, list_id_override) {
|
|
return new Promise((resolve, reject) => {
|
|
let list_id = list_id_override || process.env.MAILMAN_LIST_ID;
|
|
let url_object = { subscriber: email, list_id: list_id, role: 'member' };
|
|
|
|
let mailman_options = {
|
|
hostname: hostname,
|
|
port: port,
|
|
path: '/3.1/members/find?' + querystring.stringify(url_object),
|
|
method: 'GET',
|
|
headers: { "Authorization": auth }
|
|
};
|
|
|
|
let req = mailman.request(mailman_options, function (res) {
|
|
let body = '';
|
|
res.on('data', (chunk) => { body += chunk; });
|
|
res.on('end', () => {
|
|
try {
|
|
let parsed = JSON.parse(body);
|
|
resolve(parsed.entries && parsed.entries.length > 0 ? parsed.entries[0].member_id : null);
|
|
} catch (err) {
|
|
reject(new Error("Error parsing JSON response"));
|
|
}
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Unsubscribe member
|
|
function unsubscribe_member_id(member_id, response) {
|
|
let mailman_options = {
|
|
hostname: hostname,
|
|
port: port,
|
|
path: '/3.1/members/' + member_id,
|
|
method: 'DELETE',
|
|
headers: { "Authorization": auth }
|
|
};
|
|
|
|
let req = mailman.request(mailman_options, function (res) {
|
|
let body = '';
|
|
res.on('data', (chunk) => { body += chunk; });
|
|
res.on('end', () => {
|
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
response.end(body);
|
|
});
|
|
});
|
|
req.on('error', (err) => {
|
|
console.error(err);
|
|
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Internal server error" }));
|
|
});
|
|
req.end();
|
|
}
|
|
|
|
// Get all members from the mailing list (handles pagination)
|
|
function get_all_members(list_id_override) {
|
|
return new Promise((resolve, reject) => {
|
|
let members = [];
|
|
let page_token = null;
|
|
let list_id = list_id_override || process.env.MAILMAN_LIST_ID;
|
|
|
|
function fetch_page() {
|
|
let path = '/3.1/lists/' + list_id + '/members';
|
|
if (page_token) {
|
|
path += '?token=' + encodeURIComponent(page_token);
|
|
}
|
|
|
|
let mailman_options = {
|
|
hostname: hostname,
|
|
port: port,
|
|
path: path,
|
|
method: 'GET',
|
|
headers: { "Authorization": auth }
|
|
};
|
|
|
|
let req = mailman.request(mailman_options, function (res) {
|
|
let body = '';
|
|
res.on('data', (chunk) => { body += chunk; });
|
|
res.on('end', () => {
|
|
try {
|
|
let parsed = JSON.parse(body);
|
|
if (parsed.entries) {
|
|
members = members.concat(parsed.entries.map(e => e.address || e.email));
|
|
}
|
|
if (parsed.token) {
|
|
page_token = parsed.token;
|
|
fetch_page();
|
|
} else {
|
|
resolve(members);
|
|
}
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.end();
|
|
}
|
|
|
|
fetch_page();
|
|
});
|
|
}
|
|
|
|
// Subscribe a single member
|
|
function subscribe_single(email, first_name, last_name, list_id_override) {
|
|
return new Promise((resolve, reject) => {
|
|
let list_id = list_id_override || process.env.MAILMAN_LIST_ID;
|
|
let url_object = {
|
|
list_id: list_id,
|
|
subscriber: email,
|
|
display_name: (first_name + ' ' + last_name).trim(),
|
|
pre_verified: 'True',
|
|
pre_confirmed: 'True',
|
|
pre_approved: 'True'
|
|
};
|
|
|
|
let mailman_options = {
|
|
hostname: hostname,
|
|
port: port,
|
|
path: '/3.1/members?' + querystring.stringify(url_object),
|
|
method: 'POST',
|
|
headers: { "Authorization": auth }
|
|
};
|
|
|
|
let req = mailman.request(mailman_options, function (res) {
|
|
let body = '';
|
|
res.on('data', (chunk) => { body += chunk; });
|
|
res.on('end', () => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
resolve(body);
|
|
} else {
|
|
reject(new Error('HTTP ' + res.statusCode + ': ' + body));
|
|
}
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Handle batch subscription requests
|
|
async function handleSubscribeBatch(data, response) {
|
|
if (secret_password !== data.password) {
|
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Unauthorized" }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Validate list_id against whitelist
|
|
let list_id = get_allowed_list_id(data.list_id);
|
|
if (!list_id) {
|
|
response.writeHead(403, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "List not allowed" }));
|
|
return;
|
|
}
|
|
console.log("Using list: " + list_id);
|
|
|
|
// Parse CSV data: email,first_name,last_name
|
|
const lines = data.csv_data.split('\n').filter(l => l.trim());
|
|
const members_to_process = lines.map(line => {
|
|
const parts = line.split(',').map(p => p.trim());
|
|
return {
|
|
email: parts[0],
|
|
first_name: parts[1] || '',
|
|
last_name: parts[2] || ''
|
|
};
|
|
});
|
|
|
|
console.log("Processing " + members_to_process.length + " members");
|
|
|
|
// Get existing subscribers
|
|
const existing_emails = await get_all_members(list_id);
|
|
const existing_set = new Set(existing_emails.map(e => e.toLowerCase()));
|
|
console.log("Found " + existing_emails.length + " existing subscribers");
|
|
|
|
// Filter out already subscribed
|
|
const to_subscribe = members_to_process.filter(m => !existing_set.has(m.email.toLowerCase()));
|
|
const already_subscribed = members_to_process.filter(m => existing_set.has(m.email.toLowerCase()));
|
|
|
|
console.log("New to subscribe: " + to_subscribe.length);
|
|
console.log("Already subscribed: " + already_subscribed.length);
|
|
|
|
// Subscribe new ones
|
|
let subscribed = [];
|
|
let errors = [];
|
|
|
|
for (const member of to_subscribe) {
|
|
try {
|
|
await subscribe_single(member.email, member.first_name, member.last_name, list_id);
|
|
subscribed.push(member.email);
|
|
console.log("Subscribed: " + member.email);
|
|
} catch (err) {
|
|
errors.push({ email: member.email, error: err.message });
|
|
console.error("Failed to subscribe " + member.email + ": " + err.message);
|
|
}
|
|
}
|
|
|
|
// Return results
|
|
response.writeHead(200, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({
|
|
success: true,
|
|
summary: {
|
|
total_received: members_to_process.length,
|
|
subscribed: subscribed.length,
|
|
already_subscribed: already_subscribed.length,
|
|
errors: errors.length
|
|
},
|
|
details: {
|
|
subscribed: subscribed,
|
|
already_subscribed: already_subscribed.map(m => m.email),
|
|
errors: errors
|
|
}
|
|
}));
|
|
} catch (error) {
|
|
console.error("Batch subscribe error:", error);
|
|
response.writeHead(500, { 'Content-Type': 'application/json' });
|
|
response.end(JSON.stringify({ error: "Internal server error", message: error.message }));
|
|
}
|
|
} |