From c6be355f494173b57530b1f614ec3299e9c1df93 Mon Sep 17 00:00:00 2001 From: Andris Reinman Date: Mon, 23 Dec 2024 14:11:32 +0200 Subject: [PATCH] Started with API tests --- .github/workflows/test.yml | 2 +- Gruntfile.js | 44 +++++- config/test.toml | 33 +++++ lib/email-client/base-client.js | 148 ++++++++++++-------- lib/email-client/imap-client.js | 210 ++++++++++++++++++----------- lib/email-client/imap/mailbox.js | 46 ++++--- package-lock.json | 225 +++++++++++++++++++++++++++++++ package.json | 5 +- test/api-test.js | 122 +++++++++++++++++ 9 files changed, 669 insertions(+), 166 deletions(-) create mode 100644 config/test.toml create mode 100644 test/api-test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c45461ba..173e53c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,4 +90,4 @@ jobs: run: | npm test env: - EENGINE_REDIS: redis://127.0.0.1:6379/1 + EENGINE_REDIS: redis://127.0.0.1:6379/13 diff --git a/Gruntfile.js b/Gruntfile.js index c5bf3618..28900f80 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,16 +1,56 @@ 'use strict'; -module.exports = function(grunt) { +const config = require('wild-config'); + +module.exports = function (grunt) { // Project configuration. grunt.initConfig({ eslint: { all: ['lib/**/*.js', 'server.js', 'worker.js', 'Gruntfile.js'] + }, + + wait: { + server: { + options: { + delay: 6 * 1000 + } + } + }, + + shell: { + server: { + command: 'node server.js', + options: { + async: true + } + }, + flush: { + command: `redis-cli -u "${config.dbs.redis}" flushdb`, + options: { + async: false + } + }, + test: { + command: 'node --test test/', + options: { + async: false + } + }, + options: { + stdout: data => console.log(data.toString().trim()), + stderr: data => console.log(data.toString().trim()), + failOnError: true + } } }); // Load the plugin(s) grunt.loadNpmTasks('grunt-eslint'); + grunt.loadNpmTasks('grunt-shell-spawn'); + grunt.loadNpmTasks('grunt-wait'); // Tasks - grunt.registerTask('default', ['eslint']); + grunt.registerTask('test', ['shell:flush', 'shell:server', 'wait:server', 'shell:test', 'shell:server:kill']); + + grunt.registerTask('default', ['eslint', 'test']); }; diff --git a/config/test.toml b/config/test.toml new file mode 100644 index 00000000..c335ca1a --- /dev/null +++ b/config/test.toml @@ -0,0 +1,33 @@ + + +# JSON formatted settings +settings = ''' +{ + "webhooksEnabled": false, + "serviceUrl": "http://127.0.0.1:7077", + "ignoreMailCertErrors": true, + "serviceSecret": "a cat" +} +''' + +# static access token +# 2aa97ad0456d6624a55d30780aa2ff61bfb7edc6fa00935b40814b271e718660 +preparedToken = "hKJpZNlAZWEzODczZjkyMzRmYjNiOGI3MDAwNDMzNzMxMzhjNzMwYzU2ZmQyZjdmYTI3ZGJjZGM2MWQ0YzljMmIyZGY4ZKdjcmVhdGVk1_-UiOcAZ2lLVqZzY29wZXORoSqrZGVzY3JpcHRpb26qdGVzdCB0b2tlbg" + + +[service] +secret = "secret cat" + +[workers] +imap = 1 + +[log] +raw = true + +[dbs] +# redis connection +redis = "redis://127.0.0.1:6379/13" + +[api] +host = "0.0.0.0" +port = 7077 diff --git a/lib/email-client/base-client.js b/lib/email-client/base-client.js index 8e84c13c..d6ab82d0 100644 --- a/lib/email-client/base-client.js +++ b/lib/email-client/base-client.js @@ -616,7 +616,7 @@ class BaseClient { return oauthCredentials; } - async queueMessage(data, meta) { + async queueMessage(data, meta, connectionOptions) { let accountData = await this.accountObject.loadAccountData(); let gatewayData; @@ -671,7 +671,7 @@ class BaseClient { } if (!data.mailMerge || !data.mailMerge.length) { - return this.queueMessageEntry(data, meta, licenseInfo); + return this.queueMessageEntry(data, meta, licenseInfo, connectionOptions); } let mailMergeList = data.mailMerge; @@ -701,7 +701,7 @@ class BaseClient { messageCopy.render.params = mailMergeEntry.params; } - messageProcessors.push(this.queueMessageEntry(messageCopy, meta, licenseInfo)); + messageProcessors.push(this.queueMessageEntry(messageCopy, meta, licenseInfo, connectionOptions)); } let response = { @@ -742,7 +742,7 @@ class BaseClient { return true; } - async prepareRawMessage(data) { + async prepareRawMessage(data, connectionOptions) { data.disableFileAccess = true; data.disableUrlAccess = true; data.boundaryPrefix = MIME_BOUNDARY_PREFIX; @@ -796,18 +796,22 @@ class BaseClient { documentStoreUsed = true; } else { let extendedData = data.reference.inline || data.reference.forwardAttachments; - referencedMessage = await this.getMessage(data.reference.message, { - fields: !extendedData - ? { - uid: true, - flags: true, - envelope: true, - headers: ['references'] - } - : false, - header: extendedData ? true : false, - textType: extendedData ? '*' : false - }); + referencedMessage = await this.getMessage( + data.reference.message, + { + fields: !extendedData + ? { + uid: true, + flags: true, + envelope: true, + headers: ['references'] + } + : false, + header: extendedData ? true : false, + textType: extendedData ? '*' : false + }, + connectionOptions + ); } if (!referencedMessage && !data.reference.ignoreMissing) { @@ -935,7 +939,7 @@ class BaseClient { } if (attachmentsToDownload && attachmentsToDownload.length) { - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); this.logger.info({ msg: 'Fetching attachments from the referenced email', @@ -954,10 +958,14 @@ class BaseClient { this.logger.trace({ msg: 'Using cached email content', attachment: attachment.id, size: content.length }); } else { // fetch from IMAP - content = await this.getAttachmentContent(attachment.id, { - chunkSize: Math.max(DOWNLOAD_CHUNK_SIZE, 2 * 1024 * 1024), - contentOnly: true - }); + content = await this.getAttachmentContent( + attachment.id, + { + chunkSize: Math.max(DOWNLOAD_CHUNK_SIZE, 2 * 1024 * 1024), + contentOnly: true + }, + connectionOptions + ); } if (!content) { // skip missing? @@ -978,12 +986,16 @@ class BaseClient { // resolve referenced attachments for (let attachment of data.attachments || []) { if (attachment.reference && !attachment.content) { - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); - let content = await this.getAttachmentContent(attachment.reference, { - chunkSize: Math.max(DOWNLOAD_CHUNK_SIZE, 2 * 1024 * 1024), - contentOnly: true - }); + let content = await this.getAttachmentContent( + attachment.reference, + { + chunkSize: Math.max(DOWNLOAD_CHUNK_SIZE, 2 * 1024 * 1024), + contentOnly: true + }, + connectionOptions + ); if (!content) { let error = new Error('Referenced attachment was not found'); error.code = 'ReferenceNotFound'; @@ -1011,7 +1023,7 @@ class BaseClient { return { raw, messageId, documentStoreUsed, referencedMessage }; } - async queueMessageEntry(data, meta, licenseInfo) { + async queueMessageEntry(data, meta, licenseInfo, connectionOptions) { let accountData = await this.accountObject.loadAccountData(); // normal message @@ -1169,21 +1181,25 @@ class BaseClient { } documentStoreUsed = true; } else { - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); let extendedData = data.reference.inline || data.reference.forwardAttachments; - referencedMessage = await this.getMessage(data.reference.message, { - fields: !extendedData - ? { - uid: true, - flags: true, - envelope: true, - headers: ['references'] - } - : false, - header: extendedData ? true : false, - textType: extendedData ? '*' : false - }); + referencedMessage = await this.getMessage( + data.reference.message, + { + fields: !extendedData + ? { + uid: true, + flags: true, + envelope: true, + headers: ['references'] + } + : false, + header: extendedData ? true : false, + textType: extendedData ? '*' : false + }, + connectionOptions + ); } if (!referencedMessage && !data.reference.ignoreMissing) { @@ -1320,7 +1336,7 @@ class BaseClient { } if (attachmentsToDownload && attachmentsToDownload.length) { - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); this.logger.info({ msg: 'Fetching attachments from the referenced email', @@ -1339,10 +1355,14 @@ class BaseClient { this.logger.trace({ msg: 'Using cached email content', attachment: attachment.id, size: content.length }); } else { // fetch from IMAP - content = await this.getAttachmentContent(attachment.id, { - chunkSize: Math.max(DOWNLOAD_CHUNK_SIZE, 2 * 1024 * 1024), - contentOnly: true - }); + content = await this.getAttachmentContent( + attachment.id, + { + chunkSize: Math.max(DOWNLOAD_CHUNK_SIZE, 2 * 1024 * 1024), + contentOnly: true + }, + connectionOptions + ); } if (!content) { // skip missing? @@ -1363,12 +1383,16 @@ class BaseClient { // resolve referenced attachments for (let attachment of data.attachments || []) { if (attachment.reference && !attachment.content) { - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); - let content = await this.getAttachmentContent(attachment.reference, { - chunkSize: Math.max(DOWNLOAD_CHUNK_SIZE, 2 * 1024 * 1024), - contentOnly: true - }); + let content = await this.getAttachmentContent( + attachment.reference, + { + chunkSize: Math.max(DOWNLOAD_CHUNK_SIZE, 2 * 1024 * 1024), + contentOnly: true + }, + connectionOptions + ); if (!content) { let error = new Error('Referenced attachment was not found'); @@ -2500,10 +2524,14 @@ class BaseClient { shouldCopy = false; } + let connectionOptions = { allowSecondary: true }; + if (shouldCopy) { + // NB! IMAP only // Upload the message to Sent Mail folder + try { - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); let sentMailbox = data.sentMailPath && typeof data.sentMailPath === 'string' @@ -2518,7 +2546,7 @@ class BaseClient { raw = Buffer.from(raw); } - const connectionClient = await this.getImapConnection(true, 'submitMessage'); + const connectionClient = await this.getImapConnection(connectionOptions, 'submitMessage'); await connectionClient.append(sentMailbox.path, raw, ['\\Seen']); @@ -2537,12 +2565,16 @@ class BaseClient { // Add \Answered flag to referenced message if needed if (reference && reference.update) { try { - this.checkIMAPConnection(); - await this.updateMessage(reference.message, { - flags: { - add: ['\\Answered'].concat(reference.action === 'forward' ? '$Forwarded' : []) - } - }); + this.checkIMAPConnection(connectionOptions); + await this.updateMessage( + reference.message, + { + flags: { + add: ['\\Answered'].concat(reference.action === 'forward' ? '$Forwarded' : []) + } + }, + connectionOptions + ); } catch (err) { this.logger.error({ msg: 'Failed to update reference flags', queueId, messageId, reference, err }); } diff --git a/lib/email-client/imap-client.js b/lib/email-client/imap-client.js index 8dea239e..7b502463 100644 --- a/lib/email-client/imap-client.js +++ b/lib/email-client/imap-client.js @@ -114,15 +114,31 @@ class IMAPClient extends BaseClient { }, ENSURE_MAIN_TTL); } - async getImapConnection(allowSecondary, reason) { + async getImapConnection(connectionOptions, reason) { + connectionOptions = connectionOptions || {}; + + let { allowSecondary, noPool, connectionClient: existingConnectionClient } = connectionOptions || {}; + + if (existingConnectionClient && existingConnectionClient.usable) { + return existingConnectionClient; + } + let syncing = this.syncing || ['init', 'connecting', 'syncing'].includes(this.state); - if (!syncing || !allowSecondary) { + if (!noPool && (!syncing || !allowSecondary)) { return this.imapClient; } + // TODO: if noPool is true, then always create a new connection + try { const connectionClient = await this.getCommandConnection(reason); - return connectionClient && connectionClient.usable ? connectionClient : this.imapClient; + if (connectionClient && connectionClient.usable) { + connectionOptions.connectionClient = connectionClient; + return connectionClient; + } else { + // fall back to default connection + return this.imapClient; + } } catch (err) { this.logger.error({ msg: 'Failed to acquire command connection', reason, err }); return this.imapClient; @@ -326,8 +342,6 @@ class IMAPClient extends BaseClient { } async clearMailboxEntry(entry) { - this.checkIMAPConnection(); - if (!entry || !entry.path) { return; // ? } @@ -343,10 +357,12 @@ class IMAPClient extends BaseClient { mailbox = false; } - async getCurrentListing(options, allowSecondary) { - this.checkIMAPConnection(); + async getCurrentListing(options, connectionOptions) { + options = options || {}; + + this.checkIMAPConnection(connectionOptions); - const connectionClient = await this.getImapConnection(allowSecondary, 'getCurrentListing'); + const connectionClient = await this.getImapConnection(connectionOptions, 'getCurrentListing'); if (!connectionClient) { this.imapClient.close(); let error = new Error('Failed to get connection'); @@ -1225,8 +1241,15 @@ class IMAPClient extends BaseClient { return this.state; } - checkIMAPConnection() { - if (!this.isConnected()) { + checkIMAPConnection(connectionOptions) { + connectionOptions = connectionOptions || {}; + + if ( + !this.isConnected() && + !connectionOptions.noPool && + !connectionOptions.allowSecondary && + (!connectionOptions.connectionClient || !connectionOptions.connectionClient.usable) + ) { let err = new Error('IMAP connection is currently not available for requested account'); err.code = 'IMAPUnavailable'; err.statusCode = 503; @@ -1245,9 +1268,11 @@ class IMAPClient extends BaseClient { * @param {string} [options.contentType] If set then limits output for selected type only * @returns {Object} Text object, where key is text type (either 'plain' or 'html') and value is a unicode string */ - async getText(textId, options) { + async getText(textId, options, connectionOptions) { options = options || {}; - this.checkIMAPConnection(); + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); let { message, textParts } = await this.getMessageTextPaths(textId); if (!message || !textParts || !textParts.length) { @@ -1279,7 +1304,7 @@ class IMAPClient extends BaseClient { textParts = []; } - let result = await mailbox.getText(message, textParts, options, true); + let result = await mailbox.getText(message, textParts, options, connectionOptions); if (textType && textType !== '*') { result = { @@ -1291,9 +1316,11 @@ class IMAPClient extends BaseClient { return result; } - async getMessage(id, options) { + async getMessage(id, options, connectionOptions) { options = options || {}; - this.checkIMAPConnection(); + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); let buf = Buffer.from(id, 'base64url'); let message = await this.unpackUid(buf.subarray(0, 8)); @@ -1307,12 +1334,14 @@ class IMAPClient extends BaseClient { let mailbox = this.mailboxes.get(normalizePath(message.path)); - return await mailbox.getMessage(message, options, true); + return await mailbox.getMessage(message, options, connectionOptions); } - async updateMessage(id, updates) { + async updateMessage(id, updates, connectionOptions) { updates = updates || {}; - this.checkIMAPConnection(); + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); let buf = Buffer.from(id, 'base64url'); let message = await this.unpackUid(buf.subarray(0, 8)); @@ -1326,12 +1355,14 @@ class IMAPClient extends BaseClient { let mailbox = this.mailboxes.get(normalizePath(message.path)); - return await mailbox.updateMessage(message, updates, true); + return await mailbox.updateMessage(message, updates, connectionOptions); } - async updateMessages(path, search, updates) { + async updateMessages(path, search, updates, connectionOptions) { updates = updates || {}; - this.checkIMAPConnection(); + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); if (!this.mailboxes.has(normalizePath(path))) { return false; //? @@ -1339,19 +1370,22 @@ class IMAPClient extends BaseClient { let mailbox = this.mailboxes.get(normalizePath(path)); - return await mailbox.updateMessages(search, updates, true); + return await mailbox.updateMessages(search, updates, connectionOptions); } - async listMailboxes(options) { - this.checkIMAPConnection(); + async listMailboxes(options, connectionOptions) { + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); - return await this.getCurrentListing(options, true); + return await this.getCurrentListing(options, connectionOptions); } - async moveMessage(id, target) { + async moveMessage(id, target, connectionOptions) { target = target || {}; + connectionOptions = connectionOptions || { allowSecondary: true }; - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); let buf = Buffer.from(id, 'base64url'); let message = await this.unpackUid(buf.subarray(0, 8)); @@ -1365,13 +1399,14 @@ class IMAPClient extends BaseClient { } let mailbox = this.mailboxes.get(normalizePath(message.path)); - return await mailbox.moveMessage(message, target, true); + return await mailbox.moveMessage(message, target, connectionOptions); } - async moveMessages(source, search, target) { + async moveMessages(source, search, target, connectionOptions) { target = target || {}; + connectionOptions = connectionOptions || { allowSecondary: true }; - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); if (!this.mailboxes.has(normalizePath(source))) { return false; //? @@ -1379,23 +1414,21 @@ class IMAPClient extends BaseClient { let mailbox = this.mailboxes.get(normalizePath(source)); - let res = await mailbox.moveMessages(search, target, true); + let res = await mailbox.moveMessages(search, target, connectionOptions); // force sync target mailbox - try { - let targetMailbox = this.mailboxes.get(normalizePath(target.path)); - if (targetMailbox) { - await targetMailbox.sync(); - } - } catch (err) { - this.logger.error({ msg: 'Mailbox sync error', path: target.path, err }); + let targetMailbox = this.mailboxes.get(normalizePath(target.path)); + if (targetMailbox) { + targetMailbox.sync().catch(err => this.logger.error({ msg: 'Mailbox sync error', path: target.path, err })); } return res; } - async deleteMessage(id, force) { - this.checkIMAPConnection(); + async deleteMessage(id, force, connectionOptions) { + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); let buf = Buffer.from(id, 'base64url'); let message = await this.unpackUid(buf.subarray(0, 8)); @@ -1409,28 +1442,27 @@ class IMAPClient extends BaseClient { let mailbox = this.mailboxes.get(normalizePath(message.path)); - return await mailbox.deleteMessage(message, force, true); + return await mailbox.deleteMessage(message, force, connectionOptions); } - async deleteMessages(path, search, force) { - this.checkIMAPConnection(); + async deleteMessages(path, search, force, connectionOptions) { + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); + if (!this.mailboxes.has(normalizePath(path))) { return false; //? } let mailbox = this.mailboxes.get(normalizePath(path)); - let res = await mailbox.deleteMessages(search, force, true); + let res = await mailbox.deleteMessages(search, force, connectionOptions); // force sync target mailbox - try { - if (res && res.moved && res.moved.destination) { - let targetMailbox = this.mailboxes.get(normalizePath(res.moved.destination)); - if (targetMailbox) { - await targetMailbox.sync(); - } + if (res && res.moved && res.moved.destination) { + let targetMailbox = this.mailboxes.get(normalizePath(res.moved.destination)); + if (targetMailbox) { + targetMailbox.sync().catch(err => this.logger.error({ msg: 'Mailbox sync error', path: res && res.moved && res.moved.destination, err })); } - } catch (err) { - this.logger.error({ msg: 'Mailbox sync error', path: res && res.moved && res.moved.destination, err }); } return res; @@ -1444,15 +1476,16 @@ class IMAPClient extends BaseClient { * @param {number} [options.maxLength] If set then limits output stream to specified bytes * @returns {Boolean|Stream} Attachment stream or `false` if not found */ - async getAttachment(attachmentId, options) { + async getAttachment(attachmentId, options, connectionOptions) { options = Object.assign( { chunkSize: DOWNLOAD_CHUNK_SIZE }, options || {} ); + connectionOptions = connectionOptions || { allowSecondary: true }; - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); let buf = Buffer.from(attachmentId, 'base64url'); let id = buf.subarray(0, 8); @@ -1469,11 +1502,11 @@ class IMAPClient extends BaseClient { let mailbox = this.mailboxes.get(normalizePath(message.path)); - return mailbox.getAttachment(message, part, options, true); + return mailbox.getAttachment(message, part, options, connectionOptions); } - async getAttachmentContent(attachmentId, options) { - let stream = await this.getAttachment(attachmentId, options); + async getAttachmentContent(attachmentId, options, connectionOptions) { + let stream = await this.getAttachment(attachmentId, options, connectionOptions); if (!stream) { return false; } @@ -1501,15 +1534,16 @@ class IMAPClient extends BaseClient { * @param {number} [options.maxLength] If set then limits output stream to specified bytes * @returns {Boolean|Stream} Attachment stream or `false` if not found */ - async getRawMessage(id, options) { + async getRawMessage(id, options, connectionOptions) { options = Object.assign( { chunkSize: DOWNLOAD_CHUNK_SIZE }, options || {} ); + connectionOptions = connectionOptions || { allowSecondary: true }; - this.checkIMAPConnection(); + this.checkIMAPConnection(connectionOptions); let buf = Buffer.from(id, 'base64url'); let message = await this.unpackUid(buf.subarray(0, 8)); @@ -1523,12 +1557,14 @@ class IMAPClient extends BaseClient { let mailbox = this.mailboxes.get(normalizePath(message.path)); - return mailbox.getAttachment(message, false, options, true); + return mailbox.getAttachment(message, false, options, connectionOptions); } - async listMessages(options) { + async listMessages(options, connectionOptions) { options = options || {}; - this.checkIMAPConnection(); + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); let path = normalizePath(options.path); if (['\\Junk', '\\Sent', '\\Trash', '\\Inbox', '\\Drafts', '\\All'].includes(path)) { @@ -1544,14 +1580,16 @@ class IMAPClient extends BaseClient { let mailbox = this.mailboxes.get(path); - let listing = await mailbox.listMessages(options, true, true); + let listing = await mailbox.listMessages(options, connectionOptions); return listing; } - async deleteMailbox(path) { - this.checkIMAPConnection(); + async deleteMailbox(path, connectionOptions) { + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); - const connectionClient = await this.getImapConnection(true, 'deleteMailbox'); + const connectionClient = await this.getImapConnection(connectionOptions, 'deleteMailbox'); let result = { path, @@ -1628,10 +1666,12 @@ class IMAPClient extends BaseClient { }); } - async getQuota() { - this.checkIMAPConnection(); + async getQuota(connectionOptions) { + connectionOptions = connectionOptions || { allowSecondary: true }; - const connectionClient = await this.getImapConnection(true, 'getQuota'); + this.checkIMAPConnection(connectionOptions); + + const connectionClient = await this.getImapConnection(connectionOptions, 'getQuota'); try { let result = await connectionClient.getQuota(); @@ -1653,10 +1693,12 @@ class IMAPClient extends BaseClient { } } - async createMailbox(path) { - this.checkIMAPConnection(); + async createMailbox(path, connectionOptions) { + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); - const connectionClient = await this.getImapConnection(true, 'createMailbox'); + const connectionClient = await this.getImapConnection(connectionOptions, 'createMailbox'); try { let result = await connectionClient.mailboxCreate(path); @@ -1688,10 +1730,12 @@ class IMAPClient extends BaseClient { } } - async renameMailbox(path, newPath) { - this.checkIMAPConnection(); + async renameMailbox(path, newPath, connectionOptions) { + connectionOptions = connectionOptions || { allowSecondary: true }; - const connectionClient = await this.getImapConnection(true, 'renameMailbox'); + this.checkIMAPConnection(connectionOptions); + + const connectionClient = await this.getImapConnection(connectionOptions, 'renameMailbox'); try { let result = await connectionClient.mailboxRename(path, newPath); @@ -1747,15 +1791,17 @@ class IMAPClient extends BaseClient { .find(entry => entry.specialUse === specialUse); } - async uploadMessage(data) { - this.checkIMAPConnection(); + async uploadMessage(data, connectionOptions) { + connectionOptions = connectionOptions || { allowSecondary: true }; + + this.checkIMAPConnection(connectionOptions); - let { raw, messageId, documentStoreUsed, referencedMessage } = await this.prepareRawMessage(data); + const connectionClient = await this.getImapConnection(connectionOptions, 'uploadMessage'); + + let { raw, messageId, documentStoreUsed, referencedMessage } = await this.prepareRawMessage(data, { connectionClient }); // Upload message to selected folder try { - const connectionClient = await this.getImapConnection(true, 'uploadMessage'); - let response = {}; let uploadResponse = await connectionClient.append(data.path, raw, data.flags, data.internalDate); @@ -1806,7 +1852,7 @@ class IMAPClient extends BaseClient { if (err.mailboxMissing) { // this mailbox is missing, refresh listing try { - await this.getCurrentListing(false, true); + await this.getCurrentListing(false, { connectionClient }); } catch (E) { this.logger.error({ msg: 'Missing mailbox', err, E }); } @@ -1842,7 +1888,7 @@ class IMAPClient extends BaseClient { const mailboxes = []; - const listing = await this.getCurrentListing(false, true); + const listing = await this.getCurrentListing(false, { allowSecondary: true }); for (const path of accountData.subconnections || []) { const entry = listing.find(entry => path === entry.path || path === entry.specialUse); diff --git a/lib/email-client/imap/mailbox.js b/lib/email-client/imap/mailbox.js index 0db78923..baee715c 100644 --- a/lib/email-client/imap/mailbox.js +++ b/lib/email-client/imap/mailbox.js @@ -2205,10 +2205,10 @@ class Mailbox { // Call `clearTimeout(this.connection.completedTimer);` after locking mailbox // Call this.onTaskCompleted() after selected mailbox is processed and lock is released - async getText(message, textParts, options, allowSecondary) { + async getText(message, textParts, options, connectionOptions) { options = options || {}; - const connectionClient = await this.connection.getImapConnection(allowSecondary); + const connectionClient = await this.connection.getImapConnection(connectionOptions); let result = {}; @@ -2273,9 +2273,10 @@ class Mailbox { return result; } - async getAttachment(message, part, options, allowSecondary) { + async getAttachment(message, part, options, connectionOptions) { options = options || {}; - const connectionClient = await this.connection.getImapConnection(allowSecondary); + + const connectionClient = await this.connection.getImapConnection(connectionOptions); let lock = await this.getMailboxLock(connectionClient, { description: `Get attachment: ${message.uid}/${part}` }); @@ -2337,11 +2338,12 @@ class Mailbox { } } - async getMessage(message, options, allowSecondary) { + async getMessage(message, options, connectionOptions) { options = options || {}; + let messageInfo; - const connectionClient = await this.connection.getImapConnection(allowSecondary); + const connectionClient = await this.connection.getImapConnection(connectionOptions); try { let lock; @@ -2382,7 +2384,7 @@ class Mailbox { return false; } - messageInfo = await this.getMessageInfo(messageData, true, allowSecondary); + messageInfo = await this.getMessageInfo(messageData, true); } finally { if (lock) { lock.release(); @@ -2410,7 +2412,7 @@ class Mailbox { } if (textParts && textParts.length) { - let textContent = await this.getText(message, textParts, options, allowSecondary); + let textContent = await this.getText(message, textParts, options, { connectionClient }); if (options.textType && options.textType !== '*') { textContent = { [options.textType]: textContent[options.textType] || '', @@ -2504,10 +2506,10 @@ class Mailbox { } } - async updateMessage(message, updates, allowSecondary) { + async updateMessage(message, updates, connectionOptions) { updates = updates || {}; - const connectionClient = await this.connection.getImapConnection(allowSecondary); + const connectionClient = await this.connection.getImapConnection(connectionOptions); let lock = await this.getMailboxLock(connectionClient, { description: `Update message: ${message.uid}` }); @@ -2573,10 +2575,10 @@ class Mailbox { } } - async updateMessages(search, updates, allowSecondary) { + async updateMessages(search, updates, connectionOptions) { updates = updates || {}; - const connectionClient = await this.connection.getImapConnection(allowSecondary); + const connectionClient = await this.connection.getImapConnection(connectionOptions); let lock = await this.getMailboxLock(connectionClient, { description: `Update messages` }); @@ -2642,10 +2644,10 @@ class Mailbox { } } - async moveMessage(message, target, allowSecondary) { + async moveMessage(message, target, connectionOptions) { target = target || {}; - const connectionClient = await this.connection.getImapConnection(allowSecondary); + const connectionClient = await this.connection.getImapConnection(connectionOptions); let lock = await this.getMailboxLock(connectionClient, { description: `Move message: ${message.uid} to: ${target.path}` }); @@ -2671,10 +2673,10 @@ class Mailbox { } } - async moveMessages(search, target, allowSecondary) { + async moveMessages(search, target, connectionOptions) { target = target || {}; - const connectionClient = await this.connection.getImapConnection(allowSecondary); + const connectionClient = await this.connection.getImapConnection(connectionOptions); let lock = await this.getMailboxLock(connectionClient, { description: `Move messages to: ${target.path}` }); @@ -2702,8 +2704,8 @@ class Mailbox { } } - async deleteMessage(message, force, allowSecondary) { - const connectionClient = await this.connection.getImapConnection(allowSecondary); + async deleteMessage(message, force, connectionOptions) { + const connectionClient = await this.connection.getImapConnection(connectionOptions); let lock = await this.getMailboxLock(connectionClient, { description: `Delete message: ${message.uid}` }); @@ -2742,8 +2744,8 @@ class Mailbox { } } - async deleteMessages(search, force, allowSecondary) { - const connectionClient = await this.connection.getImapConnection(allowSecondary); + async deleteMessages(search, force, connectionOptions) { + const connectionClient = await this.connection.getImapConnection(connectionOptions); let lock = await this.getMailboxLock(connectionClient, { description: `Delete messages` }); @@ -2786,7 +2788,7 @@ class Mailbox { } } - async listMessages(options, allowSecondary) { + async listMessages(options, connectionOptions) { options = options || {}; let page = Number(options.page) || 0; @@ -2800,7 +2802,7 @@ class Mailbox { let pageSize = Math.abs(Number(options.pageSize) || 20); - const connectionClient = await this.connection.getImapConnection(allowSecondary); + const connectionClient = await this.connection.getImapConnection(connectionOptions); let lock = await this.getMailboxLock(connectionClient, { description: `List messages from: ${this.path}` }); diff --git a/package-lock.json b/package-lock.json index 1d7f1c9e..46093e16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,10 +89,13 @@ "grunt": "1.6.1", "grunt-cli": "1.5.0", "grunt-eslint": "24.3.0", + "grunt-shell-spawn": "^0.4.0", + "grunt-wait": "^0.3.0", "jsxgettext": "0.11.0", "pino-pretty": "13.0.0", "resedit": "2.0.3", "spdx-satisfies": "5.0.1", + "supertest": "^7.0.0", "xgettext-template": "5.0.0" }, "engines": { @@ -2085,6 +2088,16 @@ "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2100,6 +2113,13 @@ "node": ">= 0.6" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3193,6 +3213,21 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", + "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dezalgo": "^1.0.4", + "hexoid": "^2.0.0", + "once": "^1.4.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3583,6 +3618,36 @@ "node": ">=10" } }, + "node_modules/grunt-shell-spawn": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/grunt-shell-spawn/-/grunt-shell-spawn-0.4.0.tgz", + "integrity": "sha512-lfYvEQjbO1Wv+1Fk3d3XlcEpuQjyXiErZMkiz/i/tDQeMHHGF1LziqA4ZcietBAo/bM2RHdEEUJfnNWt1VRMwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "grunt": ">=0.4.x" + }, + "bin": { + "grunt-shell-spawn": "bin/grunt-shell-spawn" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/grunt-wait": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/grunt-wait/-/grunt-wait-0.3.0.tgz", + "integrity": "sha512-KzqiYG3g+vL66nhKKOgdefze4QG2pPtrbEgcHwkkLQWct1/XXjqlcIhp0axAzAkheKymtJdALpE+Y2nEIxgBsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4", + "npm": ">= 2.14.3" + }, + "peerDependencies": { + "grunt": ">=0.4.0" + } + }, "node_modules/grunt/node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3880,6 +3945,16 @@ "dev": true, "license": "MIT" }, + "node_modules/hexoid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", + "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -5144,6 +5219,16 @@ "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5475,6 +5560,19 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object.defaults": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", @@ -6074,6 +6172,22 @@ "node": ">=10.13.0" } }, + "node_modules/qs": { + "version": "6.13.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", + "integrity": "sha512-EJPeIn0CYrGu+hli1xilKAPXODtJ12T0sP63Ijx2/khC2JtuaN3JyNIpvmnkmaEtha9ocbG4A4cMcr+TvqvwQg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -6481,6 +6595,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/slick": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", @@ -6734,6 +6924,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", + "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^3.5.1", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.0.0.tgz", + "integrity": "sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^9.0.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index b84bc018..37625900 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev": "EE_OPENAPI_VERBOSE=true EENGINE_LOG_RAW=true node --tls-keylog=keylog.txt server --dbs.redis='redis://127.0.0.1:6379/9' --api.port=7003 --api.host=0.0.0.0 | tee $HOME/ee.log.dev.txt | pino-pretty", "single": "EE_OPENAPI_VERBOSE=true EENGINE_LOG_RAW=true EENGINE_SECRET=your-encryption-key EENGINE_WORKERS=1 node --inspect server --dbs.redis='redis://127.0.0.1:6379/10' --api.port=7003 --api.host=0.0.0.0 | tee $HOME/ee.log.single.txt | pino-pretty", "gmail": "EE_OPENAPI_VERBOSE=true EENGINE_LOG_RAW=true EENGINE_SECRET=your-encryption-key EENGINE_WORKERS=2 node --inspect server --dbs.redis='redis://127.0.0.1:6379/11' --api.port=7003 --api.host=0.0.0.0 | tee $HOME/ee.log.gmail.txt | pino-pretty", - "test": "grunt && node --test test/", + "test": "NODE_ENV=test grunt", "swagger": "./getswagger.sh", "build-source": "rm -rf node_modules && npm install && rm -rf node_modules && npm ci --omit=dev && ./update-info.sh", "build-dist": "pkg --compress Brotli package.json && npm install && node winconf.js", @@ -117,10 +117,13 @@ "grunt": "1.6.1", "grunt-cli": "1.5.0", "grunt-eslint": "24.3.0", + "grunt-shell-spawn": "^0.4.0", + "grunt-wait": "^0.3.0", "jsxgettext": "0.11.0", "pino-pretty": "13.0.0", "resedit": "2.0.3", "spdx-satisfies": "5.0.1", + "supertest": "^7.0.0", "xgettext-template": "5.0.0" }, "engines": { diff --git a/test/api-test.js b/test/api-test.js new file mode 100644 index 00000000..a6215536 --- /dev/null +++ b/test/api-test.js @@ -0,0 +1,122 @@ +'use strict'; + +const config = require('wild-config'); +const supertest = require('supertest'); +const test = require('node:test'); +const assert = require('node:assert').strict; +const nodemailer = require('nodemailer'); + +const accessToken = '2aa97ad0456d6624a55d30780aa2ff61bfb7edc6fa00935b40814b271e718660'; + +const server = supertest.agent(`http://127.0.0.1:${config.api.port}`).auth(accessToken, { type: 'bearer' }); + +let testAccount; +const defaultAccountId = 'main-account'; + +test('API tests', async t => { + t.before(async () => { + testAccount = await nodemailer.createTestAccount(); + }); + + await t.test('list existing users (empty list)', async () => { + const response = await server.get(`/v1/accounts`).expect(200); + + assert.strictEqual(response.body.accounts.length, 0); + }); + + await t.test('Verify IMAP account', async () => { + const response = await server + .post(`/v1/verifyAccount`) + .send({ + mailboxes: true, + imap: { + host: testAccount.imap.host, + port: testAccount.imap.port, + secure: testAccount.imap.secure, + auth: { + user: testAccount.user, + pass: testAccount.pass + } + }, + smtp: { + host: testAccount.smtp.host, + port: testAccount.smtp.port, + secure: testAccount.smtp.secure, + auth: { + user: testAccount.user, + pass: testAccount.pass + } + } + }) + .expect(200); + + assert.strictEqual(response.body.imap.success, true); + assert.strictEqual(response.body.smtp.success, true); + // Check if Inbox folder exists + assert.ok(response.body.mailboxes.some(mb => mb.specialUse === '\\Inbox')); + }); + + await t.test('Register new IMAP account', async () => { + const response = await server + .post(`/v1/account`) + .send({ + account: defaultAccountId, + name: 'Test User 🫥', + email: testAccount.user, + imap: { + host: testAccount.imap.host, + port: testAccount.imap.port, + secure: testAccount.imap.secure, + auth: { + user: testAccount.user, + pass: testAccount.pass + } + }, + smtp: { + host: testAccount.smtp.host, + port: testAccount.smtp.port, + secure: testAccount.smtp.secure, + auth: { + user: testAccount.user, + pass: testAccount.pass + } + } + }) + .expect(200); + + assert.strictEqual(response.body.state, 'new'); + + // wait until connected + let available = false; + while (!available) { + await new Promise(r => setTimeout(r, 1000)); + const response = await server.get(`/v1/account/${defaultAccountId}`).expect(200); + switch (response.body.state) { + case 'authenticationError': + case 'connectError': + throw new Error('Invalid account state ' + response.body.state); + case 'connected': + available = true; + break; + } + } + }); + + await t.test('list existing users (1 account)', async () => { + const response = await server.get(`/v1/accounts`).expect(200); + + assert.strictEqual(response.body.accounts.length, 1); + }); + + await t.test('list mailboxes for an account', async () => { + const response = await server.get(`/v1/account/${defaultAccountId}/mailboxes`).expect(200); + + assert.ok(response.body.mailboxes.some(mb => mb.specialUse === '\\Inbox')); + }); + + await t.test('list inbox messages (empty)', async () => { + const response = await server.get(`/v1/account/${defaultAccountId}/messages?path=INBOX`).expect(200); + + assert.strictEqual(response.body.total, 0); + }); +});