@bobfrankston/mailx-settings 0.1.3 → 0.1.6
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 +47 -0
- package/cloud.js +403 -0
- package/index.d.ts +86 -10
- package/index.js +671 -38
- package/package.json +8 -2
package/cloud.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
/** Find the settings folder on GDrive, or create a flat "mailx" folder.
|
|
20
|
+
* Supports nested paths from config (e.g. "home/.mailx") by walking each
|
|
21
|
+
* segment. Falls back to the folderId in config if set, so a stale ID that
|
|
22
|
+
* points to the wrong folder gets corrected on next lookup. */
|
|
23
|
+
export declare function gDriveFindOrCreateFolder(): Promise<string | null>;
|
|
24
|
+
export type CloudProvider = "gdrive" | "google" | "local";
|
|
25
|
+
export interface CloudFile {
|
|
26
|
+
/** Read a file. For gdrive, path is just the filename (folder ID is implicit). */
|
|
27
|
+
read(filePath: string): Promise<string | null>;
|
|
28
|
+
/** Write a file. For gdrive, path is just the filename. Throws on failure with a descriptive error. */
|
|
29
|
+
write(filePath: string, content: string): Promise<void>;
|
|
30
|
+
/** Check if a file exists. */
|
|
31
|
+
exists(filePath: string): Promise<boolean>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get a cloud file provider. For gdrive, pass the folder ID.
|
|
35
|
+
* Files are stored flat in the folder (no subdirectory navigation).
|
|
36
|
+
*/
|
|
37
|
+
export declare function getCloudProvider(provider: string, folderId?: string): CloudFile | null;
|
|
38
|
+
/** Fetch the authenticated Google user's profile (name + email) via the People API.
|
|
39
|
+
* Caller must supply an OAuth access token whose scopes include `contacts.readonly`
|
|
40
|
+
* or `userinfo.profile`. The Drive token (drive.file) does NOT have the right scope
|
|
41
|
+
* on its own — use the Gmail/IMAP OAuth token instead (mailx-imap's
|
|
42
|
+
* ImapManager.getOAuthToken). Returns null on failure. */
|
|
43
|
+
export declare function getGoogleProfile(token: string): Promise<{
|
|
44
|
+
name?: string;
|
|
45
|
+
email?: string;
|
|
46
|
+
} | null>;
|
|
47
|
+
//# sourceMappingURL=cloud.d.ts.map
|
package/cloud.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
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 || ".", ".mailx");
|
|
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
|
+
/** Paths to try when no config.path is set (fresh install). Order matters:
|
|
58
|
+
* "home/.mailx" is the user convention for shared family settings; "mailx"
|
|
59
|
+
* is the auto-created default. First one that exists on GDrive wins. */
|
|
60
|
+
const GDRIVE_PATH_SEARCH_ORDER = ["home/.mailx", "mailx"];
|
|
61
|
+
// ── Token helpers ──
|
|
62
|
+
/** Get a GDrive-capable token. Prefers the Gmail token (which now includes
|
|
63
|
+
* drive scope) — avoids a second OAuth consent prompt. Falls back to the
|
|
64
|
+
* dedicated GDrive token dir for non-Gmail setups or when Gmail token
|
|
65
|
+
* doesn't have drive scope yet. */
|
|
66
|
+
async function getGoogleDriveToken() {
|
|
67
|
+
// Strategy 1: reuse a Gmail token that already has drive scope.
|
|
68
|
+
// Scan the tokens/ dir for any account token with drive in its scope.
|
|
69
|
+
const tokensDir = path.join(SETTINGS_DIR, "tokens");
|
|
70
|
+
if (fs.existsSync(tokensDir)) {
|
|
71
|
+
try {
|
|
72
|
+
for (const entry of fs.readdirSync(tokensDir)) {
|
|
73
|
+
if (entry === "gdrive")
|
|
74
|
+
continue;
|
|
75
|
+
const tokenPath = path.join(tokensDir, entry, "oauth-token.json");
|
|
76
|
+
if (!fs.existsSync(tokenPath))
|
|
77
|
+
continue;
|
|
78
|
+
try {
|
|
79
|
+
const tok = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
|
80
|
+
if (tok.scope?.includes("drive") && tok.refresh_token) {
|
|
81
|
+
console.log(` [cloud] Reusing Gmail token from ${entry} (has drive scope)`);
|
|
82
|
+
const creds = findGoogleCredentials();
|
|
83
|
+
if (!creds)
|
|
84
|
+
continue;
|
|
85
|
+
const refreshed = await authenticateOAuth(creds, {
|
|
86
|
+
scope: tok.scope,
|
|
87
|
+
tokenDirectory: path.join(tokensDir, entry),
|
|
88
|
+
tokenFileName: "oauth-token.json",
|
|
89
|
+
credentialsKey: "installed",
|
|
90
|
+
includeOfflineAccess: true,
|
|
91
|
+
});
|
|
92
|
+
if (refreshed?.access_token) {
|
|
93
|
+
console.log(` [cloud] GDrive auth: success (via Gmail token)`);
|
|
94
|
+
return refreshed.access_token;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch { /* skip this token */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch { /* tokens dir unreadable */ }
|
|
102
|
+
}
|
|
103
|
+
// Strategy 2: dedicated GDrive token (legacy path or non-Gmail setups)
|
|
104
|
+
const creds = findGoogleCredentials();
|
|
105
|
+
if (!creds) {
|
|
106
|
+
console.error(" [cloud] No Google credentials found (checked ~/.mailx/google-credentials.json and iflow package)");
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const tokenPath = path.join(GDRIVE_TOKEN_DIR, "token.json");
|
|
110
|
+
if (fs.existsSync(tokenPath)) {
|
|
111
|
+
try {
|
|
112
|
+
const existing = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
|
|
113
|
+
const expired = existing.expires_at ? Date.now() >= existing.expires_at : true;
|
|
114
|
+
const hasRefresh = !!existing.refresh_token;
|
|
115
|
+
console.log(` [cloud] GDrive token (dedicated): ${expired ? "expired" : "valid"}, refresh_token: ${hasRefresh ? "yes" : "NO"}, scope: ${existing.scope || "?"}`);
|
|
116
|
+
if (existing.scope && existing.scope !== GDRIVE_SCOPES) {
|
|
117
|
+
console.log(` [cloud] Scope changed (${existing.scope} → ${GDRIVE_SCOPES}) — re-authenticating...`);
|
|
118
|
+
fs.unlinkSync(tokenPath);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch { /* ignore */ }
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
console.log(` [cloud] GDrive token: not found at ${tokenPath} (will auth)`);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const token = await authenticateOAuth(creds, {
|
|
128
|
+
scope: GDRIVE_SCOPES,
|
|
129
|
+
tokenDirectory: GDRIVE_TOKEN_DIR,
|
|
130
|
+
tokenFileName: "token.json",
|
|
131
|
+
credentialsKey: "installed",
|
|
132
|
+
includeOfflineAccess: true,
|
|
133
|
+
});
|
|
134
|
+
if (token?.access_token) {
|
|
135
|
+
console.log(` [cloud] GDrive auth: success (dedicated token)`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
console.error(` [cloud] GDrive auth: returned null — OAuth likely timed out`);
|
|
139
|
+
}
|
|
140
|
+
return token?.access_token || null;
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
console.error(` [cloud] GDrive auth FAILED: ${e.message}`);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ── Google Drive API (folder-ID based) ──
|
|
148
|
+
/** Walk a nested folder path on GDrive ("home/.mailx" → find "home", find
|
|
149
|
+
* ".mailx" inside it). Returns the leaf folder ID, or null if any segment is
|
|
150
|
+
* missing. When `create` is true, creates the LAST segment if missing (won't
|
|
151
|
+
* create intermediate segments — that's a user error). */
|
|
152
|
+
async function walkGDrivePath(token, segments, create) {
|
|
153
|
+
const fullPath = segments.join("/");
|
|
154
|
+
let parentId;
|
|
155
|
+
for (let i = 0; i < segments.length; i++) {
|
|
156
|
+
const seg = segments[i];
|
|
157
|
+
const found = await gDriveFindFolder(token, seg, parentId);
|
|
158
|
+
if (found) {
|
|
159
|
+
console.log(` [cloud] walk '${fullPath}': '${seg}' → ${found}${parentId ? ` (in ${parentId})` : ""}`);
|
|
160
|
+
parentId = found;
|
|
161
|
+
}
|
|
162
|
+
else if (create && i === segments.length - 1) {
|
|
163
|
+
const body = { name: seg, mimeType: "application/vnd.google-apps.folder" };
|
|
164
|
+
if (parentId)
|
|
165
|
+
body.parents = [parentId];
|
|
166
|
+
const res = await fetch("https://www.googleapis.com/drive/v3/files", {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify(body),
|
|
170
|
+
});
|
|
171
|
+
if (!res.ok)
|
|
172
|
+
return null;
|
|
173
|
+
const created = await res.json();
|
|
174
|
+
parentId = created.id;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return parentId || null;
|
|
181
|
+
}
|
|
182
|
+
/** Resolve a folder ID back to its name (for diagnostic logging). */
|
|
183
|
+
async function gDriveGetFolderName(token, folderId) {
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetch(`https://www.googleapis.com/drive/v3/files/${folderId}?fields=name,parents`, {
|
|
186
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok)
|
|
189
|
+
return null;
|
|
190
|
+
const data = await res.json();
|
|
191
|
+
return data.name || null;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Find a single folder by name, optionally inside a parent. */
|
|
198
|
+
async function gDriveFindFolder(token, name, parentId) {
|
|
199
|
+
let query = `name='${name}' and mimeType='application/vnd.google-apps.folder' and trashed=false`;
|
|
200
|
+
if (parentId)
|
|
201
|
+
query += ` and '${parentId}' in parents`;
|
|
202
|
+
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id,name)`, {
|
|
203
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
204
|
+
});
|
|
205
|
+
if (!res.ok) {
|
|
206
|
+
console.error(` [cloud] gdrive folder search '${name}': ${res.status} ${res.statusText}`);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const data = await res.json();
|
|
210
|
+
return data.files?.[0]?.id || null;
|
|
211
|
+
}
|
|
212
|
+
/** Find the settings folder on GDrive, or create a flat "mailx" folder.
|
|
213
|
+
* Supports nested paths from config (e.g. "home/.mailx") by walking each
|
|
214
|
+
* segment. Falls back to the folderId in config if set, so a stale ID that
|
|
215
|
+
* points to the wrong folder gets corrected on next lookup. */
|
|
216
|
+
export async function gDriveFindOrCreateFolder() {
|
|
217
|
+
const token = await getGoogleDriveToken();
|
|
218
|
+
if (!token)
|
|
219
|
+
return null;
|
|
220
|
+
// Read path from config — supports nested like "home/.mailx"
|
|
221
|
+
let cfgEntry = null;
|
|
222
|
+
try {
|
|
223
|
+
const cfgPath = path.join(SETTINGS_DIR, "config.jsonc");
|
|
224
|
+
if (fs.existsSync(cfgPath)) {
|
|
225
|
+
const raw = fs.readFileSync(cfgPath, "utf-8").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
226
|
+
cfgEntry = JSON.parse(raw).sharedDir;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch { /* ignore */ }
|
|
230
|
+
// Build search list: configured path first, then common conventions
|
|
231
|
+
const configuredPath = (typeof cfgEntry === "object" && cfgEntry?.path) ? cfgEntry.path : null;
|
|
232
|
+
const pathsToTry = configuredPath
|
|
233
|
+
? [configuredPath]
|
|
234
|
+
: GDRIVE_PATH_SEARCH_ORDER;
|
|
235
|
+
try {
|
|
236
|
+
for (const tryPath of pathsToTry) {
|
|
237
|
+
console.log(` [cloud] Trying GDrive path: '${tryPath}'`);
|
|
238
|
+
const segments = tryPath.split(/[/\\]/).filter(Boolean);
|
|
239
|
+
const folderId = await walkGDrivePath(token, segments, false);
|
|
240
|
+
if (folderId) {
|
|
241
|
+
// Resolve folder ID back to name for verification
|
|
242
|
+
const name = await gDriveGetFolderName(token, folderId);
|
|
243
|
+
console.log(` [cloud] Found existing '${tryPath}' → folder '${name || "?"}' (${folderId})`);
|
|
244
|
+
return folderId;
|
|
245
|
+
}
|
|
246
|
+
console.log(` [cloud] Path '${tryPath}' not found on GDrive`);
|
|
247
|
+
}
|
|
248
|
+
// None found — create the last (simplest) path
|
|
249
|
+
const createPath = configuredPath || GDRIVE_PATH_SEARCH_ORDER[GDRIVE_PATH_SEARCH_ORDER.length - 1];
|
|
250
|
+
const segments = createPath.split(/[/\\]/).filter(Boolean);
|
|
251
|
+
const created = await walkGDrivePath(token, segments, true);
|
|
252
|
+
if (created)
|
|
253
|
+
console.log(` [cloud] Created '${createPath}' folder: ${created}`);
|
|
254
|
+
return created;
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
console.error(` [cloud] gdrive folder setup: ${e.message}`);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** Read a file by name from a folder (by ID) */
|
|
262
|
+
async function gDriveReadFromFolder(folderId, fileName) {
|
|
263
|
+
const token = await getGoogleDriveToken();
|
|
264
|
+
if (!token) {
|
|
265
|
+
console.error(` [cloud] gdrive read ${fileName}: no token`);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
// Find file in folder
|
|
270
|
+
const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
|
|
271
|
+
const res = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
272
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
273
|
+
});
|
|
274
|
+
if (!res.ok) {
|
|
275
|
+
console.error(` [cloud] gdrive lookup '${fileName}': ${res.status}`);
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const data = await res.json();
|
|
279
|
+
const fileId = data.files?.[0]?.id;
|
|
280
|
+
if (!fileId)
|
|
281
|
+
return null;
|
|
282
|
+
// Download content
|
|
283
|
+
const contentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
|
|
284
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
285
|
+
});
|
|
286
|
+
if (!contentRes.ok) {
|
|
287
|
+
console.error(` [cloud] gdrive download ${fileName}: ${contentRes.status}`);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
return await contentRes.text();
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
console.error(` [cloud] gdrive read ${fileName}: ${e.message}`);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Write a file by name to a folder (by ID) — creates or updates.
|
|
298
|
+
* Throws on failure with a descriptive error so callers can surface it to the UI. */
|
|
299
|
+
async function gDriveWriteToFolder(folderId, fileName, content) {
|
|
300
|
+
const token = await getGoogleDriveToken();
|
|
301
|
+
if (!token)
|
|
302
|
+
throw new Error("Google Drive: no auth token (OAuth not granted or expired)");
|
|
303
|
+
// Check if file exists in folder
|
|
304
|
+
const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
|
|
305
|
+
const findRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
306
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
307
|
+
});
|
|
308
|
+
if (!findRes.ok) {
|
|
309
|
+
const body = await findRes.text().catch(() => "");
|
|
310
|
+
throw new Error(`Google Drive: lookup '${fileName}' failed (${findRes.status} ${findRes.statusText}) ${body.slice(0, 200)}`);
|
|
311
|
+
}
|
|
312
|
+
const findData = await findRes.json();
|
|
313
|
+
const existingId = findData.files?.[0]?.id;
|
|
314
|
+
if (existingId) {
|
|
315
|
+
// Update existing file
|
|
316
|
+
const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
|
|
317
|
+
method: "PATCH",
|
|
318
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
319
|
+
body: content,
|
|
320
|
+
});
|
|
321
|
+
if (!res.ok) {
|
|
322
|
+
const body = await res.text().catch(() => "");
|
|
323
|
+
throw new Error(`Google Drive: update '${fileName}' failed (${res.status} ${res.statusText}) ${body.slice(0, 200)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// Create new file in folder
|
|
328
|
+
const boundary = "mailx_boundary_" + Date.now();
|
|
329
|
+
const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
|
|
330
|
+
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}--`;
|
|
331
|
+
const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}` },
|
|
334
|
+
body,
|
|
335
|
+
});
|
|
336
|
+
if (!res.ok) {
|
|
337
|
+
const errBody = await res.text().catch(() => "");
|
|
338
|
+
throw new Error(`Google Drive: create '${fileName}' failed (${res.status} ${res.statusText}) ${errBody.slice(0, 200)}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Get a cloud file provider. For gdrive, pass the folder ID.
|
|
344
|
+
* Files are stored flat in the folder (no subdirectory navigation).
|
|
345
|
+
*/
|
|
346
|
+
export function getCloudProvider(provider, folderId) {
|
|
347
|
+
switch (provider) {
|
|
348
|
+
case "google":
|
|
349
|
+
case "gdrive":
|
|
350
|
+
if (!folderId) {
|
|
351
|
+
console.error(" [cloud] gdrive requires a folder ID — run initCloudConfig first");
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
read: (fileName) => gDriveReadFromFolder(folderId, fileName),
|
|
356
|
+
write: (fileName, content) => gDriveWriteToFolder(folderId, fileName, content),
|
|
357
|
+
exists: async (fileName) => (await gDriveReadFromFolder(folderId, fileName)) !== null,
|
|
358
|
+
};
|
|
359
|
+
case "local":
|
|
360
|
+
return {
|
|
361
|
+
read: async (p) => { try {
|
|
362
|
+
return fs.readFileSync(p, "utf-8");
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return null;
|
|
366
|
+
} },
|
|
367
|
+
write: async (p, c) => { fs.writeFileSync(p, c); },
|
|
368
|
+
exists: async (p) => fs.existsSync(p),
|
|
369
|
+
};
|
|
370
|
+
default:
|
|
371
|
+
console.error(` [cloud] Provider "${provider}" not supported — only gdrive is available`);
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/** Fetch the authenticated Google user's profile (name + email) via the People API.
|
|
376
|
+
* Caller must supply an OAuth access token whose scopes include `contacts.readonly`
|
|
377
|
+
* or `userinfo.profile`. The Drive token (drive.file) does NOT have the right scope
|
|
378
|
+
* on its own — use the Gmail/IMAP OAuth token instead (mailx-imap's
|
|
379
|
+
* ImapManager.getOAuthToken). Returns null on failure. */
|
|
380
|
+
export async function getGoogleProfile(token) {
|
|
381
|
+
if (!token)
|
|
382
|
+
return null;
|
|
383
|
+
try {
|
|
384
|
+
const res = await fetch("https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses", {
|
|
385
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
386
|
+
});
|
|
387
|
+
if (!res.ok) {
|
|
388
|
+
console.error(` [cloud] People API: ${res.status} ${res.statusText}`);
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const data = await res.json();
|
|
392
|
+
const name = data.names?.find((n) => n.displayName)?.displayName
|
|
393
|
+
|| data.names?.[0]?.displayName;
|
|
394
|
+
const email = data.emailAddresses?.find((e) => e.metadata?.primary)?.value
|
|
395
|
+
|| data.emailAddresses?.[0]?.value;
|
|
396
|
+
return { name, email };
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
console.error(` [cloud] getGoogleProfile: ${e.message}`);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
//# sourceMappingURL=cloud.js.map
|
package/index.d.ts
CHANGED
|
@@ -8,19 +8,46 @@
|
|
|
8
8
|
* allowlist.jsonc — remote content allow-list
|
|
9
9
|
*
|
|
10
10
|
* Local overrides (~/.mailx/):
|
|
11
|
-
* config.
|
|
11
|
+
* config.jsonc — pointer to shared dir + local-only settings (storePath, historyDays)
|
|
12
12
|
* accounts.jsonc — cached copy, fallback when shared unavailable
|
|
13
13
|
* preferences.jsonc — local overrides merged on top of shared
|
|
14
14
|
* allowlist.jsonc — cached copy
|
|
15
15
|
*
|
|
16
16
|
* The old settings.jsonc is still supported for backward compatibility.
|
|
17
17
|
*/
|
|
18
|
-
import type { MailxSettings, AccountConfig } from "@bobfrankston/mailx-types";
|
|
18
|
+
import type { MailxSettings, AccountConfig, AutocompleteSettings } from "@bobfrankston/mailx-types";
|
|
19
19
|
declare const LOCAL_DIR: string;
|
|
20
|
+
/** Subscribers notified whenever lastCloudError changes (push to UI immediately). */
|
|
21
|
+
type CloudErrorListener = (error: string | null, context?: {
|
|
22
|
+
op: "read" | "write";
|
|
23
|
+
filename: string;
|
|
24
|
+
}) => void;
|
|
25
|
+
export declare function onCloudError(cb: CloudErrorListener): () => void;
|
|
26
|
+
export declare function getLastCloudError(): string | null;
|
|
20
27
|
declare function getSharedDir(): string;
|
|
28
|
+
/** Read a file via cloud API (when filesystem mount not available) */
|
|
29
|
+
export declare function cloudRead(filename: string): Promise<string | null>;
|
|
30
|
+
/** Write a file via cloud API. Throws on failure with a descriptive error,
|
|
31
|
+
* and updates lastCloudError so UI banners pick it up via getStorageInfo()
|
|
32
|
+
* and the onCloudError listener. */
|
|
33
|
+
export declare function cloudWrite(filename: string, content: string): Promise<void>;
|
|
34
|
+
/** Whether cloud API fallback is active */
|
|
35
|
+
export declare function isCloudMode(): boolean;
|
|
36
|
+
/** Get storage provider info for display */
|
|
37
|
+
export declare function getStorageInfo(): {
|
|
38
|
+
provider: string;
|
|
39
|
+
mode: "mount" | "api" | "local";
|
|
40
|
+
cloudPath?: string;
|
|
41
|
+
cloudError?: string;
|
|
42
|
+
};
|
|
43
|
+
/** Fill in provider defaults for an account based on email domain.
|
|
44
|
+
* Exported so mailx-service's leanAccountsJsonc helper can reuse the same
|
|
45
|
+
* canonicalization rules that loadAccounts uses. */
|
|
46
|
+
export declare function normalizeAccount(acct: any, globalName?: string): AccountConfig;
|
|
21
47
|
declare const DEFAULT_PREFERENCES: {
|
|
22
48
|
ui: {
|
|
23
|
-
theme: "dark" | "light";
|
|
49
|
+
theme: "system" | "dark" | "light";
|
|
50
|
+
editor: "quill" | "tiptap";
|
|
24
51
|
folderWidth: number;
|
|
25
52
|
listViewerSplit: number;
|
|
26
53
|
fontSize: number;
|
|
@@ -28,29 +55,69 @@ declare const DEFAULT_PREFERENCES: {
|
|
|
28
55
|
sync: {
|
|
29
56
|
intervalMinutes: number;
|
|
30
57
|
historyDays: number;
|
|
58
|
+
prefetch: boolean;
|
|
59
|
+
};
|
|
60
|
+
autocomplete: {
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
provider: "ollama";
|
|
63
|
+
ollamaUrl: string;
|
|
64
|
+
ollamaModel: string;
|
|
65
|
+
cloudApiKey: string;
|
|
66
|
+
cloudModel: string;
|
|
67
|
+
debounceMs: number;
|
|
68
|
+
maxTokens: number;
|
|
31
69
|
};
|
|
32
70
|
};
|
|
71
|
+
declare const DEFAULT_AUTOCOMPLETE: AutocompleteSettings;
|
|
33
72
|
declare const DEFAULT_ALLOWLIST: {
|
|
34
73
|
senders: string[];
|
|
35
74
|
domains: string[];
|
|
36
75
|
recipients: string[];
|
|
76
|
+
flaggedSenders: string[];
|
|
77
|
+
flaggedDomains: string[];
|
|
37
78
|
};
|
|
38
79
|
/** Load account configs */
|
|
39
80
|
export declare function loadAccounts(): AccountConfig[];
|
|
81
|
+
/** Load accounts with cloud API fallback (async — use when cloud settings may not be mounted) */
|
|
82
|
+
export declare function loadAccountsAsync(): Promise<AccountConfig[]>;
|
|
83
|
+
/** Strip default-valued fields from a normalized AccountConfig so the
|
|
84
|
+
* serialized JSONC stays compact and human-editable. The previous version
|
|
85
|
+
* was round-tripping every defaulted field (port: 993, tls: true, auth:
|
|
86
|
+
* "password", enabled: true, etc.), which bloated a 10-line accounts.jsonc
|
|
87
|
+
* to 60+ lines and embedded unnecessary "knowledge" about defaults.
|
|
88
|
+
*
|
|
89
|
+
* Rules:
|
|
90
|
+
* - Drop fields that match the provider default (host/port/tls/auth derived
|
|
91
|
+
* from email domain via PROVIDERS).
|
|
92
|
+
* - Drop `enabled: true` (default).
|
|
93
|
+
* - Drop `name` if equal to the file-level `globalName`.
|
|
94
|
+
* - Drop `imap.user` / `smtp.user` if they equal the email.
|
|
95
|
+
* - Drop `sig.html: false` (default; only keep when user enabled HTML sigs).
|
|
96
|
+
* - Keep field order: id → label → email → primary* → imap → smtp → defaultSend
|
|
97
|
+
* → relayDomains → deliveredToPrefix → identityDomains → syncContacts → sig.
|
|
98
|
+
* Curated for readability, not alphabetic. */
|
|
99
|
+
export declare function denormalizeAccount(acct: AccountConfig, globalName?: string): any;
|
|
40
100
|
/** Save account configs */
|
|
41
|
-
|
|
42
|
-
|
|
101
|
+
/** Save accounts — merges with cloud copy by email (multi-client safe).
|
|
102
|
+
* Writes the lean form via denormalizeAccount so accounts.jsonc stays
|
|
103
|
+
* compact and human-editable. */
|
|
104
|
+
export declare function saveAccounts(accounts: AccountConfig[]): Promise<void>;
|
|
105
|
+
/** Load preferences (shared + local overrides, with legacy fallback) */
|
|
43
106
|
export declare function loadPreferences(): typeof DEFAULT_PREFERENCES;
|
|
44
107
|
/** Save preferences */
|
|
45
|
-
export declare function savePreferences(prefs:
|
|
108
|
+
export declare function savePreferences(prefs: any): void;
|
|
109
|
+
/** Load autocomplete settings */
|
|
110
|
+
export declare function loadAutocomplete(): AutocompleteSettings;
|
|
111
|
+
/** Save autocomplete settings */
|
|
112
|
+
export declare function saveAutocomplete(settings: AutocompleteSettings): void;
|
|
46
113
|
/** Load remote content allow-list */
|
|
47
114
|
export declare function loadAllowlist(): typeof DEFAULT_ALLOWLIST;
|
|
48
|
-
/** Save allow-list */
|
|
49
|
-
export declare function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): void
|
|
115
|
+
/** Save allow-list — merges with existing cloud copy (multi-client safe) */
|
|
116
|
+
export declare function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): Promise<void>;
|
|
50
117
|
/** Load settings — unified view combining all files (backward compatible) */
|
|
51
118
|
export declare function loadSettings(): MailxSettings;
|
|
52
119
|
/** Save settings — writes to split files */
|
|
53
|
-
export declare function saveSettings(settings: MailxSettings): void
|
|
120
|
+
export declare function saveSettings(settings: MailxSettings): Promise<void>;
|
|
54
121
|
/** Get the local store base path */
|
|
55
122
|
export declare function getStorePath(): string;
|
|
56
123
|
/** Get the local data directory (DB, store, etc.) */
|
|
@@ -59,6 +126,15 @@ export declare function getConfigDir(): string;
|
|
|
59
126
|
export { getSharedDir };
|
|
60
127
|
/** Initialize local config if it doesn't exist */
|
|
61
128
|
export declare function initLocalConfig(sharedDir?: string, storePath?: string): void;
|
|
129
|
+
/** Initialize config with Google Drive cloud storage.
|
|
130
|
+
* Finds or creates the app-owned "mailx" folder via Drive API and stores its ID.
|
|
131
|
+
* No mount scanning — API only. Existing settings at other paths (e.g., home/.mailx
|
|
132
|
+
* from Desktop sync) must be migrated manually or via config.jsonc importPath. */
|
|
133
|
+
export declare function initCloudConfig(provider?: "gdrive"): Promise<void>;
|
|
62
134
|
declare const DEFAULT_SETTINGS: MailxSettings;
|
|
63
|
-
|
|
135
|
+
/** Get historyDays for an account: per-account override > system override > shared default */
|
|
136
|
+
export declare function getHistoryDays(accountId?: string): number;
|
|
137
|
+
/** Get prefetch setting: download bodies during sync (default true) */
|
|
138
|
+
export declare function getPrefetch(): boolean;
|
|
139
|
+
export { DEFAULT_SETTINGS, DEFAULT_ALLOWLIST, DEFAULT_PREFERENCES, DEFAULT_AUTOCOMPLETE, LOCAL_DIR };
|
|
64
140
|
//# sourceMappingURL=index.d.ts.map
|