mailgun-inbound-email 1.0.0 â 2.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/LICENSE +22 -0
- package/README.md +811 -83
- package/index.js +339 -139
- package/package.json +18 -13
- package/SETUP.md +0 -62
package/index.js
CHANGED
|
@@ -1,36 +1,63 @@
|
|
|
1
|
-
const express = require("express");
|
|
2
1
|
const crypto = require("crypto");
|
|
3
|
-
const multer = require("multer");
|
|
4
|
-
|
|
5
|
-
const upload = multer({ storage: multer.memoryStorage() });
|
|
6
2
|
|
|
7
3
|
/**
|
|
8
4
|
* Verify Mailgun webhook signature
|
|
5
|
+
* @param {string} token - Mailgun token
|
|
6
|
+
* @param {string} timestamp - Request timestamp
|
|
7
|
+
* @param {string} signature - Mailgun signature
|
|
8
|
+
* @param {string} signingKey - Mailgun webhook signing key
|
|
9
|
+
* @returns {boolean} True if signature is valid
|
|
9
10
|
*/
|
|
10
11
|
function verifyMailgunSignature(token, timestamp, signature, signingKey) {
|
|
11
12
|
if (!signingKey) {
|
|
12
|
-
console.error("MAILGUN_WEBHOOK_SIGNING_KEY missing");
|
|
13
|
+
console.error("[MailgunInbound] MAILGUN_WEBHOOK_SIGNING_KEY missing");
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!token || !timestamp || !signature) {
|
|
18
|
+
console.error("[MailgunInbound] Missing required signature parameters");
|
|
13
19
|
return false;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
const currentTime = Math.floor(Date.now() / 1000);
|
|
23
|
+
const requestTime = Number(timestamp);
|
|
17
24
|
|
|
18
|
-
//
|
|
19
|
-
if (
|
|
20
|
-
console.error("
|
|
25
|
+
// Validate timestamp is a number
|
|
26
|
+
if (isNaN(requestTime)) {
|
|
27
|
+
console.error("[MailgunInbound] Invalid timestamp format");
|
|
21
28
|
return false;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.
|
|
27
|
-
|
|
31
|
+
// Prevent replay attack (15 min window)
|
|
32
|
+
if (Math.abs(currentTime - requestTime) > 900) {
|
|
33
|
+
console.error("[MailgunInbound] Expired timestamp", {
|
|
34
|
+
currentTime,
|
|
35
|
+
requestTime,
|
|
36
|
+
difference: Math.abs(currentTime - requestTime)
|
|
37
|
+
});
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
28
40
|
|
|
29
|
-
|
|
41
|
+
try {
|
|
42
|
+
const hmac = crypto
|
|
43
|
+
.createHmac("sha256", signingKey)
|
|
44
|
+
.update(timestamp + token)
|
|
45
|
+
.digest("hex");
|
|
46
|
+
|
|
47
|
+
return crypto.timingSafeEqual(
|
|
48
|
+
Buffer.from(hmac),
|
|
49
|
+
Buffer.from(signature)
|
|
50
|
+
);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error("[MailgunInbound] Signature verification error:", error.message);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
30
55
|
}
|
|
31
56
|
|
|
32
57
|
/**
|
|
33
58
|
* Parse headers safely
|
|
59
|
+
* @param {string|Array} headers - Email headers as string or array
|
|
60
|
+
* @returns {Array} Parsed headers array
|
|
34
61
|
*/
|
|
35
62
|
function parseHeaders(headers) {
|
|
36
63
|
if (Array.isArray(headers)) return headers;
|
|
@@ -44,6 +71,8 @@ function parseHeaders(headers) {
|
|
|
44
71
|
|
|
45
72
|
/**
|
|
46
73
|
* Extract email from "Name <email@domain.com>" or plain email
|
|
74
|
+
* @param {string} value - Email string
|
|
75
|
+
* @returns {string} Extracted email address
|
|
47
76
|
*/
|
|
48
77
|
function extractEmail(value = "") {
|
|
49
78
|
if (!value || typeof value !== 'string') return "";
|
|
@@ -53,6 +82,8 @@ function extractEmail(value = "") {
|
|
|
53
82
|
|
|
54
83
|
/**
|
|
55
84
|
* Extract multiple emails from comma-separated string
|
|
85
|
+
* @param {string} value - Comma-separated email string
|
|
86
|
+
* @returns {Array<string>} Array of email addresses
|
|
56
87
|
*/
|
|
57
88
|
function extractEmails(value = "") {
|
|
58
89
|
if (!value || typeof value !== 'string') return [];
|
|
@@ -61,6 +92,8 @@ function extractEmails(value = "") {
|
|
|
61
92
|
|
|
62
93
|
/**
|
|
63
94
|
* Remove angle brackets from message ID
|
|
95
|
+
* @param {string} value - Message ID string
|
|
96
|
+
* @returns {string|null} Cleaned message ID
|
|
64
97
|
*/
|
|
65
98
|
function cleanMessageId(value) {
|
|
66
99
|
if (!value || typeof value !== 'string') return null;
|
|
@@ -68,9 +101,60 @@ function cleanMessageId(value) {
|
|
|
68
101
|
}
|
|
69
102
|
|
|
70
103
|
/**
|
|
71
|
-
*
|
|
104
|
+
* Verify Mailgun webhook signature automatically from request
|
|
105
|
+
*
|
|
106
|
+
* This function automatically extracts token, timestamp, and signature
|
|
107
|
+
* from the request body and verifies the signature. You only need to
|
|
108
|
+
* provide the signing key.
|
|
109
|
+
*
|
|
110
|
+
* @param {Object} req - Express request object with body
|
|
111
|
+
* @param {Object} req.body - Request body containing token, timestamp, signature
|
|
112
|
+
* @param {string} signingKey - Mailgun webhook signing key (or use MAILGUN_WEBHOOK_SIGNING_KEY env var)
|
|
113
|
+
* @returns {boolean} True if signature is valid
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* const { verifyRequestSignature } = require('mailgun-inbound-email');
|
|
117
|
+
* const signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
|
|
118
|
+
* if (!verifyRequestSignature(req, signingKey)) {
|
|
119
|
+
* return res.status(401).json({ error: 'Invalid signature' });
|
|
120
|
+
* }
|
|
121
|
+
*/
|
|
122
|
+
function verifyRequestSignature(req, signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY) {
|
|
123
|
+
if (!req || !req.body) {
|
|
124
|
+
console.error("[MailgunInbound] Invalid request: missing body");
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { token, timestamp, signature } = req.body;
|
|
129
|
+
return verifyMailgunSignature(token, timestamp, signature, signingKey);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Process email data from Mailgun webhook request
|
|
134
|
+
*
|
|
135
|
+
* This function processes the raw Express request body and files
|
|
136
|
+
* to extract and structure email data for manual processing.
|
|
137
|
+
*
|
|
138
|
+
* @param {Object} req - Express request object with body and files
|
|
139
|
+
* @param {Object} req.body - Request body containing email fields
|
|
140
|
+
* @param {Array} req.files - Array of uploaded files (attachments)
|
|
141
|
+
* @returns {Object} Processed email data with token, timestamp, signature
|
|
142
|
+
* @returns {Object} return.emailData - Structured email data
|
|
143
|
+
* @returns {string} return.token - Mailgun token
|
|
144
|
+
* @returns {string} return.timestamp - Request timestamp
|
|
145
|
+
* @returns {string} return.signature - Mailgun signature
|
|
146
|
+
* @throws {Error} If request body is invalid
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* const { processEmailData } = require('mailgun-inbound-email');
|
|
150
|
+
* const { emailData } = processEmailData(req);
|
|
151
|
+
* console.log(emailData.from, emailData.subject);
|
|
72
152
|
*/
|
|
73
153
|
function processEmailData(req) {
|
|
154
|
+
if (!req || !req.body) {
|
|
155
|
+
throw new Error("Invalid request: missing body");
|
|
156
|
+
}
|
|
157
|
+
|
|
74
158
|
const {
|
|
75
159
|
token,
|
|
76
160
|
timestamp,
|
|
@@ -88,18 +172,21 @@ function processEmailData(req) {
|
|
|
88
172
|
"attachment-count": attachmentCount,
|
|
89
173
|
} = req.body;
|
|
90
174
|
|
|
91
|
-
// Process attachments metadata
|
|
92
|
-
const processedAttachments = (req.files || []).map((file) => ({
|
|
93
|
-
filename: file.originalname || `attachment-${Date.now()}`,
|
|
175
|
+
// Process attachments metadata with buffers for manual processing
|
|
176
|
+
const processedAttachments = (req.files || []).map((file, index) => ({
|
|
177
|
+
filename: file.originalname || `attachment-${Date.now()}-${index}`,
|
|
94
178
|
originalname: file.originalname || null,
|
|
95
|
-
mimetype: file.mimetype ||
|
|
179
|
+
mimetype: file.mimetype || "application/octet-stream",
|
|
96
180
|
size: file.size || 0,
|
|
97
|
-
extension: file.originalname
|
|
181
|
+
extension: file.originalname
|
|
182
|
+
? file.originalname.split('.').pop().toLowerCase()
|
|
183
|
+
: null,
|
|
98
184
|
encoding: file.encoding || null,
|
|
99
185
|
fieldname: file.fieldname || null,
|
|
186
|
+
buffer: file.buffer || null, // Include buffer for manual processing
|
|
100
187
|
}));
|
|
101
188
|
|
|
102
|
-
// Convert headers array to object for easier
|
|
189
|
+
// Convert headers array to object for easier access
|
|
103
190
|
const headersObj = {};
|
|
104
191
|
const parsedHeaders = parseHeaders(messageHeaders);
|
|
105
192
|
if (parsedHeaders && parsedHeaders.length > 0) {
|
|
@@ -112,9 +199,9 @@ function processEmailData(req) {
|
|
|
112
199
|
}
|
|
113
200
|
|
|
114
201
|
// Extract CC from body or headers
|
|
115
|
-
const ccValue = cc || headersObj['Cc'] || headersObj['CC'] || ""
|
|
202
|
+
const ccValue = cc || headersObj['Cc'] || headersObj['CC'] || "";
|
|
116
203
|
// Extract TO from body or headers (can be multiple recipients)
|
|
117
|
-
const toValue = recipient || headersObj['To'] || headersObj['TO'] || ""
|
|
204
|
+
const toValue = recipient || headersObj['To'] || headersObj['TO'] || "";
|
|
118
205
|
|
|
119
206
|
const emailData = {
|
|
120
207
|
messageId: cleanMessageId(headersObj['Message-ID'] || headersObj['Message-Id'] || null),
|
|
@@ -140,143 +227,256 @@ function processEmailData(req) {
|
|
|
140
227
|
}
|
|
141
228
|
|
|
142
229
|
/**
|
|
143
|
-
*
|
|
230
|
+
* Production-ready Mailgun event webhook handler
|
|
231
|
+
*
|
|
232
|
+
* Handles Mailgun event webhooks (delivered, opened, clicked, bounced, etc.)
|
|
233
|
+
* with proper error handling, validation, and logging. Returns event data
|
|
234
|
+
* for manual processing and saving to database.
|
|
144
235
|
*
|
|
145
|
-
* @param {Object}
|
|
146
|
-
* @param {
|
|
147
|
-
* @param {
|
|
148
|
-
* @
|
|
149
|
-
*
|
|
150
|
-
* @
|
|
236
|
+
* @param {Object} req - Express request object
|
|
237
|
+
* @param {Object} res - Express response object
|
|
238
|
+
* @param {string} signingKey - Mailgun webhook signing key (optional, defaults to MAILGUN_WEBHOOK_SIGNING_KEY env var)
|
|
239
|
+
* @returns {Promise<Object|null>} Returns event data if successfully processed, null otherwise
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* const { mailgunWebhook } = require('mailgun-inbound-email');
|
|
243
|
+
*
|
|
244
|
+
* app.post('/webhook/mailgun-events', express.json(), async (req, res) => {
|
|
245
|
+
* const eventData = await mailgunWebhook(req, res);
|
|
246
|
+
* // eventData contains the processed event data for manual saving
|
|
247
|
+
* if (eventData && eventData.received && eventData.event) {
|
|
248
|
+
* await db.events.create(eventData);
|
|
249
|
+
* }
|
|
250
|
+
* });
|
|
151
251
|
*/
|
|
152
|
-
function
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
path = '/inbound',
|
|
158
|
-
requireSignature = true,
|
|
159
|
-
} = options;
|
|
160
|
-
|
|
161
|
-
router.post(path, express.urlencoded({ extended: true }), upload.any(), (req, res) => {
|
|
162
|
-
try {
|
|
163
|
-
const { emailData, token, timestamp, signature } = processEmailData(req);
|
|
164
|
-
|
|
165
|
-
// đ Verify Mailgun authenticity
|
|
166
|
-
if (requireSignature && !verifyMailgunSignature(token, timestamp, signature, signingKey)) {
|
|
167
|
-
return res.status(401).json({ error: "Invalid Mailgun signature" });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Validate required fields
|
|
171
|
-
if (!emailData.from || !emailData.to || emailData.to.length === 0) {
|
|
172
|
-
console.error("Missing required email fields:", { from: emailData.from, to: emailData.to });
|
|
173
|
-
// Still return 200 to prevent Mailgun retries
|
|
174
|
-
return res.status(200).json({ received: true, error: "Missing required fields" });
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Call user-provided callback if provided
|
|
178
|
-
if (onEmailReceived && typeof onEmailReceived === 'function') {
|
|
179
|
-
try {
|
|
180
|
-
onEmailReceived(emailData);
|
|
181
|
-
} catch (callbackError) {
|
|
182
|
-
console.error("Error in onEmailReceived callback:", callbackError);
|
|
183
|
-
}
|
|
184
|
-
} else {
|
|
185
|
-
// Default: log clean JSON data ready to save
|
|
186
|
-
console.log(JSON.stringify(emailData, null, 2));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
res.status(200).json({ received: true });
|
|
252
|
+
async function mailgunWebhook(req, res, signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY) {
|
|
253
|
+
const startTime = Date.now();
|
|
254
|
+
const correlationId = req.headers['x-request-id'] ||
|
|
255
|
+
req.headers['x-correlation-id'] ||
|
|
256
|
+
`mg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
190
257
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
258
|
+
try {
|
|
259
|
+
// Validate request body
|
|
260
|
+
if (!req || !req.body) {
|
|
261
|
+
console.error(`[MailgunWebhook:${correlationId}] Invalid request: missing body`, {
|
|
262
|
+
ip: req.ip || req.connection?.remoteAddress,
|
|
263
|
+
userAgent: req.headers['user-agent'],
|
|
264
|
+
});
|
|
265
|
+
res.status(400).json({
|
|
266
|
+
received: false,
|
|
267
|
+
error: 'Invalid request',
|
|
268
|
+
correlationId
|
|
196
269
|
});
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
197
272
|
|
|
198
|
-
|
|
199
|
-
|
|
273
|
+
// đ Verify Mailgun request signature
|
|
274
|
+
const isValid = verifyRequestSignature(req, signingKey);
|
|
275
|
+
if (!isValid) {
|
|
276
|
+
console.warn(`[MailgunWebhook:${correlationId}] Invalid Mailgun webhook signature`, {
|
|
277
|
+
ip: req.ip || req.connection?.remoteAddress,
|
|
278
|
+
userAgent: req.headers['user-agent'],
|
|
279
|
+
hasBody: !!req.body,
|
|
280
|
+
hasToken: !!req.body.token,
|
|
281
|
+
hasTimestamp: !!req.body.timestamp,
|
|
282
|
+
hasSignature: !!req.body.signature,
|
|
283
|
+
});
|
|
284
|
+
res.status(401).json({
|
|
285
|
+
received: false,
|
|
286
|
+
error: 'Invalid signature',
|
|
287
|
+
correlationId
|
|
288
|
+
});
|
|
289
|
+
return null;
|
|
200
290
|
}
|
|
201
|
-
});
|
|
202
291
|
|
|
203
|
-
|
|
204
|
-
}
|
|
292
|
+
// Extract event data from request body
|
|
293
|
+
const eventData = req.body['event-data'] || {};
|
|
294
|
+
const event = eventData.event;
|
|
295
|
+
const eventId = eventData.id || eventData['event-id'] || null;
|
|
296
|
+
const recipient = eventData.recipient;
|
|
297
|
+
const messageId = eventData.message?.headers?.['message-id'] ||
|
|
298
|
+
eventData['message-id'] ||
|
|
299
|
+
eventData.messageId ||
|
|
300
|
+
null;
|
|
301
|
+
const url = eventData.url;
|
|
302
|
+
const timestamp = eventData.timestamp || Date.now() / 1000;
|
|
303
|
+
const domain = eventData.domain?.name || eventData.domain;
|
|
304
|
+
const reason = eventData['delivery-status']?.description ||
|
|
305
|
+
eventData.reason ||
|
|
306
|
+
eventData['failure-reason'] ||
|
|
307
|
+
null;
|
|
308
|
+
|
|
309
|
+
// Validate required fields
|
|
310
|
+
if (!event) {
|
|
311
|
+
console.warn(`[MailgunWebhook:${correlationId}] Missing event type`, {
|
|
312
|
+
body: req.body
|
|
313
|
+
});
|
|
314
|
+
const errorResponse = {
|
|
315
|
+
received: true,
|
|
316
|
+
error: 'Missing event type',
|
|
317
|
+
correlationId
|
|
318
|
+
};
|
|
319
|
+
res.status(200).json(errorResponse);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
205
322
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
323
|
+
// Prepare response data with correlation ID for tracking
|
|
324
|
+
let responseData = {
|
|
325
|
+
received: true,
|
|
326
|
+
event,
|
|
327
|
+
eventId,
|
|
328
|
+
recipient,
|
|
329
|
+
messageId,
|
|
330
|
+
timestamp: typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp,
|
|
331
|
+
domain,
|
|
332
|
+
correlationId,
|
|
333
|
+
processedAt: new Date().toISOString(),
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Handle different event types and add event-specific data
|
|
337
|
+
switch (event) {
|
|
338
|
+
case "delivered":
|
|
339
|
+
console.log(`[MailgunWebhook:${correlationId}] â
Email delivered to:`, recipient);
|
|
340
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
341
|
+
responseData.status = "delivered";
|
|
342
|
+
responseData.deliveredAt = typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp;
|
|
343
|
+
if (eventData['delivery-status']) {
|
|
344
|
+
responseData.deliveryStatus = {
|
|
345
|
+
code: eventData['delivery-status'].code,
|
|
346
|
+
message: eventData['delivery-status'].message,
|
|
347
|
+
description: eventData['delivery-status'].description,
|
|
348
|
+
tls: eventData['delivery-status'].tls,
|
|
349
|
+
certificateVerified: eventData['delivery-status']['certificate-verified'],
|
|
350
|
+
};
|
|
232
351
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
352
|
+
break;
|
|
353
|
+
|
|
354
|
+
case "opened":
|
|
355
|
+
console.log(`[MailgunWebhook:${correlationId}] đ Email opened by:`, recipient);
|
|
356
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
357
|
+
responseData.status = "opened";
|
|
358
|
+
responseData.openedAt = typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp;
|
|
359
|
+
responseData.clientInfo = eventData['client-info'] || null;
|
|
360
|
+
responseData.geolocation = eventData.geolocation || null;
|
|
361
|
+
responseData.userAgent = eventData['client-info']?.clientName || null;
|
|
362
|
+
break;
|
|
363
|
+
|
|
364
|
+
case "clicked":
|
|
365
|
+
console.log(`[MailgunWebhook:${correlationId}] đ Link clicked:`, url);
|
|
366
|
+
console.log(`[MailgunWebhook:${correlationId}] Recipient:`, recipient);
|
|
367
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
368
|
+
responseData.status = "clicked";
|
|
369
|
+
responseData.url = url;
|
|
370
|
+
responseData.clickedAt = typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp;
|
|
371
|
+
responseData.clientInfo = eventData['client-info'] || null;
|
|
372
|
+
responseData.geolocation = eventData.geolocation || null;
|
|
373
|
+
break;
|
|
374
|
+
|
|
375
|
+
case "bounced":
|
|
376
|
+
console.log(`[MailgunWebhook:${correlationId}] â Email bounced:`, recipient);
|
|
377
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
378
|
+
responseData.status = "bounced";
|
|
379
|
+
responseData.reason = reason;
|
|
380
|
+
responseData.bouncedAt = typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp;
|
|
381
|
+
if (eventData['delivery-status']) {
|
|
382
|
+
responseData.deliveryStatus = {
|
|
383
|
+
code: eventData['delivery-status'].code,
|
|
384
|
+
message: eventData['delivery-status'].message,
|
|
385
|
+
description: eventData['delivery-status'].description,
|
|
386
|
+
attemptNo: eventData['delivery-status']['attempt-no'],
|
|
387
|
+
sessionSeconds: eventData['delivery-status']['session-seconds'],
|
|
388
|
+
};
|
|
238
389
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
|
|
390
|
+
responseData.severity = eventData.severity || 'permanent';
|
|
391
|
+
break;
|
|
392
|
+
|
|
393
|
+
case "complained":
|
|
394
|
+
console.log(`[MailgunWebhook:${correlationId}] đ¨ Spam complaint:`, recipient);
|
|
395
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
396
|
+
responseData.status = "complained";
|
|
397
|
+
responseData.complainedAt = typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp;
|
|
398
|
+
break;
|
|
399
|
+
|
|
400
|
+
case "failed":
|
|
401
|
+
console.log(`[MailgunWebhook:${correlationId}] â ī¸ Email failed:`, recipient);
|
|
402
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
403
|
+
responseData.status = "failed";
|
|
404
|
+
responseData.reason = reason;
|
|
405
|
+
responseData.failedAt = typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp;
|
|
406
|
+
if (eventData['delivery-status']) {
|
|
407
|
+
responseData.deliveryStatus = {
|
|
408
|
+
code: eventData['delivery-status'].code,
|
|
409
|
+
message: eventData['delivery-status'].message,
|
|
410
|
+
description: eventData['delivery-status'].description,
|
|
411
|
+
attemptNo: eventData['delivery-status']['attempt-no'],
|
|
412
|
+
sessionSeconds: eventData['delivery-status']['session-seconds'],
|
|
413
|
+
};
|
|
253
414
|
}
|
|
415
|
+
break;
|
|
416
|
+
|
|
417
|
+
case "unsubscribed":
|
|
418
|
+
console.log(`[MailgunWebhook:${correlationId}] đ¤ User unsubscribed:`, recipient);
|
|
419
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
420
|
+
responseData.status = "unsubscribed";
|
|
421
|
+
responseData.unsubscribedAt = typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp;
|
|
422
|
+
break;
|
|
423
|
+
|
|
424
|
+
case "stored":
|
|
425
|
+
console.log(`[MailgunWebhook:${correlationId}] đž Email stored:`, recipient);
|
|
426
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
427
|
+
responseData.status = "stored";
|
|
428
|
+
responseData.storedAt = typeof timestamp === 'number' ? new Date(timestamp * 1000).toISOString() : timestamp;
|
|
429
|
+
break;
|
|
430
|
+
|
|
431
|
+
default:
|
|
432
|
+
console.log(`[MailgunWebhook:${correlationId}] âšī¸ Other event:`, event);
|
|
433
|
+
console.log(`[MailgunWebhook:${correlationId}] Message ID:`, messageId);
|
|
434
|
+
responseData.status = "unknown";
|
|
435
|
+
responseData.fullEventData = eventData;
|
|
436
|
+
}
|
|
254
437
|
|
|
255
|
-
|
|
438
|
+
// Log successful processing
|
|
439
|
+
const duration = Date.now() - startTime;
|
|
440
|
+
console.log(`[MailgunWebhook:${correlationId}] â
Webhook processed successfully`, {
|
|
441
|
+
event,
|
|
442
|
+
eventId,
|
|
443
|
+
duration: `${duration}ms`,
|
|
444
|
+
});
|
|
256
445
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
446
|
+
// â
MUST return 200 OK with event data for manual saving
|
|
447
|
+
res.status(200).json(responseData);
|
|
448
|
+
|
|
449
|
+
// Return event data so caller can save it manually
|
|
450
|
+
return responseData;
|
|
451
|
+
|
|
452
|
+
} catch (error) {
|
|
453
|
+
const duration = Date.now() - startTime;
|
|
454
|
+
console.error(`[MailgunWebhook:${correlationId}] â Webhook Error:`, {
|
|
455
|
+
error: error.message,
|
|
456
|
+
stack: error.stack,
|
|
457
|
+
duration: `${duration}ms`,
|
|
458
|
+
});
|
|
263
459
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
460
|
+
// â ī¸ Still return 200 so Mailgun doesn't retry forever
|
|
461
|
+
const errorResponse = {
|
|
462
|
+
received: true,
|
|
463
|
+
error: 'Processing failed but webhook acknowledged',
|
|
464
|
+
correlationId,
|
|
465
|
+
timestamp: new Date().toISOString(),
|
|
466
|
+
};
|
|
467
|
+
res.status(200).json(errorResponse);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
269
470
|
}
|
|
270
471
|
|
|
271
|
-
// Export
|
|
472
|
+
// Export utility functions for manual processing
|
|
272
473
|
module.exports = {
|
|
273
|
-
createMailgunInboundRouter,
|
|
274
|
-
createMailgunInboundMiddleware,
|
|
275
474
|
processEmailData,
|
|
276
|
-
|
|
475
|
+
verifyRequestSignature, // Automatic signature verification (recommended)
|
|
476
|
+
verifyMailgunSignature, // Manual signature verification (advanced)
|
|
477
|
+
mailgunWebhook, // Production-ready event webhook handler
|
|
277
478
|
extractEmail,
|
|
278
479
|
extractEmails,
|
|
279
480
|
cleanMessageId,
|
|
280
481
|
parseHeaders,
|
|
281
482
|
};
|
|
282
|
-
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mailgun-inbound-email",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Production-ready utility functions for manual processing of Mailgun inbound email webhooks. Full manual control - you handle everything.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
8
|
+
"lint": "echo \"Linting not configured\"",
|
|
9
|
+
"prepublishOnly": "echo \"Ready to publish\""
|
|
8
10
|
},
|
|
9
11
|
"keywords": [
|
|
10
12
|
"mailgun",
|
|
@@ -12,19 +14,17 @@
|
|
|
12
14
|
"email",
|
|
13
15
|
"webhook",
|
|
14
16
|
"express",
|
|
15
|
-
"middleware"
|
|
17
|
+
"middleware",
|
|
18
|
+
"email-processing",
|
|
19
|
+
"mailgun-webhook",
|
|
20
|
+
"inbound-email",
|
|
21
|
+
"email-handler"
|
|
16
22
|
],
|
|
17
23
|
"author": "",
|
|
18
24
|
"license": "MIT",
|
|
19
|
-
"dependencies": {
|
|
20
|
-
"express": "^4.16.1",
|
|
21
|
-
"multer": "^2.0.2"
|
|
22
|
-
},
|
|
23
|
-
"peerDependencies": {
|
|
24
|
-
"express": "^4.0.0"
|
|
25
|
-
},
|
|
25
|
+
"dependencies": {},
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=14"
|
|
27
|
+
"node": ">=14.0.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {},
|
|
30
30
|
"repository": {
|
|
@@ -35,5 +35,10 @@
|
|
|
35
35
|
"bugs": {
|
|
36
36
|
"url": "https://github.com/RitikKumarSahoo/mailgun-inbound/issues"
|
|
37
37
|
},
|
|
38
|
-
"homepage": "https://github.com/RitikKumarSahoo/mailgun-inbound#readme"
|
|
38
|
+
"homepage": "https://github.com/RitikKumarSahoo/mailgun-inbound#readme",
|
|
39
|
+
"files": [
|
|
40
|
+
"index.js",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
]
|
|
39
44
|
}
|