@tribe-x/imapsync 0.0.1

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.
package/.env ADDED
@@ -0,0 +1,12 @@
1
+ # SOURCE_EMAIL=vendormaster.usa@gmail.com
2
+ # SOURCE_PASSWORD=lwqc ryhs kfyw wxsh
3
+ # DEST_EMAIL=thajmoha@tribe-x.org
4
+ # DEST_PASSWORD=yummygummy
5
+ # LOG_LEVEL=debug
6
+
7
+
8
+ SOURCE_EMAIL=tmhajmoha@gmail.com
9
+ SOURCE_PASSWORD=tiuw qsno slup svqh
10
+ DEST_EMAIL=thajmoha@tribe-x.org
11
+ DEST_PASSWORD=yummygummy
12
+ LOG_LEVEL=debug
package/.env.example ADDED
@@ -0,0 +1,18 @@
1
+ # source config
2
+ SOURCE_HOST=imap.gmail.com
3
+ SOURCE_PORT=993
4
+ SOURCE_EMAIL=src@gmail.com
5
+ # provide one of the following for authentication:
6
+ SOURCE_PASSWORD=xxxx xxxx xxxx xxxx
7
+ SOURCE_ACCESS_TOKEN=xx
8
+
9
+ # destination config
10
+ DEST_HOST=imap.tribe-x.org
11
+ DEST_PORT=993
12
+ DEST_EMAIL=dest@example.com
13
+ # provide one of the following for authentication:
14
+ DEST_PASSWORD=password
15
+ DEST_ACCESS_TOKEN=xx
16
+
17
+ # logging
18
+ LOG_LEVEL=debug
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <module type="WEB_MODULE" version="4">
3
+ <component name="NewModuleRootManager">
4
+ <content url="file://$MODULE_DIR$">
5
+ <excludeFolder url="file://$MODULE_DIR$/.tmp" />
6
+ <excludeFolder url="file://$MODULE_DIR$/temp" />
7
+ <excludeFolder url="file://$MODULE_DIR$/tmp" />
8
+ </content>
9
+ <orderEntry type="inheritedJdk" />
10
+ <orderEntry type="sourceFolder" forTests="false" />
11
+ </component>
12
+ </module>
@@ -0,0 +1,8 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="ProjectModuleManager">
4
+ <modules>
5
+ <module fileurl="file://$PROJECT_DIR$/.idea/imapsync.iml" filepath="$PROJECT_DIR$/.idea/imapsync.iml" />
6
+ </modules>
7
+ </component>
8
+ </project>
package/.idea/vcs.xml ADDED
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="VcsDirectoryMappings">
4
+ <mapping directory="" vcs="Git" />
5
+ </component>
6
+ </project>
@@ -0,0 +1,29 @@
1
+ {
2
+ // Use IntelliSense to learn about possible attributes.
3
+ // Hover to view descriptions of existing attributes.
4
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
+ "version": "0.2.0",
6
+ "configurations": [
7
+ {
8
+ "type": "node",
9
+ "request": "launch",
10
+ "name": "Launch Program",
11
+ "skipFiles": [
12
+ "<node_internals>/**"
13
+ ],
14
+ "program": "${workspaceFolder}/src/bin/runMigration.js"
15
+ },
16
+ {
17
+ "type": "node",
18
+ "request": "launch",
19
+ "name": "Debug Jest Tests",
20
+ "program": "${workspaceFolder}/node_modules/jest/bin/jest",
21
+ "args": [
22
+ "--runInBand",
23
+ "--watchAll=false"
24
+ ],
25
+ "console": "integratedTerminal",
26
+ "internalConsoleOptions": "neverOpen"
27
+ }
28
+ ]
29
+ }
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2025 Tribe-x
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # IMAPSYNC
2
+
3
+ A Node.js library for migrating emails between IMAP servers. Includes a developer-friendly CLI for testing and local development. Inspired by IMAPSYNC https://imapsync.lamiral.info/
4
+
5
+
6
+ ## Installation
7
+ `npm install`
8
+
9
+ ## Running the migration (for development)
10
+ ### Set up environment variables
11
+ Copy the example environment file and adjust values for your local setup:
12
+
13
+ ```bash
14
+ cp .env.example .env
15
+ ```
16
+ ### Run the migration script or cli
17
+
18
+ ```bash
19
+ node src/scripts/runMigrations.js
20
+ ```
21
+ OR
22
+ ```bash
23
+ npm run migrate
24
+ ```
25
+ which is equivelent to
26
+
27
+ ```bash
28
+ node src/bin/cli.js
29
+ ```
30
+
31
+ ## Testing
32
+ - to run unittests using jest
33
+ ```bash
34
+ npm test
35
+ ```
36
+
37
+
38
+ ## Supported Migration Paths
39
+
40
+ | Source | Destination | Status | Notes |
41
+ |--------|------------|--------|-------|
42
+ | Gmail | Iroco | ⚠️ WIP | Supports Mailbox |
43
+
44
+
45
+ ## Credentials & Requirements
46
+
47
+ Before performing a migration, you need to provide the following information for both the **source** and **destination** accounts:
48
+
49
+ - **Email address** – identifies the account on the IMAP server.
50
+
51
+ - **Access Token** - OAuth2 access token, if using OAuth2 authentication.
52
+
53
+ - **Password or app password** – Password for regular authentication.
54
+
55
+ The requirements vary slightly depending on the email provider:
56
+
57
+ | Provider | Required Input | Notes |
58
+ |----------|----------------------------------------|-------|
59
+ | Gmail | Email + (OAuth Access Toekn OR App Password) | regular password does not work for Gmail. You can either use OAuth access token (recommended by Google) or App Password with 2FA [Learn how to generate an App Password](https://support.google.com/accounts/answer/185833?hl=en) |
60
+ | Iroco | Email + Password | Regular account password is used for IMAP access. |
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ presets: [
3
+ ['@babel/preset-env', { targets: { node: 'current' } }]
4
+ ],
5
+ };
package/jest.config.js ADDED
@@ -0,0 +1,11 @@
1
+ export default {
2
+ transform: {
3
+ '^.+\\.js$': 'babel-jest'
4
+ },
5
+ // Allow transforming ESM packages in node_modules
6
+ transformIgnorePatterns: [
7
+ '/node_modules/(?!(@tribe-x/imap-server)/)'
8
+ ],
9
+ maxWorkers: 1,
10
+ testEnvironment: 'node'
11
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@tribe-x/imapsync",
3
+ "version": "0.0.1",
4
+ "description": "Imapsync command and library to synchronize 2 imap accounts",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "directories": {
8
+ "test": "test"
9
+ },
10
+ "scripts": {
11
+ "test": "jest",
12
+ "start": "node src/index.js",
13
+ "migrate": "node src/bin/cli.js"
14
+ },
15
+ "bin": {
16
+ "imapsync": "src/bin/cli.js"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://codeberg.org/Tribe-X/imapsync"
21
+ },
22
+ "keywords": [
23
+ "imap",
24
+ "migration",
25
+ "CLI",
26
+ "library"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "author": "tribe-x",
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@inquirer/prompts": "^7.8.6",
35
+ "@tribe-x/imap-server": "^0.0.25",
36
+ "imapflow": "^1.0.191",
37
+ "mailparser": "^3.7.4",
38
+ "prisma": "^6.12.0",
39
+ "winston": "^3.17.0",
40
+ "yargs": "^18.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@babel/core": "^7.28.4",
44
+ "@babel/preset-env": "^7.28.3",
45
+ "@jest/globals": "^30.1.2",
46
+ "babel-jest": "^30.1.2",
47
+ "dotenv": "^17.2.2",
48
+ "jest": "^30.1.3"
49
+ }
50
+ }
package/src/bin/cli.js ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { input, password, select } from '@inquirer/prompts';
4
+ import { ImapSyncManager } from '../imap/migrate.js';
5
+
6
+ (async () => {
7
+ // Source
8
+ const sourceHost = await input({
9
+ message: 'Enter source IMAP host:',
10
+ initial: 'imap.gmail.com'
11
+ });
12
+ const sourcePort = await number({
13
+ message: 'Enter source IMAP port:',
14
+ initial: 993
15
+ });
16
+ const sourceUser = await input({ message: 'Enter source email:' });
17
+
18
+
19
+ const sourceAuthMethod = await select({
20
+ message: 'Choose authentication method for source:',
21
+ choices: ['Password', 'Access Token']
22
+ });
23
+
24
+ let sourcePassword, sourceAccessToken;
25
+ if (sourceAuthMethod === 'Access Token') {
26
+ sourceAccessToken = await password({ message: 'Enter source access token:' });
27
+ } else {
28
+ sourcePassword = await password({ message: 'Enter source password:' });
29
+ }
30
+
31
+ // Destination
32
+ const destHost = await input({
33
+ message: 'Enter destination IMAP host:',
34
+ initial: 'imap.tribe-x.org'
35
+ });
36
+
37
+ const destPort = await number({
38
+ message: 'Enter destination IMAP port:',
39
+ initial: 993
40
+ });
41
+ const destUser = await input({ message: 'Enter destination email:' });
42
+
43
+ const destAuthMethod = await select({
44
+ message: 'Choose authentication method for destination:',
45
+ choices: ['Password', 'Access Token']
46
+ });
47
+
48
+ let destPassword, destAccessToken;
49
+ if (destAuthMethod === 'Access Token') {
50
+ destAccessToken = await password({ message: 'Enter destination access token:' });
51
+ } else {
52
+ destPassword = await password({ message: 'Enter destination password:' });
53
+ }
54
+
55
+
56
+ const manager = new ImapSyncManager(
57
+ {
58
+ host: sourceHost,
59
+ port: sourcePort,
60
+ secure: true,
61
+ user: sourceUser,
62
+ ...(sourceAccessToken ? { accessToken: sourceAccessToken } : { password: sourcePassword })
63
+ },
64
+ {
65
+ host: destHost,
66
+ port: destPort,
67
+ secure: true,
68
+ user: destUser,
69
+ ...(destAccessToken ? { accessToken: destAccessToken } : { password: destPassword })
70
+ }
71
+ );
72
+
73
+ try {
74
+ await manager.init();
75
+ await manager.runFullMigration();
76
+ console.log('✅ Migration complete!');
77
+ } catch (err) {
78
+ console.error('❌ Migration failed:', err.message);
79
+ process.exit(1);
80
+ }
81
+ })();
@@ -0,0 +1,38 @@
1
+ import { ImapFlow } from 'imapflow';
2
+
3
+ /**
4
+ * Creates a connection to an IMAP server
5
+ * @param {Object} config
6
+ * @param {string} config.host
7
+ * @param {number} config.port
8
+ * @param {boolean} config.secure
9
+ * @param {string} config.user
10
+ * @param {string} [config.password] - Optional if using OAuth
11
+ * @param {string} [config.accessToken] - Optional if using OAuth
12
+ * @returns {Promise<ImapFlow>}
13
+ */
14
+ export async function connectToImap(config) {
15
+ const auth = { user: config.user };
16
+
17
+ if (config.accessToken) {
18
+ // OAuth2 authentication
19
+ auth.accessToken = config.accessToken;
20
+ } else if (config.password) {
21
+ // Password authentication
22
+ auth.pass = config.password;
23
+ } else {
24
+ throw new Error('Either password or accessToken must be provided');
25
+ }
26
+
27
+
28
+ const client = new ImapFlow({
29
+ host: config.host,
30
+ port: config.port,
31
+ secure: config.secure,
32
+ auth,
33
+ keepalive: { interval: 60 * 1000, idleInterval: 10 * 1000, forceNoop: true }
34
+ });
35
+
36
+ await client.connect();
37
+ return client;
38
+ }
@@ -0,0 +1,260 @@
1
+ import { Metrics } from "./metrics.js";
2
+ import pLimit from "p-limit";
3
+ import logger from "../utils/logger.js";
4
+ import Quota from "./model/Quota.js";
5
+ import { ensurePath } from "../lib/pathUtils.js";
6
+ import { fingerprintMessage } from "../lib/fingerprint.js";
7
+ import Mailbox from "./model/Mailbox.js";
8
+ import { ClientType } from "./model/ClientType.js";
9
+ import { ImapWrapper } from "./imapWrapper.js";
10
+
11
+ const BATCH_SIZE = 100;
12
+ const CONCURRENCY = 10;
13
+ const ALWAYS_SKIP = [
14
+ '\\All', // avoids duplicates
15
+ ];
16
+
17
+ export class ImapSyncManager {
18
+ constructor(sourceConfig, destinationConfig, options = {}) {
19
+ this.source = new ImapWrapper(sourceConfig, "source");
20
+ this.destination = new ImapWrapper(destinationConfig, "destination");
21
+ this.metrics = new Metrics();
22
+ this.batchSize = options.batchSize || BATCH_SIZE;
23
+ this.concurrency = options.concurrency || CONCURRENCY;
24
+ this.limit = pLimit(this.concurrency);
25
+ this.alwaysSkip = new Set(options.alwaysSkip || ALWAYS_SKIP);
26
+ }
27
+
28
+ async init() {
29
+ await this.source.setupClient();
30
+ await this.destination.setupClient();
31
+ }
32
+
33
+ async getAccountQuota(clientType) {
34
+ const client = this.#getClient(clientType);
35
+
36
+ try {
37
+ const quota = await client.getQuota();
38
+ if (!quota || !quota.storage) {
39
+ logger.error(`No quota info available for ${clientType}`);
40
+ return null;
41
+ }
42
+ const { usage, limit, status } = quota.storage;
43
+
44
+ return new Quota(usage, limit, status)
45
+ } catch (err) {
46
+ logger.error(`Failed to fetch quota for ${err.message}`);
47
+ return null;
48
+ }
49
+ }
50
+
51
+ async listMailboxes(clientType) {
52
+ const client = this.#getClient(clientType);
53
+ const boxes = await client.list();
54
+
55
+ const mailboxPromises = boxes.map(box =>
56
+ this.limit(() => this.#toMailbox(clientType, box))
57
+ );
58
+ return Promise.all(mailboxPromises);
59
+ }
60
+
61
+ async listTreeMailboxes(clientType) {
62
+ const mailboxes = await this.listMailboxes(clientType)
63
+
64
+ function buildTree(flatList) {
65
+ const pathMap = new Map();
66
+ const tree = [];
67
+
68
+ for (const box of flatList) {
69
+ pathMap.set(box.path, box);
70
+ }
71
+
72
+ for (const box of pathMap.values()) {
73
+ if (box.parentPath && pathMap.has(box.parentPath)) {
74
+ pathMap.get(box.parentPath).children.push(box);
75
+ } else {
76
+ tree.push(box);
77
+ }
78
+ }
79
+
80
+ return tree;
81
+ }
82
+
83
+ return buildTree(mailboxes);
84
+ }
85
+
86
+
87
+ async migrateMailbox(mailboxOrPath) {
88
+ const boxPath =
89
+ typeof mailboxOrPath === "string"
90
+ ? mailboxOrPath
91
+ : mailboxOrPath?.path;
92
+
93
+ if (!boxPath) {
94
+ throw new Error("Invalid argument: mailboxOrPath must be a string or a Mailbox object with a path");
95
+ }
96
+
97
+ const destMailboxes = await this.#listSelectableMailboxes('destination');
98
+ const destPaths = new Set(destMailboxes.map(m => m.path));
99
+ await ensurePath(
100
+ boxPath,
101
+ '/',
102
+ destPaths,
103
+ async (p) => { await this.destination.client.mailboxCreate(p) }
104
+ );
105
+
106
+ await this.source.client.mailboxOpen(boxPath);
107
+ await this.destination.client.mailboxOpen(boxPath);
108
+
109
+ const sourceUids = await this.source.client.search({ all: true });
110
+ const destUids = await this.destination.client.search({ all: true });
111
+
112
+
113
+ const destFingerprints = new Set();
114
+ for (const msg of await this.destination.fetch(destUids, { headers: true, source: true })) {
115
+ const fp = await fingerprintMessage(msg);
116
+ destFingerprints.add(fp);
117
+ }
118
+
119
+ // process source mails in batches
120
+ for (let i = 0; i < sourceUids.length; i += this.batchSize) {
121
+ const batch = sourceUids.slice(i, i + this.batchSize);
122
+ logger.info(`[source] Fetching batch ${i / this.batchSize + 1} (${batch.length} messages)`);
123
+
124
+ const messages = await this.source.fetch(batch, {
125
+ headers: true,
126
+ source: true,
127
+ flags: true,
128
+ internalDate: true
129
+ });
130
+
131
+ // append with concurrency
132
+ await Promise.all(messages.map(msg => this.limit(async () => {
133
+ const fp = await fingerprintMessage(msg);
134
+
135
+ if (destFingerprints.has(fp)) {
136
+ logger.debug(`[destination] Skipping duplicate: ${fp}`);
137
+ return;
138
+ }
139
+
140
+ try {
141
+ await this.destination.append(boxPath, msg);
142
+ this.metrics.recordMessage(msg);
143
+ logger.info(`[destination] Migrated: ${fp}`);
144
+ } catch (err) {
145
+ logger.error(`[destination] Failed to migrate message: ${fp}`, { error: err.message });
146
+ }
147
+ })));
148
+ }
149
+
150
+ }
151
+
152
+ async logoutClients() {
153
+ try {
154
+ if (this.source.client.usable) await this.source.client.logout();
155
+ } catch (err) {
156
+ logger.warn(`[source] Logout failed: ${err.message}`);
157
+ }
158
+
159
+ try {
160
+ if (this.destination.client.usable) await this.destination.client.logout();
161
+ } catch (err) {
162
+ logger.warn(`[destination] Logout failed: ${err.message}`);
163
+ }
164
+ }
165
+
166
+ async runFullMigration() {
167
+ try {
168
+ const sourceMailboxes = await this.#listSelectableMailboxes('source');
169
+
170
+ for (const box of sourceMailboxes) {
171
+ await this.migrateMailbox(box.path)
172
+ }
173
+
174
+ logger.info('[migration] Migration completed.');
175
+ logger.info(`[migration] Metrics: ${JSON.stringify(this.metrics.report())}`);
176
+
177
+ const source_stats = this.source.client.stats();
178
+ console.log('Source statistics:', source_stats);
179
+
180
+ const dest_stats = this.destination.client.stats();
181
+ console.log('Destination statistics:', dest_stats);
182
+ } finally {
183
+ await this.logoutClients();
184
+ }
185
+
186
+ }
187
+
188
+ // Private helper
189
+
190
+ async #toMailbox(clientType, box) {
191
+ const size = await this.#calculateMailboxSize(clientType, box)
192
+ return Mailbox.fromObject({ ...box, size, children: [] })
193
+ }
194
+
195
+ async #calculateMailboxSize(clientType, box) {
196
+ if (box.flags?.has('\\Noselect')) {
197
+ return 0;
198
+ }
199
+ const client = this.#getClient(clientType);
200
+ const selectablePath = box.pathAsListed || box.path;
201
+
202
+ let mailbox;
203
+ try {
204
+ mailbox = await client.mailboxOpen(selectablePath);
205
+ } catch (err) {
206
+ logger.warn(`Skipping non-selectable mailbox: ${selectablePath}`);
207
+ return 0;
208
+ }
209
+ if (!mailbox.exists || mailbox.exists === 0) {
210
+ return 0;
211
+ }
212
+ let totalSize = 0;
213
+ try {
214
+ for await (const msg of client.fetch(`1:${mailbox.exists}`, { size: true })) {
215
+ totalSize += msg.size ?? 0;
216
+ }
217
+ } catch (err) {
218
+ logger.error(`Error fetching size ${selectablePath} - ${err.message}`);
219
+ }
220
+
221
+ return totalSize;
222
+ }
223
+
224
+
225
+
226
+ async #listSelectableMailboxes(clientType) {
227
+ const client = this.#getClient(clientType);
228
+
229
+ const mailboxes = await client.list();
230
+
231
+ return mailboxes
232
+ .filter(box => !box.flags.has('\\Noselect')) // skip unselectable folders
233
+ .filter(box => !this.alwaysSkip.has(box.specialUse)); // skip ones you always ignore
234
+ }
235
+
236
+ #getClient(clientType) {
237
+ let client;
238
+ switch (clientType) {
239
+ case ClientType.DESTINATION:
240
+ client = this.destination?.client;
241
+ break;
242
+ case ClientType.SOURCE:
243
+ client = this.source?.client;
244
+ break;
245
+ default:
246
+ throw new Error(`Invalid clientType: ${clientType}`);
247
+ }
248
+
249
+ if (!client) {
250
+ throw new Error(`Client not initialized for ${clientType}`);
251
+ }
252
+
253
+ return client;
254
+ }
255
+
256
+ setClients(sourceClient, destinationClient) {
257
+ this.source.client = sourceClient;
258
+ this.destination.client = destinationClient;
259
+ }
260
+ }
@@ -0,0 +1,99 @@
1
+ import { connectToImap } from './client.js';
2
+ import logger from "../utils/logger.js";
3
+ import { withRetry } from '../utils/retry.js';
4
+
5
+
6
+ const SKIP_SPECIAL_USE = [
7
+ '\\All', // avoids duplicates
8
+ '\\Trash', // usually not migrated
9
+ '\\Junk',
10
+ '\\Spam',
11
+ '\\Drafts'
12
+ ];
13
+
14
+
15
+ export class ImapWrapper {
16
+ constructor(config, label) {
17
+ this.config = config;
18
+ this.label = label;
19
+ this.client = null;
20
+ this.lastMailbox = null;
21
+ this._reconnecting = null;
22
+
23
+ }
24
+
25
+ async setupClient() {
26
+ this.client = await connectToImap(this.config, this.label);
27
+
28
+ // event listens
29
+ this.client.on('error', (err) => {
30
+ logger.error(`[${this.label}] IMAP error: ${err.message}`, { code: err.code });
31
+ });
32
+
33
+ this.client.on('mailboxOpen', (mailbox) => {
34
+ logger.info(`[${this.label}] Opened mailbox: ${mailbox.path} (${mailbox.exists} messages)`);
35
+ this.lastMailbox = mailbox.path;
36
+ });
37
+
38
+ this.client.on('mailboxClose', (mailbox) => {
39
+ logger.info(`[${this.label}] Closed mailbox: ${mailbox.path}`);
40
+ });
41
+
42
+ return this.client;
43
+ }
44
+
45
+ async recreateClient() {
46
+ logger.warn(`[${this.label}] Recreating client due to connection loss...`);
47
+ this.client = await this.setupClient();
48
+ if (this.lastMailbox) {
49
+ try {
50
+ await this.client.mailboxOpen(this.lastMailbox);
51
+ } catch (err) {
52
+ logger.warn(`[${this.label}] Failed to reopen last mailbox: ${err.message}`);
53
+ }
54
+ }
55
+ return this.client
56
+ }
57
+
58
+ async checkClient() {
59
+ if (this.client && this.client.usable) {
60
+ return this.client;
61
+ }
62
+ if (!this._reconnecting) {
63
+ this._reconnecting = this.recreateClient()
64
+ .finally(() => {
65
+ this._reconnecting = null;
66
+ });
67
+ }
68
+ return this._reconnecting;
69
+ }
70
+
71
+ async fetch(uids, options) {
72
+ return withRetry(async () => {
73
+ await this.checkClient();
74
+ const results = [];
75
+ for await (const msg of this.client.fetch(uids, options)) {
76
+ results.push(msg);
77
+ }
78
+ return results;
79
+ }).catch(err => {
80
+ logger.error(`[${this.label}] Fetch failed after retries: ${err.message}`);
81
+ throw err;
82
+ });
83
+ }
84
+
85
+ async append(boxPath, msg) {
86
+ return withRetry(async () => {
87
+ await this.checkClient();
88
+ await this.client.append(
89
+ boxPath,
90
+ msg.source,
91
+ Array.from(msg.flags),
92
+ msg.internalDate
93
+ );
94
+ }).catch(err => {
95
+ logger.error(`[${this.label}] Append failed after retries: ${err.message}`);
96
+ throw err;
97
+ });
98
+ }
99
+ }
@@ -0,0 +1,21 @@
1
+ export class Metrics {
2
+ constructor() {
3
+ this.startTime = Date.now();
4
+ this.bytesMigrated = 0;
5
+ this.messagesMigrated = 0;
6
+ }
7
+
8
+ recordMessage(msg) {
9
+ this.bytesMigrated += Buffer.byteLength(msg.source);
10
+ this.messagesMigrated++;
11
+ }
12
+
13
+ report() {
14
+ const elapsed = (Date.now() - this.startTime) / 1000;
15
+ return {
16
+ durationSeconds: elapsed,
17
+ migratedMessages: this.messagesMigrated,
18
+ migratedMB: (this.bytesMigrated / (1024 * 1024)).toFixed(2)
19
+ };
20
+ }
21
+ }
@@ -0,0 +1,4 @@
1
+ export const ClientType = Object.freeze({
2
+ SOURCE: "source",
3
+ DESTINATION: "destination"
4
+ });
@@ -0,0 +1,31 @@
1
+ import logger from "../../utils/logger.js";
2
+
3
+ class Mailbox {
4
+ constructor(name, path, size = 0, children = [], parentPath = '') {
5
+ this.name = name;
6
+ this.path = path;
7
+ this.size = size; // in bytes
8
+ this.children = children;
9
+ this.parentPath = parentPath;
10
+ }
11
+
12
+ static fromObject(obj) {
13
+ return new Mailbox(
14
+ obj.name,
15
+ obj.path,
16
+ obj.size ?? 0,
17
+ obj.children ?? [],
18
+ obj.parentPath ?? '');
19
+ }
20
+
21
+
22
+ get sizeMB() {
23
+ return +(this.size / 1024 / 1024).toFixed(2);
24
+ }
25
+
26
+ toString() {
27
+ return `${this.name} (${this.path}) - ${this.sizeMB} MB`;
28
+ }
29
+ }
30
+
31
+ export default Mailbox;
@@ -0,0 +1,26 @@
1
+
2
+ class Quota {
3
+ /**
4
+ * @param {number} usage - Used storage in bytes
5
+ * @param {number} limit - Storage limit in bytes
6
+ * @param {string} status - Status string (e.g., "80%")
7
+ */
8
+ constructor(usage, limit, status) {
9
+ this.usage = usage;
10
+ this.limit = limit;
11
+ this.status = status;
12
+ }
13
+
14
+ /**
15
+ * Returns usage and limit in MB for easier readability.
16
+ */
17
+ get infoMB() {
18
+ return {
19
+ usageMB: +(this.usage / 1024 / 1024).toFixed(2),
20
+ limitMB: +(this.limit / 1024 / 1024).toFixed(2),
21
+ status: this.status
22
+ };
23
+ }
24
+ }
25
+
26
+ export default Quota;
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ import { migrateMailbox } from './imap/migrate.js';
2
+ export { migrateMailbox };
@@ -0,0 +1,34 @@
1
+ import crypto from 'crypto';
2
+ import { simpleParser } from 'mailparser';
3
+
4
+ async function getMessageId(message) {
5
+ // Use headers if available, fallback to source
6
+ const buffer = Buffer.from(message.headers ?? message.source);
7
+
8
+ // Parse only the headers
9
+ const parsed = await simpleParser(buffer, {
10
+ skipHtmlToText: true,
11
+ skipTextToHtml: true
12
+ });
13
+
14
+ if (parsed.messageId) {
15
+ // normalize: strip <> and lowercase
16
+ return parsed.messageId.replace(/^<|>$/g, "").toLowerCase();
17
+ }
18
+ return null;
19
+ }
20
+
21
+
22
+ /**
23
+ * Generate a fingerprint for a message
24
+ * - Prefer Message-ID
25
+ * - Fallback: SHA256 hash of raw source
26
+ */
27
+ export async function fingerprintMessage(msg) {
28
+ const messageId = await getMessageId(msg);
29
+ if (messageId) return `mid:${messageId.trim()}`;
30
+ return `hash:${crypto
31
+ .createHash("sha256")
32
+ .update(Buffer.from(msg.source))
33
+ .digest("hex")}`;
34
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Ensure a nested folder structure exists by creating missing parts.
3
+ *
4
+ * @param {string} fullPath - Full path like "Parent/Sub1/Sub2"
5
+ * @param {string} delimiter - Path separator ("/" for IMAP, path.sep for OS)
6
+ * @param {Set<string>} existing - Known existing paths
7
+ * @param {Function} creator - Function that creates a folder (async)
8
+ * signature: async (path: string) => void
9
+ */
10
+ export async function ensurePath(fullPath, delimiter, existing, creator) {
11
+ const parts = fullPath.split(delimiter);
12
+ let current = "";
13
+
14
+ for (const part of parts) {
15
+ current = current ? `${current}${delimiter}${part}` : part;
16
+
17
+ if (!existing.has(current)) {
18
+ try {
19
+ await creator(current);
20
+ } catch (err) {
21
+ if (err.responseText.includes("Mailbox already exists")) {
22
+ console.log(`Already exists: ${current}`);
23
+ } else {
24
+ throw err;
25
+ }
26
+ }
27
+ existing.add(current); // mark as created
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,62 @@
1
+ import dotenv from 'dotenv';
2
+ import { connectToImap } from "../imap/client.js";
3
+
4
+ dotenv.config();
5
+
6
+ (async () => {
7
+ const destUser = process.env.DEST_EMAIL;
8
+ const destPassword = process.env.DEST_PASSWORD;
9
+ const destAccessToken = process.env.DEST_ACCESS_TOKEN;
10
+
11
+ if (!destUser) {
12
+ console.error('Please set DEST_EMAIL in your .env file');
13
+ process.exit(1);
14
+ }
15
+
16
+ if (!destPassword && !destAccessToken) {
17
+ console.error('Please set either DEST_PASSWORD or DEST_ACCESS_TOKEN in your .env file');
18
+ process.exit(1);
19
+ }
20
+
21
+ const destHost = process.env.DEST_HOST || 'imap.tribe-x.org';
22
+ const destPort = parseInt(process.env.DEST_PORT, 10) || 993;
23
+
24
+
25
+ const destinationConfig = {
26
+ host: destHost,
27
+ port: destPort,
28
+ secure: true,
29
+ user: destUser,
30
+ ...(destAccessToken ? { accessToken: destAccessToken } : { password: destPassword })
31
+ };
32
+
33
+ const client = await connectToImap(destinationConfig);
34
+
35
+ try {
36
+ console.log('Connected to destination provider');
37
+
38
+ for await (const mailbox of await client.list()) {
39
+ console.log(`Cleaning mailbox: ${mailbox.path}`);
40
+
41
+ await client.mailboxOpen(mailbox.path);
42
+
43
+ const messages = await client.search({});
44
+ if (messages.length === 0) {
45
+ console.log(`No messages found in ${mailbox.path}`);
46
+ continue;
47
+ }
48
+
49
+ await client.messageFlagsAdd(messages, ['\\Deleted']);
50
+
51
+ await client.mailboxClose({ expunge: true });
52
+
53
+ console.log(`Deleted ${messages.length} messages from ${mailbox.path}`);
54
+ }
55
+
56
+ console.log('Destination mailboxes cleaned up!');
57
+ } catch (err) {
58
+ console.error('Error cleaning destination:', err);
59
+ } finally {
60
+ await client.logout();
61
+ }
62
+ })();
@@ -0,0 +1,64 @@
1
+ import { ImapSyncManager } from '../imap/migrate.js';
2
+ import dotenv from 'dotenv';
3
+
4
+ dotenv.config();
5
+
6
+ (async () => {
7
+ const sourceUser = process.env.SOURCE_EMAIL;
8
+ const destUser = process.env.DEST_EMAIL;
9
+
10
+ const sourcePassword = process.env.SOURCE_PASSWORD;
11
+ const sourceAccessToken = process.env.SOURCE_ACCESS_TOKEN;
12
+
13
+ const destPassword = process.env.DEST_PASSWORD;
14
+ const destAccessToken = process.env.DEST_ACCESS_TOKEN;
15
+
16
+ if (!sourceUser || !destUser) {
17
+ console.error('Please set SOURCE_EMAIL and DEST_EMAIL in your .env file');
18
+ process.exit(1);
19
+ }
20
+
21
+ if (!sourcePassword && !sourceAccessToken) {
22
+ console.error('Please set either SOURCE_PASSWORD or SOURCE_ACCESS_TOKEN in your .env file');
23
+ process.exit(1);
24
+ }
25
+
26
+ if (!destPassword && !destAccessToken) {
27
+ console.error('Please set either DEST_PASSWORD or DEST_ACCESS_TOKEN in your .env file');
28
+ process.exit(1);
29
+ }
30
+
31
+ // Optional host & port from .env, defaults to 993
32
+ const sourceHost = process.env.SOURCE_HOST || 'imap.gmail.com';
33
+ const sourcePort = parseInt(process.env.SOURCE_PORT, 10) || 993;
34
+
35
+ const destHost = process.env.DEST_HOST || 'imap.tribe-x.org';
36
+ const destPort = parseInt(process.env.DEST_PORT, 10) || 993;
37
+
38
+ const manager = new ImapSyncManager(
39
+ {
40
+ host: sourceHost,
41
+ port: sourcePort,
42
+ secure: true,
43
+ user: sourceUser,
44
+ ...(sourceAccessToken ? { accessToken: sourceAccessToken } : { password: sourcePassword })
45
+ },
46
+ {
47
+ host: destHost,
48
+ port: destPort,
49
+ secure: true,
50
+ user: destUser,
51
+ ...(destAccessToken ? { accessToken: destAccessToken } : { password: destPassword })
52
+ }
53
+ );
54
+
55
+ try {
56
+ await manager.init();
57
+ await manager.runFullMigration();
58
+ console.log('✅ Migration complete!');
59
+ } catch (err) {
60
+ console.error('❌ Migration failed:', err.message);
61
+ process.exit(1);
62
+ }
63
+
64
+ })();
@@ -0,0 +1,21 @@
1
+ import { createLogger, format, transports } from "winston";
2
+
3
+ const logger = createLogger({
4
+ level: process.env.LOG_LEVEL || "info", // default to "info"
5
+ format: format.combine(
6
+ format.timestamp(),
7
+ format.colorize(), // nice colors in console
8
+ format.printf(({ level, message, timestamp, ...meta }) => {
9
+ return `[${timestamp}] [${level}] ${message} ${
10
+ Object.keys(meta).length ? JSON.stringify(meta) : ""
11
+ }`;
12
+ })
13
+ ),
14
+ transports: [
15
+ new transports.Console(),
16
+ new transports.File({ filename: "logs/error.log", level: "error" }),
17
+ new transports.File({ filename: "logs/combined.log" }),
18
+ ],
19
+ });
20
+
21
+ export default logger;
@@ -0,0 +1,33 @@
1
+ import logger from "../utils/logger.js";
2
+
3
+ const retryableCodes = ["ECONNRESET", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND"];
4
+ const permanentImapErrors = ["OVERQUOTA", "NO", "BAD"];
5
+
6
+ export async function withRetry(fn, { retries = 3, delay = 1000 } = {}) {
7
+ let attempt = 0;
8
+
9
+ while (true) {
10
+ try {
11
+ return await fn();
12
+ } catch (err) {
13
+ attempt++;
14
+
15
+ // Check if permanent IMAP error
16
+ if (permanentImapErrors.some(e => err.response?.includes(e) || err.message?.includes(e))) {
17
+ logger.error(`[retry] Permanent failure (not retrying) with code ${err.code} : ${err.message}`);
18
+ throw err;
19
+ }
20
+
21
+ // Check if transient network error
22
+ const isRetryable = retryableCodes.includes(err.code);
23
+
24
+ if (!isRetryable || attempt > retries) {
25
+ logger.error(`[retry] Failed after ${attempt} attempts: ${err.message}`);
26
+ throw err;
27
+ }
28
+
29
+ logger.warn(`[retry] Attempt ${attempt} failed: ${err.message}. Retrying in ${delay}ms...`);
30
+ await new Promise(res => setTimeout(res, delay));
31
+ }
32
+ }
33
+ }