@bobfrankston/mailx-sync 0.1.3
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/README.md +87 -0
- package/gmail.d.ts +52 -0
- package/gmail.js +367 -0
- package/imap.d.ts +39 -0
- package/imap.js +144 -0
- package/index.d.ts +20 -0
- package/index.js +18 -0
- package/outlook.d.ts +37 -0
- package/outlook.js +212 -0
- package/package.json +48 -0
- package/storage.d.ts +62 -0
- package/storage.js +13 -0
- package/types.d.ts +66 -0
- package/types.js +10 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @bobfrankston/mailx-sync
|
|
2
|
+
|
|
3
|
+
Platform-agnostic mail provider implementations + sync orchestration interfaces. Single source of truth for protocol code consumed by both desktop (Node service) and Android (WebView).
|
|
4
|
+
|
|
5
|
+
## Why this package exists
|
|
6
|
+
|
|
7
|
+
Before this extraction, mailx maintained **parallel implementations** of the same Gmail provider in two places:
|
|
8
|
+
|
|
9
|
+
- `mailx-imap/providers/gmail-api.ts` — used by the Node desktop service
|
|
10
|
+
- `mailx-store-web/gmail-api-web.ts` — used by the Android WebView
|
|
11
|
+
|
|
12
|
+
They drifted in practice. A 403-quota retry added to the desktop copy one day was missed in the web copy until users reported broken prefetch. Per `programming.md`'s **Economy of Mechanism** rule (the operational TS-spirit example), platform-forking the same protocol logic is a textbook violation: TypeScript's value is *one typed implementation imported everywhere it's needed*, not separate "Node" and "browser" copies of the same algorithm.
|
|
13
|
+
|
|
14
|
+
## What's in here
|
|
15
|
+
|
|
16
|
+
| Export | What it is |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `MailProvider` (interface) | Protocol-agnostic provider contract — list folders, fetch messages, get UIDs, close. |
|
|
19
|
+
| `ProviderFolder`, `ProviderMessage`, `FetchOptions` | Shared data shapes used at provider boundaries. |
|
|
20
|
+
| `GmailApiProvider` | Gmail REST API. Bounded-concurrency batch fetch, 403-quota retry, exponential backoff. |
|
|
21
|
+
| `ImapProvider` | Generic IMAP via `iflow-direct`'s `CompatImapClient`. Auto-reconnect on broken pipe. |
|
|
22
|
+
| `OutlookApiProvider` | Microsoft Graph for Outlook.com / Office 365. Skeleton — needs Azure app reg for production. |
|
|
23
|
+
| `SyncStorage`, `SyncBodyStore`, `SyncEventSink` (interfaces) | What sync orchestration needs from the host platform's database / file store / event bus. Used to keep `sync.ts` (planned) platform-free. |
|
|
24
|
+
| `SyncFolder` | Storage-side folder shape. |
|
|
25
|
+
|
|
26
|
+
## Platform requirements
|
|
27
|
+
|
|
28
|
+
Pure TypeScript, no Node-specific imports. Runtime needs only:
|
|
29
|
+
|
|
30
|
+
- `globalThis.fetch` — Node 18+ and all browsers/WebViews.
|
|
31
|
+
- `atob`, `Uint8Array`, `TextDecoder` — universal.
|
|
32
|
+
- A TCP byte-stream transport for `ImapProvider` (caller injects via `TransportFactory` — `NodeTcpTransport` on desktop, `BridgeTcpTransport` on Android).
|
|
33
|
+
|
|
34
|
+
## How to instantiate
|
|
35
|
+
|
|
36
|
+
**Desktop (Node service):**
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { GmailApiProvider, ImapProvider } from "@bobfrankston/mailx-sync";
|
|
40
|
+
import { NodeTcpTransport } from "@bobfrankston/node-tcp-transport";
|
|
41
|
+
|
|
42
|
+
const gmail = new GmailApiProvider(() => getGmailToken());
|
|
43
|
+
|
|
44
|
+
const imap = new ImapProvider(
|
|
45
|
+
{ server: "imap.example.com", port: 993, username: "u", password: "p" },
|
|
46
|
+
() => new NodeTcpTransport(),
|
|
47
|
+
);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Android (WebView Worker or main):**
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { GmailApiProvider, ImapProvider } from "@bobfrankston/mailx-sync";
|
|
54
|
+
import { BridgeTcpTransport } from "@bobfrankston/tcp-transport";
|
|
55
|
+
|
|
56
|
+
const gmail = new GmailApiProvider(() => getGmailToken());
|
|
57
|
+
|
|
58
|
+
const imap = new ImapProvider(
|
|
59
|
+
{ server: "imap.example.com", port: 993, username: "u", password: "p" },
|
|
60
|
+
() => new BridgeTcpTransport(),
|
|
61
|
+
);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Identical API. The only platform-specific bit is the transport factory, which the package consumer injects.
|
|
65
|
+
|
|
66
|
+
## Back-compat shims
|
|
67
|
+
|
|
68
|
+
The earlier per-platform files now exist as thin re-exports so older import paths keep compiling:
|
|
69
|
+
|
|
70
|
+
- `mailx-imap/providers/gmail-api.ts` re-exports `GmailApiProvider`.
|
|
71
|
+
- `mailx-imap/providers/types.ts` re-exports the type interfaces.
|
|
72
|
+
- `mailx-imap/providers/outlook-api.ts` re-exports `OutlookApiProvider`.
|
|
73
|
+
- `mailx-store-web/gmail-api-web.ts` re-exports `GmailApiProvider as GmailApiWebProvider`.
|
|
74
|
+
- `mailx-store-web/imap-web-provider.ts` re-exports `ImapProvider as ImapWebProvider`.
|
|
75
|
+
- `mailx-store-web/provider-types.ts` re-exports the type interfaces.
|
|
76
|
+
|
|
77
|
+
Don't extend these shims — extend `mailx-sync` itself.
|
|
78
|
+
|
|
79
|
+
## Future work (in mailx's TODO.md)
|
|
80
|
+
|
|
81
|
+
- Sync orchestration (`syncFolderViaApi`, `prefetchBodies`) extracted to `sync.ts` consuming the `SyncStorage`/`SyncBodyStore` interfaces. Eliminates the "android-bootstrap.ts has its own simpler syncFolder" duplication.
|
|
82
|
+
- Storage adapters in mailx-store and mailx-store-web that satisfy `SyncStorage`.
|
|
83
|
+
- Outbox claim logic shared (currently desktop-only).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
ISC
|
package/gmail.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail API provider — replaces IMAP for Gmail accounts.
|
|
3
|
+
* Uses REST API for fast, reliable sync without connection limit issues.
|
|
4
|
+
*
|
|
5
|
+
* SOURCE OF TRUTH: this file is the single canonical Gmail provider for
|
|
6
|
+
* mailx. Both desktop (mailx-imap, Node) and Android (mailx-store-web,
|
|
7
|
+
* WebView) re-export from here. Earlier they had parallel copies that
|
|
8
|
+
* silently drifted (the 403-quota retry was added to desktop one day and
|
|
9
|
+
* missed in the web copy until a user reported broken prefetch).
|
|
10
|
+
*
|
|
11
|
+
* Platform requirements: globalThis.fetch (Node 18+ and all browsers/WebViews),
|
|
12
|
+
* atob, Uint8Array, TextDecoder. No Node-specific imports.
|
|
13
|
+
*/
|
|
14
|
+
import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./types.js";
|
|
15
|
+
export declare class GmailApiProvider implements MailProvider {
|
|
16
|
+
private tokenProvider;
|
|
17
|
+
constructor(tokenProvider: () => Promise<string>);
|
|
18
|
+
private fetch;
|
|
19
|
+
listFolders(): Promise<ProviderFolder[]>;
|
|
20
|
+
/** List message IDs matching a query, handling pagination.
|
|
21
|
+
* IMPORTANT: on any error we throw — do NOT return a partial list, because
|
|
22
|
+
* callers use this for sync reconciliation and a short list would delete
|
|
23
|
+
* real messages from the local DB. Returning [] silently caused the
|
|
24
|
+
* "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
|
|
25
|
+
private listMessageIds;
|
|
26
|
+
/** Batch-fetch message metadata or full content */
|
|
27
|
+
private batchFetch;
|
|
28
|
+
/** Parse a Gmail API message response into ProviderMessage */
|
|
29
|
+
private parseMessage;
|
|
30
|
+
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
31
|
+
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
32
|
+
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
33
|
+
/** Bulk-fetch raw bodies for many UIDs in one "folder" (Gmail label).
|
|
34
|
+
* Lists the label once, builds UID→ID map, then streams bodies through
|
|
35
|
+
* `onBody` with bounded concurrency (lets Gmail's HTTP/2 stream multiplex;
|
|
36
|
+
* `fetch()`'s built-in 429/5xx retry handles backoff automatically).
|
|
37
|
+
*
|
|
38
|
+
* NOTE: Gmail's model is labels, not folders — a single message can be in
|
|
39
|
+
* many labels. Treating each label as a folder causes duplicate fetches
|
|
40
|
+
* across labels. Proper fix tracked as separate TODO ("Gmail label-native
|
|
41
|
+
* model"). For now we mirror the IMAP folder grouping, accepting duplicate
|
|
42
|
+
* fetches of multi-labeled messages. */
|
|
43
|
+
fetchBodiesBatch(folder: string, uids: number[], onBody: (uid: number, source: string) => void): Promise<void>;
|
|
44
|
+
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
45
|
+
getUids(folder: string): Promise<number[]>;
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
/** Map folder path to Gmail label query term */
|
|
48
|
+
private folderToLabel;
|
|
49
|
+
/** Format date for Gmail query (YYYY/MM/DD) */
|
|
50
|
+
private formatDate;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=gmail.d.ts.map
|
package/gmail.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail API provider — replaces IMAP for Gmail accounts.
|
|
3
|
+
* Uses REST API for fast, reliable sync without connection limit issues.
|
|
4
|
+
*
|
|
5
|
+
* SOURCE OF TRUTH: this file is the single canonical Gmail provider for
|
|
6
|
+
* mailx. Both desktop (mailx-imap, Node) and Android (mailx-store-web,
|
|
7
|
+
* WebView) re-export from here. Earlier they had parallel copies that
|
|
8
|
+
* silently drifted (the 403-quota retry was added to desktop one day and
|
|
9
|
+
* missed in the web copy until a user reported broken prefetch).
|
|
10
|
+
*
|
|
11
|
+
* Platform requirements: globalThis.fetch (Node 18+ and all browsers/WebViews),
|
|
12
|
+
* atob, Uint8Array, TextDecoder. No Node-specific imports.
|
|
13
|
+
*/
|
|
14
|
+
const API = "https://gmail.googleapis.com/gmail/v1/users/me";
|
|
15
|
+
/** Convert Gmail hex ID to integer UID (lower 48 bits — deterministic, stable) */
|
|
16
|
+
function idToUid(id) {
|
|
17
|
+
const hex = id.length > 12 ? id.slice(-12) : id;
|
|
18
|
+
return parseInt(hex, 16);
|
|
19
|
+
}
|
|
20
|
+
/** Map Gmail label to IMAP-style specialUse */
|
|
21
|
+
function labelSpecialUse(label) {
|
|
22
|
+
switch (label.id) {
|
|
23
|
+
case "INBOX": return "inbox";
|
|
24
|
+
case "SENT": return "sent";
|
|
25
|
+
case "DRAFT": return "drafts";
|
|
26
|
+
case "TRASH": return "trash";
|
|
27
|
+
case "SPAM": return "junk";
|
|
28
|
+
case "STARRED": return "";
|
|
29
|
+
case "IMPORTANT": return "";
|
|
30
|
+
default: return "";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Parse RFC 2822 headers from Gmail metadata payload */
|
|
34
|
+
function getHeader(headers, name) {
|
|
35
|
+
return headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || "";
|
|
36
|
+
}
|
|
37
|
+
/** Parse "Name <addr>" or "addr" into { name, address } */
|
|
38
|
+
function parseAddress(raw) {
|
|
39
|
+
const match = raw.match(/^"?([^"<]*?)"?\s*<([^>]+)>/);
|
|
40
|
+
if (match)
|
|
41
|
+
return { name: match[1].trim(), address: match[2].trim() };
|
|
42
|
+
return { address: raw.trim() };
|
|
43
|
+
}
|
|
44
|
+
function parseAddressList(raw) {
|
|
45
|
+
if (!raw)
|
|
46
|
+
return [];
|
|
47
|
+
// Split on commas that aren't inside quotes
|
|
48
|
+
return raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(s => parseAddress(s.trim())).filter(a => a.address);
|
|
49
|
+
}
|
|
50
|
+
export class GmailApiProvider {
|
|
51
|
+
tokenProvider;
|
|
52
|
+
constructor(tokenProvider) {
|
|
53
|
+
this.tokenProvider = tokenProvider;
|
|
54
|
+
}
|
|
55
|
+
async fetch(path, options = {}) {
|
|
56
|
+
const token = await this.tokenProvider();
|
|
57
|
+
const maxAttempts = 6;
|
|
58
|
+
const baseDelayMs = 1000;
|
|
59
|
+
const maxDelayMs = 60_000;
|
|
60
|
+
let lastStatus = 0;
|
|
61
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
62
|
+
const res = await globalThis.fetch(`${API}${path}`, {
|
|
63
|
+
...options,
|
|
64
|
+
headers: {
|
|
65
|
+
"Authorization": `Bearer ${token}`,
|
|
66
|
+
"Content-Type": "application/json",
|
|
67
|
+
...options.headers,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
// 403 with "Quota exceeded" body is Google's per-minute quota limit.
|
|
71
|
+
// Not 429. Retry like a rate-limit, don't throw as a permanent failure.
|
|
72
|
+
let isQuota403 = false;
|
|
73
|
+
if (res.status === 403) {
|
|
74
|
+
try {
|
|
75
|
+
const cloned = res.clone();
|
|
76
|
+
const body = await cloned.text();
|
|
77
|
+
if (/quota exceeded|rate ?limit|user[-_ ]rate/i.test(body))
|
|
78
|
+
isQuota403 = true;
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
}
|
|
82
|
+
if (res.status === 429 || res.status >= 500 || isQuota403) {
|
|
83
|
+
lastStatus = res.status;
|
|
84
|
+
// Honor Retry-After if present (seconds or HTTP-date)
|
|
85
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
86
|
+
let delay = baseDelayMs * Math.pow(2, attempt);
|
|
87
|
+
if (retryAfter) {
|
|
88
|
+
const asInt = parseInt(retryAfter, 10);
|
|
89
|
+
if (!isNaN(asInt))
|
|
90
|
+
delay = asInt * 1000;
|
|
91
|
+
else {
|
|
92
|
+
const when = Date.parse(retryAfter);
|
|
93
|
+
if (!isNaN(when))
|
|
94
|
+
delay = Math.max(0, when - Date.now());
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Per-minute-quota 403 deserves a minimum 60s wait — the quota
|
|
98
|
+
// resets per minute, so shorter backoff just re-triggers.
|
|
99
|
+
if (isQuota403)
|
|
100
|
+
delay = Math.max(delay, 60_000);
|
|
101
|
+
// Full jitter to avoid synchronized retries
|
|
102
|
+
delay = Math.min(maxDelayMs, delay);
|
|
103
|
+
delay = Math.floor(delay * (0.5 + Math.random() * 0.5));
|
|
104
|
+
console.log(` [gmail] ${res.status}${isQuota403 ? " (per-minute quota)" : ""} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}...`);
|
|
105
|
+
await new Promise(r => setTimeout(r, delay));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
const err = await res.text().catch(() => "");
|
|
110
|
+
throw new Error(`Gmail API ${res.status}: ${err.substring(0, 200)}`);
|
|
111
|
+
}
|
|
112
|
+
return res.json();
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`Gmail API: failed after ${maxAttempts} retries (last status ${lastStatus})`);
|
|
115
|
+
}
|
|
116
|
+
async listFolders() {
|
|
117
|
+
const data = await this.fetch("/labels");
|
|
118
|
+
const labels = data.labels || [];
|
|
119
|
+
const folders = [];
|
|
120
|
+
for (const label of labels) {
|
|
121
|
+
// Skip system labels that aren't useful as folders
|
|
122
|
+
if (["UNREAD", "STARRED", "IMPORTANT", "CATEGORY_PERSONAL",
|
|
123
|
+
"CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_UPDATES",
|
|
124
|
+
"CATEGORY_FORUMS", "CHAT"].includes(label.id))
|
|
125
|
+
continue;
|
|
126
|
+
const specialUse = labelSpecialUse(label);
|
|
127
|
+
// Map Gmail path separators (/) to IMAP-style
|
|
128
|
+
const path = label.name || label.id;
|
|
129
|
+
const name = path.includes("/") ? path.split("/").pop() : path;
|
|
130
|
+
folders.push({
|
|
131
|
+
path,
|
|
132
|
+
name,
|
|
133
|
+
delimiter: "/",
|
|
134
|
+
specialUse,
|
|
135
|
+
flags: label.type === "system" ? ["\\Noselect"] : [],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return folders;
|
|
139
|
+
}
|
|
140
|
+
/** List message IDs matching a query, handling pagination.
|
|
141
|
+
* IMPORTANT: on any error we throw — do NOT return a partial list, because
|
|
142
|
+
* callers use this for sync reconciliation and a short list would delete
|
|
143
|
+
* real messages from the local DB. Returning [] silently caused the
|
|
144
|
+
* "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
|
|
145
|
+
async listMessageIds(query, maxResults = 500) {
|
|
146
|
+
const ids = [];
|
|
147
|
+
let pageToken = "";
|
|
148
|
+
let truncated = false;
|
|
149
|
+
while (true) {
|
|
150
|
+
const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
|
|
151
|
+
if (pageToken)
|
|
152
|
+
params.set("pageToken", pageToken);
|
|
153
|
+
const data = await this.fetch(`/messages?${params}`);
|
|
154
|
+
for (const msg of data.messages || []) {
|
|
155
|
+
ids.push(msg.id);
|
|
156
|
+
}
|
|
157
|
+
if (!data.nextPageToken)
|
|
158
|
+
break;
|
|
159
|
+
if (ids.length >= maxResults) {
|
|
160
|
+
// Hit the caller's cap but the server has more. Flag it so
|
|
161
|
+
// reconcile-style callers can refuse to treat this as complete.
|
|
162
|
+
truncated = true;
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
pageToken = data.nextPageToken;
|
|
166
|
+
}
|
|
167
|
+
ids._truncated = truncated;
|
|
168
|
+
return ids;
|
|
169
|
+
}
|
|
170
|
+
/** Batch-fetch message metadata or full content */
|
|
171
|
+
async batchFetch(ids, options = {}, onChunk) {
|
|
172
|
+
const all = [];
|
|
173
|
+
const chunkSize = options.source ? 10 : 50; // Smaller chunks for full bodies
|
|
174
|
+
const format = options.source ? "raw" : "metadata";
|
|
175
|
+
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
176
|
+
const chunk = ids.slice(i, i + chunkSize);
|
|
177
|
+
// Sequential fetches to avoid Gmail 429 rate limits
|
|
178
|
+
const messages = [];
|
|
179
|
+
for (const id of chunk) {
|
|
180
|
+
const params = new URLSearchParams({ format });
|
|
181
|
+
if (format === "metadata") {
|
|
182
|
+
for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date", "In-Reply-To", "References"]) {
|
|
183
|
+
params.append("metadataHeaders", h);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
messages.push(await this.fetch(`/messages/${id}?${params}`));
|
|
187
|
+
}
|
|
188
|
+
const parsed = messages.map(msg => this.parseMessage(msg, options));
|
|
189
|
+
all.push(...parsed);
|
|
190
|
+
if (onChunk)
|
|
191
|
+
onChunk(parsed);
|
|
192
|
+
}
|
|
193
|
+
return all;
|
|
194
|
+
}
|
|
195
|
+
/** Parse a Gmail API message response into ProviderMessage */
|
|
196
|
+
parseMessage(msg, options = {}) {
|
|
197
|
+
const labels = msg.labelIds || [];
|
|
198
|
+
const headers = msg.payload?.headers || [];
|
|
199
|
+
let source = "";
|
|
200
|
+
if (options.source && msg.raw) {
|
|
201
|
+
// Gmail returns URL-safe base64 — convert to standard base64 then decode
|
|
202
|
+
const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
203
|
+
source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
|
|
204
|
+
}
|
|
205
|
+
const fromRaw = getHeader(headers, "From");
|
|
206
|
+
const toRaw = getHeader(headers, "To");
|
|
207
|
+
const ccRaw = getHeader(headers, "Cc");
|
|
208
|
+
const dateRaw = getHeader(headers, "Date") || "";
|
|
209
|
+
const subject = getHeader(headers, "Subject") || msg.snippet || "";
|
|
210
|
+
const messageId = getHeader(headers, "Message-ID") || "";
|
|
211
|
+
const inReplyTo = getHeader(headers, "In-Reply-To") || "";
|
|
212
|
+
const referencesRaw = getHeader(headers, "References") || "";
|
|
213
|
+
const references = referencesRaw.trim()
|
|
214
|
+
? referencesRaw.split(/\s+/).filter(r => r.startsWith("<") && r.endsWith(">"))
|
|
215
|
+
: [];
|
|
216
|
+
return {
|
|
217
|
+
uid: idToUid(msg.id),
|
|
218
|
+
messageId,
|
|
219
|
+
providerId: msg.id,
|
|
220
|
+
date: dateRaw ? new Date(dateRaw) : (msg.internalDate ? new Date(Number(msg.internalDate)) : null),
|
|
221
|
+
subject,
|
|
222
|
+
from: parseAddressList(fromRaw),
|
|
223
|
+
to: parseAddressList(toRaw),
|
|
224
|
+
cc: parseAddressList(ccRaw),
|
|
225
|
+
inReplyTo,
|
|
226
|
+
references,
|
|
227
|
+
seen: !labels.includes("UNREAD"),
|
|
228
|
+
flagged: labels.includes("STARRED"),
|
|
229
|
+
answered: false, // Gmail API doesn't expose this directly
|
|
230
|
+
draft: labels.includes("DRAFT"),
|
|
231
|
+
size: msg.sizeEstimate || 0,
|
|
232
|
+
source,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async fetchSince(folder, sinceUid, options = {}) {
|
|
236
|
+
// Gmail message IDs are hash-derived, NOT monotonic — filtering by
|
|
237
|
+
// `uid > sinceUid` silently drops new messages whose hash happens to
|
|
238
|
+
// fall below the high-water mark. Fetch the most recent page and let
|
|
239
|
+
// upsertMessage dedupe by (account, folder, uid). The sinceUid arg is
|
|
240
|
+
// kept for interface compatibility but no longer used for filtering.
|
|
241
|
+
void sinceUid;
|
|
242
|
+
const query = `in:${this.folderToLabel(folder)}`;
|
|
243
|
+
const ids = await this.listMessageIds(query, 200);
|
|
244
|
+
return this.batchFetch(ids, options);
|
|
245
|
+
}
|
|
246
|
+
async fetchByDate(folder, since, before, options = {}, onChunk) {
|
|
247
|
+
const afterDate = this.formatDate(since);
|
|
248
|
+
const beforeDate = this.formatDate(before);
|
|
249
|
+
const query = `in:${this.folderToLabel(folder)} after:${afterDate} before:${beforeDate}`;
|
|
250
|
+
const ids = await this.listMessageIds(query);
|
|
251
|
+
return this.batchFetch(ids, options, onChunk);
|
|
252
|
+
}
|
|
253
|
+
async fetchByUids(folder, uids, options = {}) {
|
|
254
|
+
// UIDs are derived from Gmail IDs — we'd need a reverse lookup
|
|
255
|
+
// For now, fetch all messages in folder and filter
|
|
256
|
+
const query = `in:${this.folderToLabel(folder)}`;
|
|
257
|
+
const ids = await this.listMessageIds(query);
|
|
258
|
+
const uidSet = new Set(uids);
|
|
259
|
+
const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
|
|
260
|
+
return this.batchFetch(matchingIds, options);
|
|
261
|
+
}
|
|
262
|
+
/** Bulk-fetch raw bodies for many UIDs in one "folder" (Gmail label).
|
|
263
|
+
* Lists the label once, builds UID→ID map, then streams bodies through
|
|
264
|
+
* `onBody` with bounded concurrency (lets Gmail's HTTP/2 stream multiplex;
|
|
265
|
+
* `fetch()`'s built-in 429/5xx retry handles backoff automatically).
|
|
266
|
+
*
|
|
267
|
+
* NOTE: Gmail's model is labels, not folders — a single message can be in
|
|
268
|
+
* many labels. Treating each label as a folder causes duplicate fetches
|
|
269
|
+
* across labels. Proper fix tracked as separate TODO ("Gmail label-native
|
|
270
|
+
* model"). For now we mirror the IMAP folder grouping, accepting duplicate
|
|
271
|
+
* fetches of multi-labeled messages. */
|
|
272
|
+
async fetchBodiesBatch(folder, uids, onBody) {
|
|
273
|
+
if (uids.length === 0)
|
|
274
|
+
return;
|
|
275
|
+
const query = `in:${this.folderToLabel(folder)}`;
|
|
276
|
+
const ids = await this.listMessageIds(query, 10000);
|
|
277
|
+
const uidToId = new Map();
|
|
278
|
+
for (const id of ids)
|
|
279
|
+
uidToId.set(idToUid(id), id);
|
|
280
|
+
const wanted = [];
|
|
281
|
+
for (const uid of uids) {
|
|
282
|
+
const id = uidToId.get(uid);
|
|
283
|
+
if (id)
|
|
284
|
+
wanted.push({ uid, id });
|
|
285
|
+
}
|
|
286
|
+
if (wanted.length === 0)
|
|
287
|
+
return;
|
|
288
|
+
// Bounded concurrency — 5 in-flight respects Gmail's PER-MINUTE quota
|
|
289
|
+
// (~15k queries/user/min shared across a project; with 10 concurrent we
|
|
290
|
+
// hit 403 "Quota exceeded for Queries per minute" on active accounts).
|
|
291
|
+
// The fetch() helper now retries 403-quota like 429, but staying under
|
|
292
|
+
// the threshold avoids the backoff pause in the first place.
|
|
293
|
+
const CONCURRENCY = 5;
|
|
294
|
+
let cursor = 0;
|
|
295
|
+
const worker = async () => {
|
|
296
|
+
while (cursor < wanted.length) {
|
|
297
|
+
const idx = cursor++;
|
|
298
|
+
const { uid, id } = wanted[idx];
|
|
299
|
+
try {
|
|
300
|
+
const msg = await this.fetch(`/messages/${id}?format=raw`);
|
|
301
|
+
if (!msg?.raw)
|
|
302
|
+
continue;
|
|
303
|
+
const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
304
|
+
const source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
|
|
305
|
+
onBody(uid, source);
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
// Per-message failure is non-fatal; keep worker alive for the rest.
|
|
309
|
+
console.error(` [gmail batch] UID ${uid}: ${e.message}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, wanted.length) }, () => worker()));
|
|
314
|
+
}
|
|
315
|
+
async fetchOne(folder, uid, options = {}) {
|
|
316
|
+
// Need to find the Gmail ID from the UID — search all messages in folder
|
|
317
|
+
const query = `in:${this.folderToLabel(folder)}`;
|
|
318
|
+
const ids = await this.listMessageIds(query, 1000);
|
|
319
|
+
const id = ids.find(id => idToUid(id) === uid);
|
|
320
|
+
if (!id)
|
|
321
|
+
return null;
|
|
322
|
+
const format = options.source ? "raw" : "metadata";
|
|
323
|
+
const params = new URLSearchParams({ format });
|
|
324
|
+
if (format === "metadata") {
|
|
325
|
+
for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date", "In-Reply-To", "References"]) {
|
|
326
|
+
params.append("metadataHeaders", h);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const msg = await this.fetch(`/messages/${id}?${params}`);
|
|
330
|
+
return this.parseMessage(msg, options);
|
|
331
|
+
}
|
|
332
|
+
async getUids(folder) {
|
|
333
|
+
const query = `in:${this.folderToLabel(folder)}`;
|
|
334
|
+
const ids = await this.listMessageIds(query, 10000);
|
|
335
|
+
const result = ids.map(idToUid);
|
|
336
|
+
// Propagate the truncation flag so reconcile can refuse to delete.
|
|
337
|
+
if (ids._truncated)
|
|
338
|
+
result._truncated = true;
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
async close() {
|
|
342
|
+
// No persistent connection to close
|
|
343
|
+
}
|
|
344
|
+
/** Map folder path to Gmail label query term */
|
|
345
|
+
folderToLabel(path) {
|
|
346
|
+
const lower = path.toLowerCase();
|
|
347
|
+
if (lower === "inbox")
|
|
348
|
+
return "inbox";
|
|
349
|
+
if (lower === "sent" || lower === "[gmail]/sent mail")
|
|
350
|
+
return "sent";
|
|
351
|
+
if (lower === "drafts" || lower === "[gmail]/drafts")
|
|
352
|
+
return "drafts";
|
|
353
|
+
if (lower === "trash" || lower === "[gmail]/trash")
|
|
354
|
+
return "trash";
|
|
355
|
+
if (lower === "spam" || lower === "[gmail]/spam" || lower === "junk email")
|
|
356
|
+
return "spam";
|
|
357
|
+
if (lower === "archive" || lower === "[gmail]/all mail")
|
|
358
|
+
return "all";
|
|
359
|
+
// Custom label — use exact name
|
|
360
|
+
return `"${path}"`;
|
|
361
|
+
}
|
|
362
|
+
/** Format date for Gmail query (YYYY/MM/DD) */
|
|
363
|
+
formatDate(d) {
|
|
364
|
+
return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
//# sourceMappingURL=gmail.js.map
|
package/imap.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IMAP provider — implements MailProvider using CompatImapClient + a caller-
|
|
3
|
+
* supplied TransportFactory. Single source of truth for IMAP-protocol-side
|
|
4
|
+
* code shared by desktop (Node) and Android (WebView).
|
|
5
|
+
*
|
|
6
|
+
* SOURCE OF TRUTH: this file is the single canonical IMAP provider for mailx.
|
|
7
|
+
* Was previously `mailx-store-web/imap-web-provider.ts` (Android-only, with a
|
|
8
|
+
* BridgeTransport default). Moved here without the platform-specific default
|
|
9
|
+
* — callers inject the right transport at construction:
|
|
10
|
+
* - Desktop: () => new NodeTcpTransport() (from @bobfrankston/node-tcp-transport)
|
|
11
|
+
* - Android: () => new BridgeTcpTransport() (from @bobfrankston/tcp-transport)
|
|
12
|
+
*
|
|
13
|
+
* Includes automatic retry on broken pipe / connection errors: if an operation
|
|
14
|
+
* fails with a connection-related error, we discard the client and retry once
|
|
15
|
+
* with a fresh connection.
|
|
16
|
+
*/
|
|
17
|
+
import { type ImapClientConfig } from "@bobfrankston/iflow-direct";
|
|
18
|
+
import type { TransportFactory } from "@bobfrankston/tcp-transport";
|
|
19
|
+
import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./types.js";
|
|
20
|
+
export declare class ImapProvider implements MailProvider {
|
|
21
|
+
private client;
|
|
22
|
+
private config;
|
|
23
|
+
private transportFactory;
|
|
24
|
+
private specialFolders;
|
|
25
|
+
private folderListCache;
|
|
26
|
+
constructor(config: ImapClientConfig, transportFactory: TransportFactory);
|
|
27
|
+
/** Discard the current client + recreate. Called after a broken-pipe error. */
|
|
28
|
+
private reconnect;
|
|
29
|
+
/** Run an operation, retrying once on connection error with a fresh client. */
|
|
30
|
+
private withRetry;
|
|
31
|
+
listFolders(): Promise<ProviderFolder[]>;
|
|
32
|
+
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
33
|
+
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
34
|
+
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
35
|
+
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
36
|
+
getUids(folder: string): Promise<number[]>;
|
|
37
|
+
close(): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=imap.d.ts.map
|
package/imap.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IMAP provider — implements MailProvider using CompatImapClient + a caller-
|
|
3
|
+
* supplied TransportFactory. Single source of truth for IMAP-protocol-side
|
|
4
|
+
* code shared by desktop (Node) and Android (WebView).
|
|
5
|
+
*
|
|
6
|
+
* SOURCE OF TRUTH: this file is the single canonical IMAP provider for mailx.
|
|
7
|
+
* Was previously `mailx-store-web/imap-web-provider.ts` (Android-only, with a
|
|
8
|
+
* BridgeTransport default). Moved here without the platform-specific default
|
|
9
|
+
* — callers inject the right transport at construction:
|
|
10
|
+
* - Desktop: () => new NodeTcpTransport() (from @bobfrankston/node-tcp-transport)
|
|
11
|
+
* - Android: () => new BridgeTcpTransport() (from @bobfrankston/tcp-transport)
|
|
12
|
+
*
|
|
13
|
+
* Includes automatic retry on broken pipe / connection errors: if an operation
|
|
14
|
+
* fails with a connection-related error, we discard the client and retry once
|
|
15
|
+
* with a fresh connection.
|
|
16
|
+
*/
|
|
17
|
+
import { CompatImapClient } from "@bobfrankston/iflow-direct";
|
|
18
|
+
/** Convert iflow-direct's NativeFolder into the protocol-agnostic ProviderFolder
|
|
19
|
+
* shape, detecting special-use from IMAP flags + special-folder hints. */
|
|
20
|
+
function toProviderFolder(f, special) {
|
|
21
|
+
const flagsLower = (f.flags || []).map(x => x.toLowerCase());
|
|
22
|
+
let specialUse = "";
|
|
23
|
+
if (f.path === special.inbox || flagsLower.includes("\\inbox") || f.path.toUpperCase() === "INBOX")
|
|
24
|
+
specialUse = "inbox";
|
|
25
|
+
else if (f.path === special.sent || flagsLower.includes("\\sent"))
|
|
26
|
+
specialUse = "sent";
|
|
27
|
+
else if (f.path === special.trash || flagsLower.includes("\\trash"))
|
|
28
|
+
specialUse = "trash";
|
|
29
|
+
else if (f.path === special.drafts || flagsLower.includes("\\drafts"))
|
|
30
|
+
specialUse = "drafts";
|
|
31
|
+
else if (f.path === special.spam || f.path === special.junk || flagsLower.includes("\\junk"))
|
|
32
|
+
specialUse = "junk";
|
|
33
|
+
else if (f.path === special.archive || flagsLower.includes("\\archive"))
|
|
34
|
+
specialUse = "archive";
|
|
35
|
+
// Leaf name = last path segment after delimiter
|
|
36
|
+
const leaf = f.delimiter ? f.path.split(f.delimiter).pop() || f.path : f.path;
|
|
37
|
+
return {
|
|
38
|
+
path: f.path,
|
|
39
|
+
name: leaf,
|
|
40
|
+
delimiter: f.delimiter || "/",
|
|
41
|
+
specialUse,
|
|
42
|
+
flags: f.flags || [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function toProviderMessage(m) {
|
|
46
|
+
return {
|
|
47
|
+
uid: m.uid,
|
|
48
|
+
messageId: m.messageId || "",
|
|
49
|
+
providerId: "",
|
|
50
|
+
date: m.date || null,
|
|
51
|
+
subject: m.subject || "",
|
|
52
|
+
from: m.from || [],
|
|
53
|
+
to: m.to || [],
|
|
54
|
+
cc: m.cc || [],
|
|
55
|
+
seen: !!m.seen,
|
|
56
|
+
flagged: !!m.flagged,
|
|
57
|
+
answered: !!m.answered,
|
|
58
|
+
draft: !!m.draft,
|
|
59
|
+
size: m.size || 0,
|
|
60
|
+
source: m.source || "",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Heuristic for "should we reconnect and try again?" — captures broken-pipe,
|
|
64
|
+
* socket-closed, EPIPE, ECONNRESET, etc. */
|
|
65
|
+
function isConnectionError(e) {
|
|
66
|
+
const msg = (e?.message || "").toLowerCase();
|
|
67
|
+
return msg.includes("broken pipe") || msg.includes("not connected") ||
|
|
68
|
+
msg.includes("connection") || msg.includes("socket") ||
|
|
69
|
+
msg.includes("timeout") || msg.includes("econnreset") ||
|
|
70
|
+
msg.includes("epipe") || msg.includes("closed");
|
|
71
|
+
}
|
|
72
|
+
export class ImapProvider {
|
|
73
|
+
client;
|
|
74
|
+
config;
|
|
75
|
+
transportFactory;
|
|
76
|
+
specialFolders = {};
|
|
77
|
+
folderListCache = null;
|
|
78
|
+
constructor(config, transportFactory) {
|
|
79
|
+
this.config = config;
|
|
80
|
+
this.transportFactory = transportFactory;
|
|
81
|
+
this.client = new CompatImapClient(config, this.transportFactory);
|
|
82
|
+
}
|
|
83
|
+
/** Discard the current client + recreate. Called after a broken-pipe error. */
|
|
84
|
+
reconnect() {
|
|
85
|
+
console.log("[imap] reconnecting after connection error");
|
|
86
|
+
try {
|
|
87
|
+
this.client.logout();
|
|
88
|
+
}
|
|
89
|
+
catch { /* ignore */ }
|
|
90
|
+
this.client = new CompatImapClient(this.config, this.transportFactory);
|
|
91
|
+
}
|
|
92
|
+
/** Run an operation, retrying once on connection error with a fresh client. */
|
|
93
|
+
async withRetry(op, label) {
|
|
94
|
+
try {
|
|
95
|
+
return await op();
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
if (isConnectionError(e)) {
|
|
99
|
+
console.warn(`[imap] ${label}: ${e.message} — reconnecting and retrying`);
|
|
100
|
+
this.reconnect();
|
|
101
|
+
return await op();
|
|
102
|
+
}
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async listFolders() {
|
|
107
|
+
const native = await this.withRetry(() => this.client.getFolderList(), "listFolders");
|
|
108
|
+
const special = this.client.getSpecialFolders(native);
|
|
109
|
+
this.specialFolders = special;
|
|
110
|
+
const result = native.map(f => toProviderFolder(f, this.specialFolders));
|
|
111
|
+
this.folderListCache = result;
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
async fetchSince(folder, sinceUid, options) {
|
|
115
|
+
const msgs = await this.withRetry(() => this.client.fetchMessagesSinceUid(folder, sinceUid, { source: !!options?.source }), `fetchSince(${folder})`);
|
|
116
|
+
return msgs.map(toProviderMessage);
|
|
117
|
+
}
|
|
118
|
+
async fetchByDate(folder, since, before, options, onChunk) {
|
|
119
|
+
const wrappedChunk = onChunk ? (raw) => onChunk(raw.map(toProviderMessage)) : undefined;
|
|
120
|
+
const msgs = await this.withRetry(() => this.client.fetchMessageByDate(folder, since, before, { source: !!options?.source }, wrappedChunk), `fetchByDate(${folder})`);
|
|
121
|
+
return msgs.map(toProviderMessage);
|
|
122
|
+
}
|
|
123
|
+
async fetchByUids(folder, uids, options) {
|
|
124
|
+
if (!uids.length)
|
|
125
|
+
return [];
|
|
126
|
+
const range = uids.join(",");
|
|
127
|
+
const msgs = await this.withRetry(() => this.client.fetchMessages(folder, range, { source: !!options?.source }), `fetchByUids(${folder})`);
|
|
128
|
+
return msgs.map(toProviderMessage);
|
|
129
|
+
}
|
|
130
|
+
async fetchOne(folder, uid, options) {
|
|
131
|
+
const msg = await this.withRetry(() => this.client.fetchMessageByUid(folder, uid, { source: !!options?.source }), `fetchOne(${folder}/${uid})`);
|
|
132
|
+
return msg ? toProviderMessage(msg) : null;
|
|
133
|
+
}
|
|
134
|
+
async getUids(folder) {
|
|
135
|
+
return this.withRetry(() => this.client.getUids(folder), `getUids(${folder})`);
|
|
136
|
+
}
|
|
137
|
+
async close() {
|
|
138
|
+
try {
|
|
139
|
+
await this.client.logout();
|
|
140
|
+
}
|
|
141
|
+
catch { /* ignore */ }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=imap.js.map
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bobfrankston/mailx-sync — platform-agnostic mail provider implementations
|
|
3
|
+
* + sync orchestration. Single source of truth for protocol code, consumed
|
|
4
|
+
* by both desktop service (Node) and Android (WebView).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: prior to this package, mailx-imap (Node) and
|
|
7
|
+
* mailx-store-web (browser) maintained parallel copies of the same Gmail
|
|
8
|
+
* provider. They drifted in practice — bug fixes in one half got lost in the
|
|
9
|
+
* other. See programming.md "Economy of Mechanism" rule and the operational
|
|
10
|
+
* TS example added 2026-04-14.
|
|
11
|
+
*
|
|
12
|
+
* Initial scope (this version): canonical types + Gmail provider. Future
|
|
13
|
+
* additions: IMAP provider, sync orchestration, batch/prefetch logic.
|
|
14
|
+
*/
|
|
15
|
+
export type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./types.js";
|
|
16
|
+
export type { SyncFolder, SyncStorage, SyncBodyStore, SyncEventSink } from "./storage.js";
|
|
17
|
+
export { GmailApiProvider } from "./gmail.js";
|
|
18
|
+
export { ImapProvider } from "./imap.js";
|
|
19
|
+
export { OutlookApiProvider } from "./outlook.js";
|
|
20
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bobfrankston/mailx-sync — platform-agnostic mail provider implementations
|
|
3
|
+
* + sync orchestration. Single source of truth for protocol code, consumed
|
|
4
|
+
* by both desktop service (Node) and Android (WebView).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: prior to this package, mailx-imap (Node) and
|
|
7
|
+
* mailx-store-web (browser) maintained parallel copies of the same Gmail
|
|
8
|
+
* provider. They drifted in practice — bug fixes in one half got lost in the
|
|
9
|
+
* other. See programming.md "Economy of Mechanism" rule and the operational
|
|
10
|
+
* TS example added 2026-04-14.
|
|
11
|
+
*
|
|
12
|
+
* Initial scope (this version): canonical types + Gmail provider. Future
|
|
13
|
+
* additions: IMAP provider, sync orchestration, batch/prefetch logic.
|
|
14
|
+
*/
|
|
15
|
+
export { GmailApiProvider } from "./gmail.js";
|
|
16
|
+
export { ImapProvider } from "./imap.js";
|
|
17
|
+
export { OutlookApiProvider } from "./outlook.js";
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
package/outlook.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft Graph API provider — replaces IMAP for Outlook.com/Office 365 accounts.
|
|
3
|
+
* Uses Graph API for reliable, fast mail sync.
|
|
4
|
+
*
|
|
5
|
+
* SOURCE OF TRUTH: this file is the canonical Outlook provider for mailx.
|
|
6
|
+
* Same Provider abstraction as Gmail and IMAP, lives alongside them so future
|
|
7
|
+
* Android Outlook support uses the same code with no re-implementation.
|
|
8
|
+
* Currently only desktop instantiates it.
|
|
9
|
+
*
|
|
10
|
+
* Platform requirements: globalThis.fetch (Node 18+ and all browsers/WebViews).
|
|
11
|
+
* No Node-specific imports.
|
|
12
|
+
*/
|
|
13
|
+
import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./types.js";
|
|
14
|
+
export declare class OutlookApiProvider implements MailProvider {
|
|
15
|
+
private tokenProvider;
|
|
16
|
+
constructor(tokenProvider: () => Promise<string>);
|
|
17
|
+
private fetch;
|
|
18
|
+
/** Fetch raw bytes (for RFC 2822 $value endpoint) */
|
|
19
|
+
private fetchRaw;
|
|
20
|
+
listFolders(): Promise<ProviderFolder[]>;
|
|
21
|
+
private fetchChildFolders;
|
|
22
|
+
/** Convert Graph message to ProviderMessage */
|
|
23
|
+
private parseMessage;
|
|
24
|
+
/** Stable integer UID from Graph string ID */
|
|
25
|
+
private idToUid;
|
|
26
|
+
/** List messages in a folder with optional date filter */
|
|
27
|
+
private listMessages;
|
|
28
|
+
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
29
|
+
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
30
|
+
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
31
|
+
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
32
|
+
getUids(folder: string): Promise<number[]>;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
/** Add RFC 2822 source to messages (batch) */
|
|
35
|
+
private addSources;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=outlook.d.ts.map
|
package/outlook.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft Graph API provider — replaces IMAP for Outlook.com/Office 365 accounts.
|
|
3
|
+
* Uses Graph API for reliable, fast mail sync.
|
|
4
|
+
*
|
|
5
|
+
* SOURCE OF TRUTH: this file is the canonical Outlook provider for mailx.
|
|
6
|
+
* Same Provider abstraction as Gmail and IMAP, lives alongside them so future
|
|
7
|
+
* Android Outlook support uses the same code with no re-implementation.
|
|
8
|
+
* Currently only desktop instantiates it.
|
|
9
|
+
*
|
|
10
|
+
* Platform requirements: globalThis.fetch (Node 18+ and all browsers/WebViews).
|
|
11
|
+
* No Node-specific imports.
|
|
12
|
+
*/
|
|
13
|
+
const GRAPH = "https://graph.microsoft.com/v1.0/me";
|
|
14
|
+
/** Map Graph well-known folder names to specialUse */
|
|
15
|
+
const WELL_KNOWN = {
|
|
16
|
+
inbox: "inbox",
|
|
17
|
+
sentitems: "sent",
|
|
18
|
+
drafts: "drafts",
|
|
19
|
+
deleteditems: "trash",
|
|
20
|
+
junkemail: "junk",
|
|
21
|
+
archive: "archive",
|
|
22
|
+
};
|
|
23
|
+
export class OutlookApiProvider {
|
|
24
|
+
tokenProvider;
|
|
25
|
+
constructor(tokenProvider) {
|
|
26
|
+
this.tokenProvider = tokenProvider;
|
|
27
|
+
}
|
|
28
|
+
async fetch(path, options = {}) {
|
|
29
|
+
const token = await this.tokenProvider();
|
|
30
|
+
const res = await globalThis.fetch(`${GRAPH}${path}`, {
|
|
31
|
+
...options,
|
|
32
|
+
headers: {
|
|
33
|
+
"Authorization": `Bearer ${token}`,
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
...options.headers,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
const err = await res.text().catch(() => "");
|
|
40
|
+
throw new Error(`Graph API ${res.status}: ${err.substring(0, 200)}`);
|
|
41
|
+
}
|
|
42
|
+
return res.json();
|
|
43
|
+
}
|
|
44
|
+
/** Fetch raw bytes (for RFC 2822 $value endpoint) */
|
|
45
|
+
async fetchRaw(path) {
|
|
46
|
+
const token = await this.tokenProvider();
|
|
47
|
+
const res = await globalThis.fetch(`${GRAPH}${path}`, {
|
|
48
|
+
headers: { "Authorization": `Bearer ${token}` },
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
throw new Error(`Graph API ${res.status} on raw fetch`);
|
|
52
|
+
}
|
|
53
|
+
return res.text();
|
|
54
|
+
}
|
|
55
|
+
async listFolders() {
|
|
56
|
+
const folders = [];
|
|
57
|
+
let url = "/mailFolders?$top=100";
|
|
58
|
+
while (url) {
|
|
59
|
+
const data = await this.fetch(url);
|
|
60
|
+
for (const f of data.value || []) {
|
|
61
|
+
const specialUse = WELL_KNOWN[f.displayName?.toLowerCase()] || "";
|
|
62
|
+
folders.push({
|
|
63
|
+
path: f.id,
|
|
64
|
+
name: f.displayName || f.id,
|
|
65
|
+
delimiter: "/",
|
|
66
|
+
specialUse,
|
|
67
|
+
flags: [],
|
|
68
|
+
});
|
|
69
|
+
// Fetch child folders
|
|
70
|
+
if (f.childFolderCount > 0) {
|
|
71
|
+
const children = await this.fetchChildFolders(f.id, f.displayName);
|
|
72
|
+
folders.push(...children);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
url = data["@odata.nextLink"]?.replace(`${GRAPH}`, "") || "";
|
|
76
|
+
}
|
|
77
|
+
return folders;
|
|
78
|
+
}
|
|
79
|
+
async fetchChildFolders(parentId, parentName) {
|
|
80
|
+
const folders = [];
|
|
81
|
+
const data = await this.fetch(`/mailFolders/${parentId}/childFolders?$top=100`);
|
|
82
|
+
for (const f of data.value || []) {
|
|
83
|
+
const path = `${parentName}/${f.displayName}`;
|
|
84
|
+
folders.push({
|
|
85
|
+
path: f.id,
|
|
86
|
+
name: f.displayName,
|
|
87
|
+
delimiter: "/",
|
|
88
|
+
specialUse: "",
|
|
89
|
+
flags: [],
|
|
90
|
+
});
|
|
91
|
+
if (f.childFolderCount > 0) {
|
|
92
|
+
const children = await this.fetchChildFolders(f.id, path);
|
|
93
|
+
folders.push(...children);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return folders;
|
|
97
|
+
}
|
|
98
|
+
/** Convert Graph message to ProviderMessage */
|
|
99
|
+
parseMessage(msg, source = "") {
|
|
100
|
+
const from = msg.from?.emailAddress
|
|
101
|
+
? [{ name: msg.from.emailAddress.name, address: msg.from.emailAddress.address }]
|
|
102
|
+
: [];
|
|
103
|
+
const to = (msg.toRecipients || []).map((r) => ({
|
|
104
|
+
name: r.emailAddress?.name, address: r.emailAddress?.address
|
|
105
|
+
}));
|
|
106
|
+
const cc = (msg.ccRecipients || []).map((r) => ({
|
|
107
|
+
name: r.emailAddress?.name, address: r.emailAddress?.address
|
|
108
|
+
}));
|
|
109
|
+
// Graph uses string IDs — hash to integer UID
|
|
110
|
+
const uid = this.idToUid(msg.id);
|
|
111
|
+
return {
|
|
112
|
+
uid,
|
|
113
|
+
messageId: msg.internetMessageId || "",
|
|
114
|
+
providerId: msg.id,
|
|
115
|
+
date: msg.receivedDateTime ? new Date(msg.receivedDateTime) : null,
|
|
116
|
+
subject: msg.subject || "",
|
|
117
|
+
from, to, cc,
|
|
118
|
+
seen: msg.isRead === true,
|
|
119
|
+
flagged: msg.flag?.flagStatus === "flagged",
|
|
120
|
+
answered: false,
|
|
121
|
+
draft: msg.isDraft === true,
|
|
122
|
+
size: 0, // Graph doesn't return size in list
|
|
123
|
+
source,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Stable integer UID from Graph string ID */
|
|
127
|
+
idToUid(id) {
|
|
128
|
+
// Graph IDs are long base64 strings — hash to 48-bit int
|
|
129
|
+
let hash = 0;
|
|
130
|
+
for (let i = 0; i < id.length; i++) {
|
|
131
|
+
hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
|
|
132
|
+
}
|
|
133
|
+
return Math.abs(hash);
|
|
134
|
+
}
|
|
135
|
+
/** List messages in a folder with optional date filter */
|
|
136
|
+
async listMessages(folderId, filter = "", top = 500) {
|
|
137
|
+
const messages = [];
|
|
138
|
+
const select = "id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,isDraft,flag,internetMessageId,hasAttachments";
|
|
139
|
+
let url = `/mailFolders/${folderId}/messages?$select=${select}&$top=${Math.min(top, 100)}&$orderby=receivedDateTime desc`;
|
|
140
|
+
if (filter)
|
|
141
|
+
url += `&$filter=${filter}`;
|
|
142
|
+
while (url && messages.length < top) {
|
|
143
|
+
const data = await this.fetch(url);
|
|
144
|
+
messages.push(...(data.value || []));
|
|
145
|
+
url = data["@odata.nextLink"]?.replace(`${GRAPH}`, "") || "";
|
|
146
|
+
}
|
|
147
|
+
return messages;
|
|
148
|
+
}
|
|
149
|
+
async fetchSince(folder, sinceUid, options = {}) {
|
|
150
|
+
// Fetch recent messages — caller filters by UID
|
|
151
|
+
const msgs = await this.listMessages(folder, "", 200);
|
|
152
|
+
let results = msgs.map(m => this.parseMessage(m));
|
|
153
|
+
results = results.filter(m => m.uid > sinceUid);
|
|
154
|
+
if (options.source) {
|
|
155
|
+
results = await this.addSources(results);
|
|
156
|
+
}
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
async fetchByDate(folder, since, before, options = {}, onChunk) {
|
|
160
|
+
const filter = `receivedDateTime ge ${since.toISOString()} and receivedDateTime lt ${before.toISOString()}`;
|
|
161
|
+
const msgs = await this.listMessages(folder, filter);
|
|
162
|
+
const all = [];
|
|
163
|
+
const chunkSize = 25;
|
|
164
|
+
for (let i = 0; i < msgs.length; i += chunkSize) {
|
|
165
|
+
const chunk = msgs.slice(i, i + chunkSize);
|
|
166
|
+
let parsed = chunk.map(m => this.parseMessage(m));
|
|
167
|
+
if (options.source) {
|
|
168
|
+
parsed = await this.addSources(parsed);
|
|
169
|
+
}
|
|
170
|
+
all.push(...parsed);
|
|
171
|
+
if (onChunk)
|
|
172
|
+
onChunk(parsed);
|
|
173
|
+
}
|
|
174
|
+
return all;
|
|
175
|
+
}
|
|
176
|
+
async fetchByUids(folder, uids, options = {}) {
|
|
177
|
+
const uidSet = new Set(uids);
|
|
178
|
+
const msgs = await this.listMessages(folder, "", 1000);
|
|
179
|
+
let results = msgs.map(m => this.parseMessage(m)).filter(m => uidSet.has(m.uid));
|
|
180
|
+
if (options.source) {
|
|
181
|
+
results = await this.addSources(results);
|
|
182
|
+
}
|
|
183
|
+
return results;
|
|
184
|
+
}
|
|
185
|
+
async fetchOne(folder, uid, options = {}) {
|
|
186
|
+
const msgs = await this.listMessages(folder, "", 500);
|
|
187
|
+
const msg = msgs.find(m => this.idToUid(m.id) === uid);
|
|
188
|
+
if (!msg)
|
|
189
|
+
return null;
|
|
190
|
+
let source = "";
|
|
191
|
+
if (options.source) {
|
|
192
|
+
source = await this.fetchRaw(`/messages/${msg.id}/$value`);
|
|
193
|
+
}
|
|
194
|
+
return this.parseMessage(msg, source);
|
|
195
|
+
}
|
|
196
|
+
async getUids(folder) {
|
|
197
|
+
const msgs = await this.listMessages(folder, "", 10000);
|
|
198
|
+
return msgs.map(m => this.idToUid(m.id));
|
|
199
|
+
}
|
|
200
|
+
async close() { }
|
|
201
|
+
/** Add RFC 2822 source to messages (batch) */
|
|
202
|
+
async addSources(messages) {
|
|
203
|
+
for (const msg of messages) {
|
|
204
|
+
try {
|
|
205
|
+
msg.source = await this.fetchRaw(`/messages/${msg.providerId}/$value`);
|
|
206
|
+
}
|
|
207
|
+
catch { /* source unavailable — leave empty */ }
|
|
208
|
+
}
|
|
209
|
+
return messages;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=outlook.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bobfrankston/mailx-sync",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Platform-agnostic mail provider implementations + sync orchestration. Single source of truth for Gmail/IMAP/Outlook protocol code, consumed by both desktop (Node) and Android (WebView) — eliminates the parallel mailx-imap/mailx-store-web Gmail providers that drifted in practice.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"watch": "tsc -watch",
|
|
11
|
+
"check": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mail",
|
|
15
|
+
"gmail",
|
|
16
|
+
"imap",
|
|
17
|
+
"platform-agnostic"
|
|
18
|
+
],
|
|
19
|
+
"author": "Bob Frankston",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@bobfrankston/tcp-transport": "^0.1.3",
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.20"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./index.ts",
|
|
28
|
+
"default": "./index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/BobFrankston/mailx-sync.git"
|
|
37
|
+
},
|
|
38
|
+
".dependencies": {
|
|
39
|
+
"@bobfrankston/tcp-transport": "file:../tcp-transport",
|
|
40
|
+
"@bobfrankston/iflow-direct": "file:../iflow-direct"
|
|
41
|
+
},
|
|
42
|
+
".transformedSnapshot": {
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@bobfrankston/tcp-transport": "^0.1.3",
|
|
45
|
+
"@bobfrankston/iflow-direct": "^0.1.20"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/storage.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage interface — what the shared sync code needs from a database.
|
|
3
|
+
*
|
|
4
|
+
* Two implementations satisfy this interface:
|
|
5
|
+
* - Desktop: `@bobfrankston/mailx-store` (node:sqlite-backed `MailxDB`)
|
|
6
|
+
* - Android: `@bobfrankston/mailx-store-web` (sql.js-backed `WebMailxDB`)
|
|
7
|
+
*
|
|
8
|
+
* Each adapts its own DB to this interface so the shared sync orchestration
|
|
9
|
+
* (in `./sync.ts`) doesn't care which platform it's running on. Methods kept
|
|
10
|
+
* deliberately small — only what sync orchestration actually needs.
|
|
11
|
+
*/
|
|
12
|
+
import type { ProviderMessage } from "./types.js";
|
|
13
|
+
export interface SyncFolder {
|
|
14
|
+
id: number;
|
|
15
|
+
accountId: string;
|
|
16
|
+
path: string;
|
|
17
|
+
name: string;
|
|
18
|
+
specialUse: string;
|
|
19
|
+
delimiter: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SyncStorage {
|
|
22
|
+
/** All folders for an account. Used to look up folder by id during sync. */
|
|
23
|
+
getFolders(accountId: string): SyncFolder[];
|
|
24
|
+
/** Highest UID currently cached for a folder. 0 means never synced. */
|
|
25
|
+
getHighestUid(accountId: string, folderId: number): number;
|
|
26
|
+
/** All UIDs currently in the DB for a folder. Used by the reconcile path
|
|
27
|
+
* to detect server-side deletions. */
|
|
28
|
+
getUidsForFolder(accountId: string, folderId: number): number[];
|
|
29
|
+
/** Insert-or-update a single message envelope. Returns true if a new row
|
|
30
|
+
* was inserted, false if it was an update of an existing row. */
|
|
31
|
+
upsertProviderMessage(accountId: string, folderId: number, msg: ProviderMessage): boolean;
|
|
32
|
+
/** Remove a message row + cascade folder counts. */
|
|
33
|
+
deleteMessage(accountId: string, uid: number): void;
|
|
34
|
+
/** Recompute total/unread for a folder (called after batch insert/delete). */
|
|
35
|
+
recalcFolderCounts(folderId: number): void;
|
|
36
|
+
/** Update the on-disk path where the body bytes are stored. Called after
|
|
37
|
+
* a successful body fetch. */
|
|
38
|
+
updateBodyPath(accountId: string, uid: number, bodyPath: string): void;
|
|
39
|
+
/** Find messages whose body hasn't been cached yet. Used by prefetch.
|
|
40
|
+
* Returns up to `limit` rows ordered most-recent-first. */
|
|
41
|
+
getMessagesWithoutBody(accountId: string, limit: number): {
|
|
42
|
+
uid: number;
|
|
43
|
+
folderId: number;
|
|
44
|
+
}[];
|
|
45
|
+
}
|
|
46
|
+
/** Where message bodies (raw RFC 2822 .eml files or equivalent blobs) live.
|
|
47
|
+
* Desktop: filesystem under ~/.mailx/mailxstore/.
|
|
48
|
+
* Android: IndexedDB or local-storage equivalent. */
|
|
49
|
+
export interface SyncBodyStore {
|
|
50
|
+
/** Write body bytes for a message. Returns an opaque path/id that
|
|
51
|
+
* `updateBodyPath` will store in the DB. */
|
|
52
|
+
putMessage(accountId: string, folderId: number, uid: number, raw: Uint8Array): Promise<string>;
|
|
53
|
+
/** Delete a body — best-effort, may resolve even if missing. */
|
|
54
|
+
deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
/** Optional event sink so shared sync can emit progress. Both platforms have
|
|
57
|
+
* some kind of event system; this is a thin shim. Callers can no-op. */
|
|
58
|
+
export interface SyncEventSink {
|
|
59
|
+
folderCountsChanged(accountId: string): void;
|
|
60
|
+
syncProgress?(accountId: string, phase: string, progress: number): void;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=storage.d.ts.map
|
package/storage.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage interface — what the shared sync code needs from a database.
|
|
3
|
+
*
|
|
4
|
+
* Two implementations satisfy this interface:
|
|
5
|
+
* - Desktop: `@bobfrankston/mailx-store` (node:sqlite-backed `MailxDB`)
|
|
6
|
+
* - Android: `@bobfrankston/mailx-store-web` (sql.js-backed `WebMailxDB`)
|
|
7
|
+
*
|
|
8
|
+
* Each adapts its own DB to this interface so the shared sync orchestration
|
|
9
|
+
* (in `./sync.ts`) doesn't care which platform it's running on. Methods kept
|
|
10
|
+
* deliberately small — only what sync orchestration actually needs.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=storage.js.map
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail provider interface — abstraction over IMAP, Gmail API, and Microsoft Graph.
|
|
3
|
+
* Sync code uses this interface; never calls IMAP/REST directly.
|
|
4
|
+
*
|
|
5
|
+
* SOURCE OF TRUTH: this file is the single canonical definition. The earlier
|
|
6
|
+
* mailx-imap/providers/types.ts and mailx-store-web/provider-types.ts have
|
|
7
|
+
* been collapsed into this; both packages now re-export from here.
|
|
8
|
+
*/
|
|
9
|
+
export interface ProviderFolder {
|
|
10
|
+
path: string;
|
|
11
|
+
name: string;
|
|
12
|
+
delimiter: string;
|
|
13
|
+
specialUse: string;
|
|
14
|
+
flags: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface ProviderMessage {
|
|
17
|
+
uid: number;
|
|
18
|
+
messageId: string;
|
|
19
|
+
providerId: string;
|
|
20
|
+
date: Date | null;
|
|
21
|
+
subject: string;
|
|
22
|
+
from: {
|
|
23
|
+
name?: string;
|
|
24
|
+
address?: string;
|
|
25
|
+
}[];
|
|
26
|
+
to: {
|
|
27
|
+
name?: string;
|
|
28
|
+
address?: string;
|
|
29
|
+
}[];
|
|
30
|
+
cc: {
|
|
31
|
+
name?: string;
|
|
32
|
+
address?: string;
|
|
33
|
+
}[];
|
|
34
|
+
inReplyTo?: string;
|
|
35
|
+
references?: string[];
|
|
36
|
+
seen: boolean;
|
|
37
|
+
flagged: boolean;
|
|
38
|
+
answered: boolean;
|
|
39
|
+
draft: boolean;
|
|
40
|
+
size: number;
|
|
41
|
+
source: string;
|
|
42
|
+
}
|
|
43
|
+
export interface FetchOptions {
|
|
44
|
+
source?: boolean;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A mail provider that can list folders, fetch messages, and perform actions.
|
|
48
|
+
* Implementations: ImapProvider (existing iflow), GmailApiProvider, GraphApiProvider.
|
|
49
|
+
*/
|
|
50
|
+
export interface MailProvider {
|
|
51
|
+
/** List all folders/labels */
|
|
52
|
+
listFolders(): Promise<ProviderFolder[]>;
|
|
53
|
+
/** Fetch messages newer than sinceUid (incremental sync) */
|
|
54
|
+
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
55
|
+
/** Fetch messages by date range (first sync) */
|
|
56
|
+
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
57
|
+
/** Fetch specific messages by UID */
|
|
58
|
+
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
59
|
+
/** Fetch a single message by UID */
|
|
60
|
+
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
61
|
+
/** Get all UIDs in a folder (for reconciliation) */
|
|
62
|
+
getUids(folder: string): Promise<number[]>;
|
|
63
|
+
/** Close/cleanup */
|
|
64
|
+
close(): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=types.d.ts.map
|
package/types.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail provider interface — abstraction over IMAP, Gmail API, and Microsoft Graph.
|
|
3
|
+
* Sync code uses this interface; never calls IMAP/REST directly.
|
|
4
|
+
*
|
|
5
|
+
* SOURCE OF TRUTH: this file is the single canonical definition. The earlier
|
|
6
|
+
* mailx-imap/providers/types.ts and mailx-store-web/provider-types.ts have
|
|
7
|
+
* been collapsed into this; both packages now re-export from here.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=types.js.map
|