@diskd-ai/email-client-mcp 0.1.20 → 0.1.22
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/dist/config/schema.d.ts +56 -0
- package/dist/config/schema.js +15 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/domain/errors.d.ts +2 -0
- package/dist/domain/errors.js +18 -0
- package/dist/domain/errors.js.map +1 -1
- package/dist/imap/fetch.d.ts +14 -1
- package/dist/imap/fetch.js +58 -4
- package/dist/imap/fetch.js.map +1 -1
- package/dist/imap/mapper.js +12 -2
- package/dist/imap/mapper.js.map +1 -1
- package/dist/server.js +31 -3
- package/dist/server.js.map +1 -1
- package/dist/store/conventions.d.ts +4 -3
- package/dist/store/conventions.js +11 -6
- package/dist/store/conventions.js.map +1 -1
- package/dist/store/payloadTypes.d.ts +7 -0
- package/dist/sync/attachmentHydration.d.ts +44 -0
- package/dist/sync/attachmentHydration.js +118 -0
- package/dist/sync/attachmentHydration.js.map +1 -0
- package/dist/sync/bodyHydration.d.ts +52 -0
- package/dist/sync/bodyHydration.js +103 -0
- package/dist/sync/bodyHydration.js.map +1 -0
- package/dist/sync/sync.d.ts +9 -5
- package/dist/sync/sync.js +74 -43
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/watcher.d.ts +2 -0
- package/dist/sync/watcher.js +4 -0
- package/dist/sync/watcher.js.map +1 -1
- package/dist/tools/registry.d.ts +4 -0
- package/dist/tools/registry.js +10 -0
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/systemHydrateEmailAttachment.d.ts +26 -0
- package/dist/tools/systemHydrateEmailAttachment.js +13 -0
- package/dist/tools/systemHydrateEmailAttachment.js.map +1 -0
- package/dist/tools/systemHydrateEmailBodies.d.ts +40 -0
- package/dist/tools/systemHydrateEmailBodies.js +21 -0
- package/dist/tools/systemHydrateEmailBodies.js.map +1 -0
- package/package.json +1 -1
|
@@ -11,14 +11,18 @@ export type EmailAddress = {
|
|
|
11
11
|
readonly name: string | null;
|
|
12
12
|
readonly address: string;
|
|
13
13
|
};
|
|
14
|
+
export type BodyState = "not_loaded" | "loading" | "loaded" | "failed_retryable" | "failed_permanent";
|
|
15
|
+
export type AttachmentStorageState = "not_loaded" | "loading" | "loaded" | "failed_retryable" | "failed_permanent";
|
|
14
16
|
export type StoredAttachment = {
|
|
15
17
|
readonly filename: string;
|
|
16
18
|
readonly contentType: string;
|
|
17
19
|
readonly sizeBytes: number;
|
|
18
20
|
readonly partId: string;
|
|
19
21
|
readonly attachmentId?: string;
|
|
22
|
+
readonly storageState?: AttachmentStorageState;
|
|
20
23
|
readonly storedSizeBytes?: number;
|
|
21
24
|
readonly storedAt?: string;
|
|
25
|
+
readonly lastLoadError?: string;
|
|
22
26
|
};
|
|
23
27
|
export type StoredEmailPayload = {
|
|
24
28
|
readonly accountId: string;
|
|
@@ -41,6 +45,9 @@ export type StoredEmailPayload = {
|
|
|
41
45
|
readonly bodyText: string | null;
|
|
42
46
|
readonly bodyHtml: string | null;
|
|
43
47
|
readonly truncated: boolean;
|
|
48
|
+
readonly bodyState?: BodyState;
|
|
49
|
+
readonly bodyFetchedAt?: string | null;
|
|
50
|
+
readonly bodyFetchError?: string | null;
|
|
44
51
|
readonly fetchedAt: string;
|
|
45
52
|
};
|
|
46
53
|
/**
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type AppError } from "../domain/errors.js";
|
|
2
|
+
import { type Result } from "../domain/result.js";
|
|
3
|
+
import { type UploadAttachmentResult } from "../store/attachments.js";
|
|
4
|
+
import type { StoredAttachment, StoredEmailPayload } from "../store/payloadTypes.js";
|
|
5
|
+
export type AttachmentHydrationRef = {
|
|
6
|
+
readonly mailboxId: string;
|
|
7
|
+
readonly folderId: string;
|
|
8
|
+
readonly externalId: string;
|
|
9
|
+
readonly attachmentId: string;
|
|
10
|
+
};
|
|
11
|
+
export type AttachmentHydrationDeps = {
|
|
12
|
+
readonly drive: {
|
|
13
|
+
readonly getMessage: (mailboxId: string, folderId: string, externalId: string) => Promise<Result<AppError, StoredEmailPayload | null>>;
|
|
14
|
+
readonly upsertMessages: (mailboxId: string, folderId: string, payloads: readonly StoredEmailPayload[], externalIds: readonly string[]) => Promise<Result<AppError, {
|
|
15
|
+
inserted: number;
|
|
16
|
+
updated: number;
|
|
17
|
+
}>>;
|
|
18
|
+
readonly uploadAttachment: (mailboxId: string, folderId: string, externalId: string, attachment: StoredAttachment & {
|
|
19
|
+
readonly attachmentId: string;
|
|
20
|
+
}, content: AsyncIterable<Uint8Array>) => Promise<Result<AppError, UploadAttachmentResult>>;
|
|
21
|
+
};
|
|
22
|
+
readonly imap: {
|
|
23
|
+
readonly downloadPart: (accountId: string, mailbox: string, uid: number, partId: string) => Promise<{
|
|
24
|
+
readonly content: AsyncIterable<Uint8Array>;
|
|
25
|
+
readonly sizeBytes: number | null;
|
|
26
|
+
readonly contentType: string | null;
|
|
27
|
+
readonly dispose: () => void;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export type AttachmentHydrationStatus = "loaded" | "skipped" | "failed_retryable" | "failed_permanent";
|
|
32
|
+
export type AttachmentHydrationOutcome = {
|
|
33
|
+
readonly status: AttachmentHydrationStatus;
|
|
34
|
+
readonly mailboxId: string;
|
|
35
|
+
readonly folderId: string;
|
|
36
|
+
readonly externalId: string;
|
|
37
|
+
readonly attachmentId: string;
|
|
38
|
+
readonly attachment: StoredAttachment | null;
|
|
39
|
+
readonly payload: StoredEmailPayload;
|
|
40
|
+
readonly error: string | null;
|
|
41
|
+
};
|
|
42
|
+
export declare const hydrateStoredMessageAttachment: (deps: AttachmentHydrationDeps, ref: AttachmentHydrationRef, options?: {
|
|
43
|
+
readonly refresh?: boolean;
|
|
44
|
+
}) => Promise<Result<AppError, AttachmentHydrationOutcome>>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { errorMessage, isRetryableImapError, notFound } from "../domain/errors.js";
|
|
2
|
+
import { Err, Ok } from "../domain/result.js";
|
|
3
|
+
import { attachmentIdFor } from "../store/attachments.js";
|
|
4
|
+
const resolvedAttachmentId = (payload, attachment) => attachment.attachmentId ?? attachmentIdFor(payload.uidValidity, payload.uid, attachment.partId);
|
|
5
|
+
const findAttachment = (payload, attachmentId) => payload.attachments.find((attachment) => resolvedAttachmentId(payload, attachment) === attachmentId) ?? null;
|
|
6
|
+
const patchAttachment = (payload, attachmentId, patch) => {
|
|
7
|
+
let patchedAttachment = null;
|
|
8
|
+
const attachments = payload.attachments.map((attachment) => {
|
|
9
|
+
const nextAttachment = {
|
|
10
|
+
...attachment,
|
|
11
|
+
attachmentId: resolvedAttachmentId(payload, attachment),
|
|
12
|
+
};
|
|
13
|
+
if (nextAttachment.attachmentId !== attachmentId)
|
|
14
|
+
return nextAttachment;
|
|
15
|
+
patchedAttachment = patch(nextAttachment);
|
|
16
|
+
return patchedAttachment;
|
|
17
|
+
});
|
|
18
|
+
return { payload: { ...payload, attachments }, attachment: patchedAttachment };
|
|
19
|
+
};
|
|
20
|
+
const patchLoaded = (payload, attachmentId, uploaded) => patchAttachment(payload, attachmentId, (attachment) => {
|
|
21
|
+
const { lastLoadError: _lastLoadError, ...rest } = attachment;
|
|
22
|
+
return {
|
|
23
|
+
...rest,
|
|
24
|
+
storageState: "loaded",
|
|
25
|
+
storedSizeBytes: uploaded.storedSizeBytes,
|
|
26
|
+
storedAt: uploaded.storedAt,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
const patchFailed = (payload, attachmentId, status, message) => patchAttachment(payload, attachmentId, (attachment) => ({
|
|
30
|
+
...attachment,
|
|
31
|
+
storageState: status,
|
|
32
|
+
lastLoadError: message,
|
|
33
|
+
}));
|
|
34
|
+
const persist = async (deps, ref, payload) => {
|
|
35
|
+
const upsert = await deps.drive.upsertMessages(ref.mailboxId, ref.folderId, [payload], [ref.externalId]);
|
|
36
|
+
if (upsert.tag === "Err")
|
|
37
|
+
return upsert;
|
|
38
|
+
return Ok(payload);
|
|
39
|
+
};
|
|
40
|
+
const isRetryableAppFailure = (cause) => isRetryableImapError(cause);
|
|
41
|
+
const outcome = (ref, payload, status, attachment, error) => ({
|
|
42
|
+
status,
|
|
43
|
+
mailboxId: ref.mailboxId,
|
|
44
|
+
folderId: ref.folderId,
|
|
45
|
+
externalId: ref.externalId,
|
|
46
|
+
attachmentId: ref.attachmentId,
|
|
47
|
+
attachment,
|
|
48
|
+
payload,
|
|
49
|
+
error,
|
|
50
|
+
});
|
|
51
|
+
export const hydrateStoredMessageAttachment = async (deps, ref, options) => {
|
|
52
|
+
const existing = await deps.drive.getMessage(ref.mailboxId, ref.folderId, ref.externalId);
|
|
53
|
+
if (existing.tag === "Err")
|
|
54
|
+
return existing;
|
|
55
|
+
if (existing.value === null)
|
|
56
|
+
return Err(notFound(`message ${ref.externalId}`));
|
|
57
|
+
const payload = existing.value;
|
|
58
|
+
const attachment = findAttachment(payload, ref.attachmentId);
|
|
59
|
+
if (attachment === null) {
|
|
60
|
+
return Ok(outcome(ref, payload, "failed_permanent", null, `attachment not found: ${ref.attachmentId}`));
|
|
61
|
+
}
|
|
62
|
+
const attachmentWithId = {
|
|
63
|
+
...attachment,
|
|
64
|
+
attachmentId: resolvedAttachmentId(payload, attachment),
|
|
65
|
+
};
|
|
66
|
+
if (attachmentWithId.storageState === "loaded" && options?.refresh !== true) {
|
|
67
|
+
return Ok(outcome(ref, payload, "skipped", attachmentWithId, null));
|
|
68
|
+
}
|
|
69
|
+
if (attachmentWithId.partId.length === 0) {
|
|
70
|
+
const message = `attachment partId is missing: ${ref.attachmentId}`;
|
|
71
|
+
const patched = patchFailed(payload, ref.attachmentId, "failed_permanent", message);
|
|
72
|
+
const persisted = await persist(deps, ref, patched.payload);
|
|
73
|
+
if (persisted.tag === "Err")
|
|
74
|
+
return persisted;
|
|
75
|
+
return Ok(outcome(ref, persisted.value, "failed_permanent", patched.attachment, message));
|
|
76
|
+
}
|
|
77
|
+
let downloaded;
|
|
78
|
+
try {
|
|
79
|
+
downloaded = await deps.imap.downloadPart(payload.accountId, payload.mailbox, payload.uid, attachmentWithId.partId);
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
const message = cause?.message ?? String(cause);
|
|
83
|
+
const status = isRetryableAppFailure(cause) ? "failed_retryable" : "failed_permanent";
|
|
84
|
+
const patched = patchFailed(payload, ref.attachmentId, status, message);
|
|
85
|
+
const persisted = await persist(deps, ref, patched.payload);
|
|
86
|
+
if (persisted.tag === "Err")
|
|
87
|
+
return persisted;
|
|
88
|
+
return Ok(outcome(ref, persisted.value, status, patched.attachment, message));
|
|
89
|
+
}
|
|
90
|
+
const uploadAttachment = {
|
|
91
|
+
...attachmentWithId,
|
|
92
|
+
sizeBytes: downloaded.sizeBytes ?? attachmentWithId.sizeBytes,
|
|
93
|
+
contentType: downloaded.contentType ?? attachmentWithId.contentType,
|
|
94
|
+
};
|
|
95
|
+
const uploaded = await (async () => {
|
|
96
|
+
try {
|
|
97
|
+
return await deps.drive.uploadAttachment(ref.mailboxId, ref.folderId, ref.externalId, uploadAttachment, downloaded.content);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
downloaded.dispose();
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
if (uploaded.tag === "Err") {
|
|
104
|
+
const message = errorMessage(uploaded.error);
|
|
105
|
+
const status = isRetryableAppFailure(message) ? "failed_retryable" : "failed_permanent";
|
|
106
|
+
const patched = patchFailed(payload, ref.attachmentId, status, message);
|
|
107
|
+
const persisted = await persist(deps, ref, patched.payload);
|
|
108
|
+
if (persisted.tag === "Err")
|
|
109
|
+
return persisted;
|
|
110
|
+
return Ok(outcome(ref, persisted.value, status, patched.attachment, message));
|
|
111
|
+
}
|
|
112
|
+
const patched = patchLoaded(payload, ref.attachmentId, uploaded.value);
|
|
113
|
+
const persisted = await persist(deps, ref, patched.payload);
|
|
114
|
+
if (persisted.tag === "Err")
|
|
115
|
+
return persisted;
|
|
116
|
+
return Ok(outcome(ref, persisted.value, "loaded", patched.attachment, null));
|
|
117
|
+
};
|
|
118
|
+
//# sourceMappingURL=attachmentHydration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attachmentHydration.js","sourceRoot":"","sources":["../../src/sync/attachmentHydration.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type AppError, type ImapError } from "../domain/errors.js";
|
|
2
|
+
import { type Result } from "../domain/result.js";
|
|
3
|
+
import type { StoredEmailPayload } from "../store/payloadTypes.js";
|
|
4
|
+
export type FetchedBodyContent = {
|
|
5
|
+
readonly bodyText: string | null;
|
|
6
|
+
readonly bodyHtml: string | null;
|
|
7
|
+
readonly truncated: boolean;
|
|
8
|
+
readonly bytesRead: number;
|
|
9
|
+
};
|
|
10
|
+
export type BodyHydrationRef = {
|
|
11
|
+
readonly mailboxId: string;
|
|
12
|
+
readonly folderId: string;
|
|
13
|
+
readonly externalId: string;
|
|
14
|
+
};
|
|
15
|
+
export type BodyHydrationDeps = {
|
|
16
|
+
readonly drive: {
|
|
17
|
+
readonly getMessage: (mailboxId: string, folderId: string, externalId: string) => Promise<Result<AppError, StoredEmailPayload | null>>;
|
|
18
|
+
readonly upsertMessages: (mailboxId: string, folderId: string, payloads: readonly StoredEmailPayload[], externalIds: readonly string[]) => Promise<Result<AppError, {
|
|
19
|
+
inserted: number;
|
|
20
|
+
updated: number;
|
|
21
|
+
}>>;
|
|
22
|
+
};
|
|
23
|
+
readonly imap: {
|
|
24
|
+
readonly fetchBody: (accountId: string, mailbox: string, uid: number) => Promise<Result<ImapError, FetchedBodyContent | null>>;
|
|
25
|
+
};
|
|
26
|
+
readonly now: () => Date;
|
|
27
|
+
};
|
|
28
|
+
export type BodyHydrationStatus = "loaded" | "skipped" | "failed_retryable" | "failed_permanent";
|
|
29
|
+
export type BodyHydrationOutcome = {
|
|
30
|
+
readonly status: BodyHydrationStatus;
|
|
31
|
+
readonly payload: StoredEmailPayload;
|
|
32
|
+
readonly error: string | null;
|
|
33
|
+
};
|
|
34
|
+
export type BodyHydrationBatchItem = BodyHydrationOutcome & {
|
|
35
|
+
readonly ref: BodyHydrationRef;
|
|
36
|
+
};
|
|
37
|
+
export type BodyHydrationBatchResult = {
|
|
38
|
+
readonly loaded: readonly BodyHydrationBatchItem[];
|
|
39
|
+
readonly skipped: readonly BodyHydrationBatchItem[];
|
|
40
|
+
readonly failedRetryable: readonly BodyHydrationBatchItem[];
|
|
41
|
+
readonly failedPermanent: readonly BodyHydrationBatchItem[];
|
|
42
|
+
};
|
|
43
|
+
export declare const markBodyLoaded: (payload: StoredEmailPayload, body: FetchedBodyContent, now: Date) => StoredEmailPayload;
|
|
44
|
+
export declare const markBodyFailedRetryable: (payload: StoredEmailPayload, error: string, now: Date) => StoredEmailPayload;
|
|
45
|
+
export declare const markBodyFailedPermanent: (payload: StoredEmailPayload, error: string, now: Date) => StoredEmailPayload;
|
|
46
|
+
export declare const hydrateStoredMessageBody: (deps: BodyHydrationDeps, ref: BodyHydrationRef, options?: {
|
|
47
|
+
readonly refresh?: boolean;
|
|
48
|
+
}) => Promise<Result<AppError, BodyHydrationOutcome>>;
|
|
49
|
+
export declare const hydrateStoredMessageBodies: (deps: BodyHydrationDeps, refs: readonly BodyHydrationRef[], options?: {
|
|
50
|
+
readonly refresh?: boolean;
|
|
51
|
+
readonly maxMessages?: number;
|
|
52
|
+
}) => Promise<Result<AppError, BodyHydrationBatchResult>>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { errorMessage, isRetryableImapError, notFound, } from "../domain/errors.js";
|
|
2
|
+
import { Err, Ok } from "../domain/result.js";
|
|
3
|
+
import { htmlToText } from "../imap/mapper.js";
|
|
4
|
+
const buildSnippet = (bodyText, bodyHtml) => {
|
|
5
|
+
const text = bodyText ?? (bodyHtml !== null ? htmlToText(bodyHtml) : null);
|
|
6
|
+
if (text === null)
|
|
7
|
+
return "";
|
|
8
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
9
|
+
};
|
|
10
|
+
const messageHasLoadedBody = (payload) => payload.bodyState === "loaded" ||
|
|
11
|
+
(payload.bodyState === undefined && (payload.bodyText !== null || payload.bodyHtml !== null));
|
|
12
|
+
export const markBodyLoaded = (payload, body, now) => ({
|
|
13
|
+
...payload,
|
|
14
|
+
snippet: buildSnippet(body.bodyText, body.bodyHtml),
|
|
15
|
+
bodyText: body.bodyText,
|
|
16
|
+
bodyHtml: body.bodyHtml,
|
|
17
|
+
truncated: payload.truncated || body.truncated,
|
|
18
|
+
bodyState: "loaded",
|
|
19
|
+
bodyFetchedAt: now.toISOString(),
|
|
20
|
+
bodyFetchError: null,
|
|
21
|
+
});
|
|
22
|
+
export const markBodyFailedRetryable = (payload, error, now) => ({
|
|
23
|
+
...payload,
|
|
24
|
+
bodyState: "failed_retryable",
|
|
25
|
+
bodyFetchedAt: now.toISOString(),
|
|
26
|
+
bodyFetchError: error,
|
|
27
|
+
});
|
|
28
|
+
export const markBodyFailedPermanent = (payload, error, now) => ({
|
|
29
|
+
...payload,
|
|
30
|
+
bodyState: "failed_permanent",
|
|
31
|
+
bodyFetchedAt: now.toISOString(),
|
|
32
|
+
bodyFetchError: error,
|
|
33
|
+
});
|
|
34
|
+
const persistBodyState = async (deps, ref, payload) => {
|
|
35
|
+
const upsert = await deps.drive.upsertMessages(ref.mailboxId, ref.folderId, [payload], [ref.externalId]);
|
|
36
|
+
if (upsert.tag === "Err")
|
|
37
|
+
return upsert;
|
|
38
|
+
return Ok(payload);
|
|
39
|
+
};
|
|
40
|
+
const imapFailureIsRetryable = (error) => isRetryableImapError(error.message) || isRetryableImapError(error.cause);
|
|
41
|
+
export const hydrateStoredMessageBody = async (deps, ref, options) => {
|
|
42
|
+
const existing = await deps.drive.getMessage(ref.mailboxId, ref.folderId, ref.externalId);
|
|
43
|
+
if (existing.tag === "Err")
|
|
44
|
+
return existing;
|
|
45
|
+
if (existing.value === null)
|
|
46
|
+
return Err(notFound(`message ${ref.externalId}`));
|
|
47
|
+
const payload = existing.value;
|
|
48
|
+
if (options?.refresh !== true && messageHasLoadedBody(payload)) {
|
|
49
|
+
return Ok({ status: "skipped", payload, error: null });
|
|
50
|
+
}
|
|
51
|
+
const fetched = await deps.imap.fetchBody(payload.accountId, payload.mailbox, payload.uid);
|
|
52
|
+
if (fetched.tag === "Err") {
|
|
53
|
+
const message = errorMessage(fetched.error);
|
|
54
|
+
const patched = imapFailureIsRetryable(fetched.error)
|
|
55
|
+
? markBodyFailedRetryable(payload, message, deps.now())
|
|
56
|
+
: markBodyFailedPermanent(payload, message, deps.now());
|
|
57
|
+
const persisted = await persistBodyState(deps, ref, patched);
|
|
58
|
+
if (persisted.tag === "Err")
|
|
59
|
+
return persisted;
|
|
60
|
+
return Ok({
|
|
61
|
+
status: imapFailureIsRetryable(fetched.error) ? "failed_retryable" : "failed_permanent",
|
|
62
|
+
payload: persisted.value,
|
|
63
|
+
error: message,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (fetched.value === null) {
|
|
67
|
+
const message = `message body not found: ${ref.externalId}`;
|
|
68
|
+
const patched = markBodyFailedPermanent(payload, message, deps.now());
|
|
69
|
+
const persisted = await persistBodyState(deps, ref, patched);
|
|
70
|
+
if (persisted.tag === "Err")
|
|
71
|
+
return persisted;
|
|
72
|
+
return Ok({ status: "failed_permanent", payload: persisted.value, error: message });
|
|
73
|
+
}
|
|
74
|
+
const patched = markBodyLoaded(payload, fetched.value, deps.now());
|
|
75
|
+
const persisted = await persistBodyState(deps, ref, patched);
|
|
76
|
+
if (persisted.tag === "Err")
|
|
77
|
+
return persisted;
|
|
78
|
+
return Ok({ status: "loaded", payload: persisted.value, error: null });
|
|
79
|
+
};
|
|
80
|
+
export const hydrateStoredMessageBodies = async (deps, refs, options) => {
|
|
81
|
+
const maxMessages = options?.maxMessages ?? refs.length;
|
|
82
|
+
const selectedRefs = refs.slice(0, Math.max(0, maxMessages));
|
|
83
|
+
const loaded = [];
|
|
84
|
+
const skipped = [];
|
|
85
|
+
const failedRetryable = [];
|
|
86
|
+
const failedPermanent = [];
|
|
87
|
+
for (const ref of selectedRefs) {
|
|
88
|
+
const result = await hydrateStoredMessageBody(deps, ref, { refresh: options?.refresh });
|
|
89
|
+
if (result.tag === "Err")
|
|
90
|
+
return result;
|
|
91
|
+
const item = { ...result.value, ref };
|
|
92
|
+
if (item.status === "loaded")
|
|
93
|
+
loaded.push(item);
|
|
94
|
+
else if (item.status === "skipped")
|
|
95
|
+
skipped.push(item);
|
|
96
|
+
else if (item.status === "failed_retryable")
|
|
97
|
+
failedRetryable.push(item);
|
|
98
|
+
else
|
|
99
|
+
failedPermanent.push(item);
|
|
100
|
+
}
|
|
101
|
+
return Ok({ loaded, skipped, failedRetryable, failedPermanent });
|
|
102
|
+
};
|
|
103
|
+
//# sourceMappingURL=bodyHydration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bodyHydration.js","sourceRoot":"","sources":["../../src/sync/bodyHydration.ts"],"names":[],"mappings":"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"}
|
package/dist/sync/sync.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { type Account, type WatcherSettings } from "../config/schema.js";
|
|
|
21
21
|
import { type AppError, type ImapError } from "../domain/errors.js";
|
|
22
22
|
import { type Result } from "../domain/result.js";
|
|
23
23
|
import { type FetchedMessageLike } from "../imap/mapper.js";
|
|
24
|
-
import {
|
|
24
|
+
import type { UploadAttachmentResult } from "../store/attachments.js";
|
|
25
25
|
import type { StoredAttachment, StoredEmailPayload, SyncState } from "../store/payloadTypes.js";
|
|
26
26
|
export type SyncDeps = {
|
|
27
27
|
readonly drive: {
|
|
@@ -56,12 +56,14 @@ export type SyncDeps = {
|
|
|
56
56
|
uidNext: number;
|
|
57
57
|
messages: number;
|
|
58
58
|
}>>;
|
|
59
|
-
readonly
|
|
60
|
-
|
|
59
|
+
readonly fetchMetadataRange: (accountId: string, path: string, fromUid: number, toUid: number) => AsyncIterable<FetchedMessageLike>;
|
|
60
|
+
readonly fetchEnvelopesRange: (accountId: string, path: string, fromUid: number, toUid: number) => AsyncIterable<FetchedMessageLike>;
|
|
61
|
+
readonly fetchBody: (accountId: string, mailbox: string, uid: number) => Promise<Result<ImapError, {
|
|
61
62
|
readonly bodyText: string | null;
|
|
62
63
|
readonly bodyHtml: string | null;
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
readonly truncated: boolean;
|
|
65
|
+
readonly bytesRead: number;
|
|
66
|
+
} | null>>;
|
|
65
67
|
readonly downloadPart: (accountId: string, path: string, uid: number, partId: string) => Promise<{
|
|
66
68
|
readonly content: AsyncIterable<Uint8Array>;
|
|
67
69
|
readonly sizeBytes: number | null;
|
|
@@ -75,6 +77,8 @@ export type SyncFolderReport = {
|
|
|
75
77
|
readonly folderId: string;
|
|
76
78
|
readonly newMessages: number;
|
|
77
79
|
readonly reconciledFlags: number;
|
|
80
|
+
readonly hydratedBodies: number;
|
|
81
|
+
readonly bodyHydrationErrors: number;
|
|
78
82
|
readonly uidValidityRolled: boolean;
|
|
79
83
|
readonly error: string | null;
|
|
80
84
|
};
|
package/dist/sync/sync.js
CHANGED
|
@@ -21,8 +21,8 @@ import { isOAuthAccount } from "../config/schema.js";
|
|
|
21
21
|
import { errorMessage, imapError } from "../domain/errors.js";
|
|
22
22
|
import { Err } from "../domain/result.js";
|
|
23
23
|
import { toStoredPayload } from "../imap/mapper.js";
|
|
24
|
-
import { patchAttachmentStorageRef, withAttachmentId, } from "../store/attachments.js";
|
|
25
24
|
import { externalIdFor, sanitizeMailboxId } from "../store/conventions.js";
|
|
25
|
+
import { hydrateStoredMessageBodies } from "./bodyHydration.js";
|
|
26
26
|
const BATCH_SIZE = 50;
|
|
27
27
|
const parseSyncState = (raw) => {
|
|
28
28
|
if (raw === null)
|
|
@@ -59,19 +59,34 @@ const mergeReconciledFlags = (existing, incoming) => {
|
|
|
59
59
|
fetchedAt: incoming.fetchedAt,
|
|
60
60
|
};
|
|
61
61
|
};
|
|
62
|
+
const isAllMailFolder = (folderPath, specialUse) => specialUse === "\\All" || /(?:^|\/)All Mail$/i.test(folderPath);
|
|
63
|
+
const bodyHydrationEnabledForFolder = (watcher, folderPath, specialUse) => {
|
|
64
|
+
const settings = watcher.body_hydration;
|
|
65
|
+
if (settings.enabled !== true || settings.max_messages_per_tick <= 0)
|
|
66
|
+
return false;
|
|
67
|
+
if (settings.skip_all_mail && isAllMailFolder(folderPath, specialUse))
|
|
68
|
+
return false;
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
62
71
|
/**
|
|
63
72
|
* Sync one folder. Returns the per-folder report; never throws.
|
|
64
73
|
* `lastSyncedUid` is advanced only after each successful batch upsert.
|
|
65
74
|
*/
|
|
66
|
-
const syncFolder = async (deps, account, mailboxId, folderPath,
|
|
75
|
+
const syncFolder = async (deps, account, mailboxId, folderPath, specialUse, watcher) => {
|
|
67
76
|
const folderId = folderPath;
|
|
68
77
|
const startIso = deps.now().toISOString();
|
|
78
|
+
const flagWindow = watcher.flag_reconcile_window;
|
|
79
|
+
const bodyHydrationCandidates = [];
|
|
80
|
+
let hydratedBodies = 0;
|
|
81
|
+
let bodyHydrationErrors = 0;
|
|
69
82
|
const statusR = await deps.imap.folderStatus(account.name, folderPath);
|
|
70
83
|
if (statusR.tag === "Err") {
|
|
71
84
|
return {
|
|
72
85
|
folderId,
|
|
73
86
|
newMessages: 0,
|
|
74
87
|
reconciledFlags: 0,
|
|
88
|
+
hydratedBodies: 0,
|
|
89
|
+
bodyHydrationErrors: 0,
|
|
75
90
|
uidValidityRolled: false,
|
|
76
91
|
error: errorMessage(statusR.error),
|
|
77
92
|
};
|
|
@@ -83,6 +98,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
83
98
|
folderId,
|
|
84
99
|
newMessages: 0,
|
|
85
100
|
reconciledFlags: 0,
|
|
101
|
+
hydratedBodies: 0,
|
|
102
|
+
bodyHydrationErrors: 0,
|
|
86
103
|
uidValidityRolled: false,
|
|
87
104
|
error: errorMessage(existing.error),
|
|
88
105
|
};
|
|
@@ -98,6 +115,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
98
115
|
folderId,
|
|
99
116
|
newMessages: 0,
|
|
100
117
|
reconciledFlags: 0,
|
|
118
|
+
hydratedBodies: 0,
|
|
119
|
+
bodyHydrationErrors: 0,
|
|
101
120
|
uidValidityRolled,
|
|
102
121
|
error: errorMessage(del.error),
|
|
103
122
|
};
|
|
@@ -126,6 +145,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
126
145
|
folderId,
|
|
127
146
|
newMessages: 0,
|
|
128
147
|
reconciledFlags: 0,
|
|
148
|
+
hydratedBodies: 0,
|
|
149
|
+
bodyHydrationErrors: 0,
|
|
129
150
|
uidValidityRolled,
|
|
130
151
|
error: errorMessage(w.error),
|
|
131
152
|
};
|
|
@@ -144,6 +165,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
144
165
|
folderId,
|
|
145
166
|
newMessages: 0,
|
|
146
167
|
reconciledFlags: 0,
|
|
168
|
+
hydratedBodies: 0,
|
|
169
|
+
bodyHydrationErrors: 0,
|
|
147
170
|
uidValidityRolled,
|
|
148
171
|
error: errorMessage(w.error),
|
|
149
172
|
};
|
|
@@ -164,6 +187,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
164
187
|
folderId,
|
|
165
188
|
newMessages,
|
|
166
189
|
reconciledFlags: 0,
|
|
190
|
+
hydratedBodies,
|
|
191
|
+
bodyHydrationErrors,
|
|
167
192
|
uidValidityRolled,
|
|
168
193
|
error: errorMessage(w.error),
|
|
169
194
|
};
|
|
@@ -172,6 +197,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
172
197
|
folderId,
|
|
173
198
|
newMessages,
|
|
174
199
|
reconciledFlags: 0,
|
|
200
|
+
hydratedBodies,
|
|
201
|
+
bodyHydrationErrors,
|
|
175
202
|
uidValidityRolled,
|
|
176
203
|
error: errorMessage(error),
|
|
177
204
|
};
|
|
@@ -179,54 +206,27 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
179
206
|
for (const [batchFrom, batchTo] of range(fromUid, toUid)) {
|
|
180
207
|
let sawMessageInBatch = false;
|
|
181
208
|
try {
|
|
182
|
-
for await (const
|
|
209
|
+
for await (const imapMessage of deps.imap.fetchMetadataRange(account.name, folderPath, batchFrom, batchTo)) {
|
|
183
210
|
sawMessageInBatch = true;
|
|
184
|
-
const uid =
|
|
211
|
+
const uid = imapMessage.uid;
|
|
185
212
|
const externalId = externalIdFor(status.uidValidity, uid);
|
|
186
|
-
|
|
213
|
+
const payload = toStoredPayload(imapMessage, {
|
|
187
214
|
accountId: account.name,
|
|
188
215
|
mailbox: folderPath,
|
|
189
216
|
uidValidity: status.uidValidity,
|
|
190
217
|
fetchedAt: deps.now(),
|
|
191
|
-
bodyText:
|
|
192
|
-
bodyHtml:
|
|
218
|
+
bodyText: null,
|
|
219
|
+
bodyHtml: null,
|
|
193
220
|
truncated: false,
|
|
194
221
|
});
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
return await finishWithError(
|
|
198
|
-
}
|
|
199
|
-
if (payload.attachments.length === 0) {
|
|
200
|
-
newMessages += initialUpsert.value.inserted + initialUpsert.value.updated;
|
|
201
|
-
lastSynced = uid;
|
|
202
|
-
continue;
|
|
222
|
+
const upsert = await deps.drive.upsertMessages(mailboxId, folderId, [payload], [externalId]);
|
|
223
|
+
if (upsert.tag === "Err") {
|
|
224
|
+
return await finishWithError(upsert.error);
|
|
203
225
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const uploadAttachment = {
|
|
208
|
-
...attachment,
|
|
209
|
-
sizeBytes: downloaded.sizeBytes ?? attachment.sizeBytes,
|
|
210
|
-
contentType: downloaded.contentType ?? attachment.contentType,
|
|
211
|
-
};
|
|
212
|
-
const uploaded = await (async () => {
|
|
213
|
-
try {
|
|
214
|
-
return await deps.drive.uploadAttachment(mailboxId, folderId, externalId, uploadAttachment, downloaded.content);
|
|
215
|
-
}
|
|
216
|
-
finally {
|
|
217
|
-
downloaded.dispose();
|
|
218
|
-
}
|
|
219
|
-
})();
|
|
220
|
-
if (uploaded.tag === "Err") {
|
|
221
|
-
return await finishWithError(uploaded.error);
|
|
222
|
-
}
|
|
223
|
-
payload = patchAttachmentStorageRef(payload, attachment.attachmentId, uploaded.value);
|
|
226
|
+
newMessages += upsert.value.inserted + upsert.value.updated;
|
|
227
|
+
if (bodyHydrationEnabledForFolder(watcher, folderPath, specialUse)) {
|
|
228
|
+
bodyHydrationCandidates.push({ mailboxId, folderId, externalId });
|
|
224
229
|
}
|
|
225
|
-
const finalUpsert = await deps.drive.upsertMessages(mailboxId, folderId, [payload], [externalId]);
|
|
226
|
-
if (finalUpsert.tag === "Err") {
|
|
227
|
-
return await finishWithError(finalUpsert.error);
|
|
228
|
-
}
|
|
229
|
-
newMessages += initialUpsert.value.inserted + initialUpsert.value.updated;
|
|
230
230
|
lastSynced = uid;
|
|
231
231
|
}
|
|
232
232
|
}
|
|
@@ -240,7 +240,7 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
240
240
|
lastSynced = batchTo;
|
|
241
241
|
}
|
|
242
242
|
// Checkpoint: write once after the batch, but the value is the
|
|
243
|
-
// highest UID whose
|
|
243
|
+
// highest UID whose metadata payload is durable.
|
|
244
244
|
const checkpoint = {
|
|
245
245
|
...state,
|
|
246
246
|
uidValidity: status.uidValidity,
|
|
@@ -255,6 +255,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
255
255
|
folderId,
|
|
256
256
|
newMessages,
|
|
257
257
|
reconciledFlags: 0,
|
|
258
|
+
hydratedBodies,
|
|
259
|
+
bodyHydrationErrors,
|
|
258
260
|
uidValidityRolled,
|
|
259
261
|
error: errorMessage(w.error),
|
|
260
262
|
};
|
|
@@ -262,6 +264,21 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
262
264
|
state = checkpoint;
|
|
263
265
|
}
|
|
264
266
|
}
|
|
267
|
+
if (bodyHydrationCandidates.length > 0) {
|
|
268
|
+
const hydration = await hydrateStoredMessageBodies({
|
|
269
|
+
drive: deps.drive,
|
|
270
|
+
imap: { fetchBody: deps.imap.fetchBody },
|
|
271
|
+
now: deps.now,
|
|
272
|
+
}, bodyHydrationCandidates.slice().reverse(), { maxMessages: watcher.body_hydration.max_messages_per_tick });
|
|
273
|
+
if (hydration.tag === "Ok") {
|
|
274
|
+
hydratedBodies = hydration.value.loaded.length;
|
|
275
|
+
bodyHydrationErrors =
|
|
276
|
+
hydration.value.failedRetryable.length + hydration.value.failedPermanent.length;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
bodyHydrationErrors = bodyHydrationCandidates.length;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
265
282
|
// Sliding-window flag reconciliation. Bounds drift to flagWindow UIDs.
|
|
266
283
|
let reconciledFlags = 0;
|
|
267
284
|
if (flagWindow > 0 && lastSynced > 0) {
|
|
@@ -286,6 +303,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
286
303
|
folderId,
|
|
287
304
|
newMessages,
|
|
288
305
|
reconciledFlags: 0,
|
|
306
|
+
hydratedBodies,
|
|
307
|
+
bodyHydrationErrors,
|
|
289
308
|
uidValidityRolled,
|
|
290
309
|
error: errorMessage(existingPayload.error),
|
|
291
310
|
};
|
|
@@ -301,6 +320,8 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
301
320
|
folderId,
|
|
302
321
|
newMessages,
|
|
303
322
|
reconciledFlags: 0,
|
|
323
|
+
hydratedBodies,
|
|
324
|
+
bodyHydrationErrors,
|
|
304
325
|
uidValidityRolled,
|
|
305
326
|
error: errorMessage(ups.error),
|
|
306
327
|
};
|
|
@@ -315,12 +336,22 @@ const syncFolder = async (deps, account, mailboxId, folderPath, flagWindow) => {
|
|
|
315
336
|
folderId,
|
|
316
337
|
newMessages,
|
|
317
338
|
reconciledFlags: 0,
|
|
339
|
+
hydratedBodies,
|
|
340
|
+
bodyHydrationErrors,
|
|
318
341
|
uidValidityRolled,
|
|
319
342
|
error: errorMessage(e),
|
|
320
343
|
};
|
|
321
344
|
}
|
|
322
345
|
}
|
|
323
|
-
return {
|
|
346
|
+
return {
|
|
347
|
+
folderId,
|
|
348
|
+
newMessages,
|
|
349
|
+
reconciledFlags,
|
|
350
|
+
hydratedBodies,
|
|
351
|
+
bodyHydrationErrors,
|
|
352
|
+
uidValidityRolled,
|
|
353
|
+
error: null,
|
|
354
|
+
};
|
|
324
355
|
};
|
|
325
356
|
/**
|
|
326
357
|
* Run one sync tick for one account. Sequentially walks all folders,
|
|
@@ -363,7 +394,7 @@ export const runSyncOnce = async (deps, account, watcher) => {
|
|
|
363
394
|
})();
|
|
364
395
|
const reports = [];
|
|
365
396
|
for (const f of filteredFolders) {
|
|
366
|
-
const r = await syncFolder(deps, account, mailboxId, f.path, watcher
|
|
397
|
+
const r = await syncFolder(deps, account, mailboxId, f.path, f.specialUse, watcher);
|
|
367
398
|
reports.push(r);
|
|
368
399
|
}
|
|
369
400
|
// Prune Drive folders that disappeared from IMAP.
|