@bobfrankston/mailx-settings 0.1.4 → 0.1.7

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/cloud.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Cloud storage for mailx settings — Google Drive API (drive.file scope).
3
+ *
4
+ * Uses a single app-owned "mailx" folder on Drive, accessed by folder ID.
5
+ * The drive.file scope only sees files/folders created by this OAuth client,
6
+ * which prevents conflicts with existing folders of the same name.
7
+ * All machines using the same OAuth client ID share the same folder.
8
+ *
9
+ * ── Restoring removed providers ──
10
+ * OneDrive (removed 2026-04-06): Required microsoft-credentials.json in ~/.mailx/,
11
+ * MS_SCOPES = "https://graph.microsoft.com/Files.ReadWrite offline_access",
12
+ * authenticateOAuth with tokenDir ~/.mailx/tokens/microsoft/.
13
+ * Read/write via Graph API: GET/PUT https://graph.microsoft.com/v1.0/me/drive/root:/{path}:/content
14
+ * Azure AD app registration needed: https://portal.azure.com → App registrations → Desktop app
15
+ *
16
+ * Dropbox (removed 2026-04-06): Never implemented — placeholder only.
17
+ * Would need Dropbox OAuth app, token management, and Dropbox API v2 calls.
18
+ */
19
+ /** Verify a cached folder ID still points to an `.rmfmail`-named folder
20
+ * the user owns and isn't trashed. Returns true if the ID is good to use,
21
+ * false if mailx should drop it and re-discover via gDriveFindOrCreateFolder.
22
+ *
23
+ * Catches the cross-version-corruption case: 1.0.504 had a buggy folder
24
+ * lookup that created an empty `rmfmail` folder at My Drive root and cached
25
+ * its ID. Once 1.0.505 fixed the lookup, the cached ID still pointed at the
26
+ * buggy folder forever — mailx kept reading the 1-account stub instead of
27
+ * the user's real `home/.rmfmail/accounts.jsonc`. This validator forces a
28
+ * re-discovery whenever the cached ID is wrong. */
29
+ export declare function gDriveValidateCachedFolder(folderId: string): Promise<boolean>;
30
+ /** Find or create the rmfmail settings folder on Google Drive.
31
+ *
32
+ * Lookup order — match the Android side (`mailx-store-web/android-bootstrap.ts`):
33
+ * 1. `home/.rmfmail/` — the established convention. Bob's family-of-two
34
+ * layout has been here since the rebrand. Path scope (`'root' in
35
+ * parents` for `home`, then `<homeId> in parents` for `.rmfmail`) is
36
+ * what the Android side does, so desktop matches.
37
+ * 2. `.rmfmail/` at My Drive root — fallback for users who don't have a
38
+ * `home/` folder. Mostly hypothetical; included so a brand-new Drive
39
+ * still works.
40
+ * 3. Create `.rmfmail` at My Drive root if neither exists. Don't auto-
41
+ * create `home/` — that's a user-organization choice we shouldn't
42
+ * make for them.
43
+ *
44
+ * Previously this looked for a literal `rmfmail` (no dot) at root, which
45
+ * on clean install created a NEW empty folder alongside the real data,
46
+ * orphaning the user's accounts/contacts/etc. Switched to the dotted name
47
+ * matching the actual folder convention. */
48
+ export declare function gDriveFindOrCreateFolder(): Promise<string | null>;
49
+ export type CloudProvider = "gdrive" | "google" | "local";
50
+ export interface CloudFile {
51
+ /** Read a file. For gdrive, path is just the filename (folder ID is implicit). */
52
+ read(filePath: string): Promise<string | null>;
53
+ /** Write a file. For gdrive, path is just the filename. Throws on failure with a descriptive error. */
54
+ write(filePath: string, content: string): Promise<void>;
55
+ /** Check if a file exists. */
56
+ exists(filePath: string): Promise<boolean>;
57
+ }
58
+ /**
59
+ * Get a cloud file provider. For gdrive, pass the folder ID.
60
+ * Files are stored flat in the folder (no subdirectory navigation).
61
+ */
62
+ export declare function getCloudProvider(provider: string, folderId?: string): CloudFile | null;
63
+ /** Fetch the authenticated Google user's profile (name + email) via the People API.
64
+ * Caller must supply an OAuth access token whose scopes include `contacts.readonly`
65
+ * or `userinfo.profile`. The Drive token (drive.file) does NOT have the right scope
66
+ * on its own — use the Gmail/IMAP OAuth token instead (mailx-imap's
67
+ * ImapManager.getOAuthToken). Returns null on failure. */
68
+ export declare function getGoogleProfile(token: string): Promise<{
69
+ name?: string;
70
+ email?: string;
71
+ } | null>;
72
+ //# sourceMappingURL=cloud.d.ts.map
package/cloud.d.ts.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloud.d.ts","sourceRoot":"","sources":["cloud.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AA0JH;;;;;;;;;oDASoD;AACpD,wBAAsB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA8BnF;AAcD;;;;;;;;;;;;;;;;;6CAiB6C;AAC7C,wBAAsB,wBAAwB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAwBvE;AA4ED,MAAM,MAAM,aAAa,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE1D,MAAM,WAAW,SAAS;IACtB,kFAAkF;IAClF,IAAI,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/C,uGAAuG;IACvG,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,8BAA8B;IAC9B,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC9C;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAuBtF;AAED;;;;2DAI2D;AAC3D,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAoBvG"}
package/cloud.js ADDED
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Cloud storage for mailx settings — Google Drive API (drive.file scope).
3
+ *
4
+ * Uses a single app-owned "mailx" folder on Drive, accessed by folder ID.
5
+ * The drive.file scope only sees files/folders created by this OAuth client,
6
+ * which prevents conflicts with existing folders of the same name.
7
+ * All machines using the same OAuth client ID share the same folder.
8
+ *
9
+ * ── Restoring removed providers ──
10
+ * OneDrive (removed 2026-04-06): Required microsoft-credentials.json in ~/.mailx/,
11
+ * MS_SCOPES = "https://graph.microsoft.com/Files.ReadWrite offline_access",
12
+ * authenticateOAuth with tokenDir ~/.mailx/tokens/microsoft/.
13
+ * Read/write via Graph API: GET/PUT https://graph.microsoft.com/v1.0/me/drive/root:/{path}:/content
14
+ * Azure AD app registration needed: https://portal.azure.com → App registrations → Desktop app
15
+ *
16
+ * Dropbox (removed 2026-04-06): Never implemented — placeholder only.
17
+ * Would need Dropbox OAuth app, token management, and Dropbox API v2 calls.
18
+ */
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { authenticateOAuth } from "@bobfrankston/oauthsupport";
22
+ const SETTINGS_DIR = path.join(process.env.USERPROFILE || process.env.HOME || ".", ".rmfmail");
23
+ // ── Credentials ──
24
+ // Google Drive: reuse iflow's OAuth credentials (same Google Cloud project)
25
+ function findGoogleCredentials() {
26
+ // Check mailx local dir first, then iflow package
27
+ const local = path.join(SETTINGS_DIR, "google-credentials.json");
28
+ if (fs.existsSync(local))
29
+ return local;
30
+ try {
31
+ let dir = import.meta.dirname;
32
+ for (let i = 0; i < 5; i++) {
33
+ for (const pkg of ["iflow-direct", "iflow"]) {
34
+ for (const name of ["iflow-credentials.json"]) {
35
+ const p = path.join(dir, "node_modules", "@bobfrankston", pkg, name);
36
+ if (fs.existsSync(p))
37
+ return p;
38
+ }
39
+ }
40
+ const parent = path.dirname(dir);
41
+ if (parent === dir)
42
+ break;
43
+ dir = parent;
44
+ }
45
+ }
46
+ catch { /* iflow not installed */ }
47
+ return null;
48
+ }
49
+ const GDRIVE_TOKEN_DIR = path.join(SETTINGS_DIR, "tokens", "gdrive");
50
+ // drive: full access so we can find pre-existing folders like "home/.mailx"
51
+ // that weren't created by this OAuth client. drive.file was safer but couldn't
52
+ // discover folders created manually or by other tools — which broke first-run
53
+ // setup when the user's convention is a nested path.
54
+ // The Gmail scope is already mail.google.com (full email), so this isn't a
55
+ // bigger privacy ask. Token cache at tokens/gdrive/ will re-auth on scope change.
56
+ const GDRIVE_SCOPES = "https://www.googleapis.com/auth/drive";
57
+ // ── Token helpers ──
58
+ /** Get a GDrive-capable token. Prefers the Gmail token (which now includes
59
+ * drive scope) — avoids a second OAuth consent prompt. Falls back to the
60
+ * dedicated GDrive token dir for non-Gmail setups or when Gmail token
61
+ * doesn't have drive scope yet. */
62
+ async function getGoogleDriveToken() {
63
+ // Strategy 1: reuse a Gmail token that already has drive scope.
64
+ // Scan the tokens/ dir for any account token with drive in its scope.
65
+ const tokensDir = path.join(SETTINGS_DIR, "tokens");
66
+ if (fs.existsSync(tokensDir)) {
67
+ try {
68
+ for (const entry of fs.readdirSync(tokensDir)) {
69
+ if (entry === "gdrive")
70
+ continue;
71
+ const tokenPath = path.join(tokensDir, entry, "oauth-token.json");
72
+ if (!fs.existsSync(tokenPath))
73
+ continue;
74
+ try {
75
+ const tok = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
76
+ if (tok.scope?.includes("drive") && tok.refresh_token) {
77
+ console.log(` [cloud] Reusing Gmail token from ${entry} (has drive scope)`);
78
+ const creds = findGoogleCredentials();
79
+ if (!creds)
80
+ continue;
81
+ const refreshed = await authenticateOAuth(creds, {
82
+ scope: tok.scope,
83
+ tokenDirectory: path.join(tokensDir, entry),
84
+ tokenFileName: "oauth-token.json",
85
+ credentialsKey: "installed",
86
+ includeOfflineAccess: true,
87
+ });
88
+ if (refreshed?.access_token) {
89
+ console.log(` [cloud] GDrive auth: success (via Gmail token)`);
90
+ return refreshed.access_token;
91
+ }
92
+ }
93
+ }
94
+ catch { /* skip this token */ }
95
+ }
96
+ }
97
+ catch { /* tokens dir unreadable */ }
98
+ }
99
+ // Strategy 2: dedicated GDrive token (legacy path or non-Gmail setups)
100
+ const creds = findGoogleCredentials();
101
+ if (!creds) {
102
+ console.error(" [cloud] No Google credentials found (checked ~/.rmfmail/google-credentials.json and iflow package)");
103
+ return null;
104
+ }
105
+ const tokenPath = path.join(GDRIVE_TOKEN_DIR, "token.json");
106
+ if (fs.existsSync(tokenPath)) {
107
+ try {
108
+ const existing = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
109
+ const expired = existing.expires_at ? Date.now() >= existing.expires_at : true;
110
+ const hasRefresh = !!existing.refresh_token;
111
+ console.log(` [cloud] GDrive token (dedicated): ${expired ? "expired" : "valid"}, refresh_token: ${hasRefresh ? "yes" : "NO"}, scope: ${existing.scope || "?"}`);
112
+ if (existing.scope && existing.scope !== GDRIVE_SCOPES) {
113
+ console.log(` [cloud] Scope changed (${existing.scope} → ${GDRIVE_SCOPES}) — re-authenticating...`);
114
+ fs.unlinkSync(tokenPath);
115
+ }
116
+ }
117
+ catch { /* ignore */ }
118
+ }
119
+ else {
120
+ console.log(` [cloud] GDrive token: not found at ${tokenPath} (will auth)`);
121
+ }
122
+ try {
123
+ const token = await authenticateOAuth(creds, {
124
+ scope: GDRIVE_SCOPES,
125
+ tokenDirectory: GDRIVE_TOKEN_DIR,
126
+ tokenFileName: "token.json",
127
+ credentialsKey: "installed",
128
+ includeOfflineAccess: true,
129
+ });
130
+ if (token?.access_token) {
131
+ console.log(` [cloud] GDrive auth: success (dedicated token)`);
132
+ }
133
+ else {
134
+ console.error(` [cloud] GDrive auth: returned null — OAuth likely timed out`);
135
+ }
136
+ return token?.access_token || null;
137
+ }
138
+ catch (e) {
139
+ console.error(` [cloud] GDrive auth FAILED: ${e.message}`);
140
+ return null;
141
+ }
142
+ }
143
+ // ── Google Drive API (folder-ID based) ──
144
+ /** Walk a nested folder path on GDrive ("home/.mailx" → find "home", find
145
+ * ".mailx" inside it). Returns the leaf folder ID, or null if any segment is
146
+ * missing. When `create` is true, creates the LAST segment if missing (won't
147
+ * create intermediate segments — that's a user error). */
148
+ async function walkGDrivePath(token, segments, create) {
149
+ const fullPath = segments.join("/");
150
+ let parentId;
151
+ for (let i = 0; i < segments.length; i++) {
152
+ const seg = segments[i];
153
+ const found = await gDriveFindFolder(token, seg, parentId);
154
+ if (found) {
155
+ console.log(` [cloud] walk '${fullPath}': '${seg}' → ${found}${parentId ? ` (in ${parentId})` : ""}`);
156
+ parentId = found;
157
+ }
158
+ else if (create && i === segments.length - 1) {
159
+ const body = { name: seg, mimeType: "application/vnd.google-apps.folder" };
160
+ if (parentId)
161
+ body.parents = [parentId];
162
+ const res = await fetch("https://www.googleapis.com/drive/v3/files", {
163
+ method: "POST",
164
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
165
+ body: JSON.stringify(body),
166
+ });
167
+ if (!res.ok)
168
+ return null;
169
+ const created = await res.json();
170
+ parentId = created.id;
171
+ }
172
+ else {
173
+ return null;
174
+ }
175
+ }
176
+ return parentId || null;
177
+ }
178
+ /** Verify a cached folder ID still points to an `.rmfmail`-named folder
179
+ * the user owns and isn't trashed. Returns true if the ID is good to use,
180
+ * false if mailx should drop it and re-discover via gDriveFindOrCreateFolder.
181
+ *
182
+ * Catches the cross-version-corruption case: 1.0.504 had a buggy folder
183
+ * lookup that created an empty `rmfmail` folder at My Drive root and cached
184
+ * its ID. Once 1.0.505 fixed the lookup, the cached ID still pointed at the
185
+ * buggy folder forever — mailx kept reading the 1-account stub instead of
186
+ * the user's real `home/.rmfmail/accounts.jsonc`. This validator forces a
187
+ * re-discovery whenever the cached ID is wrong. */
188
+ export async function gDriveValidateCachedFolder(folderId) {
189
+ const token = await getGoogleDriveToken();
190
+ if (!token)
191
+ return true; // can't verify → trust the cache (no token = nothing else to do)
192
+ try {
193
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files/${folderId}?fields=id,name,trashed,mimeType`, { headers: { Authorization: `Bearer ${token}` } });
194
+ if (res.status === 404) {
195
+ console.log(` [cloud] cached folderId ${folderId} → 404 (folder deleted), re-discovering`);
196
+ return false;
197
+ }
198
+ if (!res.ok)
199
+ return true;
200
+ const data = await res.json();
201
+ if (data.trashed) {
202
+ console.log(` [cloud] cached folderId ${folderId} is trashed, re-discovering`);
203
+ return false;
204
+ }
205
+ if (data.mimeType !== "application/vnd.google-apps.folder") {
206
+ console.log(` [cloud] cached folderId ${folderId} is not a folder (${data.mimeType}), re-discovering`);
207
+ return false;
208
+ }
209
+ if (data.name !== ".rmfmail") {
210
+ console.log(` [cloud] cached folderId ${folderId} is named '${data.name}', not '.rmfmail' — re-discovering`);
211
+ return false;
212
+ }
213
+ return true;
214
+ }
215
+ catch (e) {
216
+ console.error(` [cloud] gDriveValidateCachedFolder: ${e.message}`);
217
+ return true; // network failure → trust the cache
218
+ }
219
+ }
220
+ /** Find a single folder by name, optionally inside a parent. */
221
+ async function gDriveFindFolder(token, name, parentId) {
222
+ let query = `name='${name}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
223
+ if (parentId)
224
+ query += ` and '${parentId}' in parents`;
225
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)`, {
226
+ headers: { Authorization: `Bearer ${token}` },
227
+ });
228
+ if (!res.ok) {
229
+ console.error(` [cloud] gdrive folder search '${name}': ${res.status} ${res.statusText}`);
230
+ return null;
231
+ }
232
+ const data = await res.json();
233
+ return data.files?.[0]?.id || null;
234
+ }
235
+ /** Find or create the rmfmail settings folder on Google Drive.
236
+ *
237
+ * Lookup order — match the Android side (`mailx-store-web/android-bootstrap.ts`):
238
+ * 1. `home/.rmfmail/` — the established convention. Bob's family-of-two
239
+ * layout has been here since the rebrand. Path scope (`'root' in
240
+ * parents` for `home`, then `<homeId> in parents` for `.rmfmail`) is
241
+ * what the Android side does, so desktop matches.
242
+ * 2. `.rmfmail/` at My Drive root — fallback for users who don't have a
243
+ * `home/` folder. Mostly hypothetical; included so a brand-new Drive
244
+ * still works.
245
+ * 3. Create `.rmfmail` at My Drive root if neither exists. Don't auto-
246
+ * create `home/` — that's a user-organization choice we shouldn't
247
+ * make for them.
248
+ *
249
+ * Previously this looked for a literal `rmfmail` (no dot) at root, which
250
+ * on clean install created a NEW empty folder alongside the real data,
251
+ * orphaning the user's accounts/contacts/etc. Switched to the dotted name
252
+ * matching the actual folder convention. */
253
+ export async function gDriveFindOrCreateFolder() {
254
+ const token = await getGoogleDriveToken();
255
+ if (!token)
256
+ return null;
257
+ try {
258
+ // 1. Try home/.rmfmail (the canonical location)
259
+ const inHome = await walkGDrivePath(token, ["home", ".rmfmail"], false);
260
+ if (inHome) {
261
+ console.log(` [cloud] Found 'home/.rmfmail' folder (${inHome})`);
262
+ return inHome;
263
+ }
264
+ // 2. Try .rmfmail at root
265
+ const atRoot = await walkGDrivePath(token, [".rmfmail"], false);
266
+ if (atRoot) {
267
+ console.log(` [cloud] Found '.rmfmail' folder at My Drive root (${atRoot})`);
268
+ return atRoot;
269
+ }
270
+ // 3. Create .rmfmail at root (don't try to create `home/`)
271
+ const created = await walkGDrivePath(token, [".rmfmail"], true);
272
+ if (created)
273
+ console.log(` [cloud] Created '.rmfmail' folder at My Drive root (${created})`);
274
+ return created;
275
+ }
276
+ catch (e) {
277
+ console.error(` [cloud] gdrive folder setup: ${e.message}`);
278
+ return null;
279
+ }
280
+ }
281
+ /** Read a file by name from a folder (by ID) */
282
+ async function gDriveReadFromFolder(folderId, fileName) {
283
+ const token = await getGoogleDriveToken();
284
+ if (!token) {
285
+ console.error(` [cloud] gdrive read ${fileName}: no token`);
286
+ return null;
287
+ }
288
+ try {
289
+ // Find file in folder
290
+ const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
291
+ const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
292
+ headers: { Authorization: `Bearer ${token}` },
293
+ });
294
+ if (!res.ok) {
295
+ console.error(` [cloud] gdrive lookup '${fileName}': ${res.status}`);
296
+ return null;
297
+ }
298
+ const data = await res.json();
299
+ const fileId = data.files?.[0]?.id;
300
+ if (!fileId)
301
+ return null;
302
+ // Download content
303
+ const contentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
304
+ headers: { Authorization: `Bearer ${token}` },
305
+ });
306
+ if (!contentRes.ok) {
307
+ console.error(` [cloud] gdrive download ${fileName}: ${contentRes.status}`);
308
+ return null;
309
+ }
310
+ return await contentRes.text();
311
+ }
312
+ catch (e) {
313
+ console.error(` [cloud] gdrive read ${fileName}: ${e.message}`);
314
+ return null;
315
+ }
316
+ }
317
+ /** Write a file by name to a folder (by ID) — creates or updates.
318
+ * Throws on failure with a descriptive error so callers can surface it to the UI. */
319
+ async function gDriveWriteToFolder(folderId, fileName, content) {
320
+ const token = await getGoogleDriveToken();
321
+ if (!token)
322
+ throw new Error("Google Drive: no auth token (OAuth not granted or expired)");
323
+ // Check if file exists in folder
324
+ const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
325
+ const findRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
326
+ headers: { Authorization: `Bearer ${token}` },
327
+ });
328
+ if (!findRes.ok) {
329
+ const body = await findRes.text().catch(() => "");
330
+ throw new Error(`Google Drive: lookup '${fileName}' failed (${findRes.status} ${findRes.statusText}) ${body.slice(0, 200)}`);
331
+ }
332
+ const findData = await findRes.json();
333
+ const existingId = findData.files?.[0]?.id;
334
+ if (existingId) {
335
+ // Update existing file
336
+ const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
337
+ method: "PATCH",
338
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
339
+ body: content,
340
+ });
341
+ if (!res.ok) {
342
+ const body = await res.text().catch(() => "");
343
+ throw new Error(`Google Drive: update '${fileName}' failed (${res.status} ${res.statusText}) ${body.slice(0, 200)}`);
344
+ }
345
+ }
346
+ else {
347
+ // Create new file in folder
348
+ const boundary = "mailx_boundary_" + Date.now();
349
+ const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
350
+ const body = `--${boundary}\r\nContent-Type: application/json\r\n\r\n${metadata}\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${content}\r\n--${boundary}--`;
351
+ const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
352
+ method: "POST",
353
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}` },
354
+ body,
355
+ });
356
+ if (!res.ok) {
357
+ const errBody = await res.text().catch(() => "");
358
+ throw new Error(`Google Drive: create '${fileName}' failed (${res.status} ${res.statusText}) ${errBody.slice(0, 200)}`);
359
+ }
360
+ }
361
+ }
362
+ /**
363
+ * Get a cloud file provider. For gdrive, pass the folder ID.
364
+ * Files are stored flat in the folder (no subdirectory navigation).
365
+ */
366
+ export function getCloudProvider(provider, folderId) {
367
+ switch (provider) {
368
+ case "google":
369
+ case "gdrive":
370
+ if (!folderId) {
371
+ console.error(" [cloud] gdrive requires a folder ID — run initCloudConfig first");
372
+ return null;
373
+ }
374
+ return {
375
+ read: (fileName) => gDriveReadFromFolder(folderId, fileName),
376
+ write: (fileName, content) => gDriveWriteToFolder(folderId, fileName, content),
377
+ exists: async (fileName) => (await gDriveReadFromFolder(folderId, fileName)) !== null,
378
+ };
379
+ case "local":
380
+ return {
381
+ read: async (p) => { try {
382
+ return fs.readFileSync(p, "utf-8");
383
+ }
384
+ catch {
385
+ return null;
386
+ } },
387
+ write: async (p, c) => { fs.writeFileSync(p, c); },
388
+ exists: async (p) => fs.existsSync(p),
389
+ };
390
+ default:
391
+ console.error(` [cloud] Provider "${provider}" not supported — only gdrive is available`);
392
+ return null;
393
+ }
394
+ }
395
+ /** Fetch the authenticated Google user's profile (name + email) via the People API.
396
+ * Caller must supply an OAuth access token whose scopes include `contacts.readonly`
397
+ * or `userinfo.profile`. The Drive token (drive.file) does NOT have the right scope
398
+ * on its own — use the Gmail/IMAP OAuth token instead (mailx-imap's
399
+ * ImapManager.getOAuthToken). Returns null on failure. */
400
+ export async function getGoogleProfile(token) {
401
+ if (!token)
402
+ return null;
403
+ try {
404
+ const res = await fetch("https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses", {
405
+ headers: { Authorization: `Bearer ${token}` },
406
+ });
407
+ if (!res.ok) {
408
+ console.error(` [cloud] People API: ${res.status} ${res.statusText}`);
409
+ return null;
410
+ }
411
+ const data = await res.json();
412
+ const name = data.names?.find((n) => n.displayName)?.displayName
413
+ || data.names?.[0]?.displayName;
414
+ const email = data.emailAddresses?.find((e) => e.metadata?.primary)?.value
415
+ || data.emailAddresses?.[0]?.value;
416
+ return { name, email };
417
+ }
418
+ catch (e) {
419
+ console.error(` [cloud] getGoogleProfile: ${e.message}`);
420
+ return null;
421
+ }
422
+ }
423
+ //# sourceMappingURL=cloud.js.map
package/copy-docs.js ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * copy-docs.js — pre-pack hook for mailx-settings.
4
+ *
5
+ * The reference docs live at `<workspace>/app/docs/*.md` so they're easy
6
+ * to edit and discover in the source tree. The published npm package
7
+ * needs them as a sibling of index.js so `deployDocs()` can find them at
8
+ * runtime via `__dirname/docs/`. This script copies the workspace docs
9
+ * into a local `./docs/` directory just before npm publish, then npm's
10
+ * `files` field includes them in the tarball.
11
+ *
12
+ * Idempotent — safe to run multiple times. Source path is the workspace
13
+ * root resolved upward from this package.
14
+ */
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ const __dirname = import.meta.dirname;
19
+ const src = path.join(__dirname, "..", "..", "docs");
20
+ const dst = path.join(__dirname, "docs");
21
+
22
+ if (!fs.existsSync(src)) {
23
+ console.log(`[mailx-settings] no source docs at ${src} — skipping copy`);
24
+ process.exit(0);
25
+ }
26
+ fs.mkdirSync(dst, { recursive: true });
27
+ const files = fs.readdirSync(src).filter(f => f.endsWith(".md"));
28
+ for (const f of files) {
29
+ fs.copyFileSync(path.join(src, f), path.join(dst, f));
30
+ }
31
+ console.log(`[mailx-settings] copied ${files.length} .md file(s) from app/docs/ → docs/`);
@@ -0,0 +1,41 @@
1
+ # accounts.jsonc
2
+
3
+ > **Owned by rmfmail. Do not edit this file — your changes will be overwritten the next time the app starts.** The canonical copy ships with each release; this file is a deployed copy for reference. To change account *settings*, edit `accounts.jsonc` (which IS user-editable). To change *documentation*, file an issue.
4
+
5
+ ## What this file documents
6
+
7
+ `accounts.jsonc` lists every email account rmfmail manages. It lives in your shared GDrive folder (`My Drive/home/.rmfmail/`) so all your devices read the same list.
8
+
9
+ ## Shape
10
+
11
+ ```jsonc
12
+ {
13
+ "name": "Bob Frankston", // optional; default name applied to every account that doesn't override
14
+ "accounts": [
15
+ {
16
+ "id": "gmail", // short tag used in folder paths and the UI
17
+ "email": "you@example.com", // primary login address
18
+ "name": "Bob Frankston", // display name (optional if file-level name is set)
19
+ "imap": { "host": "imap.example.com", "port": 993, "tls": true, "auth": "password" },
20
+ "smtp": { "host": "smtp.example.com", "port": 465, "tls": true, "auth": "password" },
21
+ "enabled": true, // false skips this account at startup
22
+ "identityDomains": ["alias.com"] // extra domains for Reply-From auto-detect
23
+ }
24
+ ]
25
+ }
26
+ ```
27
+
28
+ ## Field rules
29
+
30
+ - **id** — short identifier. Used in folder paths (e.g. `bob.ma/INBOX`) and as a stable key when accounts.jsonc is edited.
31
+ - **email** — primary login. Determines provider auto-config when `imap`/`smtp` are omitted.
32
+ - **imap / smtp** — explicit server config. Omit for known providers (Gmail / Outlook / Yahoo / iCloud / Google Workspace are detected from the email domain via MX records).
33
+ - **auth** — `"password"` for traditional IMAP/SMTP, `"oauth2"` for Gmail/Google Workspace/Outlook.
34
+ - **enabled** — set `false` to keep the account record but skip sync at startup.
35
+ - **identityDomains** — addresses you receive at on alternative domains. When you Reply, rmfmail picks the matching identity address as From instead of the account's primary.
36
+
37
+ ## Notes
38
+
39
+ - JSONC: `// line comments` and trailing commas are allowed. The parser is lenient.
40
+ - Adding/removing accounts requires a daemon restart (`rmfmail -kill && rmfmail`). A status banner reminds you when the file changes.
41
+ - For OAuth accounts (Gmail, Google Workspace), the token cache lives in `~/.rmfmail/tokens/` and is per-machine.
@@ -0,0 +1,27 @@
1
+ # allowlist.jsonc
2
+
3
+ > **Owned by rmfmail. Do not edit this file — your changes will be overwritten the next time the app starts.** This is reference documentation. To change allow-list data, edit `allowlist.jsonc` itself.
4
+
5
+ ## What this file documents
6
+
7
+ `allowlist.jsonc` controls which senders' remote content (images, etc.) loads automatically and which senders/domains are flagged with a ⚠ banner. Lives in `My Drive/home/.rmfmail/`.
8
+
9
+ ## Shape
10
+
11
+ ```jsonc
12
+ {
13
+ "senders": ["alice@example.com"], // load remote content for these senders
14
+ "domains": ["example.com"], // load remote content for any sender at these domains
15
+ "recipients": ["you@example.com"], // addresses you receive at — treated as "your inbox identity"
16
+ "flaggedSenders": ["spam@bad.com"], // viewer shows ⚠ FLAGGED banner for these senders
17
+ "flaggedDomains": ["phishing.example"] // viewer shows ⚠ FLAGGED banner for any sender at these domains
18
+ }
19
+ ```
20
+
21
+ ## Notes
22
+
23
+ - All entries are case-insensitive plain email or domain strings.
24
+ - Multi-client safe: each device's save merges with the cloud copy (set-union), so adds from any device propagate.
25
+ - Use the message viewer's "allow remote content" button to add a sender/domain to `senders`/`domains` interactively.
26
+ - Use the right-click menu in the viewer to flag a sender/domain — adds to `flaggedSenders`/`flaggedDomains`.
27
+ - JSONC: `// line comments` and trailing commas are allowed.
@@ -0,0 +1,24 @@
1
+ # clients.jsonc
2
+
3
+ > **Owned by rmfmail. Do not edit this file — your changes will be overwritten the next time the app starts.** This is reference documentation.
4
+
5
+ ## What this file documents
6
+
7
+ `clients.jsonc` is the registry of devices running rmfmail under your account. It's auto-managed: each device adds itself on first launch and refreshes its `lastSeen` timestamp on every startup. You don't normally need to look at it.
8
+
9
+ ## Shape
10
+
11
+ ```jsonc
12
+ {
13
+ "devices": [
14
+ { "id": "abc-uuid", "name": "Pixel 9 Pro Fold", "lastSeen": 1746381600000, "accounts": ["gmail", "bobma"] },
15
+ { "id": "def-uuid", "name": "rmf39 desktop", "lastSeen": 1746399999000, "accounts": ["gmail", "bobma"] }
16
+ ]
17
+ }
18
+ ```
19
+
20
+ ## Notes
21
+
22
+ - `id` is a stable UUID generated on first launch and stored locally (`localStorage` on Android, config dir on desktop).
23
+ - `accounts` is the list of account ids this device is currently configured for.
24
+ - Removing a device from this file forces it to re-register on next launch — harmless but pointless.
package/docs/config.md ADDED
@@ -0,0 +1,32 @@
1
+ # config.jsonc
2
+
3
+ > **Owned by rmfmail. Do not edit this DOCUMENTATION file — changes will be overwritten.** Note: the **actual** `config.jsonc` IS user-editable; this `.md` file is just the reference for it.
4
+
5
+ ## What this file documents
6
+
7
+ `config.jsonc` is your *local, per-machine* config. It is **NOT** synced to GDrive — every machine has its own. It points at the shared GDrive folder and overrides machine-specific paths.
8
+
9
+ Lives at `~/.rmfmail/config.jsonc` on the local filesystem.
10
+
11
+ ## Shape
12
+
13
+ ```jsonc
14
+ {
15
+ "sharedDir": {
16
+ "provider": "gdrive",
17
+ "path": ".rmfmail", // folder name in My Drive (currently My Drive/home/.rmfmail)
18
+ "folderId": "1AbCdEf...XYZ" // resolved Drive folderId, cached after first lookup
19
+ },
20
+ "storePath": "C:/Users/Bob/.rmfmail/mailxstore", // local directory for .eml message bodies
21
+ "historyDays": 30 // how far back to sync; overrides the shared default
22
+ }
23
+ ```
24
+
25
+ ## Notes
26
+
27
+ - `sharedDir.provider`: `"gdrive"` is the only currently-supported value (OneDrive/Dropbox were removed).
28
+ - `sharedDir.folderId` is resolved at startup if missing; caching it avoids a Drive query on every launch.
29
+ - `storePath` is where `.eml` bodies are stored locally. Defaults to `~/.rmfmail/mailxstore` on desktop. Android uses app-private sandbox storage and ignores this field.
30
+ - `historyDays` is a per-machine override for the cloud-synced default in `preferences.jsonc`.
31
+ - Because this file is local, edits don't propagate to other devices. Use `accounts.jsonc` / `preferences.jsonc` for shared settings.
32
+ - JSONC: `// line comments` and trailing commas are allowed.