Haraka 3.1.4 → 3.1.5

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.
@@ -25,279 +25,202 @@ const queue_dir = path.resolve(__dirname, '../test-queue')
25
25
 
26
26
  // ── Helpers ───────────────────────────────────────────────────────────────────
27
27
 
28
- const ensureQueueDir = () =>
28
+ const ensureQueueDir = () => fs.promises.mkdir(queue_dir, { recursive: true })
29
+
30
+ const cleanQueueDir = async () => {
31
+ if (!fs.existsSync(queue_dir)) return
32
+ for (const file of fs.readdirSync(queue_dir)) {
33
+ const full = path.resolve(queue_dir, file)
34
+ if (fs.lstatSync(full).isDirectory()) throw new Error(`unexpected subdirectory: ${full}`)
35
+ fs.unlinkSync(full)
36
+ }
37
+ }
38
+
39
+ const mockHMailItem = (ctx, opts = {}) =>
29
40
  new Promise((resolve, reject) => {
30
- fs.exists(queue_dir, (exists) => {
31
- if (exists) return resolve()
32
- fs.mkdir(queue_dir, (err) => (err ? reject(err) : resolve()))
33
- })
41
+ util_hmailitem.newMockHMailItem(ctx, reject, opts, resolve)
34
42
  })
35
43
 
36
- const cleanQueueDir = () =>
44
+ /** Spies on HMailItem.prototype.temp_fail and resolves when called. */
45
+ const interceptTempFail = (mock_hmail, mock_socket, assertion, conversation) =>
37
46
  new Promise((resolve, reject) => {
38
- fs.exists(queue_dir, (exists) => {
39
- if (!exists) return resolve()
47
+ const orig = HMailItem.prototype.temp_fail
48
+ HMailItem.prototype.temp_fail = function () {
40
49
  try {
41
- for (const file of fs.readdirSync(queue_dir)) {
42
- const full = path.resolve(queue_dir, file)
43
- if (fs.lstatSync(full).isDirectory()) return reject(new Error(`unexpected subdirectory: ${full}`))
44
- fs.unlinkSync(full)
45
- }
50
+ assertion(this)
46
51
  resolve()
47
- } catch (err) {
48
- reject(err)
52
+ } catch (e) {
53
+ reject(e)
54
+ } finally {
55
+ HMailItem.prototype.temp_fail = orig
49
56
  }
50
- })
57
+ }
58
+ util_hmailitem.playTestSmtpConversation(mock_hmail, mock_socket, reject, conversation, () => {})
51
59
  })
52
60
 
53
- const mockHMailItem = (ctx, opts = {}) =>
61
+ /** Spies on outbound.send_email and resolves when called. */
62
+ const interceptSendEmail = (mock_hmail, mock_socket, assertion, conversation) =>
54
63
  new Promise((resolve, reject) => {
55
- util_hmailitem.newMockHMailItem(ctx, reject, opts, resolve)
64
+ const orig = outbound_context.exports.send_email
65
+ outbound_context.exports.send_email = (from, to, contents) => {
66
+ try {
67
+ assertion(contents)
68
+ resolve()
69
+ } catch (e) {
70
+ reject(e)
71
+ } finally {
72
+ outbound_context.exports.send_email = orig
73
+ }
74
+ }
75
+ util_hmailitem.playTestSmtpConversation(mock_hmail, mock_socket, reject, conversation, () => {})
56
76
  })
57
77
 
78
+ // ── Shared conversation building blocks ───────────────────────────────────────
79
+
80
+ const EHLO_PREAMBLE = [
81
+ { from: 'remote', line: '220 testing-smtp' },
82
+ { from: 'haraka', test: (l) => l.match(/^EHLO /), description: 'EHLO' },
83
+ { from: 'remote', line: '220-testing-smtp' },
84
+ { from: 'remote', line: '220 8BITMIME' },
85
+ ]
86
+ const MAIL_OK = [
87
+ { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
88
+ { from: 'remote', line: '250 2.1.0 Ok' },
89
+ ]
90
+ const RCPT_OK = [
91
+ { from: 'haraka', test: 'RCPT TO:<recipient@domain>' },
92
+ { from: 'remote', line: '250 2.1.5 Ok' },
93
+ ]
94
+ const QUIT = { from: 'haraka', test: 'QUIT', end_test: true }
95
+
58
96
  // ── Tests ─────────────────────────────────────────────────────────────────────
59
97
 
98
+ // Permanent bounce tests: spy on send_email, check DSN bounce contents
99
+ const bounceCases = [
100
+ {
101
+ name: 'MAIL FROM 500 triggers RFC3464 bounce with status 5.0.0',
102
+ statusRe: /^Status: 5\.0\.0/m,
103
+ messageRe: /Absolutely not acceptable\. Basic Test Only\./,
104
+ conversation: [
105
+ ...EHLO_PREAMBLE,
106
+ { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
107
+ { from: 'remote', line: '500 5.0.0 Absolutely not acceptable. Basic Test Only.' },
108
+ QUIT,
109
+ ],
110
+ },
111
+ {
112
+ name: 'RCPT-TO 5XX triggers RFC3464 bounce with status 5.1.1',
113
+ statusRe: /^Status: 5\.1\.1/m,
114
+ messageRe: /Not available and will not come back/,
115
+ conversation: [
116
+ ...EHLO_PREAMBLE,
117
+ ...MAIL_OK,
118
+ { from: 'haraka', test: 'RCPT TO:<recipient@domain>' },
119
+ { from: 'remote', line: '550 5.1.1 Not available and will not come back' },
120
+ QUIT,
121
+ ],
122
+ },
123
+ {
124
+ name: 'DATA 5XX triggers RFC3464 bounce with status 5.6.0',
125
+ statusRe: /^Status: 5\.6\.0/m,
126
+ messageRe: /I never did and will like ascii art cats/,
127
+ conversation: [
128
+ ...EHLO_PREAMBLE,
129
+ ...MAIL_OK,
130
+ ...RCPT_OK,
131
+ { from: 'haraka', test: 'DATA' },
132
+ { from: 'remote', line: '550 5.6.0 I never did and will like ascii art cats.' },
133
+ QUIT,
134
+ ],
135
+ },
136
+ ]
137
+
138
+ // Temporary failure tests: spy on temp_fail, check DSN rcpt fields
139
+ const tempFailCases = [
140
+ {
141
+ name: 'early 3XX response triggers temp_fail with status 3.0.0',
142
+ dsn_status: '3.0.0',
143
+ dsn_action: 'delayed',
144
+ smtpRe: /No time for you right now/,
145
+ conversation: [
146
+ ...EHLO_PREAMBLE,
147
+ { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
148
+ { from: 'remote', line: '300 3.0.0 No time for you right now' },
149
+ QUIT,
150
+ ],
151
+ },
152
+ {
153
+ name: 'RCPT-TO 4XX triggers temp_fail with status 4.0.0',
154
+ dsn_status: '4.0.0',
155
+ dsn_action: 'delayed',
156
+ smtpRe: /Currently not available\. Try again later\./,
157
+ conversation: [
158
+ ...EHLO_PREAMBLE,
159
+ ...MAIL_OK,
160
+ { from: 'haraka', test: 'RCPT TO:<recipient@domain>' },
161
+ { from: 'remote', line: '400 4.0.0 Currently not available. Try again later.' },
162
+ QUIT,
163
+ ],
164
+ },
165
+ {
166
+ name: 'DATA 4XX triggers temp_fail with status 4.6.0',
167
+ dsn_status: '4.6.0',
168
+ dsn_action: 'delayed',
169
+ smtpRe: /Currently I do not like ascii art cats\./,
170
+ conversation: [
171
+ ...EHLO_PREAMBLE,
172
+ ...MAIL_OK,
173
+ ...RCPT_OK,
174
+ { from: 'haraka', test: 'DATA' },
175
+ { from: 'remote', line: '450 4.6.0 Currently I do not like ascii art cats.' },
176
+ QUIT,
177
+ ],
178
+ },
179
+ ]
180
+
60
181
  describe('outbound_bounce_rfc3464', () => {
61
182
  beforeEach(ensureQueueDir)
62
183
  afterEach(cleanQueueDir)
63
184
 
64
- it('MAIL FROM 500 triggers RFC3464 bounce with status 5.0.0', async () => {
65
- const mock_hmail = await mockHMailItem(outbound_context)
66
- const mock_socket = mock_sock.connect('testhost', 'testport')
67
- mock_socket.writable = true
68
-
69
- await new Promise((resolve, reject) => {
70
- const orig = outbound_context.exports.send_email
71
- outbound_context.exports.send_email = (from, to, contents, cb, opts) => {
72
- try {
73
- assert.match(contents, /^Content-type: message\/delivery-status/m)
74
- assert.match(contents, /^Final-Recipient: rfc822;recipient@domain/m)
75
- assert.match(contents, /^Action: failed/m)
76
- assert.match(contents, /^Status: 5\.0\.0/m)
77
- assert.match(contents, /Absolutely not acceptable\. Basic Test Only\./)
78
- resolve()
79
- } catch (e) {
80
- reject(e)
81
- } finally {
82
- outbound_context.exports.send_email = orig
83
- }
84
- }
85
- util_hmailitem.playTestSmtpConversation(
86
- mock_hmail,
87
- mock_socket,
88
- reject,
89
- [
90
- { from: 'remote', line: '220 testing-smtp' },
91
- { from: 'haraka', test: (l) => l.match(/^EHLO /), description: 'EHLO' },
92
- { from: 'remote', line: '220-testing-smtp' },
93
- { from: 'remote', line: '220 8BITMIME' },
94
- { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
95
- { from: 'remote', line: '500 5.0.0 Absolutely not acceptable. Basic Test Only.' },
96
- { from: 'haraka', test: 'QUIT', end_test: true },
97
- ],
98
- () => {},
99
- )
100
- })
185
+ describe('permanent bounce (send_email)', () => {
186
+ for (const { name, statusRe, messageRe, conversation } of bounceCases) {
187
+ it(name, async () => {
188
+ const mock_hmail = await mockHMailItem(outbound_context)
189
+ const mock_socket = mock_sock.connect('testhost', 'testport')
190
+ mock_socket.writable = true
191
+ await interceptSendEmail(
192
+ mock_hmail,
193
+ mock_socket,
194
+ (contents) => {
195
+ assert.match(contents, /^Content-type: message\/delivery-status/m)
196
+ assert.match(contents, /^Final-Recipient: rfc822;recipient@domain/m)
197
+ assert.match(contents, /^Action: failed/m)
198
+ assert.match(contents, statusRe)
199
+ assert.match(contents, messageRe)
200
+ },
201
+ conversation,
202
+ )
203
+ })
204
+ }
101
205
  })
102
206
 
103
- it('early 3XX response triggers temp_fail with status 3.0.0', async () => {
104
- const mock_hmail = await mockHMailItem(outbound_context)
105
- const mock_socket = mock_sock.connect('testhost', 'testport')
106
- mock_socket.writable = true
107
-
108
- await new Promise((resolve, reject) => {
109
- const orig = HMailItem.prototype.temp_fail
110
- HMailItem.prototype.temp_fail = function (err, opts) {
111
- try {
112
- assert.equal(this.todo.rcpt_to[0].dsn_status, '3.0.0')
113
- assert.equal(this.todo.rcpt_to[0].dsn_action, 'delayed')
114
- assert.match(this.todo.rcpt_to[0].dsn_smtp_response, /No time for you right now/)
115
- resolve()
116
- } catch (e) {
117
- reject(e)
118
- } finally {
119
- HMailItem.prototype.temp_fail = orig
120
- }
121
- }
122
- util_hmailitem.playTestSmtpConversation(
123
- mock_hmail,
124
- mock_socket,
125
- reject,
126
- [
127
- { from: 'remote', line: '220 testing-smtp' },
128
- { from: 'haraka', test: (l) => l.match(/^EHLO /), description: 'EHLO' },
129
- { from: 'remote', line: '220-testing-smtp' },
130
- { from: 'remote', line: '220 8BITMIME' },
131
- { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
132
- { from: 'remote', line: '300 3.0.0 No time for you right now' },
133
- { from: 'haraka', test: 'QUIT', end_test: true },
134
- ],
135
- () => {},
136
- )
137
- })
138
- })
139
-
140
- it('RCPT-TO 4XX triggers temp_fail with status 4.0.0', async () => {
141
- const mock_hmail = await mockHMailItem(outbound_context)
142
- const mock_socket = mock_sock.connect('testhost', 'testport')
143
- mock_socket.writable = true
144
-
145
- await new Promise((resolve, reject) => {
146
- const orig = HMailItem.prototype.temp_fail
147
- HMailItem.prototype.temp_fail = function (err, opts) {
148
- try {
149
- assert.equal(this.todo.rcpt_to[0].dsn_status, '4.0.0')
150
- assert.equal(this.todo.rcpt_to[0].dsn_action, 'delayed')
151
- assert.match(this.todo.rcpt_to[0].dsn_smtp_response, /Currently not available\. Try again later\./)
152
- resolve()
153
- } catch (e) {
154
- reject(e)
155
- } finally {
156
- HMailItem.prototype.temp_fail = orig
157
- }
158
- }
159
- util_hmailitem.playTestSmtpConversation(
160
- mock_hmail,
161
- mock_socket,
162
- reject,
163
- [
164
- { from: 'remote', line: '220 testing-smtp' },
165
- { from: 'haraka', test: (l) => l.match(/^EHLO /), description: 'EHLO' },
166
- { from: 'remote', line: '220-testing-smtp' },
167
- { from: 'remote', line: '220 8BITMIME' },
168
- { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
169
- { from: 'remote', line: '250 2.1.0 Ok' },
170
- { from: 'haraka', test: 'RCPT TO:<recipient@domain>' },
171
- { from: 'remote', line: '400 4.0.0 Currently not available. Try again later.' },
172
- { from: 'haraka', test: 'QUIT', end_test: true },
173
- ],
174
- () => {},
175
- )
176
- })
177
- })
178
-
179
- it('DATA 4XX triggers temp_fail with status 4.6.0', async () => {
180
- const mock_hmail = await mockHMailItem(outbound_context)
181
- const mock_socket = mock_sock.connect('testhost', 'testport')
182
- mock_socket.writable = true
183
-
184
- await new Promise((resolve, reject) => {
185
- const orig = HMailItem.prototype.temp_fail
186
- HMailItem.prototype.temp_fail = function (err, opts) {
187
- try {
188
- assert.equal(this.todo.rcpt_to[0].dsn_status, '4.6.0')
189
- assert.equal(this.todo.rcpt_to[0].dsn_action, 'delayed')
190
- assert.match(this.todo.rcpt_to[0].dsn_smtp_response, /Currently I do not like ascii art cats\./)
191
- resolve()
192
- } catch (e) {
193
- reject(e)
194
- } finally {
195
- HMailItem.prototype.temp_fail = orig
196
- }
197
- }
198
- util_hmailitem.playTestSmtpConversation(
199
- mock_hmail,
200
- mock_socket,
201
- reject,
202
- [
203
- { from: 'remote', line: '220 testing-smtp' },
204
- { from: 'haraka', test: (l) => l.match(/^EHLO /), description: 'EHLO' },
205
- { from: 'remote', line: '220-testing-smtp' },
206
- { from: 'remote', line: '220 8BITMIME' },
207
- { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
208
- { from: 'remote', line: '250 2.1.0 Ok' },
209
- { from: 'haraka', test: 'RCPT TO:<recipient@domain>' },
210
- { from: 'remote', line: '250 2.1.5 Ok' },
211
- { from: 'haraka', test: 'DATA' },
212
- { from: 'remote', line: '450 4.6.0 Currently I do not like ascii art cats.' },
213
- { from: 'haraka', test: 'QUIT', end_test: true },
214
- ],
215
- () => {},
216
- )
217
- })
218
- })
219
-
220
- it('RCPT-TO 5XX triggers RFC3464 bounce with status 5.1.1', async () => {
221
- const mock_hmail = await mockHMailItem(outbound_context)
222
- const mock_socket = mock_sock.connect('testhost', 'testport')
223
- mock_socket.writable = true
224
-
225
- await new Promise((resolve, reject) => {
226
- const orig = outbound_context.exports.send_email
227
- outbound_context.exports.send_email = (from, to, contents, cb, opts) => {
228
- try {
229
- assert.match(contents, /^Content-type: message\/delivery-status/m)
230
- assert.match(contents, /^Final-Recipient: rfc822;recipient@domain/m)
231
- assert.match(contents, /^Action: failed/m)
232
- assert.match(contents, /^Status: 5\.1\.1/m)
233
- assert.match(contents, /Not available and will not come back/)
234
- resolve()
235
- } catch (e) {
236
- reject(e)
237
- } finally {
238
- outbound_context.exports.send_email = orig
239
- }
240
- }
241
- util_hmailitem.playTestSmtpConversation(
242
- mock_hmail,
243
- mock_socket,
244
- reject,
245
- [
246
- { from: 'remote', line: '220 testing-smtp' },
247
- { from: 'haraka', test: (l) => l.match(/^EHLO /), description: 'EHLO' },
248
- { from: 'remote', line: '220-testing-smtp' },
249
- { from: 'remote', line: '220 8BITMIME' },
250
- { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
251
- { from: 'remote', line: '250 2.1.0 Ok' },
252
- { from: 'haraka', test: 'RCPT TO:<recipient@domain>' },
253
- { from: 'remote', line: '550 5.1.1 Not available and will not come back' },
254
- { from: 'haraka', test: 'QUIT', end_test: true },
255
- ],
256
- () => {},
257
- )
258
- })
259
- })
260
-
261
- it('DATA 5XX triggers RFC3464 bounce with status 5.6.0', async () => {
262
- const mock_hmail = await mockHMailItem(outbound_context)
263
- const mock_socket = mock_sock.connect('testhost', 'testport')
264
- mock_socket.writable = true
265
-
266
- await new Promise((resolve, reject) => {
267
- const orig = outbound_context.exports.send_email
268
- outbound_context.exports.send_email = (from, to, contents, cb, opts) => {
269
- try {
270
- assert.match(contents, /^Content-type: message\/delivery-status/m)
271
- assert.match(contents, /^Final-Recipient: rfc822;recipient@domain/m)
272
- assert.match(contents, /^Action: failed/m)
273
- assert.match(contents, /^Status: 5\.6\.0/m)
274
- assert.match(contents, /I never did and will like ascii art cats/)
275
- resolve()
276
- } catch (e) {
277
- reject(e)
278
- } finally {
279
- outbound_context.exports.send_email = orig
280
- }
281
- }
282
- util_hmailitem.playTestSmtpConversation(
283
- mock_hmail,
284
- mock_socket,
285
- reject,
286
- [
287
- { from: 'remote', line: '220 testing-smtp' },
288
- { from: 'haraka', test: (l) => l.match(/^EHLO /), description: 'EHLO' },
289
- { from: 'remote', line: '220-testing-smtp' },
290
- { from: 'remote', line: '220 8BITMIME' },
291
- { from: 'haraka', test: 'MAIL FROM:<sender@domain>' },
292
- { from: 'remote', line: '250 2.1.0 Ok' },
293
- { from: 'haraka', test: 'RCPT TO:<recipient@domain>' },
294
- { from: 'remote', line: '250 2.1.5 Ok' },
295
- { from: 'haraka', test: 'DATA' },
296
- { from: 'remote', line: '550 5.6.0 I never did and will like ascii art cats.' },
297
- { from: 'haraka', test: 'QUIT', end_test: true },
298
- ],
299
- () => {},
300
- )
301
- })
207
+ describe('temporary failure (temp_fail)', () => {
208
+ for (const { name, dsn_status, dsn_action, smtpRe, conversation } of tempFailCases) {
209
+ it(name, async () => {
210
+ const mock_hmail = await mockHMailItem(outbound_context)
211
+ const mock_socket = mock_sock.connect('testhost', 'testport')
212
+ mock_socket.writable = true
213
+ await interceptTempFail(
214
+ mock_hmail,
215
+ mock_socket,
216
+ (h) => {
217
+ assert.equal(h.todo.rcpt_to[0].dsn_status, dsn_status)
218
+ assert.equal(h.todo.rcpt_to[0].dsn_action, dsn_action)
219
+ assert.match(h.todo.rcpt_to[0].dsn_smtp_response, smtpRe)
220
+ },
221
+ conversation,
222
+ )
223
+ })
224
+ }
302
225
  })
303
226
  })