|
| 1 | +/* eslint-disable @typescript-eslint/no-use-before-define */ |
| 2 | +import { EventEmitter } from 'events'; |
| 3 | +import type Imap from 'imap'; |
| 4 | +import { type ImapMessage } from 'imap'; |
| 5 | +import * as qp from 'quoted-printable'; |
| 6 | +import * as iconvlite from 'iconv-lite'; |
| 7 | +import * as utf8 from 'utf8'; |
| 8 | +import * as uuencode from 'uuencode'; |
| 9 | + |
| 10 | +import { getMessage } from './helpers/getMessage'; |
| 11 | +import type { Message, MessagePart } from './types'; |
| 12 | + |
| 13 | +const IMAP_EVENTS = ['alert', 'mail', 'expunge', 'uidvalidity', 'update', 'close', 'end'] as const; |
| 14 | + |
| 15 | +export class ImapSimple extends EventEmitter { |
| 16 | + /** flag to determine whether we should suppress ECONNRESET from bubbling up to listener */ |
| 17 | + private ending = false; |
| 18 | + |
| 19 | + constructor(private readonly imap: Imap) { |
| 20 | + super(); |
| 21 | + |
| 22 | + // pass most node-imap `Connection` events through 1:1 |
| 23 | + IMAP_EVENTS.forEach((event) => { |
| 24 | + this.imap.on(event, this.emit.bind(this, event)); |
| 25 | + }); |
| 26 | + |
| 27 | + // special handling for `error` event |
| 28 | + this.imap.on('error', (e: Error & { code?: string }) => { |
| 29 | + // if .end() has been called and an 'ECONNRESET' error is received, don't bubble |
| 30 | + if (e && this.ending && e.code?.toUpperCase() === 'ECONNRESET') { |
| 31 | + return; |
| 32 | + } |
| 33 | + this.emit('error', e); |
| 34 | + }); |
| 35 | + } |
| 36 | + |
| 37 | + /** disconnect from the imap server */ |
| 38 | + end(): void { |
| 39 | + // set state flag to suppress 'ECONNRESET' errors that are triggered when .end() is called. |
| 40 | + // it is a known issue that has no known fix. This just temporarily ignores that error. |
| 41 | + // https://github.com/mscdex/node-imap/issues/391 |
| 42 | + // https://github.com/mscdex/node-imap/issues/395 |
| 43 | + this.ending = true; |
| 44 | + |
| 45 | + // using 'close' event to unbind ECONNRESET error handler, because the node-imap |
| 46 | + // maintainer claims it is the more reliable event between 'end' and 'close'. |
| 47 | + // https://github.com/mscdex/node-imap/issues/394 |
| 48 | + this.imap.once('close', () => { |
| 49 | + this.ending = false; |
| 50 | + }); |
| 51 | + |
| 52 | + this.imap.end(); |
| 53 | + } |
| 54 | + |
| 55 | + /** |
| 56 | + * Search the currently open mailbox, and retrieve the results |
| 57 | + * |
| 58 | + * Results are in the form: |
| 59 | + * |
| 60 | + * [{ |
| 61 | + * attributes: object, |
| 62 | + * parts: [ { which: string, size: number, body: string }, ... ] |
| 63 | + * }, ...] |
| 64 | + * |
| 65 | + * See node-imap's ImapMessage signature for information about `attributes`, `which`, `size`, and `body`. |
| 66 | + * For any message part that is a `HEADER`, the body is automatically parsed into an object. |
| 67 | + */ |
| 68 | + async search( |
| 69 | + /** Criteria to use to search. Passed to node-imap's .search() 1:1 */ |
| 70 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 71 | + searchCriteria: any[], |
| 72 | + /** Criteria to use to fetch the search results. Passed to node-imap's .fetch() 1:1 */ |
| 73 | + fetchOptions: Imap.FetchOptions, |
| 74 | + ) { |
| 75 | + return await new Promise<Message[]>((resolve, reject) => { |
| 76 | + this.imap.search(searchCriteria, (e, uids) => { |
| 77 | + if (e) { |
| 78 | + reject(e); |
| 79 | + return; |
| 80 | + } |
| 81 | + |
| 82 | + if (uids.length === 0) { |
| 83 | + resolve([]); |
| 84 | + return; |
| 85 | + } |
| 86 | + |
| 87 | + const fetch = this.imap.fetch(uids, fetchOptions); |
| 88 | + let messagesRetrieved = 0; |
| 89 | + const messages: Message[] = []; |
| 90 | + |
| 91 | + const fetchOnMessage = async (message: Imap.ImapMessage, seqNo: number) => { |
| 92 | + const msg: Message = await getMessage(message); |
| 93 | + msg.seqNo = seqNo; |
| 94 | + messages[seqNo] = msg; |
| 95 | + |
| 96 | + messagesRetrieved++; |
| 97 | + if (messagesRetrieved === uids.length) { |
| 98 | + resolve(messages.filter((m) => !!m)); |
| 99 | + } |
| 100 | + }; |
| 101 | + |
| 102 | + const fetchOnError = (error: Error) => { |
| 103 | + fetch.removeListener('message', fetchOnMessage); |
| 104 | + fetch.removeListener('end', fetchOnEnd); |
| 105 | + reject(error); |
| 106 | + }; |
| 107 | + |
| 108 | + const fetchOnEnd = () => { |
| 109 | + fetch.removeListener('message', fetchOnMessage); |
| 110 | + fetch.removeListener('error', fetchOnError); |
| 111 | + }; |
| 112 | + |
| 113 | + fetch.on('message', fetchOnMessage); |
| 114 | + fetch.once('error', fetchOnError); |
| 115 | + fetch.once('end', fetchOnEnd); |
| 116 | + }); |
| 117 | + }); |
| 118 | + } |
| 119 | + |
| 120 | + /** Download a "part" (either a portion of the message body, or an attachment) */ |
| 121 | + async getPartData( |
| 122 | + /** The message returned from `search()` */ |
| 123 | + message: Message, |
| 124 | + /** The message part to be downloaded, from the `message.attributes.struct` Array */ |
| 125 | + part: MessagePart, |
| 126 | + ) { |
| 127 | + return await new Promise<string>((resolve, reject) => { |
| 128 | + const fetch = this.imap.fetch(message.attributes.uid, { |
| 129 | + bodies: [part.partID], |
| 130 | + struct: true, |
| 131 | + }); |
| 132 | + |
| 133 | + const fetchOnMessage = async (msg: ImapMessage) => { |
| 134 | + const result = await getMessage(msg); |
| 135 | + if (result.parts.length !== 1) { |
| 136 | + reject(new Error('Got ' + result.parts.length + ' parts, should get 1')); |
| 137 | + return; |
| 138 | + } |
| 139 | + |
| 140 | + const data = result.parts[0].body as string; |
| 141 | + |
| 142 | + const encoding = part.encoding.toUpperCase(); |
| 143 | + |
| 144 | + if (encoding === 'BASE64') { |
| 145 | + resolve(Buffer.from(data, 'base64').toString()); |
| 146 | + return; |
| 147 | + } |
| 148 | + |
| 149 | + if (encoding === 'QUOTED-PRINTABLE') { |
| 150 | + if (part.params?.charset?.toUpperCase() === 'UTF-8') { |
| 151 | + resolve(Buffer.from(utf8.decode(qp.decode(data))).toString()); |
| 152 | + } else { |
| 153 | + resolve(Buffer.from(qp.decode(data)).toString()); |
| 154 | + } |
| 155 | + return; |
| 156 | + } |
| 157 | + |
| 158 | + if (encoding === '7BIT') { |
| 159 | + resolve(Buffer.from(data).toString('ascii')); |
| 160 | + return; |
| 161 | + } |
| 162 | + |
| 163 | + if (encoding === '8BIT' || encoding === 'BINARY') { |
| 164 | + const charset = part.params?.charset ?? 'utf-8'; |
| 165 | + resolve(iconvlite.decode(Buffer.from(data), charset)); |
| 166 | + return; |
| 167 | + } |
| 168 | + |
| 169 | + if (encoding === 'UUENCODE') { |
| 170 | + const parts = data.toString().split('\n'); // remove newline characters |
| 171 | + const merged = parts.splice(1, parts.length - 4).join(''); // remove excess lines and join lines with empty string |
| 172 | + resolve(uuencode.decode(merged)); |
| 173 | + return; |
| 174 | + } |
| 175 | + |
| 176 | + // if it gets here, the encoding is not currently supported |
| 177 | + reject(new Error('Unknown encoding ' + part.encoding)); |
| 178 | + }; |
| 179 | + |
| 180 | + const fetchOnError = (error: Error) => { |
| 181 | + fetch.removeListener('message', fetchOnMessage); |
| 182 | + fetch.removeListener('end', fetchOnEnd); |
| 183 | + reject(error); |
| 184 | + }; |
| 185 | + |
| 186 | + const fetchOnEnd = () => { |
| 187 | + fetch.removeListener('message', fetchOnMessage); |
| 188 | + fetch.removeListener('error', fetchOnError); |
| 189 | + }; |
| 190 | + |
| 191 | + fetch.once('message', fetchOnMessage); |
| 192 | + fetch.once('error', fetchOnError); |
| 193 | + fetch.once('end', fetchOnEnd); |
| 194 | + }); |
| 195 | + } |
| 196 | + |
| 197 | + /** Adds the provided flag(s) to the specified message(s). */ |
| 198 | + async addFlags( |
| 199 | + /** The messages uid */ |
| 200 | + uid: number[], |
| 201 | + /** The flags to add to the message(s). */ |
| 202 | + flags: string | string[], |
| 203 | + ) { |
| 204 | + return await new Promise<void>((resolve, reject) => { |
| 205 | + this.imap.addFlags(uid, flags, (e) => (e ? reject(e) : resolve())); |
| 206 | + }); |
| 207 | + } |
| 208 | + |
| 209 | + /** Returns a list of mailboxes (folders). */ |
| 210 | + async getBoxes() { |
| 211 | + return await new Promise<Imap.MailBoxes>((resolve, reject) => { |
| 212 | + this.imap.getBoxes((e, boxes) => (e ? reject(e) : resolve(boxes))); |
| 213 | + }); |
| 214 | + } |
| 215 | + |
| 216 | + /** Open a mailbox */ |
| 217 | + async openBox( |
| 218 | + /** The name of the box to open */ |
| 219 | + boxName: string, |
| 220 | + ): Promise<Imap.Box> { |
| 221 | + return await new Promise((resolve, reject) => { |
| 222 | + this.imap.openBox(boxName, (e, result) => (e ? reject(e) : resolve(result))); |
| 223 | + }); |
| 224 | + } |
| 225 | + |
| 226 | + /** Close a mailbox */ |
| 227 | + async closeBox( |
| 228 | + /** If autoExpunge is true, any messages marked as Deleted in the currently open mailbox will be removed @default true */ |
| 229 | + autoExpunge = true, |
| 230 | + ) { |
| 231 | + return await new Promise<void>((resolve, reject) => { |
| 232 | + this.imap.closeBox(autoExpunge, (e) => (e ? reject(e) : resolve())); |
| 233 | + }); |
| 234 | + } |
| 235 | +} |
0 commit comments