|
6 | 6 | const ms = require('ms'); |
7 | 7 | const pWaitFor = require('p-wait-for'); |
8 | 8 |
|
9 | | -const logger = require('#helpers/logger'); |
10 | | - |
11 | | -// |
12 | | -// NOTE: We use polling instead of IMAP IDLE for all providers because: |
13 | | -// 1. Gmail's IDLE EXISTS notifications are delayed 30+ seconds |
14 | | -// 2. Yahoo does not support IMAP IDLE at all |
15 | | -// - https://stackoverflow.com/a/71254393 |
16 | | -// - https://github.com/ikvk/imap_tools/blob/master/tests/test_idle.py |
17 | | -// 3. Polling provides consistent, predictable behavior across all providers |
18 | | -// |
19 | | -const POLLING_INTERVAL = 0; // ms('1s'); |
20 | | - |
21 | | -/** |
22 | | - * Wait for a message to arrive using continuous polling. |
23 | | - * Checks the mailbox at regular intervals until the message is found or timeout. |
24 | | - * |
25 | | - * @param {Object} imapClient - ImapFlow client instance (should have mailbox selected) |
26 | | - * @param {Object} info - Message info containing messageId |
27 | | - * @param {Object} provider - Provider configuration |
28 | | - * @returns {Promise<{provider: Object, received: Date|null, err: Error|null}>} |
29 | | - */ |
30 | 9 | async function getMessage(imapClient, info, provider) { |
31 | 10 | let received; |
32 | 11 | let err; |
33 | | - const startTime = Date.now(); |
34 | | - const timeout = ms('1m'); |
35 | | - |
36 | | - // Extract the unique part of message ID for matching |
37 | | - const messageIdPart = info.messageId |
38 | | - .replace('<', '') |
39 | | - .replace('>', '') |
40 | | - .split('@')[0]; |
41 | | - |
42 | | - logger.debug('getMessage started', { |
43 | | - provider: provider.name, |
44 | | - messageId: info.messageId, |
45 | | - messageIdPart, |
46 | | - timeout, |
47 | | - pollingInterval: POLLING_INTERVAL, |
48 | | - mailboxPath: imapClient.mailbox?.path, |
49 | | - mailboxExists: imapClient.mailbox?.exists, |
50 | | - clientUsable: imapClient.usable |
51 | | - }); |
52 | | - |
53 | | - try { |
54 | | - // First check if message already exists (in case it arrived before we started waiting) |
55 | | - logger.debug('Checking if message already exists', { |
56 | | - provider: provider.name, |
57 | | - messageIdPart |
58 | | - }); |
59 | | - |
60 | | - const checkStartTime = Date.now(); |
61 | | - const alreadyExists = await checkForMessage(imapClient, messageIdPart); |
62 | | - const checkDuration = Date.now() - checkStartTime; |
63 | | - |
64 | | - logger.debug('Initial message check completed', { |
65 | | - provider: provider.name, |
66 | | - alreadyExists, |
67 | | - checkDurationMs: checkDuration |
68 | | - }); |
69 | | - |
70 | | - if (alreadyExists) { |
71 | | - received = new Date(); |
72 | | - logger.info('Message already exists (no wait needed)', { |
73 | | - provider: provider.name, |
74 | | - messageId: info.messageId, |
75 | | - timeMs: Date.now() - startTime |
76 | | - }); |
77 | | - return { provider, received, err }; |
78 | | - } |
79 | | - |
80 | | - // Use polling to wait for message |
81 | | - logger.debug('Message not found, starting polling', { |
82 | | - provider: provider.name, |
83 | | - messageIdPart, |
84 | | - timeoutMs: timeout, |
85 | | - pollingIntervalMs: POLLING_INTERVAL |
86 | | - }); |
87 | | - |
88 | | - received = await waitForMessageWithPolling(imapClient, messageIdPart, { |
89 | | - timeout, |
90 | | - provider, |
91 | | - info, |
92 | | - startTime |
93 | | - }); |
94 | | - } catch (_err) { |
95 | | - err = _err; |
96 | | - logger.debug('getMessage caught error', { |
97 | | - provider: provider.name, |
98 | | - error: _err.message, |
99 | | - stack: _err.stack |
100 | | - }); |
101 | | - } |
102 | | - |
103 | | - const totalTime = Date.now() - startTime; |
104 | | - |
105 | | - // Log timing for debugging |
106 | | - if (received) { |
107 | | - logger.info('getMessage completed successfully', { |
108 | | - provider: provider.name, |
109 | | - totalTimeMs: totalTime, |
110 | | - messageId: info.messageId |
111 | | - }); |
112 | | - } else { |
113 | | - logger.warn('getMessage completed without finding message', { |
114 | | - provider: provider.name, |
115 | | - totalTimeMs: totalTime, |
116 | | - messageId: info.messageId, |
117 | | - error: err ? err.message : 'no error' |
118 | | - }); |
119 | | - } |
120 | | - |
121 | | - return { provider, received, err }; |
122 | | -} |
123 | | - |
124 | | -/** |
125 | | - * Check if a message with the given ID part already exists in the mailbox |
126 | | - */ |
127 | | -async function checkForMessage(imapClient, messageIdPart) { |
128 | | - const checkStartTime = Date.now(); |
129 | | - |
130 | | - try { |
131 | | - // Issue NOOP to refresh mailbox state before checking |
132 | | - // This is required for Yahoo which doesn't push EXISTS updates |
133 | | - // Without NOOP, Yahoo returns stale mailbox data |
134 | | - try { |
135 | | - await imapClient.noop(); |
136 | | - } catch (noopErr) { |
137 | | - logger.debug('NOOP failed, continuing anyway', { |
138 | | - error: noopErr.message |
139 | | - }); |
140 | | - } |
141 | | - |
142 | | - // Get current mailbox status (now refreshed after NOOP) |
143 | | - const status = imapClient.mailbox; |
144 | | - |
145 | | - logger.debug('checkForMessage: mailbox status', { |
146 | | - path: status?.path, |
147 | | - exists: status?.exists, |
148 | | - uidNext: status?.uidNext, |
149 | | - uidValidity: status?.uidValidity |
150 | | - }); |
151 | | - |
152 | | - if (!status || !status.exists || status.exists === 0) { |
153 | | - logger.debug('checkForMessage: mailbox empty or not selected', { |
154 | | - hasStatus: Boolean(status), |
155 | | - exists: status?.exists |
156 | | - }); |
157 | | - return false; |
158 | | - } |
159 | | - |
160 | | - // Only check the last 10 messages for efficiency |
161 | | - const startSeq = Math.max(1, status.exists - 9); |
162 | | - const range = `${startSeq}:*`; |
163 | | - |
164 | | - logger.debug('checkForMessage: fetching messages', { |
165 | | - range, |
166 | | - totalMessages: status.exists, |
167 | | - checkingCount: status.exists - startSeq + 1 |
168 | | - }); |
169 | | - |
170 | | - let messagesChecked = 0; |
171 | | - for await (const message of imapClient.fetch(range, { |
172 | | - envelope: true |
173 | | - })) { |
174 | | - messagesChecked++; |
175 | | - const msgId = message.envelope?.messageId; |
176 | | - |
177 | | - logger.debug('checkForMessage: checking message', { |
178 | | - seq: message.seq, |
179 | | - uid: message.uid, |
180 | | - messageId: msgId, |
181 | | - lookingFor: messageIdPart, |
182 | | - matches: msgId ? msgId.includes(messageIdPart) : false |
183 | | - }); |
184 | | - |
185 | | - if (msgId && msgId.includes(messageIdPart)) { |
186 | | - logger.debug('checkForMessage: FOUND matching message', { |
187 | | - seq: message.seq, |
188 | | - uid: message.uid, |
189 | | - messageId: msgId, |
190 | | - durationMs: Date.now() - checkStartTime |
191 | | - }); |
192 | | - return true; |
193 | | - } |
194 | | - } |
195 | | - |
196 | | - logger.debug('checkForMessage: no match found', { |
197 | | - messagesChecked, |
198 | | - durationMs: Date.now() - checkStartTime |
199 | | - }); |
200 | | - } catch (err) { |
201 | | - // Log but don't throw - we'll retry on next poll |
202 | | - logger.warn('checkForMessage: error during check', { |
203 | | - error: err.message, |
204 | | - stack: err.stack, |
205 | | - durationMs: Date.now() - checkStartTime |
206 | | - }); |
207 | | - } |
208 | | - |
209 | | - return false; |
210 | | -} |
211 | | - |
212 | | -/** |
213 | | - * Wait for a message using continuous polling. |
214 | | - * |
215 | | - * We use polling instead of IMAP IDLE because: |
216 | | - * 1. Gmail's IDLE EXISTS notifications are delayed 30+ seconds |
217 | | - * 2. Yahoo does not support IMAP IDLE at all |
218 | | - * - https://stackoverflow.com/a/71254393 |
219 | | - * - https://github.com/ikvk/imap_tools/blob/master/tests/test_idle.py |
220 | | - * 3. Polling provides consistent, predictable behavior across all providers |
221 | | - */ |
222 | | -async function waitForMessageWithPolling(imapClient, messageIdPart, options) { |
223 | | - const { timeout, provider, info, startTime } = options; |
224 | | - |
225 | | - logger.debug('waitForMessageWithPolling: starting', { |
226 | | - provider: provider.name, |
227 | | - messageIdPart, |
228 | | - timeoutMs: timeout, |
229 | | - pollingIntervalMs: POLLING_INTERVAL |
230 | | - }); |
231 | | - |
232 | | - let pollCount = 0; |
233 | | - |
234 | 12 | try { |
235 | 13 | await pWaitFor( |
236 | 14 | async () => { |
237 | | - pollCount++; |
238 | | - const pollStartTime = Date.now(); |
239 | | - |
240 | | - logger.debug('Polling: checking for message', { |
241 | | - provider: provider.name, |
242 | | - pollCount, |
243 | | - timeSinceStartMs: Date.now() - startTime |
244 | | - }); |
245 | | - |
246 | | - const found = await checkForMessage(imapClient, messageIdPart); |
247 | | - |
248 | | - logger.debug('Polling: check completed', { |
249 | | - provider: provider.name, |
250 | | - pollCount, |
251 | | - found, |
252 | | - checkDurationMs: Date.now() - pollStartTime |
253 | | - }); |
254 | | - |
255 | | - return found; |
| 15 | + // TODO: IMAP Protocol Extension Support |
| 16 | + // TODO: render a page with each provider's capabilities |
| 17 | + // <https://gist.github.com/nevans/8ef449da0786f9d1cc7c8324a288dd9b> |
| 18 | + // /blog/smtp-capability-command-by-provider |
| 19 | + // /blog/smtp-jmap-capability-imaprev |
| 20 | + // console.log('capabilities', imapClient.capabilities); |
| 21 | + |
| 22 | + try { |
| 23 | + // |
| 24 | + // NOTE: We issue NOOP before each fetch to refresh mailbox state. |
| 25 | + // This is required for Yahoo which doesn't push EXISTS updates. |
| 26 | + // Without NOOP, Yahoo returns stale mailbox data. |
| 27 | + // - https://stackoverflow.com/a/71254393 |
| 28 | + // - https://github.com/ikvk/imap_tools/blob/master/tests/test_idle.py |
| 29 | + // |
| 30 | + try { |
| 31 | + await imapClient.noop(); |
| 32 | + } catch {} |
| 33 | + |
| 34 | + for await (const message of imapClient.fetch('*', { |
| 35 | + headers: ['Message-ID'] |
| 36 | + })) { |
| 37 | + if (received) break; |
| 38 | + if ( |
| 39 | + message.headers && |
| 40 | + message.headers |
| 41 | + .toString() |
| 42 | + .includes( |
| 43 | + info.messageId.replace('<', '').replace('>', '').split('@')[1] |
| 44 | + ) |
| 45 | + ) { |
| 46 | + // |
| 47 | + // NOTE: due to NTP time differences we cannot rely on |
| 48 | + // a message's internal date from a given provider |
| 49 | + // nor can we rely on Recieved headers |
| 50 | + // nor can we rely on message envelope date |
| 51 | + // |
| 52 | + received = new Date(); |
| 53 | + } |
| 54 | + } |
| 55 | + } catch (_err) { |
| 56 | + err = _err; |
| 57 | + } |
| 58 | + |
| 59 | + if (err) throw err; |
| 60 | + |
| 61 | + return Boolean(received); |
256 | 62 | }, |
257 | 63 | { |
258 | | - interval: POLLING_INTERVAL, |
259 | | - timeout |
| 64 | + interval: 0, |
| 65 | + timeout: ms('1m') |
260 | 66 | } |
261 | 67 | ); |
262 | | - |
263 | | - const receivedTime = new Date(); |
264 | | - logger.info('Polling: message FOUND', { |
265 | | - provider: provider.name, |
266 | | - messageId: info.messageId, |
267 | | - totalTimeMs: Date.now() - startTime, |
268 | | - pollCount |
269 | | - }); |
270 | | - |
271 | | - return receivedTime; |
272 | | - } catch (err) { |
273 | | - logger.warn('Polling: timeout or error', { |
274 | | - provider: provider.name, |
275 | | - messageId: info.messageId, |
276 | | - error: err.message, |
277 | | - totalTimeMs: Date.now() - startTime, |
278 | | - pollCount |
279 | | - }); |
280 | | - throw new Error('Polling timeout waiting for message'); |
| 68 | + } catch (_err) { |
| 69 | + err = _err; |
281 | 70 | } |
| 71 | + |
| 72 | + return { provider, received, err }; |
282 | 73 | } |
283 | 74 |
|
284 | 75 | module.exports = getMessage; |
0 commit comments