Skip to content

Commit 1cf179f

Browse files
committed
feat: Improve autoreply detection and add comprehensive tests
Enhanced isAutoreply method to detect more autoreply patterns: - Expand subject matching to include "Automatic reply:", "Auto reply:", and "Auto response:" variants (with or without space) - Add X-Auto-Response-Suppress header detection (Microsoft Exchange) - Make strong subject patterns work without requiring inReplyTo header - Keep inReplyTo requirement only for weaker "auto:" prefix matches Detection improved from 50% (3/6) to 100% (6/6) of RFC 3834 test emails. Added comprehensive test suite (25 tests) covering: - RFC 3834 email fixture parsing and detection - Subject pattern variations (Out of Office, OOF, OOO, Automatic reply) - Header-based detection (Auto-Submitted, Precedence, X-Auto-Response-Suppress) - Vendor-specific headers (X-Autoresponder, X-Autorespond, X-Autoreply) - Edge cases and negative tests Test fixtures from sisimai/set-of-emails (BSD 2-Clause License).
1 parent ec9347c commit 1cf179f

File tree

9 files changed

+558
-3
lines changed

9 files changed

+558
-3
lines changed

lib/email-client/base-client.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2385,8 +2385,14 @@ class BaseClient {
23852385
* @returns {boolean} True if message appears to be an auto-reply
23862386
*/
23872387
isAutoreply(messageData) {
2388-
// Check subject patterns
2389-
if (/^(auto:|Out of Office|OOF:|OOO:)/i.test(messageData.subject) && messageData.inReplyTo) {
2388+
// Check subject patterns - these are strong autoreply indicators
2389+
// Note: "Automatic reply:" and "Auto reply:" (with space) are common variants
2390+
if (/^(auto(matic)?\s*(reply|response)|Out of Office|OOF:|OOO:)/i.test(messageData.subject)) {
2391+
return true;
2392+
}
2393+
2394+
// Weaker subject patterns require inReplyTo as confirmation
2395+
if (/^auto:/i.test(messageData.subject) && messageData.inReplyTo) {
23902396
return true;
23912397
}
23922398

@@ -2399,11 +2405,17 @@ class BaseClient {
23992405
return true;
24002406
}
24012407

2402-
// Check Auto-Submitted header
2408+
// Check Auto-Submitted header (RFC 3834)
24032409
if (messageData.headers['auto-submitted'] && messageData.headers['auto-submitted'].some(e => /auto[_-]?replied/.test(e))) {
24042410
return true;
24052411
}
24062412

2413+
// Check X-Auto-Response-Suppress header (Microsoft Exchange)
2414+
// Values like "All", "OOF", "AutoReply" indicate this is an autoreply
2415+
if (messageData.headers['x-auto-response-suppress'] && messageData.headers['x-auto-response-suppress'].length) {
2416+
return true;
2417+
}
2418+
24072419
// Check various vendor-specific headers
24082420
for (let headerKey of ['x-autoresponder', 'x-autorespond', 'x-autoreply']) {
24092421
if (messageData.headers[headerKey] && messageData.headers[headerKey].length) {

test/autoreply-test.js

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
'use strict';
2+
3+
// Test fixtures in fixtures/autoreply/ are from:
4+
// https://github.com/sisimai/set-of-emails/
5+
// Licensed under BSD 2-Clause License, Copyright (C) 2014, azumakuniyuki
6+
7+
const test = require('node:test');
8+
const assert = require('node:assert').strict;
9+
10+
const { simpleParser } = require('mailparser');
11+
const fs = require('fs');
12+
const Path = require('path');
13+
14+
const path = fname => Path.join(__dirname, 'fixtures', 'autoreply', fname);
15+
16+
// Replicate isAutoreply logic from base-client.js for testing
17+
function isAutoreply(messageData) {
18+
// Check subject patterns - these are strong autoreply indicators
19+
// Note: "Automatic reply:" and "Auto reply:" (with space) are common variants
20+
if (/^(auto(matic)?\s*(reply|response)|Out of Office|OOF:|OOO:)/i.test(messageData.subject)) {
21+
return true;
22+
}
23+
24+
// Weaker subject patterns require inReplyTo as confirmation
25+
if (/^auto:/i.test(messageData.subject) && messageData.inReplyTo) {
26+
return true;
27+
}
28+
29+
if (!messageData.headers) {
30+
return false;
31+
}
32+
33+
// Check Precedence header
34+
if (messageData.headers.precedence && messageData.headers.precedence.some(e => /auto[_-]?reply/.test(e))) {
35+
return true;
36+
}
37+
38+
// Check Auto-Submitted header (RFC 3834)
39+
if (messageData.headers['auto-submitted'] && messageData.headers['auto-submitted'].some(e => /auto[_-]?replied/.test(e))) {
40+
return true;
41+
}
42+
43+
// Check X-Auto-Response-Suppress header (Microsoft Exchange)
44+
// Values like "All", "OOF", "AutoReply" indicate this is an autoreply
45+
if (messageData.headers['x-auto-response-suppress'] && messageData.headers['x-auto-response-suppress'].length) {
46+
return true;
47+
}
48+
49+
// Check various vendor-specific headers
50+
for (let headerKey of ['x-autoresponder', 'x-autorespond', 'x-autoreply']) {
51+
if (messageData.headers[headerKey] && messageData.headers[headerKey].length) {
52+
return true;
53+
}
54+
}
55+
56+
return false;
57+
}
58+
59+
// Convert parsed email headers to the format expected by isAutoreply
60+
function convertHeaders(parsed) {
61+
const headers = {};
62+
if (parsed.headers) {
63+
for (let [key, value] of parsed.headers) {
64+
let normalizedKey = key.toLowerCase();
65+
if (!headers[normalizedKey]) {
66+
headers[normalizedKey] = [];
67+
}
68+
headers[normalizedKey].push(value);
69+
}
70+
}
71+
return headers;
72+
}
73+
74+
// Helper to parse email and prepare messageData for isAutoreply
75+
async function parseForAutoreply(filePath) {
76+
const content = await fs.promises.readFile(filePath);
77+
const parsed = await simpleParser(content);
78+
79+
return {
80+
subject: parsed.subject || '',
81+
inReplyTo: parsed.inReplyTo || null,
82+
headers: convertHeaders(parsed)
83+
};
84+
}
85+
86+
test('RFC 3834 autoreply detection tests', async t => {
87+
await t.test('Auto-Submitted header detection (rfc3834-01)', async () => {
88+
const messageData = await parseForAutoreply(path('rfc3834-01.eml'));
89+
const result = isAutoreply(messageData);
90+
91+
// Has Auto-Submitted: auto-replied header
92+
assert.strictEqual(result, true);
93+
assert.ok(messageData.headers['auto-submitted']);
94+
assert.ok(messageData.headers['auto-submitted'].some(e => /auto-replied/.test(e)));
95+
});
96+
97+
await t.test('Automatic reply subject with X-Auto-Response-Suppress (rfc3834-02)', async () => {
98+
const messageData = await parseForAutoreply(path('rfc3834-02.eml'));
99+
const result = isAutoreply(messageData);
100+
101+
// Has subject "Automatic reply:" and X-Auto-Response-Suppress: All
102+
assert.strictEqual(result, true);
103+
assert.ok(/^Automatic reply:/i.test(messageData.subject));
104+
});
105+
106+
await t.test('Auto reply subject with In-Reply-To (rfc3834-03)', async () => {
107+
const messageData = await parseForAutoreply(path('rfc3834-03.eml'));
108+
const result = isAutoreply(messageData);
109+
110+
// Has subject "Auto reply:" with In-Reply-To header
111+
assert.strictEqual(result, true);
112+
assert.ok(/^Auto reply:/i.test(messageData.subject));
113+
assert.ok(messageData.inReplyTo);
114+
});
115+
116+
await t.test('Microsoft Exchange automatic reply (rfc3834-04)', async () => {
117+
const messageData = await parseForAutoreply(path('rfc3834-04.eml'));
118+
const result = isAutoreply(messageData);
119+
120+
// Has subject "Automatic reply:" and X-Auto-Response-Suppress: All
121+
assert.strictEqual(result, true);
122+
assert.ok(messageData.headers['x-auto-response-suppress']);
123+
});
124+
125+
await t.test('Auto-Submitted with random subject (rfc3834-05)', async () => {
126+
const messageData = await parseForAutoreply(path('rfc3834-05.eml'));
127+
const result = isAutoreply(messageData);
128+
129+
// Has Auto-Submitted: auto-replied with non-standard subject
130+
assert.strictEqual(result, true);
131+
assert.ok(messageData.headers['auto-submitted']);
132+
});
133+
134+
await t.test('Mimecast auto-response (rfc3834-06)', async () => {
135+
const messageData = await parseForAutoreply(path('rfc3834-06.eml'));
136+
const result = isAutoreply(messageData);
137+
138+
// Has Auto-Submitted and X-Auto-Response-Suppress headers
139+
assert.strictEqual(result, true);
140+
assert.ok(messageData.headers['auto-submitted']);
141+
assert.ok(messageData.headers['x-auto-response-suppress']);
142+
});
143+
});
144+
145+
test('isAutoreply heuristics', async t => {
146+
await t.test('Detects Out of Office subject', async () => {
147+
const messageData = {
148+
subject: 'Out of Office: I am away',
149+
inReplyTo: null,
150+
headers: {}
151+
};
152+
assert.strictEqual(isAutoreply(messageData), true);
153+
});
154+
155+
await t.test('Detects OOF prefix', async () => {
156+
const messageData = {
157+
subject: 'OOF: Automatic Reply',
158+
inReplyTo: null,
159+
headers: {}
160+
};
161+
assert.strictEqual(isAutoreply(messageData), true);
162+
});
163+
164+
await t.test('Detects OOO prefix', async () => {
165+
const messageData = {
166+
subject: 'OOO: Out of Office',
167+
inReplyTo: null,
168+
headers: {}
169+
};
170+
assert.strictEqual(isAutoreply(messageData), true);
171+
});
172+
173+
await t.test('Detects Automatic reply subject', async () => {
174+
const messageData = {
175+
subject: 'Automatic reply: Your message',
176+
inReplyTo: null,
177+
headers: {}
178+
};
179+
assert.strictEqual(isAutoreply(messageData), true);
180+
});
181+
182+
await t.test('Detects Auto reply subject (with space)', async () => {
183+
const messageData = {
184+
subject: 'Auto reply: Thanks',
185+
inReplyTo: null,
186+
headers: {}
187+
};
188+
assert.strictEqual(isAutoreply(messageData), true);
189+
});
190+
191+
await t.test('Detects Automatic response subject', async () => {
192+
const messageData = {
193+
subject: 'Automatic response: Meeting',
194+
inReplyTo: null,
195+
headers: {}
196+
};
197+
assert.strictEqual(isAutoreply(messageData), true);
198+
});
199+
200+
await t.test('Requires inReplyTo for weak auto: subject', async () => {
201+
const messageData = {
202+
subject: 'auto: Something',
203+
inReplyTo: null,
204+
headers: {}
205+
};
206+
assert.strictEqual(isAutoreply(messageData), false);
207+
208+
messageData.inReplyTo = '<some-message-id@example.com>';
209+
assert.strictEqual(isAutoreply(messageData), true);
210+
});
211+
212+
await t.test('Detects Precedence: auto_reply header', async () => {
213+
const messageData = {
214+
subject: 'Some subject',
215+
inReplyTo: null,
216+
headers: {
217+
precedence: ['auto_reply']
218+
}
219+
};
220+
assert.strictEqual(isAutoreply(messageData), true);
221+
});
222+
223+
await t.test('Detects Precedence: auto-reply header', async () => {
224+
const messageData = {
225+
subject: 'Some subject',
226+
inReplyTo: null,
227+
headers: {
228+
precedence: ['auto-reply']
229+
}
230+
};
231+
assert.strictEqual(isAutoreply(messageData), true);
232+
});
233+
234+
await t.test('Detects Auto-Submitted: auto-replied header', async () => {
235+
const messageData = {
236+
subject: 'Some subject',
237+
inReplyTo: null,
238+
headers: {
239+
'auto-submitted': ['auto-replied']
240+
}
241+
};
242+
assert.strictEqual(isAutoreply(messageData), true);
243+
});
244+
245+
await t.test('Detects X-Auto-Response-Suppress header', async () => {
246+
const messageData = {
247+
subject: 'Some subject',
248+
inReplyTo: null,
249+
headers: {
250+
'x-auto-response-suppress': ['All']
251+
}
252+
};
253+
assert.strictEqual(isAutoreply(messageData), true);
254+
});
255+
256+
await t.test('Detects X-Autoresponder header', async () => {
257+
const messageData = {
258+
subject: 'Some subject',
259+
inReplyTo: null,
260+
headers: {
261+
'x-autoresponder': ['true']
262+
}
263+
};
264+
assert.strictEqual(isAutoreply(messageData), true);
265+
});
266+
267+
await t.test('Detects X-Autorespond header', async () => {
268+
const messageData = {
269+
subject: 'Some subject',
270+
inReplyTo: null,
271+
headers: {
272+
'x-autorespond': ['yes']
273+
}
274+
};
275+
assert.strictEqual(isAutoreply(messageData), true);
276+
});
277+
278+
await t.test('Detects X-Autoreply header', async () => {
279+
const messageData = {
280+
subject: 'Some subject',
281+
inReplyTo: null,
282+
headers: {
283+
'x-autoreply': ['yes']
284+
}
285+
};
286+
assert.strictEqual(isAutoreply(messageData), true);
287+
});
288+
289+
await t.test('Rejects regular email', async () => {
290+
const messageData = {
291+
subject: 'Weekly Newsletter',
292+
inReplyTo: null,
293+
headers: {
294+
from: ['newsletter@example.com']
295+
}
296+
};
297+
assert.strictEqual(isAutoreply(messageData), false);
298+
});
299+
300+
await t.test('Rejects email with similar but non-matching subject', async () => {
301+
const messageData = {
302+
subject: 'Automatic update notification',
303+
inReplyTo: null,
304+
headers: {}
305+
};
306+
assert.strictEqual(isAutoreply(messageData), false);
307+
});
308+
309+
await t.test('Handles missing headers gracefully', async () => {
310+
const messageData = {
311+
subject: 'Test',
312+
inReplyTo: null,
313+
headers: null
314+
};
315+
assert.strictEqual(isAutoreply(messageData), false);
316+
});
317+
});

test/fixtures/autoreply/LICENSE

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Email fixtures in this directory are from:
2+
https://github.com/sisimai/set-of-emails/
3+
4+
THIS SOFTWARE IS DISTRIBUTED UNDER THE FOLLOWING BSD 2-CLAUSE LICENSE:
5+
Copyright (C) 2014, azumakuniyuki
6+
7+
Redistribution and use in source and binary forms, with or without
8+
modification, are permitted provided that the following conditions are met:
9+
10+
1. Redistributions of source code must retain the above copyright notice,
11+
this list of conditions and the following disclaimer.
12+
13+
2. Redistributions in binary form must reproduce the above copyright notice,
14+
this list of conditions and the following disclaimer in the documentation
15+
and/or other materials provided with the distribution.
16+
17+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
21+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27+
POSSIBILITY OF SUCH DAMAGE.

0 commit comments

Comments
 (0)