@kandleconsultinggroup/email-blaster 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/index.js +9 -0
- package/adapters/nodemailer.js +45 -0
- package/adapters/sendgrid.js +4 -0
- package/adapters/ses.js +4 -0
- package/config.js +24 -0
- package/index.js +8 -0
- package/initMailBlaster.js +34 -0
- package/models/EmailBlast.js +19 -0
- package/models/EmailSendLog.js +19 -0
- package/models/EmalTemplate.js +15 -0
- package/models/Unsubscribe.js +11 -0
- package/package.json +16 -0
- package/queue/dispatch.js +11 -0
- package/queue/enqueue.js +26 -0
- package/queue/sendImmediate.js +50 -0
- package/queue/worker.js +48 -0
- package/routes/blasts.routes.js +42 -0
- package/routes/templates.routes.js +12 -0
- package/routes/unsubscribe.routes.js +13 -0
- package/tokens/applyTokens.js +24 -0
- package/tokens/unsubscribeFooter.js +20 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const nodemailer = require("nodemailer");
|
|
2
|
+
const config = require("../config");
|
|
3
|
+
|
|
4
|
+
let transporter;
|
|
5
|
+
|
|
6
|
+
function getTransporter() {
|
|
7
|
+
if (transporter) return transporter;
|
|
8
|
+
|
|
9
|
+
// Allow fully custom transporter (best for advanced users)
|
|
10
|
+
if (config.nodemailer?.transporter) {
|
|
11
|
+
transporter = config.nodemailer.transporter;
|
|
12
|
+
return transporter;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Default SMTP setup (Heroku-compatible)
|
|
16
|
+
transporter = nodemailer.createTransport({
|
|
17
|
+
host: config.nodemailer.host,
|
|
18
|
+
port: config.nodemailer.port || 587,
|
|
19
|
+
secure: config.nodemailer.secure || false,
|
|
20
|
+
auth: {
|
|
21
|
+
user: config.nodemailer.user,
|
|
22
|
+
pass: config.nodemailer.pass,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return transporter;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = async function sendWithNodemailer({
|
|
30
|
+
to,
|
|
31
|
+
subject,
|
|
32
|
+
html,
|
|
33
|
+
from,
|
|
34
|
+
cc,
|
|
35
|
+
}) {
|
|
36
|
+
const mailer = getTransporter();
|
|
37
|
+
|
|
38
|
+
await mailer.sendMail({
|
|
39
|
+
from,
|
|
40
|
+
to,
|
|
41
|
+
cc,
|
|
42
|
+
subject,
|
|
43
|
+
html,
|
|
44
|
+
});
|
|
45
|
+
};
|
package/adapters/ses.js
ADDED
package/config.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
provider: null,
|
|
3
|
+
|
|
4
|
+
rateLimit: {
|
|
5
|
+
perMinute: 30,
|
|
6
|
+
perDay: null, // optional, recommended later
|
|
7
|
+
},
|
|
8
|
+
|
|
9
|
+
redis: null,
|
|
10
|
+
|
|
11
|
+
unsubscribe: {
|
|
12
|
+
enabled: true,
|
|
13
|
+
baseUrl: null,
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
nodemailer: {
|
|
17
|
+
host: null,
|
|
18
|
+
port: null,
|
|
19
|
+
secure: false,
|
|
20
|
+
user: null,
|
|
21
|
+
pass: null,
|
|
22
|
+
transporter: null,
|
|
23
|
+
},
|
|
24
|
+
};
|
package/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const mongoose = require("mongoose");
|
|
2
|
+
const config = require("./config");
|
|
3
|
+
|
|
4
|
+
function initMailBlaster(options = {}) {
|
|
5
|
+
Object.assign(config, options);
|
|
6
|
+
|
|
7
|
+
if (!config.provider) {
|
|
8
|
+
throw new Error("Mail provider is required");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!config.rateLimit || !config.rateLimit.perMinute) {
|
|
12
|
+
throw new Error("rateLimit.perMinute is required");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!config.redis) {
|
|
16
|
+
console.warn(
|
|
17
|
+
"[mail-blaster] Redis not configured. Falling back to synchronous sending.",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (config.unsubscribe.enabled && !config.unsubscribe.baseUrl) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"unsubscribe.baseUrl is required when unsubscribe is enabled",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (options.mongoUri) {
|
|
28
|
+
mongoose.connect(options.mongoUri);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = initMailBlaster;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const mongoose = require("mongoose");
|
|
2
|
+
|
|
3
|
+
module.exports = mongoose.model(
|
|
4
|
+
"EmailBlast",
|
|
5
|
+
new mongoose.Schema(
|
|
6
|
+
{
|
|
7
|
+
subject: String,
|
|
8
|
+
bodyHtml: String,
|
|
9
|
+
sender: Object,
|
|
10
|
+
cc: [String],
|
|
11
|
+
recipients: [Object],
|
|
12
|
+
status: { type: String, default: "draft" },
|
|
13
|
+
scheduledAt: Date,
|
|
14
|
+
followUpAt: Date,
|
|
15
|
+
createdBy: mongoose.Schema.Types.ObjectId,
|
|
16
|
+
},
|
|
17
|
+
{ timestamps: true },
|
|
18
|
+
),
|
|
19
|
+
);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const mongoose = require("mongoose");
|
|
2
|
+
|
|
3
|
+
module.exports = mongoose.model(
|
|
4
|
+
"EmailSendLog",
|
|
5
|
+
new mongoose.Schema(
|
|
6
|
+
{
|
|
7
|
+
blastId: mongoose.Schema.Types.ObjectId,
|
|
8
|
+
to: String,
|
|
9
|
+
status: {
|
|
10
|
+
type: String,
|
|
11
|
+
enum: ["pending", "sent", "failed", "unsubscribed"],
|
|
12
|
+
default: "pending",
|
|
13
|
+
},
|
|
14
|
+
error: String,
|
|
15
|
+
sentAt: Date,
|
|
16
|
+
},
|
|
17
|
+
{ timestamps: true },
|
|
18
|
+
),
|
|
19
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const mongoose = require("mongoose");
|
|
2
|
+
|
|
3
|
+
module.exports = mongoose.model(
|
|
4
|
+
"EmailTemplate",
|
|
5
|
+
new mongoose.Schema(
|
|
6
|
+
{
|
|
7
|
+
name: String,
|
|
8
|
+
subject: String,
|
|
9
|
+
bodyHtml: String,
|
|
10
|
+
createdBy: mongoose.Schema.Types.ObjectId,
|
|
11
|
+
isShared: Boolean,
|
|
12
|
+
},
|
|
13
|
+
{ timestamps: true },
|
|
14
|
+
),
|
|
15
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kandleconsultinggroup/email-blaster",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"mongoose": "^8.0.0",
|
|
11
|
+
"express": "^4.19.0",
|
|
12
|
+
"bullmq": "^5.1.0",
|
|
13
|
+
"ioredis": "^5.3.0",
|
|
14
|
+
"nodemailer": "^6.9.8"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const enqueue = require("./enqueue");
|
|
2
|
+
const sendImmediate = require("./sendImmediate");
|
|
3
|
+
const config = require("../config");
|
|
4
|
+
|
|
5
|
+
module.exports = async function dispatch(job) {
|
|
6
|
+
if (config.redis) {
|
|
7
|
+
return enqueue(job);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return sendImmediate(job);
|
|
11
|
+
};
|
package/queue/enqueue.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const { Queue } = require("bullmq");
|
|
2
|
+
const config = require("../config");
|
|
3
|
+
|
|
4
|
+
let queue;
|
|
5
|
+
|
|
6
|
+
function getQueue() {
|
|
7
|
+
if (!config.redis) {
|
|
8
|
+
throw new Error("Redis not configured");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (!queue) {
|
|
12
|
+
queue = new Queue("mail-blaster", {
|
|
13
|
+
connection: config.redis,
|
|
14
|
+
limiter: {
|
|
15
|
+
max: config.rateLimit.perMinute,
|
|
16
|
+
duration: 60 * 1000,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return queue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = async function enqueue(job) {
|
|
25
|
+
return getQueue().add("send-email", job);
|
|
26
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const adapters = require("../adapters");
|
|
2
|
+
const applyTokens = require("../tokens/applyTokens");
|
|
3
|
+
const config = require("../config");
|
|
4
|
+
const EmailSendLog = require("../models/EmailSendLog");
|
|
5
|
+
|
|
6
|
+
let lastSend = 0;
|
|
7
|
+
|
|
8
|
+
module.exports = async function sendImmediate({ blast, recipient }) {
|
|
9
|
+
const send = adapters[config.provider];
|
|
10
|
+
if (!send) {
|
|
11
|
+
throw new Error(`Unsupported mail provider: ${config.provider}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Soft rate limit (per-minute)
|
|
15
|
+
const interval = Math.ceil(60000 / (config.rateLimit?.perMinute || 30));
|
|
16
|
+
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const wait = Math.max(0, interval - (now - lastSend));
|
|
19
|
+
if (wait) {
|
|
20
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
21
|
+
}
|
|
22
|
+
lastSend = Date.now();
|
|
23
|
+
|
|
24
|
+
const html = applyTokens(blast.bodyHtml, recipient, blast.sender, {
|
|
25
|
+
includeUnsubscribe: config.unsubscribe.enabled,
|
|
26
|
+
unsubscribeBaseUrl: config.unsubscribe.baseUrl,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await send({
|
|
31
|
+
to: recipient.email,
|
|
32
|
+
cc: blast.cc,
|
|
33
|
+
subject: blast.subject,
|
|
34
|
+
html,
|
|
35
|
+
from: blast.sender.email,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await EmailSendLog.updateOne(
|
|
39
|
+
{ blastId: blast._id, to: recipient.email },
|
|
40
|
+
{ status: "sent", sentAt: new Date() },
|
|
41
|
+
);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
await EmailSendLog.updateOne(
|
|
44
|
+
{ blastId: blast._id, to: recipient.email },
|
|
45
|
+
{ status: "failed", error: err.message },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
};
|
package/queue/worker.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { Worker } = require("bullmq");
|
|
2
|
+
const config = require("../config");
|
|
3
|
+
const adapters = require("../adapters");
|
|
4
|
+
const applyTokens = require("../tokens/applyTokens");
|
|
5
|
+
const EmailSendLog = require("../models/EmailSendLog");
|
|
6
|
+
|
|
7
|
+
new Worker(
|
|
8
|
+
"mail-blaster",
|
|
9
|
+
async (job) => {
|
|
10
|
+
const { blast, recipient } = job.data;
|
|
11
|
+
|
|
12
|
+
const send = adapters[config.provider];
|
|
13
|
+
if (!send) {
|
|
14
|
+
throw new Error(`Unsupported mail provider: ${config.provider}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const html = applyTokens(blast.bodyHtml, recipient, blast.sender, {
|
|
18
|
+
includeUnsubscribe: config.unsubscribe.enabled,
|
|
19
|
+
unsubscribeBaseUrl: config.unsubscribe.baseUrl,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await send({
|
|
24
|
+
to: recipient.email,
|
|
25
|
+
cc: blast.cc,
|
|
26
|
+
subject: blast.subject,
|
|
27
|
+
html,
|
|
28
|
+
from: blast.sender.email,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await EmailSendLog.updateOne(
|
|
32
|
+
{ blastId: blast._id, to: recipient.email },
|
|
33
|
+
{ status: "sent", sentAt: new Date() },
|
|
34
|
+
);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
await EmailSendLog.updateOne(
|
|
37
|
+
{ blastId: blast._id, to: recipient.email },
|
|
38
|
+
{ status: "failed", error: err.message },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
connection: config.redis,
|
|
46
|
+
concurrency: 1, // safest default; raise later if needed
|
|
47
|
+
},
|
|
48
|
+
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const router = require("express").Router();
|
|
2
|
+
const EmailBlast = require("../models/EmailBlast");
|
|
3
|
+
const EmailSendLog = require("../models/EmailSendLog");
|
|
4
|
+
const Unsubscribe = require("../models/Unsubscribe");
|
|
5
|
+
const dispatch = require("../queue/dispatch");
|
|
6
|
+
|
|
7
|
+
router.post("/:id/send", async (req, res) => {
|
|
8
|
+
const blast = await EmailBlast.findById(req.params.id);
|
|
9
|
+
|
|
10
|
+
let queued = 0;
|
|
11
|
+
let skipped = 0;
|
|
12
|
+
|
|
13
|
+
for (const recipient of blast.recipients) {
|
|
14
|
+
const isUnsubscribed = await Unsubscribe.findOne({
|
|
15
|
+
email: recipient.email,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (isUnsubscribed) {
|
|
19
|
+
skipped++;
|
|
20
|
+
|
|
21
|
+
await EmailSendLog.create({
|
|
22
|
+
blastId: blast._id,
|
|
23
|
+
to: recipient.email,
|
|
24
|
+
status: "unsubscribed",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await EmailSendLog.create({
|
|
31
|
+
blastId: blast._id,
|
|
32
|
+
to: recipient.email,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await dispatch({ blast, recipient });
|
|
36
|
+
queued++;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
res.json({ queued, skipped });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
module.exports = router;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const router = require("express").Router();
|
|
2
|
+
const EmailTemplate = require("../models/EmailTemplate");
|
|
3
|
+
|
|
4
|
+
router.post("/", async (req, res) => {
|
|
5
|
+
res.json(await EmailTemplate.create(req.body));
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
router.get("/", async (req, res) => {
|
|
9
|
+
res.json(await EmailTemplate.find());
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
module.exports = router;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const router = require("express").Router();
|
|
2
|
+
const Unsubscribe = require("../models/Unsubscribe");
|
|
3
|
+
|
|
4
|
+
router.get("/", async (req, res) => {
|
|
5
|
+
await Unsubscribe.updateOne(
|
|
6
|
+
{ email: req.query.email },
|
|
7
|
+
{ email: req.query.email },
|
|
8
|
+
{ upsert: true },
|
|
9
|
+
);
|
|
10
|
+
res.send("Unsubscribed");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
module.exports = router;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const unsubscribeFooter = require("./unsubscribeFooter");
|
|
2
|
+
|
|
3
|
+
module.exports = function applyTokens(
|
|
4
|
+
html,
|
|
5
|
+
recipient = {},
|
|
6
|
+
sender = {},
|
|
7
|
+
options = {},
|
|
8
|
+
) {
|
|
9
|
+
let output = html
|
|
10
|
+
.replace(/{{firstName}}/g, recipient.firstName || "")
|
|
11
|
+
.replace(/{{company}}/g, recipient.company || "")
|
|
12
|
+
.replace(/{{city}}/g, recipient.city || "")
|
|
13
|
+
.replace(/{{senderName}}/g, sender.name || "");
|
|
14
|
+
|
|
15
|
+
if (options.includeUnsubscribe !== false) {
|
|
16
|
+
output += unsubscribeFooter({
|
|
17
|
+
email: recipient.email,
|
|
18
|
+
unsubscribeBaseUrl: options.unsubscribeBaseUrl,
|
|
19
|
+
senderName: sender.name,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return output;
|
|
24
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module.exports = function unsubscribeFooter({
|
|
2
|
+
email,
|
|
3
|
+
unsubscribeBaseUrl,
|
|
4
|
+
senderName,
|
|
5
|
+
}) {
|
|
6
|
+
if (!unsubscribeBaseUrl) {
|
|
7
|
+
throw new Error("unsubscribeBaseUrl is required");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const url = `${unsubscribeBaseUrl}?email=${encodeURIComponent(email)}`;
|
|
11
|
+
|
|
12
|
+
return `
|
|
13
|
+
<hr style="margin-top:24px;" />
|
|
14
|
+
<p style="font-size:12px;color:#777;">
|
|
15
|
+
You’re receiving this email because you were contacted by ${senderName || "us"}.
|
|
16
|
+
<br />
|
|
17
|
+
<a href="${url}" style="color:#777;">Unsubscribe</a>
|
|
18
|
+
</p>
|
|
19
|
+
`;
|
|
20
|
+
};
|