@remit/imap-worker 0.0.81 → 0.0.83
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/package.json +1 -1
- package/src/account-check.test.ts +27 -0
- package/src/account-check.ts +13 -0
- package/src/handlers/mailbox-management.test.ts +51 -0
- package/src/handlers/mailbox-management.ts +39 -2
- package/src/handlers/sync-mailboxes.ts +9 -1
- package/src/handlers/sync-messages.test.ts +23 -0
- package/src/handlers/sync-messages.ts +9 -1
- package/src/mail-sync-selection.sqlite.test.ts +229 -0
- package/src/scheduler/attachment-sweep.test.ts +1 -0
- package/src/scheduler/run-tick.test.ts +49 -0
- package/src/scheduler/run-tick.ts +2 -0
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import type { AccountItem } from "@remit/data-ports";
|
|
|
4
4
|
import {
|
|
5
5
|
isAccountDeleted,
|
|
6
6
|
isAccountReauthRequired,
|
|
7
|
+
isMailSyncDisabled,
|
|
7
8
|
isReservedHost,
|
|
8
9
|
isUnsyncableHost,
|
|
9
10
|
} from "./account-check.js";
|
|
@@ -120,3 +121,29 @@ describe("isAccountReauthRequired", () => {
|
|
|
120
121
|
);
|
|
121
122
|
});
|
|
122
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("isMailSyncDisabled", () => {
|
|
126
|
+
it("returns false for a mail-only account", () => {
|
|
127
|
+
assert.equal(
|
|
128
|
+
isMailSyncDisabled(buildAccount({ syncedServices: ["Mail"] }), log),
|
|
129
|
+
false,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("returns false for an account syncing mail and calendar", () => {
|
|
134
|
+
assert.equal(
|
|
135
|
+
isMailSyncDisabled(
|
|
136
|
+
buildAccount({ syncedServices: ["Mail", "Calendar"] }),
|
|
137
|
+
log,
|
|
138
|
+
),
|
|
139
|
+
false,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns true for a calendar-only account", () => {
|
|
144
|
+
assert.equal(
|
|
145
|
+
isMailSyncDisabled(buildAccount({ syncedServices: ["Calendar"] }), log),
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/account-check.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AccountItem } from "@remit/data-ports";
|
|
2
|
+
import { AccountService } from "@remit/domain-enums";
|
|
2
3
|
import type { Logger } from "@remit/logger-lambda";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -86,3 +87,15 @@ export const isAccountReauthRequired = (
|
|
|
86
87
|
}
|
|
87
88
|
return false;
|
|
88
89
|
};
|
|
90
|
+
|
|
91
|
+
export const isMailSyncDisabled = (
|
|
92
|
+
account: AccountItem,
|
|
93
|
+
log: Logger,
|
|
94
|
+
): boolean => {
|
|
95
|
+
if (account.syncedServices.includes(AccountService.Mail)) return false;
|
|
96
|
+
log.info(
|
|
97
|
+
{ accountId: account.accountId, syncedServices: account.syncedServices },
|
|
98
|
+
"Skipping account: mail sync is off",
|
|
99
|
+
);
|
|
100
|
+
return true;
|
|
101
|
+
};
|
|
@@ -711,6 +711,23 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
711
711
|
assert.equal(called("mailbox.transition").length, 0);
|
|
712
712
|
});
|
|
713
713
|
|
|
714
|
+
it("marks failed and acks when the server refuses the DELETE with NOPERM", async () => {
|
|
715
|
+
recordDeleteIntent();
|
|
716
|
+
h.connection.deleteMailbox = async () => {
|
|
717
|
+
throw Object.assign(new Error("Command failed"), {
|
|
718
|
+
serverResponseCode: "NOPERM",
|
|
719
|
+
responseText: "Permission denied",
|
|
720
|
+
});
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
await assert.doesNotReject(
|
|
724
|
+
processMailboxManagement(deleteEvent, noopLogger, deps()),
|
|
725
|
+
);
|
|
726
|
+
assert.equal(lastSettle().to, "failed");
|
|
727
|
+
assert.equal(lastSettle().set?.syncFailureReason, "Permission denied");
|
|
728
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
|
|
729
|
+
});
|
|
730
|
+
|
|
714
731
|
it("still marks failed and rethrows when the server fails for any other reason", async () => {
|
|
715
732
|
recordDeleteIntent();
|
|
716
733
|
h.connection.deleteMailbox = async () => {
|
|
@@ -747,6 +764,21 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
747
764
|
assert.equal(lastSettle().to, "synced");
|
|
748
765
|
});
|
|
749
766
|
|
|
767
|
+
it("marks failed with the server's reason and acks when a CREATE is refused", async () => {
|
|
768
|
+
h.connection.createMailbox = async () => {
|
|
769
|
+
throw Object.assign(new Error("Command failed"), {
|
|
770
|
+
serverResponseCode: "NOPERM",
|
|
771
|
+
responseText: "Permission denied",
|
|
772
|
+
});
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
await assert.doesNotReject(
|
|
776
|
+
processMailboxManagement(createEvent, noopLogger, deps()),
|
|
777
|
+
);
|
|
778
|
+
assert.equal(lastSettle().to, "failed");
|
|
779
|
+
assert.equal(lastSettle().set?.syncFailureReason, "Permission denied");
|
|
780
|
+
});
|
|
781
|
+
|
|
750
782
|
it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
|
|
751
783
|
h.connection.createMailbox = async () => {
|
|
752
784
|
throw Object.assign(new Error("Command failed"), {
|
|
@@ -781,6 +813,25 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
781
813
|
]);
|
|
782
814
|
});
|
|
783
815
|
|
|
816
|
+
it("marks failed with the server's reason and acks when a RENAME is refused", async () => {
|
|
817
|
+
recordRenameIntent();
|
|
818
|
+
h.connection.renameMailbox = async () => {
|
|
819
|
+
throw Object.assign(new Error("Command failed"), {
|
|
820
|
+
serverResponseCode: "CANNOT",
|
|
821
|
+
responseText: "Can't rename mailbox with its children",
|
|
822
|
+
});
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
await assert.doesNotReject(
|
|
826
|
+
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
827
|
+
);
|
|
828
|
+
assert.equal(lastSettle().to, "failed");
|
|
829
|
+
assert.equal(
|
|
830
|
+
lastSettle().set?.syncFailureReason,
|
|
831
|
+
"Can't rename mailbox with its children",
|
|
832
|
+
);
|
|
833
|
+
});
|
|
834
|
+
|
|
784
835
|
it("still refuses the intent and rethrows when a RENAME fails for any other reason", async () => {
|
|
785
836
|
recordRenameIntent();
|
|
786
837
|
h.connection.renameMailbox = async () => {
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
FolderRenameSettleError,
|
|
10
10
|
isMailboxAbsentUpstream,
|
|
11
11
|
isMailboxPresentUpstream,
|
|
12
|
+
isMailboxRefusedUpstream,
|
|
12
13
|
MailboxManagementService,
|
|
14
|
+
upstreamFailureReason,
|
|
13
15
|
} from "@remit/mailbox-service";
|
|
14
16
|
import { isAccountDeleted } from "../account-check.js";
|
|
15
17
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
@@ -47,12 +49,13 @@ const recordCreateOutcome = async (
|
|
|
47
49
|
accountId: string,
|
|
48
50
|
mailboxId: string,
|
|
49
51
|
to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
|
|
52
|
+
syncFailureReason = "",
|
|
50
53
|
): Promise<void> => {
|
|
51
54
|
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
52
55
|
from: [MailboxSyncStatus.pending],
|
|
53
56
|
wherePendingPath: null,
|
|
54
57
|
to,
|
|
55
|
-
set: {},
|
|
58
|
+
set: { syncFailureReason },
|
|
56
59
|
});
|
|
57
60
|
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
58
61
|
};
|
|
@@ -167,7 +170,15 @@ const handleCreate = async (
|
|
|
167
170
|
accountId,
|
|
168
171
|
mailboxId,
|
|
169
172
|
MailboxSyncStatus.failed,
|
|
173
|
+
upstreamFailureReason(error),
|
|
170
174
|
);
|
|
175
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
176
|
+
log.error(
|
|
177
|
+
{ accountId, mailboxId, path, intent: "create", error },
|
|
178
|
+
"Mail server refused the create",
|
|
179
|
+
);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
171
182
|
throw error;
|
|
172
183
|
}
|
|
173
184
|
})
|
|
@@ -333,7 +344,22 @@ const handleRename = async (
|
|
|
333
344
|
mailboxId,
|
|
334
345
|
oldPath,
|
|
335
346
|
newPath,
|
|
347
|
+
upstreamFailureReason(error),
|
|
336
348
|
);
|
|
349
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
350
|
+
log.error(
|
|
351
|
+
{
|
|
352
|
+
accountId,
|
|
353
|
+
mailboxId,
|
|
354
|
+
oldPath,
|
|
355
|
+
newPath,
|
|
356
|
+
intent: "rename",
|
|
357
|
+
error,
|
|
358
|
+
},
|
|
359
|
+
"Mail server refused the rename",
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
337
363
|
throw error;
|
|
338
364
|
})
|
|
339
365
|
.finally(() => scope.disconnect());
|
|
@@ -431,7 +457,11 @@ const handleDelete = async (
|
|
|
431
457
|
// worker's backstop is unreachable from it; if something else
|
|
432
458
|
// reaches it, the honest outcome is `failed`, not `synced` —
|
|
433
459
|
// the folder was never deleted and nothing was undone.
|
|
434
|
-
await managementService.failDelete(
|
|
460
|
+
await managementService.failDelete(
|
|
461
|
+
accountId,
|
|
462
|
+
mailboxId,
|
|
463
|
+
upstreamFailureReason(error),
|
|
464
|
+
);
|
|
435
465
|
if (
|
|
436
466
|
error instanceof Error &&
|
|
437
467
|
error.message.includes("Cannot delete INBOX")
|
|
@@ -443,6 +473,13 @@ const handleDelete = async (
|
|
|
443
473
|
// Don't rethrow — no retry can make this succeed.
|
|
444
474
|
return;
|
|
445
475
|
}
|
|
476
|
+
if (isMailboxRefusedUpstream(error)) {
|
|
477
|
+
log.error(
|
|
478
|
+
{ accountId, mailboxId, path, intent: "delete", error },
|
|
479
|
+
"Mail server refused the delete",
|
|
480
|
+
);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
446
483
|
throw error;
|
|
447
484
|
}
|
|
448
485
|
})
|
|
@@ -21,7 +21,11 @@ import {
|
|
|
21
21
|
type MailCredentials,
|
|
22
22
|
} from "@remit/mailbox-service";
|
|
23
23
|
import pMap from "p-map";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
isAccountDeleted,
|
|
26
|
+
isMailSyncDisabled,
|
|
27
|
+
isUnsyncableHost,
|
|
28
|
+
} from "../account-check.js";
|
|
25
29
|
import { emitEvent } from "../emit.js";
|
|
26
30
|
import type { SyncMailboxesEvent, SyncMessagesEvent } from "../events.js";
|
|
27
31
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
@@ -120,6 +124,10 @@ export const syncMailboxes = async (
|
|
|
120
124
|
return;
|
|
121
125
|
}
|
|
122
126
|
|
|
127
|
+
if (isMailSyncDisabled(account, log)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
123
131
|
// A reserved/never-resolvable IMAP host (RFC 2606) can never connect, so a
|
|
124
132
|
// sync attempt would retry and dead-letter forever. Skip cleanly — ack the
|
|
125
133
|
// event without connecting or throwing.
|
|
@@ -256,6 +256,7 @@ const liveAccount = {
|
|
|
256
256
|
accountId: "acc-1",
|
|
257
257
|
accountConfigId: "acc-cfg-1",
|
|
258
258
|
imapHost: "localhost",
|
|
259
|
+
syncedServices: ["Mail"],
|
|
259
260
|
username: "user@localhost",
|
|
260
261
|
} as unknown as AccountItem;
|
|
261
262
|
|
|
@@ -542,3 +543,25 @@ describe("syncMessages — a delete that lands mid-round (issue #339)", () => {
|
|
|
542
543
|
assert.equal(harness.accountUpdates[0]?.syncPhase, "error");
|
|
543
544
|
});
|
|
544
545
|
});
|
|
546
|
+
|
|
547
|
+
describe("syncMessages — an account with mail sync off", () => {
|
|
548
|
+
it("acks the event without connecting and without touching the mailbox", async () => {
|
|
549
|
+
const { log } = buildLogger();
|
|
550
|
+
let mailboxReads = 0;
|
|
551
|
+
const harness = buildSyncDeps({
|
|
552
|
+
accountGet: async () =>
|
|
553
|
+
({ ...liveAccount, syncedServices: ["Calendar"] }) as AccountItem,
|
|
554
|
+
mailboxGet: async () => {
|
|
555
|
+
mailboxReads += 1;
|
|
556
|
+
return {};
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await assert.doesNotReject(
|
|
561
|
+
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
assert.equal(harness.lifecycleCalls, 0);
|
|
565
|
+
assert.equal(mailboxReads, 0);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
@@ -27,7 +27,11 @@ import {
|
|
|
27
27
|
type SyncedMessage,
|
|
28
28
|
} from "@remit/mailbox-service";
|
|
29
29
|
import pMap from "p-map";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
isAccountDeleted,
|
|
32
|
+
isMailSyncDisabled,
|
|
33
|
+
isUnsyncableHost,
|
|
34
|
+
} from "../account-check.js";
|
|
31
35
|
import { emitEvent } from "../emit.js";
|
|
32
36
|
import type {
|
|
33
37
|
FlagPushEvent,
|
|
@@ -126,6 +130,10 @@ export const syncMessages = async (
|
|
|
126
130
|
return;
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
if (isMailSyncDisabled(account, log)) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
129
137
|
// A reserved/never-resolvable IMAP host (RFC 2606) can never connect, so a
|
|
130
138
|
// sync attempt would retry and dead-letter forever. Skip cleanly — ack the
|
|
131
139
|
// event without connecting or throwing.
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
4
|
+
import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
5
|
+
import {
|
|
6
|
+
_resetForTest,
|
|
7
|
+
type RemitClient,
|
|
8
|
+
setClient,
|
|
9
|
+
} from "@remit/backend/client";
|
|
10
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
11
|
+
import {
|
|
12
|
+
AccountAuthType,
|
|
13
|
+
AccountService,
|
|
14
|
+
ConnectionState,
|
|
15
|
+
MailboxCursorState,
|
|
16
|
+
} from "@remit/domain-enums";
|
|
17
|
+
import {
|
|
18
|
+
AccountRepo,
|
|
19
|
+
DrizzleMessageRepository,
|
|
20
|
+
MailboxRepo,
|
|
21
|
+
} from "@remit/drizzle-service";
|
|
22
|
+
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
23
|
+
import type { Logger } from "@remit/logger-lambda";
|
|
24
|
+
import { syncMailboxes } from "./handlers/sync-mailboxes.js";
|
|
25
|
+
import {
|
|
26
|
+
type SyncMessagesDeps,
|
|
27
|
+
syncMessages,
|
|
28
|
+
} from "./handlers/sync-messages.js";
|
|
29
|
+
import { runSchedulerTick } from "./scheduler/run-tick.js";
|
|
30
|
+
|
|
31
|
+
const NOW = 1_700_000_000_000;
|
|
32
|
+
|
|
33
|
+
const silentLogger: Logger = (() => {
|
|
34
|
+
const noop = () => {};
|
|
35
|
+
const stub = {
|
|
36
|
+
info: noop,
|
|
37
|
+
warn: noop,
|
|
38
|
+
error: noop,
|
|
39
|
+
debug: noop,
|
|
40
|
+
fatal: noop,
|
|
41
|
+
trace: noop,
|
|
42
|
+
child: () => stub,
|
|
43
|
+
};
|
|
44
|
+
return stub as unknown as Logger;
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
let accounts: AccountRepo;
|
|
48
|
+
let mailboxes: MailboxRepo;
|
|
49
|
+
let messages: DrizzleMessageRepository;
|
|
50
|
+
let client: RemitClient;
|
|
51
|
+
let close: () => void;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
const store = createShippedSqliteDb();
|
|
55
|
+
close = store.close;
|
|
56
|
+
accounts = new AccountRepo(store.db);
|
|
57
|
+
mailboxes = new MailboxRepo(store.db);
|
|
58
|
+
messages = new DrizzleMessageRepository(store.db as never);
|
|
59
|
+
client = {
|
|
60
|
+
account: accounts,
|
|
61
|
+
mailbox: mailboxes,
|
|
62
|
+
message: messages,
|
|
63
|
+
} as unknown as RemitClient;
|
|
64
|
+
setClient(client);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
_resetForTest();
|
|
69
|
+
close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const seedSyncedAccount = async (): Promise<{
|
|
73
|
+
account: AccountItem;
|
|
74
|
+
mailboxId: string;
|
|
75
|
+
messageId: string;
|
|
76
|
+
}> => {
|
|
77
|
+
const account = await accounts.create({
|
|
78
|
+
accountConfigId: "cfg-1181",
|
|
79
|
+
username: "person@example.com",
|
|
80
|
+
email: "person@example.com",
|
|
81
|
+
authType: AccountAuthType.Password,
|
|
82
|
+
passwordHash: '{"ciphertext":"x"}',
|
|
83
|
+
imapHost: "imap.example.com",
|
|
84
|
+
imapPort: 993,
|
|
85
|
+
imapTls: true,
|
|
86
|
+
imapStartTls: false,
|
|
87
|
+
isActive: true,
|
|
88
|
+
connectionState: ConnectionState.Authenticated,
|
|
89
|
+
});
|
|
90
|
+
const created = await mailboxes.create({
|
|
91
|
+
accountId: account.accountId,
|
|
92
|
+
namespacePrefix: "",
|
|
93
|
+
hierarchyDelimiter: "/",
|
|
94
|
+
fullPath: "INBOX",
|
|
95
|
+
uidValidity: 7,
|
|
96
|
+
uidNext: 4,
|
|
97
|
+
highestModseq: "42",
|
|
98
|
+
messageCount: 1,
|
|
99
|
+
unseenCount: 0,
|
|
100
|
+
deletedCount: 0,
|
|
101
|
+
totalSize: 10,
|
|
102
|
+
lastSyncUid: 3,
|
|
103
|
+
highWaterMarkUid: 3,
|
|
104
|
+
lastMessageSyncAt: NOW,
|
|
105
|
+
cursorState: MailboxCursorState.normal,
|
|
106
|
+
} as Parameters<MailboxRepo["create"]>[0]);
|
|
107
|
+
if (created.outcome !== "Created") throw new Error("INBOX path taken");
|
|
108
|
+
const mailbox = created.mailbox;
|
|
109
|
+
const messageId = randomUUID();
|
|
110
|
+
await messages.create({
|
|
111
|
+
messageId,
|
|
112
|
+
mailboxId: mailbox.mailboxId,
|
|
113
|
+
uid: 3,
|
|
114
|
+
sequenceNumber: 1,
|
|
115
|
+
rfc822Size: 10,
|
|
116
|
+
internalDate: NOW,
|
|
117
|
+
envelopeId: randomUUID(),
|
|
118
|
+
rootBodyPartId: randomUUID(),
|
|
119
|
+
status: "active",
|
|
120
|
+
syncStatus: "synced",
|
|
121
|
+
});
|
|
122
|
+
return { account, mailboxId: mailbox.mailboxId, messageId };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const tick = async (): Promise<{ enqueued: number; accountIds: string[] }> => {
|
|
126
|
+
const sent: SendMessageCommand[] = [];
|
|
127
|
+
const sqsClient = {
|
|
128
|
+
send: async (command: SendMessageCommand) => {
|
|
129
|
+
sent.push(command);
|
|
130
|
+
return {};
|
|
131
|
+
},
|
|
132
|
+
} as unknown as SQSClient;
|
|
133
|
+
const result = await runSchedulerTick({
|
|
134
|
+
accountService: accounts,
|
|
135
|
+
sqsClient,
|
|
136
|
+
queueUrl: "https://queue.test/mailboxes",
|
|
137
|
+
log: silentLogger,
|
|
138
|
+
tickIntervalMs: 60_000,
|
|
139
|
+
offlineIntervalMs: 0,
|
|
140
|
+
now: NOW,
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
enqueued: result.enqueued,
|
|
144
|
+
accountIds: sent.map(
|
|
145
|
+
(command) => JSON.parse(command.input.MessageBody ?? "{}").accountId,
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const setServices = (
|
|
151
|
+
accountId: string,
|
|
152
|
+
syncedServices: AccountItem["syncedServices"],
|
|
153
|
+
) => accounts.update(accountId, { syncedServices });
|
|
154
|
+
|
|
155
|
+
describe("turning mail sync off and on again", () => {
|
|
156
|
+
it("stops every mail sync entry point and keeps the stored mail", async () => {
|
|
157
|
+
const { account, mailboxId, messageId } = await seedSyncedAccount();
|
|
158
|
+
const storedMailboxes = await mailboxes.listAllByAccount(account.accountId);
|
|
159
|
+
const storedMessage = await messages.get(messageId);
|
|
160
|
+
|
|
161
|
+
await setServices(account.accountId, [AccountService.Calendar]);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(await tick(), { enqueued: 0, accountIds: [] });
|
|
164
|
+
|
|
165
|
+
await syncMailboxes(
|
|
166
|
+
{
|
|
167
|
+
type: "SYNC_MAILBOXES",
|
|
168
|
+
eventId: "evt-mailboxes",
|
|
169
|
+
timestamp: NOW,
|
|
170
|
+
accountId: account.accountId,
|
|
171
|
+
},
|
|
172
|
+
silentLogger,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
let lifecycleCalls = 0;
|
|
176
|
+
const deps = {
|
|
177
|
+
getClient: async () => client,
|
|
178
|
+
buildLifecycleDeps: () => ({}),
|
|
179
|
+
withOAuthLifecycle: async () => {
|
|
180
|
+
lifecycleCalls += 1;
|
|
181
|
+
},
|
|
182
|
+
} as unknown as SyncMessagesDeps;
|
|
183
|
+
await syncMessages(
|
|
184
|
+
{
|
|
185
|
+
type: "SYNC_MESSAGES",
|
|
186
|
+
eventId: "evt-messages",
|
|
187
|
+
timestamp: NOW,
|
|
188
|
+
accountId: account.accountId,
|
|
189
|
+
mailboxId,
|
|
190
|
+
},
|
|
191
|
+
silentLogger,
|
|
192
|
+
deps,
|
|
193
|
+
);
|
|
194
|
+
assert.equal(lifecycleCalls, 0);
|
|
195
|
+
|
|
196
|
+
assert.deepEqual(
|
|
197
|
+
await mailboxes.listAllByAccount(account.accountId),
|
|
198
|
+
storedMailboxes,
|
|
199
|
+
);
|
|
200
|
+
assert.deepEqual(await messages.get(messageId), storedMessage);
|
|
201
|
+
assert.equal(
|
|
202
|
+
(await accounts.get(account.accountId)).connectionState,
|
|
203
|
+
ConnectionState.Authenticated,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("resumes from the stored markers when mail is turned back on", async () => {
|
|
208
|
+
const { account, messageId } = await seedSyncedAccount();
|
|
209
|
+
const storedMailboxes = await mailboxes.listAllByAccount(account.accountId);
|
|
210
|
+
const storedMessage = await messages.get(messageId);
|
|
211
|
+
|
|
212
|
+
await setServices(account.accountId, [AccountService.Calendar]);
|
|
213
|
+
await tick();
|
|
214
|
+
await setServices(account.accountId, [
|
|
215
|
+
AccountService.Mail,
|
|
216
|
+
AccountService.Calendar,
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
assert.deepEqual(await tick(), {
|
|
220
|
+
enqueued: 1,
|
|
221
|
+
accountIds: [account.accountId],
|
|
222
|
+
});
|
|
223
|
+
assert.deepEqual(
|
|
224
|
+
await mailboxes.listAllByAccount(account.accountId),
|
|
225
|
+
storedMailboxes,
|
|
226
|
+
);
|
|
227
|
+
assert.deepEqual(await messages.get(messageId), storedMessage);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
|
|
4
4
|
import type { AccountItem, AccountSchedulerPage } from "@remit/data-ports";
|
|
5
|
+
import { AccountService } from "@remit/domain-enums";
|
|
5
6
|
import type { Logger } from "@remit/logger-lambda";
|
|
6
7
|
import { runSchedulerTick } from "./run-tick.js";
|
|
7
8
|
|
|
@@ -62,6 +63,7 @@ const baseAccount = (overrides: Partial<AccountItem>): AccountItem =>
|
|
|
62
63
|
smtpTls: false,
|
|
63
64
|
smtpStartTls: true,
|
|
64
65
|
smtpUsername: "",
|
|
66
|
+
syncedServices: [AccountService.Mail],
|
|
65
67
|
isActive: true,
|
|
66
68
|
connectionState: "authenticated",
|
|
67
69
|
createdAt: NOW - 1_000_000,
|
|
@@ -364,3 +366,50 @@ describe("the attachment sweep inside a tick", () => {
|
|
|
364
366
|
assert.equal(result.sweepFailed, 0);
|
|
365
367
|
});
|
|
366
368
|
});
|
|
369
|
+
|
|
370
|
+
describe("runSchedulerTick and the account's synced services", () => {
|
|
371
|
+
const tickWith = async (syncedServices: AccountItem["syncedServices"]) => {
|
|
372
|
+
const accountService = fakeAccountService([
|
|
373
|
+
{
|
|
374
|
+
items: [baseAccount({ accountId: "acct_1", syncedServices })],
|
|
375
|
+
cursor: null,
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
const { sqsClient, sent } = fakeSqsClient();
|
|
379
|
+
const result = await runSchedulerTick({
|
|
380
|
+
accountService,
|
|
381
|
+
sqsClient,
|
|
382
|
+
queueUrl: "https://queue.test/mailboxes",
|
|
383
|
+
log: createNoopLogger(),
|
|
384
|
+
tickIntervalMs: TICK_INTERVAL_MS,
|
|
385
|
+
offlineIntervalMs: OFFLINE_INTERVAL_MS,
|
|
386
|
+
now: NOW,
|
|
387
|
+
});
|
|
388
|
+
return { result, sent };
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
it("enqueues mail sync for a mail-only account", async () => {
|
|
392
|
+
const { result, sent } = await tickWith([AccountService.Mail]);
|
|
393
|
+
|
|
394
|
+
assert.equal(result.enqueued, 1);
|
|
395
|
+
assert.equal(sent.length, 1);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("enqueues mail sync for an account syncing mail and calendar", async () => {
|
|
399
|
+
const { result, sent } = await tickWith([
|
|
400
|
+
AccountService.Mail,
|
|
401
|
+
AccountService.Calendar,
|
|
402
|
+
]);
|
|
403
|
+
|
|
404
|
+
assert.equal(result.enqueued, 1);
|
|
405
|
+
assert.equal(sent.length, 1);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("skips a calendar-only account", async () => {
|
|
409
|
+
const { result, sent } = await tickWith([AccountService.Calendar]);
|
|
410
|
+
|
|
411
|
+
assert.equal(result.enqueued, 0);
|
|
412
|
+
assert.equal(result.skipped, 1);
|
|
413
|
+
assert.equal(sent.length, 0);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -10,6 +10,7 @@ import pMap from "p-map";
|
|
|
10
10
|
import {
|
|
11
11
|
isAccountDeleted,
|
|
12
12
|
isAccountReauthRequired,
|
|
13
|
+
isMailSyncDisabled,
|
|
13
14
|
isUnsyncableHost,
|
|
14
15
|
} from "../account-check.js";
|
|
15
16
|
import {
|
|
@@ -78,6 +79,7 @@ const silentLogger: Logger = (() => {
|
|
|
78
79
|
|
|
79
80
|
const isEligible = (account: AccountItem): boolean => {
|
|
80
81
|
if (isAccountDeleted(account, silentLogger)) return false;
|
|
82
|
+
if (isMailSyncDisabled(account, silentLogger)) return false;
|
|
81
83
|
if (isUnsyncableHost(account, silentLogger)) return false;
|
|
82
84
|
if (isAccountReauthRequired(account, silentLogger)) return false;
|
|
83
85
|
// An account storing no credential can only produce a sync that fails at
|