emailengine-app 2.60.1 → 2.61.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.61.0](https://github.com/postalsys/emailengine/compare/v2.60.1...v2.61.0) (2025-12-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * Add check-bounce CLI command for analyzing bounce emails ([ae3a85d](https://github.com/postalsys/emailengine/commit/ae3a85dd5acdf9c893196284487e536ac3d6d9ec))
9
+ * Add Exim-style bounce detection for diagnostic messages ([b82e588](https://github.com/postalsys/emailengine/commit/b82e588d3e699f3c757c6a01f3fb9933c94bbb4c))
10
+ * Improve ARF complaint detection and add comprehensive tests ([5533552](https://github.com/postalsys/emailengine/commit/55335526a19c6948675d126d1dbb14db353253a0))
11
+ * Improve autoreply detection and add comprehensive tests ([1cf179f](https://github.com/postalsys/emailengine/commit/1cf179f8289eb05e8add420fe02d6410f61718cc))
12
+ * Improve bounce detection coverage for non-standard formats ([5393872](https://github.com/postalsys/emailengine/commit/53938724c699b32177ece83c3d88be9a5432c067))
13
+ * Improve bounce detection for legacy formats ([90a0289](https://github.com/postalsys/emailengine/commit/90a0289f7999e23149e14ff6ea5d703de056d5b0))
14
+ * Replace static help.txt with dynamic CLI help system ([4cd5fb0](https://github.com/postalsys/emailengine/commit/4cd5fb059c2bf7436c4262a7daee1e85f535c322))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * Detect "Out of the Office" autoreply subject pattern ([7191c50](https://github.com/postalsys/emailengine/commit/7191c50ae62930a66bf6598cb55f94130e022cf9))
20
+ * Harden bounce detection against edge cases and ReDoS attacks ([d6c72c2](https://github.com/postalsys/emailengine/commit/d6c72c29d4c2bc1a20025204cc0cf20f803b04a6))
21
+
3
22
  ## [2.60.1](https://github.com/postalsys/emailengine/compare/v2.60.0...v2.60.1) (2025-12-17)
4
23
 
5
24
 
@@ -17,6 +17,309 @@ const argv = require('minimist')(process.argv.slice(2));
17
17
  const msgpack = require('msgpack5')();
18
18
  const crypto = require('crypto');
19
19
 
20
+ // Command definitions for dynamic help generation
21
+ const COMMANDS = {
22
+ '': {
23
+ description: 'Start the EmailEngine server'
24
+ },
25
+ help: {
26
+ description: 'Show help for a command'
27
+ },
28
+ version: {
29
+ description: 'Show version number'
30
+ },
31
+ license: {
32
+ description: 'Show license information',
33
+ subcommands: {
34
+ export: { description: 'Export license key for backup' },
35
+ import: {
36
+ description: 'Import license key',
37
+ options: [{ name: '--license, -l', description: 'Encoded license key', type: 'string' }]
38
+ }
39
+ }
40
+ },
41
+ password: {
42
+ description: 'Set or reset admin password',
43
+ options: [
44
+ { name: '--password, -p', description: 'Password to set (auto-generated if not provided)', type: 'string' },
45
+ { name: '--hash, -r', description: 'Output password hash instead of plaintext', type: 'boolean' }
46
+ ]
47
+ },
48
+ scan: {
49
+ description: 'Scan Redis keyspace and output as CSV'
50
+ },
51
+ encrypt: {
52
+ description: 'Manage field-level encryption for stored credentials',
53
+ options: [{ name: '--decrypt', description: 'Previous secret for re-encryption (repeatable)', type: 'string' }]
54
+ },
55
+ tokens: {
56
+ description: 'Manage API access tokens',
57
+ subcommands: {
58
+ issue: {
59
+ description: 'Create a new access token',
60
+ options: [
61
+ { name: '--description, -d', description: 'Token description', type: 'string' },
62
+ { name: '--scope, -s', description: 'Access scope', type: 'string', default: '*' },
63
+ { name: '--account, -a', description: 'Limit token to specific account', type: 'string' }
64
+ ]
65
+ },
66
+ export: {
67
+ description: 'Export token for backup',
68
+ options: [{ name: '--token, -t', description: 'Token to export', type: 'string' }]
69
+ },
70
+ import: {
71
+ description: 'Import previously exported token',
72
+ options: [{ name: '--token, -t', description: 'Exported token data', type: 'string' }]
73
+ }
74
+ }
75
+ },
76
+ export: {
77
+ description: 'Export account data including credentials',
78
+ options: [{ name: '--account, -a', description: 'Account ID to export', type: 'string' }]
79
+ },
80
+ 'check-bounce': {
81
+ description: 'Analyze a bounce email and classify it',
82
+ options: [{ name: '--file, -f', description: 'Path to EML file', type: 'string' }],
83
+ example: 'emailengine check-bounce /path/to/bounce.eml'
84
+ }
85
+ };
86
+
87
+ const GLOBAL_OPTIONS = [
88
+ { name: '-h, --help', description: 'Show this help message' },
89
+ { name: '--dbs.redis', description: 'Redis connection URL', type: 'string', group: 'General' },
90
+ { name: '--workers.imap', description: 'Number of IMAP worker threads', type: 'number', default: 4, group: 'General' },
91
+ { name: '--settings', description: 'Pre-configured settings as JSON', type: 'json-string', group: 'General' },
92
+ { name: '--service.secret', description: 'Secret key for encrypting stored credentials', type: 'string', group: 'General' },
93
+ { name: '--service.commandTimeout', description: 'Maximum time for IMAP commands', type: 'number/string', default: '10s', group: 'General' },
94
+ { name: '--service.setupDelay', description: 'Delay between worker connection assignments', type: 'number/string', default: '0ms', group: 'General' },
95
+ { name: '--log.level', description: 'Logging level', type: 'string', default: 'trace', group: 'General' },
96
+ { name: '--log.raw', description: 'Log raw IMAP traffic', type: 'boolean', default: false, group: 'General' },
97
+ { name: '--workers.webhooks', description: 'Number of webhook worker threads', type: 'number', default: 1, group: 'General' },
98
+ { name: '--api.host', description: 'API server bind address', type: 'string', default: '127.0.0.1', group: 'API server' },
99
+ { name: '--api.port', description: 'API server port', type: 'number', default: 3000, group: 'API server' },
100
+ { name: '--api.maxSize', description: 'Maximum attachment size', type: 'number/string', default: '5M', group: 'API server' },
101
+ { name: '--queues.notify', description: 'Concurrent webhook deliveries', type: 'number', default: 1, group: 'Background tasks' },
102
+ { name: '--queues.submit', description: 'Concurrent email submissions', type: 'number', default: 1, group: 'Background tasks' },
103
+ { name: '--smtp.enabled', description: 'Enable SMTP submission server', type: 'boolean', default: false, group: 'SMTP server' },
104
+ { name: '--smtp.secret', description: 'Shared SMTP password for all accounts', type: 'string', group: 'SMTP server' },
105
+ { name: '--smtp.host', description: 'SMTP server bind address', type: 'string', default: '127.0.0.1', group: 'SMTP server' },
106
+ { name: '--smtp.port', description: 'SMTP server port', type: 'number', default: 2525, group: 'SMTP server' },
107
+ { name: '--smtp.proxy', description: 'Enable HAProxy PROXY protocol', type: 'boolean', default: false, group: 'SMTP server' },
108
+ { name: '--smtp.maxMessageSize', description: 'Maximum email size', type: 'number/string', default: '25M', group: 'SMTP server' }
109
+ ];
110
+
111
+ // Help formatting functions
112
+ function getTerminalWidth() {
113
+ // Check stderr first since help outputs there, fallback to stdout, then 80
114
+ return process.stderr.columns || process.stdout.columns || 80;
115
+ }
116
+
117
+ function wrapText(text, width, indent) {
118
+ if (width <= 0) {
119
+ return text;
120
+ }
121
+ const words = text.split(' ');
122
+ const lines = [];
123
+ let currentLine = '';
124
+
125
+ for (const word of words) {
126
+ if (currentLine.length === 0) {
127
+ currentLine = word;
128
+ } else if (currentLine.length + 1 + word.length <= width) {
129
+ currentLine += ' ' + word;
130
+ } else {
131
+ lines.push(currentLine);
132
+ currentLine = word;
133
+ }
134
+ }
135
+ if (currentLine) {
136
+ lines.push(currentLine);
137
+ }
138
+
139
+ return lines.join('\n' + ' '.repeat(indent));
140
+ }
141
+
142
+ function formatLine(name, description, type, defaultVal, width, nameWidth) {
143
+ const indent = 2;
144
+ const gap = 2;
145
+ let line = ' '.repeat(indent) + name.padEnd(nameWidth);
146
+
147
+ let descParts = [description];
148
+ if (type) {
149
+ descParts.push(`[${type}]`);
150
+ }
151
+ if (defaultVal !== undefined && defaultVal !== null) {
152
+ descParts.push(`[default: ${defaultVal}]`);
153
+ }
154
+
155
+ const descText = descParts.join(' ');
156
+ const descWidth = width - nameWidth - indent - gap;
157
+
158
+ if (descWidth > 20) {
159
+ line += ' '.repeat(gap) + wrapText(descText, descWidth, nameWidth + indent + gap);
160
+ } else {
161
+ line += ' '.repeat(gap) + descText;
162
+ }
163
+
164
+ return line;
165
+ }
166
+
167
+ // Calculate global name width for consistent alignment
168
+ function calculateGlobalNameWidth() {
169
+ let maxWidth = 0;
170
+
171
+ // Commands
172
+ for (const cmd of Object.keys(COMMANDS)) {
173
+ const cmdName = cmd ? `emailengine ${cmd}` : 'emailengine';
174
+ maxWidth = Math.max(maxWidth, cmdName.length);
175
+ if (COMMANDS[cmd].subcommands) {
176
+ maxWidth = Math.max(maxWidth, `emailengine ${cmd} [command]`.length);
177
+ for (const subCmd of Object.keys(COMMANDS[cmd].subcommands)) {
178
+ maxWidth = Math.max(maxWidth, `emailengine ${cmd} ${subCmd}`.length);
179
+ const subDef = COMMANDS[cmd].subcommands[subCmd];
180
+ if (subDef.options) {
181
+ for (const opt of subDef.options) {
182
+ maxWidth = Math.max(maxWidth, opt.name.length);
183
+ }
184
+ }
185
+ }
186
+ }
187
+ if (COMMANDS[cmd].options) {
188
+ for (const opt of COMMANDS[cmd].options) {
189
+ maxWidth = Math.max(maxWidth, opt.name.length);
190
+ }
191
+ }
192
+ }
193
+
194
+ // Global options
195
+ for (const opt of GLOBAL_OPTIONS) {
196
+ maxWidth = Math.max(maxWidth, opt.name.length);
197
+ }
198
+
199
+ return maxWidth;
200
+ }
201
+
202
+ function generateHelp() {
203
+ const width = getTerminalWidth();
204
+ const nameWidth = calculateGlobalNameWidth();
205
+ const lines = [];
206
+
207
+ lines.push('emailengine [command] [options]');
208
+ lines.push('');
209
+ lines.push(wrapText(
210
+ 'EmailEngine is the self-hosted service that allows you to access any email account using an easy-to-use REST API.',
211
+ width,
212
+ 0
213
+ ));
214
+ lines.push('');
215
+ lines.push('Commands:');
216
+
217
+ // Output commands
218
+ for (const [cmd, def] of Object.entries(COMMANDS)) {
219
+ const cmdName = cmd ? `emailengine ${cmd}` : 'emailengine';
220
+ lines.push(formatLine(cmdName, def.description, null, null, width, nameWidth));
221
+ if (def.subcommands) {
222
+ lines.push(formatLine(
223
+ `emailengine ${cmd} [command]`,
224
+ `${cmd.charAt(0).toUpperCase() + cmd.slice(1)} management`,
225
+ null,
226
+ null,
227
+ width,
228
+ nameWidth
229
+ ));
230
+ }
231
+ }
232
+
233
+ // Output global options by group
234
+ lines.push('');
235
+ lines.push('Options:');
236
+
237
+ let currentGroup = null;
238
+ for (const opt of GLOBAL_OPTIONS) {
239
+ if (opt.group && opt.group !== currentGroup) {
240
+ currentGroup = opt.group;
241
+ lines.push('');
242
+ lines.push(' ' + currentGroup + ':');
243
+ }
244
+ lines.push(formatLine(opt.name, opt.description, opt.type, opt.default, width, nameWidth));
245
+ }
246
+
247
+ // Output subcommand details
248
+ for (const [cmd, def] of Object.entries(COMMANDS)) {
249
+ if (def.subcommands) {
250
+ lines.push('');
251
+ lines.push(`${cmd.charAt(0).toUpperCase() + cmd.slice(1)} management commands:`);
252
+ for (const [subCmd, subDef] of Object.entries(def.subcommands)) {
253
+ lines.push(formatLine(`emailengine ${cmd} ${subCmd}`, subDef.description, null, null, width, nameWidth));
254
+ if (subDef.options) {
255
+ for (const opt of subDef.options) {
256
+ lines.push(formatLine(opt.name, opt.description, opt.type, opt.default, width, nameWidth));
257
+ }
258
+ }
259
+ }
260
+ } else if (def.options) {
261
+ lines.push('');
262
+ lines.push(`${cmd.charAt(0).toUpperCase() + cmd.slice(1).replace(/-/g, ' ')} options:`);
263
+ lines.push(formatLine(`emailengine ${cmd}`, def.description, null, null, width, nameWidth));
264
+ for (const opt of def.options) {
265
+ lines.push(formatLine(opt.name, opt.description, opt.type, opt.default, width, nameWidth));
266
+ }
267
+ if (def.example) {
268
+ lines.push('');
269
+ lines.push(' Example: ' + def.example);
270
+ }
271
+ }
272
+ }
273
+
274
+ return lines.join('\n');
275
+ }
276
+
277
+ function generateCommandHelp(cmdName) {
278
+ const width = getTerminalWidth();
279
+ const nameWidth = calculateGlobalNameWidth();
280
+ const lines = [];
281
+
282
+ // Check if command exists
283
+ if (!COMMANDS[cmdName]) {
284
+ return null;
285
+ }
286
+
287
+ const def = COMMANDS[cmdName];
288
+
289
+ lines.push(`emailengine ${cmdName} [options]`);
290
+ lines.push('');
291
+ lines.push(wrapText(def.description, width, 0));
292
+
293
+ if (def.subcommands) {
294
+ lines.push('');
295
+ lines.push('Commands:');
296
+ for (const [subCmd, subDef] of Object.entries(def.subcommands)) {
297
+ lines.push(formatLine(`emailengine ${cmdName} ${subCmd}`, subDef.description, null, null, width, nameWidth));
298
+ if (subDef.options) {
299
+ lines.push('');
300
+ lines.push(' Options:');
301
+ for (const opt of subDef.options) {
302
+ lines.push(formatLine(opt.name, opt.description, opt.type, opt.default, width, nameWidth));
303
+ }
304
+ }
305
+ }
306
+ } else if (def.options) {
307
+ lines.push('');
308
+ lines.push('Options:');
309
+ for (const opt of def.options) {
310
+ lines.push(formatLine(opt.name, opt.description, opt.type, opt.default, width, nameWidth));
311
+ }
312
+ }
313
+
314
+ if (def.example) {
315
+ lines.push('');
316
+ lines.push('Example:');
317
+ lines.push(' ' + def.example);
318
+ }
319
+
320
+ return lines.join('\n');
321
+ }
322
+
20
323
  function run() {
21
324
  let cmd = ((argv._ && argv._[0]) || '').toLowerCase();
22
325
  if (!cmd) {
@@ -88,16 +391,24 @@ function run() {
88
391
  break;
89
392
 
90
393
  case 'help':
91
- // Show version
92
- fs.readFile(pathlib.join(__dirname, '..', 'help.txt'), (err, helpText) => {
93
- if (err) {
94
- console.error('Failed to load help information');
95
- console.error(err);
96
- return process.exit(1);
394
+ {
395
+ // Show help for specific command or general help
396
+ let helpCmd = ((argv._ && argv._[1]) || '').toLowerCase();
397
+ if (helpCmd) {
398
+ let cmdHelp = generateCommandHelp(helpCmd);
399
+ if (cmdHelp) {
400
+ console.error(cmdHelp);
401
+ } else {
402
+ console.error(`Unknown command: ${helpCmd}`);
403
+ console.error('');
404
+ console.error('Run "emailengine help" to see available commands.');
405
+ process.exit(1);
406
+ }
407
+ } else {
408
+ console.error(generateHelp());
97
409
  }
98
- console.error(helpText.toString().trim());
99
410
  process.exit();
100
- });
411
+ }
101
412
  break;
102
413
 
103
414
  case 'version':
@@ -290,6 +601,75 @@ function run() {
290
601
  }
291
602
  break;
292
603
 
604
+ case 'check-bounce':
605
+ {
606
+ process.title = 'emailengine-check-bounce';
607
+ const { bounceDetect } = require('../lib/bounce-detect');
608
+ const bounceClassifier = require('@postalsys/bounce-classifier');
609
+
610
+ let emlPath = argv._[1] || argv.file || argv.f;
611
+
612
+ if (!emlPath) {
613
+ console.error('Error: EML file path is required');
614
+ console.error('Usage: emailengine check-bounce <path-to-eml-file>');
615
+ console.error(' emailengine check-bounce --file <path-to-eml-file>');
616
+ return process.exit(1);
617
+ }
618
+
619
+ // Resolve the path
620
+ emlPath = pathlib.resolve(emlPath);
621
+
622
+ // Check if file exists
623
+ if (!fs.existsSync(emlPath)) {
624
+ console.error(`Error: File not found: ${emlPath}`);
625
+ return process.exit(1);
626
+ }
627
+
628
+ const checkBounce = async () => {
629
+ // Initialize the bounce classifier
630
+ await bounceClassifier.initialize();
631
+
632
+ // Read the EML file
633
+ const emlStream = fs.createReadStream(emlPath);
634
+
635
+ // Detect bounce information
636
+ const bounce = await bounceDetect(emlStream);
637
+
638
+ // Classify the bounce if we have a response message
639
+ if (bounce?.response?.message) {
640
+ const classification = await bounceClassifier.classify(bounce.response.message);
641
+
642
+ if (classification?.label) {
643
+ bounce.response.category = classification.label;
644
+ }
645
+ if (classification?.action) {
646
+ bounce.response.recommendedAction = classification.action;
647
+ }
648
+ if (classification?.blocklist) {
649
+ bounce.response.blocklist = classification.blocklist;
650
+ }
651
+ if (classification?.retryAfter) {
652
+ bounce.response.retryAfter = classification.retryAfter;
653
+ }
654
+ }
655
+
656
+ return bounce;
657
+ };
658
+
659
+ checkBounce()
660
+ .then(result => {
661
+ process.stdout.write(JSON.stringify(result, null, 2));
662
+ process.stdout.write('\n');
663
+ return process.exit(0);
664
+ })
665
+ .catch(err => {
666
+ console.error('Failed to analyze bounce email');
667
+ console.error(err);
668
+ return process.exit(1);
669
+ });
670
+ }
671
+ break;
672
+
293
673
  default:
294
674
  // run normally
295
675
  require('../server');
@@ -1,5 +1,5 @@
1
1
  {
2
- "creationTime": "2025-12-16T15:46:10.000000",
2
+ "creationTime": "2025-12-18T15:45:44.000000",
3
3
  "prefixes": [
4
4
  {
5
5
  "ipv6Prefix": "2001:4860:4801:2008::/64"
package/lib/arf-detect.js CHANGED
@@ -77,7 +77,9 @@ const arfDetect = async messageInfo => {
77
77
  }
78
78
 
79
79
  case 'message/rfc822':
80
- case 'message/rfc822-headers': {
80
+ case 'message/rfc822-headers':
81
+ case 'text/rfc822-headers':
82
+ case 'text/rfc822-header': {
81
83
  let contents = (attachment.content || '').toString();
82
84
  const headerPos = contents.match(/\r?\n\r?\n/);
83
85