@bobfrankston/mailx 1.0.265 → 1.0.283
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/bin/mailx.js +146 -46
- package/client/.msger-window.json +1 -1
- package/client/android.html +1 -0
- package/client/app.js +126 -15
- package/client/components/folder-tree.js +9 -5
- package/client/components/message-list.js +43 -3
- package/client/components/message-viewer.js +47 -14
- package/client/index.html +1 -0
- package/client/lib/api-client.js +5 -2
- package/client/lib/mailxapi.js +5 -2
- package/client/styles/components.css +65 -0
- package/package.json +15 -15
- package/packages/mailx-api/index.js +10 -0
- package/packages/mailx-core/index.d.ts +2 -0
- package/packages/mailx-core/index.js +1 -0
- package/packages/mailx-imap/index.d.ts +22 -1
- package/packages/mailx-imap/index.js +251 -44
- package/packages/mailx-service/index.d.ts +7 -1
- package/packages/mailx-service/index.js +72 -10
- package/packages/mailx-service/jsonrpc.js +3 -1
- package/packages/mailx-settings/cloud.d.ts +11 -2
- package/packages/mailx-settings/cloud.js +66 -39
- package/packages/mailx-settings/index.d.ts +11 -2
- package/packages/mailx-settings/index.js +64 -15
- package/packages/mailx-store/db.d.ts +1 -0
- package/packages/mailx-store/db.js +22 -5
- package/packages/mailx-store-web/web-service.js +13 -2
- package/packages/mailx-types/index.d.ts +6 -0
- package/tsconfig.base.json +1 -0
|
@@ -161,44 +161,49 @@ async function gDriveReadFromFolder(folderId, fileName) {
|
|
|
161
161
|
return null;
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
|
-
/** Write a file by name to a folder (by ID) — creates or updates
|
|
164
|
+
/** Write a file by name to a folder (by ID) — creates or updates.
|
|
165
|
+
* Throws on failure with a descriptive error so callers can surface it to the UI. */
|
|
165
166
|
async function gDriveWriteToFolder(folderId, fileName, content) {
|
|
166
167
|
const token = await getGoogleDriveToken();
|
|
167
168
|
if (!token)
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
169
|
+
throw new Error("Google Drive: no auth token (OAuth not granted or expired)");
|
|
170
|
+
// Check if file exists in folder
|
|
171
|
+
const query = `name='${fileName}' and '${folderId}' in parents and trashed=false`;
|
|
172
|
+
const findRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(query)}&fields=files(id)`, {
|
|
173
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
174
|
+
});
|
|
175
|
+
if (!findRes.ok) {
|
|
176
|
+
const body = await findRes.text().catch(() => "");
|
|
177
|
+
throw new Error(`Google Drive: lookup '${fileName}' failed (${findRes.status} ${findRes.statusText}) ${body.slice(0, 200)}`);
|
|
178
|
+
}
|
|
179
|
+
const findData = await findRes.json();
|
|
180
|
+
const existingId = findData.files?.[0]?.id;
|
|
181
|
+
if (existingId) {
|
|
182
|
+
// Update existing file
|
|
183
|
+
const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
|
|
184
|
+
method: "PATCH",
|
|
185
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
186
|
+
body: content,
|
|
174
187
|
});
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Update existing file
|
|
179
|
-
const res = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${existingId}?uploadType=media`, {
|
|
180
|
-
method: "PATCH",
|
|
181
|
-
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
182
|
-
body: content,
|
|
183
|
-
});
|
|
184
|
-
return res.ok;
|
|
185
|
-
}
|
|
186
|
-
else {
|
|
187
|
-
// Create new file in folder
|
|
188
|
-
const boundary = "mailx_boundary_" + Date.now();
|
|
189
|
-
const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
|
|
190
|
-
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}--`;
|
|
191
|
-
const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
|
|
192
|
-
method: "POST",
|
|
193
|
-
headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}` },
|
|
194
|
-
body,
|
|
195
|
-
});
|
|
196
|
-
return res.ok;
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const body = await res.text().catch(() => "");
|
|
190
|
+
throw new Error(`Google Drive: update '${fileName}' failed (${res.status} ${res.statusText}) ${body.slice(0, 200)}`);
|
|
197
191
|
}
|
|
198
192
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
else {
|
|
194
|
+
// Create new file in folder
|
|
195
|
+
const boundary = "mailx_boundary_" + Date.now();
|
|
196
|
+
const metadata = JSON.stringify({ name: fileName, parents: [folderId] });
|
|
197
|
+
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}--`;
|
|
198
|
+
const res = await fetch("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}` },
|
|
201
|
+
body,
|
|
202
|
+
});
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
const errBody = await res.text().catch(() => "");
|
|
205
|
+
throw new Error(`Google Drive: create '${fileName}' failed (${res.status} ${res.statusText}) ${errBody.slice(0, 200)}`);
|
|
206
|
+
}
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
/**
|
|
@@ -226,13 +231,7 @@ export function getCloudProvider(provider, folderId) {
|
|
|
226
231
|
catch {
|
|
227
232
|
return null;
|
|
228
233
|
} },
|
|
229
|
-
write: async (p, c) => {
|
|
230
|
-
fs.writeFileSync(p, c);
|
|
231
|
-
return true;
|
|
232
|
-
}
|
|
233
|
-
catch {
|
|
234
|
-
return false;
|
|
235
|
-
} },
|
|
234
|
+
write: async (p, c) => { fs.writeFileSync(p, c); },
|
|
236
235
|
exists: async (p) => fs.existsSync(p),
|
|
237
236
|
};
|
|
238
237
|
default:
|
|
@@ -240,4 +239,32 @@ export function getCloudProvider(provider, folderId) {
|
|
|
240
239
|
return null;
|
|
241
240
|
}
|
|
242
241
|
}
|
|
242
|
+
/** Fetch the authenticated Google user's profile (name + email) via the People API.
|
|
243
|
+
* Caller must supply an OAuth access token whose scopes include `contacts.readonly`
|
|
244
|
+
* or `userinfo.profile`. The Drive token (drive.file) does NOT have the right scope
|
|
245
|
+
* on its own — use the Gmail/IMAP OAuth token instead (mailx-imap's
|
|
246
|
+
* ImapManager.getOAuthToken). Returns null on failure. */
|
|
247
|
+
export async function getGoogleProfile(token) {
|
|
248
|
+
if (!token)
|
|
249
|
+
return null;
|
|
250
|
+
try {
|
|
251
|
+
const res = await fetch("https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses", {
|
|
252
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
console.error(` [cloud] People API: ${res.status} ${res.statusText}`);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const data = await res.json();
|
|
259
|
+
const name = data.names?.find((n) => n.displayName)?.displayName
|
|
260
|
+
|| data.names?.[0]?.displayName;
|
|
261
|
+
const email = data.emailAddresses?.find((e) => e.metadata?.primary)?.value
|
|
262
|
+
|| data.emailAddresses?.[0]?.value;
|
|
263
|
+
return { name, email };
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
console.error(` [cloud] getGoogleProfile: ${e.message}`);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
243
270
|
//# sourceMappingURL=cloud.js.map
|
|
@@ -17,11 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
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;
|
|
21
28
|
/** Read a file via cloud API (when filesystem mount not available) */
|
|
22
29
|
export declare function cloudRead(filename: string): Promise<string | null>;
|
|
23
|
-
/** Write a file via cloud API
|
|
24
|
-
|
|
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>;
|
|
25
34
|
/** Whether cloud API fallback is active */
|
|
26
35
|
export declare function isCloudMode(): boolean;
|
|
27
36
|
/** Get storage provider info for display */
|
|
@@ -62,6 +62,25 @@ function resolveProvider(cfg) {
|
|
|
62
62
|
let pendingCloudConfig = null;
|
|
63
63
|
/** Last cloud API error (for UI display) */
|
|
64
64
|
let lastCloudError = null;
|
|
65
|
+
const cloudErrorListeners = [];
|
|
66
|
+
export function onCloudError(cb) {
|
|
67
|
+
cloudErrorListeners.push(cb);
|
|
68
|
+
return () => {
|
|
69
|
+
const i = cloudErrorListeners.indexOf(cb);
|
|
70
|
+
if (i >= 0)
|
|
71
|
+
cloudErrorListeners.splice(i, 1);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function setCloudError(error, context) {
|
|
75
|
+
lastCloudError = error;
|
|
76
|
+
for (const cb of cloudErrorListeners) {
|
|
77
|
+
try {
|
|
78
|
+
cb(error, context);
|
|
79
|
+
}
|
|
80
|
+
catch { /* listener faults shouldn't break writes */ }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function getLastCloudError() { return lastCloudError; }
|
|
65
84
|
function resolveSharedEntry(entry) {
|
|
66
85
|
if (typeof entry === "string") {
|
|
67
86
|
const p = resolvePath(entry);
|
|
@@ -97,16 +116,20 @@ export async function cloudRead(filename) {
|
|
|
97
116
|
pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
|
|
98
117
|
if (pendingCloudConfig.folderId)
|
|
99
118
|
saveFolderIdToConfig(pendingCloudConfig.folderId);
|
|
119
|
+
if (!pendingCloudConfig.folderId) {
|
|
120
|
+
setCloudError(`Cannot read ${filename}: Google Drive folder unavailable (OAuth not granted?)`, { op: "read", filename });
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
100
123
|
}
|
|
101
124
|
const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
|
|
102
125
|
if (!provider) {
|
|
103
|
-
|
|
126
|
+
setCloudError(`No cloud provider for ${pendingCloudConfig.provider}`, { op: "read", filename });
|
|
104
127
|
return null;
|
|
105
128
|
}
|
|
106
129
|
console.log(` [cloud] Reading ${filename} via ${pendingCloudConfig.provider} API...`);
|
|
107
130
|
const content = await provider.read(filename);
|
|
108
131
|
if (content) {
|
|
109
|
-
|
|
132
|
+
setCloudError(null);
|
|
110
133
|
// Cache locally
|
|
111
134
|
try {
|
|
112
135
|
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
@@ -117,20 +140,40 @@ export async function cloudRead(filename) {
|
|
|
117
140
|
// Don't set error for missing files — they may not exist yet (e.g., clients.jsonc on first run)
|
|
118
141
|
return content;
|
|
119
142
|
}
|
|
120
|
-
/** Write a file via cloud API
|
|
143
|
+
/** Write a file via cloud API. Throws on failure with a descriptive error,
|
|
144
|
+
* and updates lastCloudError so UI banners pick it up via getStorageInfo()
|
|
145
|
+
* and the onCloudError listener. */
|
|
121
146
|
export async function cloudWrite(filename, content) {
|
|
122
|
-
if (!pendingCloudConfig)
|
|
123
|
-
|
|
147
|
+
if (!pendingCloudConfig) {
|
|
148
|
+
const err = `No cloud configured — cannot save ${filename} to cloud`;
|
|
149
|
+
setCloudError(err, { op: "write", filename });
|
|
150
|
+
throw new Error(err);
|
|
151
|
+
}
|
|
124
152
|
// Ensure we have a folder ID
|
|
125
153
|
if (!pendingCloudConfig.folderId) {
|
|
126
154
|
pendingCloudConfig.folderId = await gDriveFindOrCreateFolder() || undefined;
|
|
127
155
|
if (pendingCloudConfig.folderId)
|
|
128
156
|
saveFolderIdToConfig(pendingCloudConfig.folderId);
|
|
157
|
+
if (!pendingCloudConfig.folderId) {
|
|
158
|
+
const err = `Cannot save ${filename}: Google Drive folder unavailable (OAuth not granted or token expired)`;
|
|
159
|
+
setCloudError(err, { op: "write", filename });
|
|
160
|
+
throw new Error(err);
|
|
161
|
+
}
|
|
129
162
|
}
|
|
130
163
|
const provider = getCloudProvider(pendingCloudConfig.provider, pendingCloudConfig.folderId);
|
|
131
|
-
if (!provider)
|
|
132
|
-
|
|
133
|
-
|
|
164
|
+
if (!provider) {
|
|
165
|
+
const err = `No cloud provider for ${pendingCloudConfig.provider}`;
|
|
166
|
+
setCloudError(err, { op: "write", filename });
|
|
167
|
+
throw new Error(err);
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await provider.write(filename, content);
|
|
171
|
+
setCloudError(null);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
setCloudError(e.message, { op: "write", filename });
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
134
177
|
}
|
|
135
178
|
/** Persist the discovered folder ID back to config.jsonc so we don't search again */
|
|
136
179
|
function saveFolderIdToConfig(folderId) {
|
|
@@ -231,7 +274,12 @@ function loadFile(filename, defaults) {
|
|
|
231
274
|
}
|
|
232
275
|
return { ...defaults, ...data };
|
|
233
276
|
}
|
|
234
|
-
/** Save a config file to the shared directory (and cloud API if active)
|
|
277
|
+
/** Save a config file to the shared directory (and cloud API if active).
|
|
278
|
+
* Always writes the local copy first so the data is never lost.
|
|
279
|
+
* If a cloud provider is configured, also writes there — failures set
|
|
280
|
+
* lastCloudError and notify onCloudError listeners so the UI can show
|
|
281
|
+
* a banner. The cloud write is fire-and-forget at this layer; callers
|
|
282
|
+
* who need the result should use the typed save* helpers below. */
|
|
235
283
|
function saveFile(filename, data) {
|
|
236
284
|
const sharedDir = getSharedDir();
|
|
237
285
|
atomicWrite(path.join(sharedDir, filename), data);
|
|
@@ -244,12 +292,13 @@ function saveFile(filename, data) {
|
|
|
244
292
|
}
|
|
245
293
|
// Write to cloud API if filesystem mount unavailable (creates app-owned file for drive.file scope)
|
|
246
294
|
if (pendingCloudConfig) {
|
|
247
|
-
cloudWrite(filename, JSON.stringify(data, null, 2))
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
295
|
+
cloudWrite(filename, JSON.stringify(data, null, 2))
|
|
296
|
+
.then(() => console.log(` [cloud] Saved ${filename} via ${pendingCloudConfig.provider} API`))
|
|
297
|
+
.catch(e => console.error(` [cloud] Failed to save ${filename}: ${e.message}`));
|
|
298
|
+
// Note: we don't await — saveFile is sync. cloudWrite() already calls
|
|
299
|
+
// setCloudError(), which fires onCloudError listeners synchronously
|
|
300
|
+
// when the promise rejects, so the UI gets the failure even though
|
|
301
|
+
// we don't propagate it back through the call chain here.
|
|
253
302
|
}
|
|
254
303
|
}
|
|
255
304
|
const PROVIDERS = {
|
|
@@ -66,6 +66,7 @@ export declare class MailxDB {
|
|
|
66
66
|
hasAttachments: boolean;
|
|
67
67
|
preview: string;
|
|
68
68
|
bodyPath: string;
|
|
69
|
+
providerId?: string;
|
|
69
70
|
}): number;
|
|
70
71
|
getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
|
|
71
72
|
/** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
|
|
@@ -146,6 +146,11 @@ export class MailxDB {
|
|
|
146
146
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
|
|
147
147
|
}
|
|
148
148
|
catch { /* already exists */ }
|
|
149
|
+
// provider_id: native server-side id for API-backed providers (Gmail
|
|
150
|
+
// hex id, Outlook Graph id, etc.). Lets fetchOne look up the message
|
|
151
|
+
// directly instead of paginating listMessageIds for every body fetch
|
|
152
|
+
// — a UID-only path costs 2-3 rate-limited API calls per message.
|
|
153
|
+
this.addColumnIfMissing("messages", "provider_id", "TEXT");
|
|
149
154
|
}
|
|
150
155
|
// ── Sent-log (dedup) ──
|
|
151
156
|
/** Has this Message-ID already been sent? Used to prevent the outbox from
|
|
@@ -301,8 +306,13 @@ export class MailxDB {
|
|
|
301
306
|
}
|
|
302
307
|
// ── Messages ──
|
|
303
308
|
upsertMessage(msg) {
|
|
304
|
-
const existing = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
|
|
309
|
+
const existing = this.db.prepare("SELECT id, provider_id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
|
|
305
310
|
if (existing) {
|
|
311
|
+
// Backfill provider_id on existing rows that predate this column —
|
|
312
|
+
// critical for body fetch to bypass listMessageIds pagination.
|
|
313
|
+
if (msg.providerId && !existing.provider_id) {
|
|
314
|
+
this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
|
|
315
|
+
}
|
|
306
316
|
this.db.prepare(`
|
|
307
317
|
UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
|
|
308
318
|
WHERE id = ?
|
|
@@ -320,9 +330,9 @@ export class MailxDB {
|
|
|
320
330
|
INSERT INTO messages (
|
|
321
331
|
account_id, folder_id, uid, message_id, in_reply_to, refs, thread_id,
|
|
322
332
|
date, subject, from_address, from_name, to_json, cc_json,
|
|
323
|
-
flags_json, size, has_attachments, preview, body_path, cached_at
|
|
324
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
325
|
-
`).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now());
|
|
333
|
+
flags_json, size, has_attachments, preview, body_path, cached_at, provider_id
|
|
334
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
335
|
+
`).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
|
|
326
336
|
const rowId = Number(result.lastInsertRowid);
|
|
327
337
|
// Index for full-text search
|
|
328
338
|
try {
|
|
@@ -345,6 +355,12 @@ export class MailxDB {
|
|
|
345
355
|
const term = `%${query.search}%`;
|
|
346
356
|
params.push(term, term, term);
|
|
347
357
|
}
|
|
358
|
+
if (query.flaggedOnly) {
|
|
359
|
+
// flags_json is a JSON array like ["\\Seen","\\Flagged"]. A plain
|
|
360
|
+
// LIKE on the serialized form is sufficient to find rows with the
|
|
361
|
+
// \Flagged flag without decoding every row.
|
|
362
|
+
where += " AND flags_json LIKE '%\\\\Flagged%'";
|
|
363
|
+
}
|
|
348
364
|
const total = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`).get(...params).cnt;
|
|
349
365
|
const rows = this.db.prepare(`SELECT * FROM messages WHERE ${where} ORDER BY ${sortCol} ${sortDir} LIMIT ? OFFSET ?`).all(...params, pageSize, offset);
|
|
350
366
|
const items = rows.map(r => ({
|
|
@@ -427,7 +443,8 @@ export class MailxDB {
|
|
|
427
443
|
flags: JSON.parse(r.flags_json),
|
|
428
444
|
size: r.size,
|
|
429
445
|
hasAttachments: !!r.has_attachments,
|
|
430
|
-
preview: r.preview
|
|
446
|
+
preview: r.preview,
|
|
447
|
+
providerId: r.provider_id || undefined,
|
|
431
448
|
};
|
|
432
449
|
}
|
|
433
450
|
getMessageBodyPath(accountId, uid) {
|
|
@@ -218,13 +218,24 @@ export class WebMailxService {
|
|
|
218
218
|
raw = await this.syncManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
|
|
219
219
|
}
|
|
220
220
|
catch (fetchErr) {
|
|
221
|
+
// Mirror the desktop service: surface as structured bodyError
|
|
222
|
+
// so the viewer shows its dedicated error banner instead of
|
|
223
|
+
// rendering the message text verbatim in the body area.
|
|
224
|
+
const rawErr = fetchErr.message || "fetch failed";
|
|
225
|
+
const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
|
|
221
226
|
return {
|
|
222
|
-
...envelope, bodyHtml: "", bodyText:
|
|
227
|
+
...envelope, bodyHtml: "", bodyText: "",
|
|
228
|
+
bodyError: rawErr, bodyErrorTransient: isTransient,
|
|
223
229
|
hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
224
230
|
};
|
|
225
231
|
}
|
|
226
232
|
if (!raw) {
|
|
227
|
-
|
|
233
|
+
return {
|
|
234
|
+
...envelope, bodyHtml: "", bodyText: "",
|
|
235
|
+
bodyError: "Message body not cached locally and the server fetch returned nothing.",
|
|
236
|
+
bodyErrorTransient: true,
|
|
237
|
+
hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
|
|
238
|
+
};
|
|
228
239
|
}
|
|
229
240
|
else {
|
|
230
241
|
const source = new TextDecoder().decode(raw);
|
|
@@ -34,6 +34,7 @@ export interface AccountConfig {
|
|
|
34
34
|
relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
|
|
35
35
|
deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
|
|
36
36
|
identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
|
|
37
|
+
spam?: string; /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
|
|
37
38
|
}
|
|
38
39
|
/** Standard IMAP special-use folder types */
|
|
39
40
|
export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
|
|
@@ -75,6 +76,7 @@ export interface MessageEnvelope {
|
|
|
75
76
|
hasAttachments: boolean;
|
|
76
77
|
preview: string; /** First ~200 chars of body text */
|
|
77
78
|
bodyPath?: string; /** Local body location: "idb:..." or "gmail:<id>" */
|
|
79
|
+
providerId?: string; /** Native server id (Gmail hex id, Outlook Graph id) — bypasses UID→id pagination on body fetch */
|
|
78
80
|
}
|
|
79
81
|
/** Full message with body content */
|
|
80
82
|
export interface Message extends MessageEnvelope {
|
|
@@ -106,6 +108,10 @@ export interface MessageQuery {
|
|
|
106
108
|
sort?: "date" | "from" | "subject";
|
|
107
109
|
sortDir?: "asc" | "desc";
|
|
108
110
|
search?: string;
|
|
111
|
+
/** Restrict to messages with the \Flagged flag set (whole-folder, not
|
|
112
|
+
* just the currently-rendered page — lets the "show flagged" filter
|
|
113
|
+
* find stars on messages that haven't been paged in yet). */
|
|
114
|
+
flaggedOnly?: boolean;
|
|
109
115
|
}
|
|
110
116
|
/** Compose/send a message */
|
|
111
117
|
export interface ComposeMessage {
|