From 944b103e88eb87ed5868075f1a452922f04efdb2 Mon Sep 17 00:00:00 2001 From: Arnt Gulbrandsen Date: Thu, 21 Aug 2025 12:13:14 +0200 Subject: [PATCH] Connection, Parser: Add support for ENABLE, NOTIFY and UTF8=ACCEPT. ENABLE (RFC 5161) adds support for optional syntax. UTF8=ACCEPT (RFC 9755) adds support for using UTF8 in quoted strings, both mail addresses and mailbox names, and is enabled using ENABLE. NOTIFY (RFC 5465) tells the server that it should send a FETCH response whenever a new message arrives, without waiting for the client to poll. Handling the response requires no new code. --- README.md | 1 - lib/Connection.js | 50 ++++++++++------- lib/Parser.js | 3 +- test/test-connection-notify-utf8accept.js | 66 +++++++++++++++++++++++ 4 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 test/test-connection-notify-utf8accept.js diff --git a/README.md b/README.md index b13862a8..135d36eb 100644 --- a/README.md +++ b/README.md @@ -749,6 +749,5 @@ TODO Several things not yet implemented in no particular order: * Support additional IMAP commands/extensions: - * NOTIFY (via NOTIFY extension -- RFC5465) * STATUS addition to LIST (via LIST-STATUS extension -- RFC5819) * QRESYNC (RFC5162) diff --git a/lib/Connection.js b/lib/Connection.js index bd31e98b..94de7d5e 100644 --- a/lib/Connection.js +++ b/lib/Connection.js @@ -297,6 +297,14 @@ Connection.prototype.serverSupports = function(cap) { return (this._caps && this._caps.indexOf(cap) > -1); }; +Connection.prototype.serverEnabled = function(cap) { + return (this._enabled && this._enabled.indexOf(cap) > -1); +}; + +Connection.prototype.escapeBox = function(str) { + return escape(this.serverEnabled("UTF8=ACCEPT") ? (''+str) : utf7.encode(''+str)); +} + Connection.prototype.destroy = function() { this._queue = []; this._curReq = undefined; @@ -325,7 +333,7 @@ Connection.prototype.append = function(data, options, cb) { else options.mailbox = this._box.name; } - var cmd = 'APPEND "' + escape(utf7.encode(''+options.mailbox)) + '"'; + var cmd = 'APPEND "' + this.escapeBox(options.mailbox) + '"'; if (options.flags) { if (!Array.isArray(options.flags)) options.flags = [options.flags]; @@ -379,7 +387,7 @@ Connection.prototype.getBoxes = function(namespace, cb) { namespace = ''; } - namespace = escape(utf7.encode(''+namespace)); + namespace = this.escapeBox(namespace); this._enqueue('LIST "' + namespace + '" "*"', cb); }; @@ -417,7 +425,7 @@ Connection.prototype.openBox = function(name, readOnly, cb) { } name = ''+name; - var encname = escape(utf7.encode(name)), + var encname = this.escapeBox(name), cmd = (readOnly ? 'EXAMINE' : 'SELECT'), self = this; @@ -478,16 +486,16 @@ Connection.prototype.closeBox = function(shouldExpunge, cb) { }; Connection.prototype.addBox = function(name, cb) { - this._enqueue('CREATE "' + escape(utf7.encode(''+name)) + '"', cb); + this._enqueue('CREATE "' + this.escapeBox(name) + '"', cb); }; Connection.prototype.delBox = function(name, cb) { - this._enqueue('DELETE "' + escape(utf7.encode(''+name)) + '"', cb); + this._enqueue('DELETE "' + this.escapeBox(name) + '"', cb); }; Connection.prototype.renameBox = function(oldname, newname, cb) { - var encoldname = escape(utf7.encode(''+oldname)), - encnewname = escape(utf7.encode(''+newname)), + var encoldname = this.escapeBox(oldname), + encnewname = this.escapeBox(newname), self = this; this._enqueue('RENAME "' + encoldname + '" "' + encnewname + '"', @@ -507,11 +515,11 @@ Connection.prototype.renameBox = function(oldname, newname, cb) { }; Connection.prototype.subscribeBox = function(name, cb) { - this._enqueue('SUBSCRIBE "' + escape(utf7.encode(''+name)) + '"', cb); + this._enqueue('SUBSCRIBE "' + this.escapeBox(name) + '"', cb); }; Connection.prototype.unsubscribeBox = function(name, cb) { - this._enqueue('UNSUBSCRIBE "' + escape(utf7.encode(''+name)) + '"', cb); + this._enqueue('UNSUBSCRIBE "' + this.escapeBox(name) + '"', cb); }; Connection.prototype.getSubscribedBoxes = function(namespace, cb) { @@ -520,7 +528,7 @@ Connection.prototype.getSubscribedBoxes = function(namespace, cb) { namespace = ''; } - namespace = escape(utf7.encode(''+namespace)); + namespace = this.escapeBox(namespace); this._enqueue('LSUB "' + namespace + '" "*"', cb); }; @@ -529,7 +537,7 @@ Connection.prototype.status = function(boxName, cb) { if (this._box && this._box.name === boxName) throw new Error('Cannot call status on currently selected mailbox'); - boxName = escape(utf7.encode(''+boxName)); + boxName = this.escapeBox(boxName); var info = [ 'MESSAGES', 'RECENT', 'UNSEEN', 'UIDVALIDITY', 'UIDNEXT' ]; @@ -685,7 +693,7 @@ Connection.prototype._copy = function(which, uids, boxTo, cb) { + 'list'); } - boxTo = escape(utf7.encode(''+boxTo)); + boxTo = this.escapeBox(boxTo); this._enqueue(which + 'COPY ' + uids.join(',') + ' "' + boxTo + '"', cb); }; @@ -710,7 +718,7 @@ Connection.prototype._move = function(which, uids, boxTo, cb) { } uids = uids.join(','); - boxTo = escape(utf7.encode(''+boxTo)); + boxTo = escapebox(To); this._enqueue(which + 'MOVE ' + uids + ' "' + boxTo + '"', cb); } else if (this._box.permFlags.indexOf('\\Deleted') === -1 @@ -906,7 +914,7 @@ Connection.prototype._storeLabels = function(which, uids, labels, mode, cb) { if (!Array.isArray(labels)) labels = [labels]; labels = labels.map(function(v) { - return '"' + escape(utf7.encode(''+v)) + '"'; + return '"' + this.escapeBox(v) + '"'; }).join(' '); uids = uids.join(','); @@ -1023,7 +1031,7 @@ Connection.prototype.setQuota = function(quotaRoot, limits, cb) { triplets += l + ' ' + limits[l]; } - quotaRoot = escape(utf7.encode(''+quotaRoot)); + quotaRoot = this.escapeBox(quotaRoot); this._enqueue('SETQUOTA "' + quotaRoot + '" (' + triplets + ')', function(err, quotalist) { @@ -1036,7 +1044,7 @@ Connection.prototype.setQuota = function(quotaRoot, limits, cb) { }; Connection.prototype.getQuota = function(quotaRoot, cb) { - quotaRoot = escape(utf7.encode(''+quotaRoot)); + quotaRoot = this.escapeBox(quotaRoot); this._enqueue('GETQUOTA "' + quotaRoot + '"', function(err, quotalist) { if (err) @@ -1047,7 +1055,7 @@ Connection.prototype.getQuota = function(quotaRoot, cb) { }; Connection.prototype.getQuotaRoot = function(boxName, cb) { - boxName = escape(utf7.encode(''+boxName)); + boxName = this.escapeBox(boxName); this._enqueue('GETQUOTAROOT "' + boxName + '"', function(err, quotalist) { if (err) @@ -1241,8 +1249,14 @@ Connection.prototype._resUntagged = function(info) { this.namespaces = info.text; else if (type === 'id') this._curReq.cbargs.push(info.text); - else if (type === 'capability') + else if (type === 'capability') { this._caps = info.text.map(function(v) { return v.toUpperCase(); }); + if (this.serverSupports('ENABLE')) + this._enqueue('ENABLE UTF8=ACCEPT CONDSTORE', function() {}); + if (this.serverSupports('NOTIFY')) + this._enqueue('NOTIFY SET (SELECTED-DELAYED (MESSAGENEW (UID) MESSAGEEXPUNGE))', function() {}); + } else if (type === 'enabled') + this._enabled = info.text.map(function(v) { return v.toUpperCase(); }); else if (type === 'preauth') this.state = 'authenticated'; else if (type === 'sort' || type === 'thread' || type === 'esearch') diff --git a/lib/Parser.js b/lib/Parser.js index 1dd386ba..f20fb28c 100644 --- a/lib/Parser.js +++ b/lib/Parser.js @@ -17,7 +17,7 @@ var CH_LF = 10, RE_SEQNO = /^\* (\d+)/, RE_LISTCONTENT = /^\((.*)\)$/, RE_LITERAL = /\{(\d+)\}$/, - RE_UNTAGGED = /^\* (?:(OK|NO|BAD|BYE|FLAGS|ID|LIST|XLIST|LSUB|SEARCH|STATUS|CAPABILITY|NAMESPACE|PREAUTH|SORT|THREAD|ESEARCH|QUOTA|QUOTAROOT)|(\d+) (EXPUNGE|FETCH|RECENT|EXISTS))(?:(?: \[([^\]]+)\])?(?: (.+))?)?$/i, + RE_UNTAGGED = /^\* (?:(OK|NO|BAD|BYE|FLAGS|ID|LIST|XLIST|LSUB|SEARCH|STATUS|CAPABILITY|ENABLED|NAMESPACE|PREAUTH|SORT|THREAD|ESEARCH|QUOTA|QUOTAROOT)|(\d+) (EXPUNGE|FETCH|RECENT|EXISTS))(?:(?: \[([^\]]+)\])?(?: (.+))?)?$/i, RE_TAGGED = /^A(\d+) (OK|NO|BAD) ?(?:\[([^\]]+)\] )?(.*)$/i, RE_CONTINUE = /^\+(?: (?:\[([^\]]+)\] )?(.+))?$/i, RE_CRLF = /\r\n/g, @@ -222,6 +222,7 @@ Parser.prototype._resUntagged = function() { if (type === 'flags' || type === 'search' || type === 'capability' + || type === 'enabled' || type === 'sort') { if (m[5]) { if (type === 'search' && RE_SEARCH_MODSEQ.test(m[5])) { diff --git a/test/test-connection-notify-utf8accept.js b/test/test-connection-notify-utf8accept.js new file mode 100644 index 00000000..d93a40fa --- /dev/null +++ b/test/test-connection-notify-utf8accept.js @@ -0,0 +1,66 @@ +var assert = require('assert'), + net = require('net'), + Imap = require('../lib/Connection'), + result; + +var CRLF = '\r\n'; + +var RESPONSES = [ + ['* CAPABILITY IMAP4rev1', + 'A0 OK Thats all she wrote!', + '' + ].join(CRLF), + ['* CAPABILITY IMAP4rev1 ENABLE NOTIFY', + 'A1 OK authenticated (Success)', + '' + ].join(CRLF), + ['* ENABLED', + 'A2 OK Success', + '' + ].join(CRLF), + ['A3 OK Success', + '' + ].join(CRLF), + ['* LIST (\\Noselect) "/" "/"', + '* LIST () "/" "भारत"', + '* LIST () "/" "&-"', + 'A4 OK Success', + '' + ].join(CRLF), + ['A5 OK Success', + '' + ].join(CRLF) +]; + +var srv = net.createServer(function(sock) { + sock.write('* OK asdf\r\n'); + var buf = '', lines; + sock.on('data', function(data) { + buf += data.toString('utf8'); + if (buf.indexOf(CRLF) > -1) { + lines = buf.split(CRLF); + buf = lines.pop(); + lines.forEach(function() { + sock.write(RESPONSES.shift()); + }); + } + }); +}); +srv.listen(0, '127.0.0.1', function() { + var port = srv.address().port; + var imap = new Imap({ + user: 'foo', + password: 'bar', + host: '127.0.0.1', + port: port, + keepalive: false, + debug: function(info) { + console.log('Debug:', info); + } + }); + imap.on('ready', function() { + srv.close(); + imap.end(); + }); + imap.connect(); +});