UNPKG

maileroo-sdk

Version:

Official Node.js SDK for Maileroo v2 API. Send transactional/marketing emails via Maileroo with a simple and intuitive API.

281 lines (280 loc) 13.1 kB
import { randomBytes } from "node:crypto"; import EmailAddress from "./EmailAddress.js"; const API_BASE_URL = "https://smtp.maileroo.com/api/v2/"; const MAX_ASSOCIATIVE_MAP_KEY_LENGTH = 128; const MAX_ASSOCIATIVE_MAP_VALUE_LENGTH = 768; const MAX_SUBJECT_LENGTH = 255; const REFERENCE_ID_LENGTH = 24; export class MailerooClient { constructor(apiKey, timeout = 30) { if (apiKey.trim() === "") { throw new Error("API key must be a non-empty string."); } if (!Number.isInteger(timeout) || timeout <= 0) { throw new Error("Timeout must be a positive integer."); } this.apiKey = apiKey; this.timeoutSec = timeout; } getReferenceId() { const bytes = randomBytes(REFERENCE_ID_LENGTH / 2); return bytes.toString("hex"); } validateReferenceId(referenceId) { if (referenceId.trim() !== referenceId) throw new Error("reference_id must not contain whitespace."); const re = new RegExp(`^[0-9a-f]{${REFERENCE_ID_LENGTH}}$`, "i"); if (!re.test(referenceId)) throw new Error(`reference_id must be a ${REFERENCE_ID_LENGTH}-character hexadecimal string.`); return referenceId; } isAcceptableTagOrHeaderValue(v) { return typeof v === "string" || typeof v === "number" || typeof v === "boolean"; } validateAssociativeMap(map, label) { if (map == null || typeof map !== "object") throw new Error(`${label} must be an associative object.`); for (const [k, v] of Object.entries(map)) { if (!this.isAcceptableTagOrHeaderValue(v)) { throw new Error(`${label} must be an associative object with string keys and scalar values.`); } if (k.length > MAX_ASSOCIATIVE_MAP_KEY_LENGTH || String(v).length > MAX_ASSOCIATIVE_MAP_VALUE_LENGTH) { throw new Error(`${label} key must not exceed ${MAX_ASSOCIATIVE_MAP_KEY_LENGTH} characters and value must not exceed ${MAX_ASSOCIATIVE_MAP_VALUE_LENGTH} characters.`); } } } normalizeEmailField(field) { if (field instanceof EmailAddress) return field; throw new Error("Email field must be an instance of EmailAddress."); } normalizeEmailFieldOrArray(list) { if (Array.isArray(list)) return list.map((x) => this.normalizeEmailField(x)); return this.normalizeEmailField(list); } getEmailArrays(emailList) { if (!emailList) return null; if (Array.isArray(emailList)) return emailList.map((e) => e.toJSON()); return emailList.toJSON(); } getParsedEmailItems(data) { const normalized = { from: this.normalizeEmailField(data.from), to: this.normalizeEmailFieldOrArray(data.to), cc: data.cc !== undefined ? this.normalizeEmailFieldOrArray(data.cc) : undefined, bcc: data.bcc !== undefined ? this.normalizeEmailFieldOrArray(data.bcc) : undefined, reply_to: data.reply_to !== undefined ? this.normalizeEmailFieldOrArray(data.reply_to) : undefined, }; return { from: this.getEmailArrays(normalized.from), to: this.getEmailArrays(normalized.to), cc: this.getEmailArrays(normalized.cc), bcc: this.getEmailArrays(normalized.bcc), reply_to: this.getEmailArrays(normalized.reply_to), }; } buildBasePayload(data) { if (data.subject.trim() === "" || data.subject.length > MAX_SUBJECT_LENGTH) { throw new Error(`Subject must be a non-empty string with a maximum length of ${MAX_SUBJECT_LENGTH} characters.`); } const payload = this.getParsedEmailItems(data); payload.subject = data.subject; if (data.tracking !== undefined) { payload.tracking = data.tracking; } if (data.tags) { this.validateAssociativeMap(data.tags, "tags"); payload.tags = data.tags; } if (data.headers) { this.validateAssociativeMap(data.headers, "headers"); payload.headers = data.headers; } if (data.attachments) { if (!Array.isArray(data.attachments)) throw new Error("attachments must be an array of Attachment instances."); payload.attachments = data.attachments.map((a) => { return a.toJSON(); }); } if (typeof data.scheduled_at === "string") { payload.scheduled_at = data.scheduled_at; } else if (data.scheduled_at instanceof Date) { payload.scheduled_at = data.scheduled_at.toISOString(); } payload.reference_id = data.reference_id ? this.validateReferenceId(data.reference_id) : this.getReferenceId(); return payload; } async sendBasicEmail(data) { const payload = this.buildBasePayload(data); if (data.html == null && data.plain == null) throw new Error("Either html or plain body is required."); payload.html = data.html ?? null; payload.plain = data.plain ?? null; const resp = await this.sendRequest("POST", "emails", payload); if (resp.success) return resp.data.reference_id; throw new Error("The API returned an error: " + resp.message); } validateTemplateData(templateData) { if (templateData == null || templateData === "" || (Array.isArray(templateData) && templateData.length === 0)) return {}; if (typeof templateData !== "object" || Array.isArray(templateData)) throw new Error("template_data must be an object if provided."); return templateData; } async sendTemplatedEmail(data) { const payload = this.buildBasePayload(data); payload.template_id = Number(data.template_id); if (data.template_data !== undefined) payload.template_data = this.validateTemplateData(data.template_data); const resp = await this.sendRequest("POST", "emails/template", payload); if (resp.success) return resp.data.reference_id; throw new Error("The API returned an error: " + resp.message); } normalizeBulkMessages(messages) { if (!Array.isArray(messages) || messages.length === 0) throw new Error("messages must be a non-empty array."); if (messages.length > 500) throw new Error("messages cannot contain more than 500 items."); return messages.map((msg, idx) => { if (typeof msg !== "object" || msg == null) throw new Error(`Each message must be an object (message index ${idx}).`); if (!msg.from || !msg.to) throw new Error(`Each message must include 'from' and 'to' (message index ${idx}).`); const from = this.normalizeEmailField(msg.from); const to = this.normalizeEmailFieldOrArray(msg.to); const cc = msg.cc !== undefined ? this.normalizeEmailFieldOrArray(msg.cc) : undefined; const bcc = msg.bcc !== undefined ? this.normalizeEmailFieldOrArray(msg.bcc) : undefined; const reply_to = msg.reply_to !== undefined ? this.normalizeEmailFieldOrArray(msg.reply_to) : undefined; const item = { from: this.getEmailArrays(from), to: this.getEmailArrays(to), cc: this.getEmailArrays(cc), bcc: this.getEmailArrays(bcc), reply_to: this.getEmailArrays(reply_to), }; item.reference_id = msg.reference_id ? this.validateReferenceId(msg.reference_id) : this.getReferenceId(); if (Object.prototype.hasOwnProperty.call(msg, "template_data")) item.template_data = this.validateTemplateData(msg.template_data); return item; }); } async sendBulkEmails(data) { if (data.subject.trim() === "" || data.subject.length > MAX_SUBJECT_LENGTH) { throw new Error(`Subject must be a non-empty string with a maximum length of ${MAX_SUBJECT_LENGTH} characters.`); } const hasHtml = typeof data.html === "string"; const hasPlain = typeof data.plain === "string"; const hasTemplate = data.template_id !== undefined && (typeof data.template_id === "number" || typeof data.template_id === "string"); if ((!hasHtml && !hasPlain) && !hasTemplate) throw new Error("You must provide either html, plain, or template_id."); if (hasTemplate && (hasHtml || hasPlain)) throw new Error("template_id cannot be combined with html or plain."); const payload = { subject: data.subject }; if (hasHtml) payload.html = data.html; if (hasPlain) payload.plain = data.plain; if (hasTemplate) payload.template_id = Number(data.template_id); if (data.tracking !== undefined) { payload.tracking = data.tracking; } if (data.tags) { this.validateAssociativeMap(data.tags, "tags"); payload.tags = data.tags; } if (data.headers) { this.validateAssociativeMap(data.headers, "headers"); payload.headers = data.headers; } if (data.attachments) { if (!Array.isArray(data.attachments)) throw new Error("attachments must be an array of Attachment instances."); payload.attachments = data.attachments.map((a) => { return a.toJSON(); }); } payload.messages = this.normalizeBulkMessages(data.messages); const resp = await this.sendRequest("POST", "emails/bulk", payload); if (resp.success && resp.data) return resp.data.reference_ids; throw new Error("The API returned an error: " + resp.message); } async deleteScheduledEmail(referenceId) { referenceId = this.validateReferenceId(referenceId); const resp = await this.sendRequest("DELETE", `emails/scheduled/${referenceId}`); if (resp.success) return true; throw new Error("The API returned an error: " + resp.message); } async getScheduledEmails(page = 1, per_page = 10) { if (!Number.isInteger(page) || page < 1) throw new Error("page must be a positive integer (>= 1)."); if (!Number.isInteger(per_page) || per_page < 1) throw new Error("per_page must be a positive integer (>= 1)."); if (per_page > 100) throw new Error("per_page cannot be greater than 100."); const resp = await this.sendRequest("GET", "emails/scheduled", { page, per_page }); if (resp.success && resp.data) return resp.data; throw new Error("The API returned an error: " + resp.message); } async sendRequest(method, endpoint, data) { const url = new URL(endpoint.replace(/^\/+/, ""), API_BASE_URL).toString(); const controller = new AbortController(); const id = setTimeout(() => controller.abort(), this.timeoutSec * 1000); try { let finalUrl = url; let body; const headers = { "Content-Type": "application/json", "Authorization": `Bearer ${this.apiKey}`, "User-Agent": "maileroo-node-sdk/2.0", }; if (method === "GET" && data && typeof data === "object") { const qs = new URLSearchParams(); Object.entries(data).forEach(([k, v]) => { if (v !== undefined && v !== null) qs.set(k, String(v)); }); finalUrl += (finalUrl.includes("?") ? "&" : "?") + qs.toString(); } else if (data !== undefined) { body = JSON.stringify(data, (_k, value) => { if (value && typeof value === "object" && typeof value.toJSON === "function") return value.toJSON(); if (value?.content && value?.file_name) return value; return value; }); } const res = await fetch(finalUrl, { method, headers, body, signal: controller.signal }); const raw = await res.text(); let decoded; decoded = JSON.parse(raw); if (typeof decoded?.success !== "boolean") throw new Error('The API response is missing the "success" field.'); if (!decoded.message) decoded.message = "Unknown"; return decoded; } catch (err) { if (err?.name === "AbortError") throw new Error("HTTP request failed: timeout"); if (err instanceof Error) throw err; throw new Error(String(err)); } finally { clearTimeout(id); } } } export default MailerooClient;