@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 +72 -0
- package/cloud.d.ts.map +1 -0
- package/cloud.js +423 -0
- package/copy-docs.js +31 -0
- package/docs/accounts.md +41 -0
- package/docs/allowlist.md +27 -0
- package/docs/clients.md +24 -0
- package/docs/config.md +32 -0
- package/docs/contact-rules.md +40 -0
- package/docs/contacts.md +48 -0
- package/docs/editor.md +92 -0
- package/docs/preferences.md +43 -0
- package/index.d.ts +109 -10
- package/index.d.ts.map +1 -0
- package/index.js +881 -40
- package/package.json +18 -2
- package/tsconfig.tsbuildinfo +0 -1
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/`);
|
package/docs/accounts.md
ADDED
|
@@ -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.
|
package/docs/clients.md
ADDED
|
@@ -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.
|