mailgun-inbound-email 1.0.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.
Files changed (4) hide show
  1. package/README.md +161 -0
  2. package/SETUP.md +62 -0
  3. package/index.js +282 -0
  4. package/package.json +39 -0
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # mailgun-inbound-email
2
+
3
+ A reusable npm package for handling Mailgun inbound email webhooks with Express.js. This package provides a clean, tested solution for receiving and processing inbound emails from Mailgun without rewriting the same code in every project.
4
+
5
+ ## Features
6
+
7
+ - ✅ Mailgun webhook signature verification
8
+ - ✅ Replay attack prevention (15-minute window)
9
+ - ✅ Automatic email parsing (from, to, cc, subject, body, attachments)
10
+ - ✅ Header extraction and normalization
11
+ - ✅ Attachment metadata processing
12
+ - ✅ Express.js router and middleware support
13
+ - ✅ Customizable callback functions
14
+ - ✅ Error handling that prevents Mailgun retries
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install mailgun-inbound-email
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ### Option 1: Using Router (Recommended)
25
+
26
+ ```javascript
27
+ const express = require('express');
28
+ const { createMailgunInboundRouter } = require('mailgun-inbound-email');
29
+
30
+ const app = express();
31
+
32
+ // Create router with callback
33
+ const mailgunRouter = createMailgunInboundRouter({
34
+ signingKey: process.env.MAILGUN_WEBHOOK_SIGNING_KEY, // or use env var
35
+ onEmailReceived: (emailData) => {
36
+ // Handle the email data
37
+ console.log('Received email:', emailData);
38
+ // Save to database, send notifications, etc.
39
+ },
40
+ path: '/inbound', // optional, defaults to '/inbound'
41
+ requireSignature: true, // optional, defaults to true
42
+ });
43
+
44
+ app.use('/email', mailgunRouter);
45
+ ```
46
+
47
+ ### Option 2: Using Middleware
48
+
49
+ ```javascript
50
+ const express = require('express');
51
+ const { createMailgunInboundMiddleware } = require('mailgun-inbound-email');
52
+
53
+ const app = express();
54
+
55
+ const mailgunMiddleware = createMailgunInboundMiddleware({
56
+ signingKey: process.env.MAILGUN_WEBHOOK_SIGNING_KEY,
57
+ onEmailReceived: (emailData) => {
58
+ // Handle the email data
59
+ console.log('Received email:', emailData);
60
+ },
61
+ });
62
+
63
+ app.post('/email/inbound', ...mailgunMiddleware);
64
+ ```
65
+
66
+ ### Option 3: Manual Processing
67
+
68
+ ```javascript
69
+ const express = require('express');
70
+ const { processEmailData, verifyMailgunSignature } = require('mailgun-inbound-email');
71
+ const multer = require('multer');
72
+
73
+ const upload = multer({ storage: multer.memoryStorage() });
74
+ const app = express();
75
+
76
+ app.post('/email/inbound', express.urlencoded({ extended: true }), upload.any(), (req, res) => {
77
+ const { emailData, token, timestamp, signature } = processEmailData(req);
78
+
79
+ if (!verifyMailgunSignature(token, timestamp, signature, process.env.MAILGUN_WEBHOOK_SIGNING_KEY)) {
80
+ return res.status(401).json({ error: 'Invalid signature' });
81
+ }
82
+
83
+ // Your custom logic here
84
+ console.log(emailData);
85
+ res.status(200).json({ received: true });
86
+ });
87
+ ```
88
+
89
+ ## Email Data Structure
90
+
91
+ The `emailData` object contains:
92
+
93
+ ```javascript
94
+ {
95
+ messageId: "string", // Cleaned message ID
96
+ from: "sender@example.com", // Sender email address
97
+ to: ["recipient@example.com"], // Array of recipient emails
98
+ cc: ["cc@example.com"], // Array of CC emails
99
+ subject: "Email Subject", // Email subject
100
+ text: "Plain text body", // Plain text body
101
+ html: "<html>...</html>", // HTML body
102
+ headers: { // Parsed headers object
103
+ "Message-ID": "...",
104
+ "From": "...",
105
+ // ... other headers
106
+ },
107
+ attachments: [ // Attachment metadata
108
+ {
109
+ filename: "document.pdf",
110
+ originalname: "document.pdf",
111
+ mimetype: "application/pdf",
112
+ size: 12345,
113
+ extension: "pdf",
114
+ encoding: "base64",
115
+ fieldname: "attachment-1"
116
+ }
117
+ ],
118
+ attachmentCount: 1,
119
+ receivedAt: "2024-01-01T00:00:00.000Z",
120
+ timestamp: "2024-01-01T00:00:00.000Z"
121
+ }
122
+ ```
123
+
124
+ ## Configuration Options
125
+
126
+ ### `createMailgunInboundRouter(options)`
127
+
128
+ - `signingKey` (string, optional): Mailgun webhook signing key. Defaults to `process.env.MAILGUN_WEBHOOK_SIGNING_KEY`
129
+ - `onEmailReceived` (function, optional): Callback function called when email is received. Receives `emailData` as parameter
130
+ - `path` (string, optional): Route path. Defaults to `'/inbound'`
131
+ - `requireSignature` (boolean, optional): Whether to require signature verification. Defaults to `true`
132
+
133
+ ### `createMailgunInboundMiddleware(options)`
134
+
135
+ - `signingKey` (string, optional): Mailgun webhook signing key. Defaults to `process.env.MAILGUN_WEBHOOK_SIGNING_KEY`
136
+ - `onEmailReceived` (function, optional): Callback function called when email is received. Receives `emailData` as parameter
137
+ - `requireSignature` (boolean, optional): Whether to require signature verification. Defaults to `true`
138
+
139
+ ## Environment Variables
140
+
141
+ - `MAILGUN_WEBHOOK_SIGNING_KEY`: Your Mailgun webhook signing key (if not provided in options)
142
+
143
+ ## Utilities
144
+
145
+ The package also exports utility functions:
146
+
147
+ - `processEmailData(req)`: Process raw request and return email data
148
+ - `verifyMailgunSignature(token, timestamp, signature, signingKey)`: Verify webhook signature
149
+ - `extractEmail(value)`: Extract email from "Name <email@domain.com>" format
150
+ - `extractEmails(value)`: Extract multiple emails from comma-separated string
151
+ - `cleanMessageId(value)`: Remove angle brackets from message ID
152
+ - `parseHeaders(headers)`: Safely parse email headers
153
+
154
+ ## Error Handling
155
+
156
+ The package automatically handles errors and always returns `200` status to Mailgun to prevent retries. Errors are logged to the console.
157
+
158
+ ## License
159
+
160
+ MIT
161
+
package/SETUP.md ADDED
@@ -0,0 +1,62 @@
1
+ # Setup Instructions
2
+
3
+ ## For Local Development
4
+
5
+ To use this package in your project before publishing to npm:
6
+
7
+ ### Option 1: npm link (Recommended for development)
8
+
9
+ 1. In the `mailgun-inbound-email` directory:
10
+ ```bash
11
+ cd /home/ritik-ls/Desktop/mailgun-inbound-email
12
+ npm link
13
+ ```
14
+
15
+ 2. In your project directory:
16
+ ```bash
17
+ cd /home/ritik-ls/Desktop/node_skeleton
18
+ npm link mailgun-inbound-email
19
+ ```
20
+
21
+ ### Option 2: Install from local path
22
+
23
+ In your project's `package.json`, add:
24
+ ```json
25
+ {
26
+ "dependencies": {
27
+ "mailgun-inbound-email": "file:../mailgun-inbound-email"
28
+ }
29
+ }
30
+ ```
31
+
32
+ Then run:
33
+ ```bash
34
+ npm install
35
+ ```
36
+
37
+ ### Option 3: Publish to npm (for production use)
38
+
39
+ 1. Update `package.json` with your details (name, author, etc.)
40
+ 2. Login to npm:
41
+ ```bash
42
+ npm login
43
+ ```
44
+
45
+ 3. Publish:
46
+ ```bash
47
+ npm publish
48
+ ```
49
+
50
+ 4. Then install in your project:
51
+ ```bash
52
+ npm install mailgun-inbound-email
53
+ ```
54
+
55
+ ## After Setup
56
+
57
+ Make sure to install dependencies in the package directory:
58
+ ```bash
59
+ cd /home/ritik-ls/Desktop/mailgun-inbound-email
60
+ npm install
61
+ ```
62
+
package/index.js ADDED
@@ -0,0 +1,282 @@
1
+ const express = require("express");
2
+ const crypto = require("crypto");
3
+ const multer = require("multer");
4
+
5
+ const upload = multer({ storage: multer.memoryStorage() });
6
+
7
+ /**
8
+ * Verify Mailgun webhook signature
9
+ */
10
+ function verifyMailgunSignature(token, timestamp, signature, signingKey) {
11
+ if (!signingKey) {
12
+ console.error("MAILGUN_WEBHOOK_SIGNING_KEY missing");
13
+ return false;
14
+ }
15
+
16
+ const currentTime = Math.floor(Date.now() / 1000);
17
+
18
+ // Prevent replay attack (15 min)
19
+ if (Math.abs(currentTime - Number(timestamp)) > 900) {
20
+ console.error("Expired timestamp");
21
+ return false;
22
+ }
23
+
24
+ const hmac = crypto
25
+ .createHmac("sha256", signingKey)
26
+ .update(timestamp + token)
27
+ .digest("hex");
28
+
29
+ return hmac === signature;
30
+ }
31
+
32
+ /**
33
+ * Parse headers safely
34
+ */
35
+ function parseHeaders(headers) {
36
+ if (Array.isArray(headers)) return headers;
37
+
38
+ try {
39
+ return JSON.parse(headers || "[]");
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Extract email from "Name <email@domain.com>" or plain email
47
+ */
48
+ function extractEmail(value = "") {
49
+ if (!value || typeof value !== 'string') return "";
50
+ const match = value.match(/<(.+?)>/);
51
+ return match ? match[1].trim() : value.trim();
52
+ }
53
+
54
+ /**
55
+ * Extract multiple emails from comma-separated string
56
+ */
57
+ function extractEmails(value = "") {
58
+ if (!value || typeof value !== 'string') return [];
59
+ return value.split(',').map(email => extractEmail(email.trim())).filter(Boolean);
60
+ }
61
+
62
+ /**
63
+ * Remove angle brackets from message ID
64
+ */
65
+ function cleanMessageId(value) {
66
+ if (!value || typeof value !== 'string') return null;
67
+ return value.replace(/^<|>$/g, '').trim() || null;
68
+ }
69
+
70
+ /**
71
+ * Process email data from Mailgun webhook
72
+ */
73
+ function processEmailData(req) {
74
+ const {
75
+ token,
76
+ timestamp,
77
+ signature,
78
+ sender,
79
+ from,
80
+ subject,
81
+ recipient,
82
+ cc,
83
+ "body-plain": bodyPlain,
84
+ "body-html": bodyHtml,
85
+ "stripped-text": strippedText,
86
+ "stripped-html": strippedHtml,
87
+ "message-headers": messageHeaders,
88
+ "attachment-count": attachmentCount,
89
+ } = req.body;
90
+
91
+ // Process attachments metadata (without buffer for clean JSON)
92
+ const processedAttachments = (req.files || []).map((file) => ({
93
+ filename: file.originalname || `attachment-${Date.now()}`,
94
+ originalname: file.originalname || null,
95
+ mimetype: file.mimetype || null,
96
+ size: file.size || 0,
97
+ extension: file.originalname ? file.originalname.split('.').pop() : null,
98
+ encoding: file.encoding || null,
99
+ fieldname: file.fieldname || null,
100
+ }));
101
+
102
+ // Convert headers array to object for easier storage
103
+ const headersObj = {};
104
+ const parsedHeaders = parseHeaders(messageHeaders);
105
+ if (parsedHeaders && parsedHeaders.length > 0) {
106
+ parsedHeaders.forEach(header => {
107
+ if (Array.isArray(header) && header.length >= 2) {
108
+ const [key, value] = header;
109
+ headersObj[key] = value;
110
+ }
111
+ });
112
+ }
113
+
114
+ // Extract CC from body or headers
115
+ const ccValue = cc || headersObj['Cc'] || headersObj['CC'] || ""
116
+ // Extract TO from body or headers (can be multiple recipients)
117
+ const toValue = recipient || headersObj['To'] || headersObj['TO'] || ""
118
+
119
+ const emailData = {
120
+ messageId: cleanMessageId(headersObj['Message-ID'] || headersObj['Message-Id'] || null),
121
+ from: extractEmail(sender || from),
122
+ to: extractEmails(toValue),
123
+ cc: extractEmails(ccValue),
124
+ subject: subject || "",
125
+ text: bodyPlain || strippedText || "",
126
+ html: bodyHtml || strippedHtml || "",
127
+ headers: headersObj,
128
+ attachments: processedAttachments,
129
+ attachmentCount: Number(attachmentCount || 0),
130
+ receivedAt: new Date().toISOString(),
131
+ timestamp: new Date().toISOString(),
132
+ };
133
+
134
+ return {
135
+ emailData,
136
+ token,
137
+ timestamp,
138
+ signature,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Create Express router for Mailgun inbound email webhook
144
+ *
145
+ * @param {Object} options - Configuration options
146
+ * @param {string} options.signingKey - Mailgun webhook signing key (or use MAILGUN_WEBHOOK_SIGNING_KEY env var)
147
+ * @param {Function} options.onEmailReceived - Callback function called when email is received (emailData) => {}
148
+ * @param {string} options.path - Route path (default: '/inbound')
149
+ * @param {boolean} options.requireSignature - Whether to require signature verification (default: true)
150
+ * @returns {express.Router} Express router
151
+ */
152
+ function createMailgunInboundRouter(options = {}) {
153
+ const router = express.Router();
154
+ const {
155
+ signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY,
156
+ onEmailReceived,
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 });
190
+
191
+ } catch (error) {
192
+ console.error("Inbound email processing error:", {
193
+ error: error.message,
194
+ stack: error.stack,
195
+ timestamp: new Date().toISOString(),
196
+ });
197
+
198
+ // Mailgun MUST receive 200 or it will retry
199
+ res.status(200).json({ received: true });
200
+ }
201
+ });
202
+
203
+ return router;
204
+ }
205
+
206
+ /**
207
+ * Create Express middleware for Mailgun inbound email webhook
208
+ *
209
+ * @param {Object} options - Configuration options
210
+ * @param {string} options.signingKey - Mailgun webhook signing key (or use MAILGUN_WEBHOOK_SIGNING_KEY env var)
211
+ * @param {Function} options.onEmailReceived - Callback function called when email is received (emailData) => {}
212
+ * @param {boolean} options.requireSignature - Whether to require signature verification (default: true)
213
+ * @returns {Array} Express middleware array
214
+ */
215
+ function createMailgunInboundMiddleware(options = {}) {
216
+ const {
217
+ signingKey = process.env.MAILGUN_WEBHOOK_SIGNING_KEY,
218
+ onEmailReceived,
219
+ requireSignature = true,
220
+ } = options;
221
+
222
+ return [
223
+ express.urlencoded({ extended: true }),
224
+ upload.any(),
225
+ (req, res, next) => {
226
+ try {
227
+ const { emailData, token, timestamp, signature } = processEmailData(req);
228
+
229
+ // 🔐 Verify Mailgun authenticity
230
+ if (requireSignature && !verifyMailgunSignature(token, timestamp, signature, signingKey)) {
231
+ return res.status(401).json({ error: "Invalid Mailgun signature" });
232
+ }
233
+
234
+ // Validate required fields
235
+ if (!emailData.from || !emailData.to || emailData.to.length === 0) {
236
+ console.error("Missing required email fields:", { from: emailData.from, to: emailData.to });
237
+ return res.status(200).json({ received: true, error: "Missing required fields" });
238
+ }
239
+
240
+ // Attach emailData to request object
241
+ req.emailData = emailData;
242
+
243
+ // Call user-provided callback if provided
244
+ if (onEmailReceived && typeof onEmailReceived === 'function') {
245
+ try {
246
+ onEmailReceived(emailData);
247
+ } catch (callbackError) {
248
+ console.error("Error in onEmailReceived callback:", callbackError);
249
+ }
250
+ } else {
251
+ // Default: log clean JSON data ready to save
252
+ console.log(JSON.stringify(emailData, null, 2));
253
+ }
254
+
255
+ res.status(200).json({ received: true });
256
+
257
+ } catch (error) {
258
+ console.error("Inbound email processing error:", {
259
+ error: error.message,
260
+ stack: error.stack,
261
+ timestamp: new Date().toISOString(),
262
+ });
263
+
264
+ // Mailgun MUST receive 200 or it will retry
265
+ res.status(200).json({ received: true });
266
+ }
267
+ }
268
+ ];
269
+ }
270
+
271
+ // Export utilities and main functions
272
+ module.exports = {
273
+ createMailgunInboundRouter,
274
+ createMailgunInboundMiddleware,
275
+ processEmailData,
276
+ verifyMailgunSignature,
277
+ extractEmail,
278
+ extractEmails,
279
+ cleanMessageId,
280
+ parseHeaders,
281
+ };
282
+
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "mailgun-inbound-email",
3
+ "version": "1.0.0",
4
+ "description": "A reusable npm package for handling Mailgun inbound email webhooks with Express.js",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "keywords": [
10
+ "mailgun",
11
+ "inbound",
12
+ "email",
13
+ "webhook",
14
+ "express",
15
+ "middleware"
16
+ ],
17
+ "author": "",
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "express": "^4.16.1",
21
+ "multer": "^2.0.2"
22
+ },
23
+ "peerDependencies": {
24
+ "express": "^4.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=14"
28
+ },
29
+ "devDependencies": {},
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/RitikKumarSahoo/mailgun-inbound.git"
33
+ },
34
+ "type": "commonjs",
35
+ "bugs": {
36
+ "url": "https://github.com/RitikKumarSahoo/mailgun-inbound/issues"
37
+ },
38
+ "homepage": "https://github.com/RitikKumarSahoo/mailgun-inbound#readme"
39
+ }