@bobfrankston/mailx 1.0.205 → 1.0.207
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/client/android.html +1 -1
- package/client/components/folder-tree.js +7 -2
- package/client/index.html +1 -1
- package/client/styles/layout.css +15 -4
- package/package.json +2 -2
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-store-web/android-bootstrap.js +122 -15
- package/packages/mailx-store-web/db.js +2 -1
- package/packages/mailx-store-web/gmail-api-web.d.ts +2 -0
- package/packages/mailx-store-web/gmail-api-web.js +25 -9
- package/packages/mailx-types/index.d.ts +1 -0
package/client/android.html
CHANGED
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
<section class="message-viewer" id="message-viewer">
|
|
136
136
|
<div class="mv-header" id="mv-header" hidden>
|
|
137
137
|
<div class="mv-toolbar">
|
|
138
|
-
<button class="tb-btn" id="btn-back" title="Back to list" hidden
|
|
138
|
+
<button class="tb-btn btn-back-list" id="btn-back" title="Back to message list" hidden>← Inbox</button>
|
|
139
139
|
<button class="tb-btn" id="btn-reply" title="Reply">↩</button>
|
|
140
140
|
<button class="tb-btn" id="btn-reply-all" title="Reply All">↩↩</button>
|
|
141
141
|
<button class="tb-btn" id="btn-forward" title="Forward">→</button>
|
|
@@ -10,6 +10,7 @@ let selectedElement;
|
|
|
10
10
|
let selectedAccountId = null;
|
|
11
11
|
let selectedFolderId = null;
|
|
12
12
|
let isFirstLoad = true; // only auto-select on first load
|
|
13
|
+
let hasAutoSelected = false; // track whether we've ever managed to auto-select
|
|
13
14
|
// Debounce timer for refreshFolderTree
|
|
14
15
|
let refreshDebounceTimer = null;
|
|
15
16
|
// Persist expand/collapse state in localStorage
|
|
@@ -670,7 +671,9 @@ async function loadFolderTree(container) {
|
|
|
670
671
|
}
|
|
671
672
|
}
|
|
672
673
|
}
|
|
673
|
-
|
|
674
|
+
// Auto-select on first load OR until we successfully auto-selected at least once
|
|
675
|
+
// (handles Android where folders don't exist on first load — they arrive after sync)
|
|
676
|
+
if (!target && (isFirstLoad || !hasAutoSelected)) {
|
|
674
677
|
// Auto-select only on first load — not on refresh (prevents jumping)
|
|
675
678
|
const unified = container.querySelector('.ft-unified');
|
|
676
679
|
if (unified) {
|
|
@@ -694,8 +697,10 @@ async function loadFolderTree(container) {
|
|
|
694
697
|
}
|
|
695
698
|
if (!target && allFolderEls.length > 0)
|
|
696
699
|
target = allFolderEls[0];
|
|
697
|
-
if (target)
|
|
700
|
+
if (target) {
|
|
698
701
|
target.click();
|
|
702
|
+
hasAutoSelected = true;
|
|
703
|
+
}
|
|
699
704
|
}
|
|
700
705
|
isFirstLoad = false;
|
|
701
706
|
// Dismiss startup overlay once tree is loaded
|
package/client/index.html
CHANGED
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
<section class="message-viewer" id="message-viewer">
|
|
100
100
|
<div class="mv-header" id="mv-header" hidden>
|
|
101
101
|
<div class="mv-toolbar">
|
|
102
|
-
<button class="tb-btn" id="btn-back" title="Back to list" hidden
|
|
102
|
+
<button class="tb-btn btn-back-list" id="btn-back" title="Back to message list" hidden>← Inbox</button>
|
|
103
103
|
<button class="tb-btn" id="btn-reply" title="Reply (Ctrl+R)">↩</button>
|
|
104
104
|
<button class="tb-btn" id="btn-reply-all" title="Reply All (Ctrl+Shift+R)">↩↩</button>
|
|
105
105
|
<button class="tb-btn" id="btn-forward" title="Forward">→</button>
|
package/client/styles/layout.css
CHANGED
|
@@ -87,8 +87,8 @@ body {
|
|
|
87
87
|
#btn-menu { display: inline-flex !important; }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/* Responsive: narrow viewport — single panel navigation */
|
|
91
|
-
@media (max-width: 768px) {
|
|
90
|
+
/* Responsive: narrow OR short viewport — single panel navigation */
|
|
91
|
+
@media (max-width: 768px), (max-height: 600px) {
|
|
92
92
|
body {
|
|
93
93
|
grid-template-columns: 1fr;
|
|
94
94
|
grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
|
|
@@ -132,9 +132,20 @@ body {
|
|
|
132
132
|
}
|
|
133
133
|
.message-list.narrow-hidden { display: none; }
|
|
134
134
|
|
|
135
|
-
/* Show hamburger
|
|
135
|
+
/* Show hamburger always on narrow */
|
|
136
136
|
#btn-menu { display: inline-flex !important; }
|
|
137
|
-
|
|
137
|
+
/* Back button: only show when viewer is active (message list hidden) */
|
|
138
|
+
#btn-back { display: none !important; }
|
|
139
|
+
.message-viewer.narrow-active ~ * #btn-back,
|
|
140
|
+
body:has(.message-viewer.narrow-active) #btn-back {
|
|
141
|
+
display: inline-flex !important;
|
|
142
|
+
font-size: 1.1em;
|
|
143
|
+
font-weight: 600;
|
|
144
|
+
padding: 0.5em 1em;
|
|
145
|
+
background: var(--color-accent, #1a6dd4);
|
|
146
|
+
color: white;
|
|
147
|
+
border-radius: 6px;
|
|
148
|
+
}
|
|
138
149
|
|
|
139
150
|
/* Message list: full width, two-line rows */
|
|
140
151
|
.message-list {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.207",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@bobfrankston/iflow-node": "^0.1.2",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.22",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.268",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { WebMailxDB } from "./db.js";
|
|
15
15
|
import { WebMessageStore } from "./web-message-store.js";
|
|
16
16
|
import { WebMailxService } from "./web-service.js";
|
|
17
|
-
import { loadAccounts, loadAccountsFromCloud, saveAccounts, clearSettings, setGDriveTokenProvider, setGDriveFolderId } from "./web-settings.js";
|
|
17
|
+
import { loadAccounts, loadAccountsFromCloud, saveAccounts, clearSettings, getDeviceId, setGDriveTokenProvider, setGDriveFolderId } from "./web-settings.js";
|
|
18
18
|
import { GmailApiWebProvider } from "./gmail-api-web.js";
|
|
19
19
|
// ── State ──
|
|
20
20
|
let db;
|
|
@@ -159,6 +159,9 @@ class AndroidSyncManager {
|
|
|
159
159
|
flags.push("\\Answered");
|
|
160
160
|
if (msg.draft)
|
|
161
161
|
flags.push("\\Draft");
|
|
162
|
+
// Store the Gmail providerId in bodyPath as "gmail:<id>" so we can
|
|
163
|
+
// fetch the body directly without re-listing 1000 messages from the folder
|
|
164
|
+
const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : "";
|
|
162
165
|
this.db.upsertMessage({
|
|
163
166
|
accountId, folderId, uid: msg.uid,
|
|
164
167
|
messageId: msg.messageId || "", inReplyTo: "", references: [],
|
|
@@ -167,7 +170,7 @@ class AndroidSyncManager {
|
|
|
167
170
|
from: toEmailAddress(msg.from?.[0]),
|
|
168
171
|
to: msg.to.map(a => toEmailAddress(a)),
|
|
169
172
|
cc: msg.cc.map(a => toEmailAddress(a)),
|
|
170
|
-
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath
|
|
173
|
+
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath,
|
|
171
174
|
});
|
|
172
175
|
}
|
|
173
176
|
this.db.commitTransaction();
|
|
@@ -182,15 +185,31 @@ class AndroidSyncManager {
|
|
|
182
185
|
return this.bodyStore.getMessage(accountId, folderId, uid);
|
|
183
186
|
}
|
|
184
187
|
const provider = this.getProvider(accountId);
|
|
185
|
-
if (!provider)
|
|
186
|
-
|
|
187
|
-
const folders = this.db.getFolders(accountId);
|
|
188
|
-
const folder = folders.find(f => f.id === folderId);
|
|
189
|
-
if (!folder)
|
|
188
|
+
if (!provider) {
|
|
189
|
+
console.warn(`[fetchBody] No provider for ${accountId}`);
|
|
190
190
|
return null;
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
}
|
|
192
|
+
// Look up the Gmail providerId stored in body_path during sync
|
|
193
|
+
const envelope = this.db.getMessageByUid(accountId, uid, folderId);
|
|
194
|
+
const bp = envelope?.bodyPath || "";
|
|
195
|
+
let msg = null;
|
|
196
|
+
if (bp.startsWith("gmail:") && provider.fetchById) {
|
|
197
|
+
const providerId = bp.substring(6);
|
|
198
|
+
msg = await provider.fetchById(providerId, { source: true });
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Fallback: list-and-find by UID (slow, fragile)
|
|
202
|
+
const folders = this.db.getFolders(accountId);
|
|
203
|
+
const folder = folders.find(f => f.id === folderId);
|
|
204
|
+
if (!folder)
|
|
205
|
+
return null;
|
|
206
|
+
msg = await provider.fetchOne(folder.path, uid, { source: true });
|
|
207
|
+
}
|
|
208
|
+
if (!msg?.source) {
|
|
209
|
+
console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp})`);
|
|
193
210
|
return null;
|
|
211
|
+
}
|
|
212
|
+
// Encode the UTF-8 string back to bytes for storage
|
|
194
213
|
const raw = new TextEncoder().encode(msg.source);
|
|
195
214
|
await this.bodyStore.putMessage(accountId, folderId, uid, raw);
|
|
196
215
|
this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
|
|
@@ -247,7 +266,10 @@ const OAUTH_CLIENT = {
|
|
|
247
266
|
// Reverse client ID scheme — auto-allowed for Google "installed" apps
|
|
248
267
|
redirectUri: "com.googleusercontent.apps.884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u:/oauth2callback",
|
|
249
268
|
};
|
|
250
|
-
|
|
269
|
+
// Use full drive scope so we can read/write the desktop's accounts.jsonc + clients.jsonc.
|
|
270
|
+
// drive.file is per-consent-grant: files created by desktop's grant aren't visible to Android's grant
|
|
271
|
+
// even with the same client_id. drive (full) lets us see all files the user has access to.
|
|
272
|
+
const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive";
|
|
251
273
|
// ── Token cache (IndexedDB) ──
|
|
252
274
|
async function getCachedToken(email) {
|
|
253
275
|
const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
|
|
@@ -364,6 +386,67 @@ function createNativeTokenProvider(email) {
|
|
|
364
386
|
};
|
|
365
387
|
}
|
|
366
388
|
// ── GDrive folder lookup ──
|
|
389
|
+
async function registerDeviceInGDrive(tokenProvider, folderId, accountIds) {
|
|
390
|
+
try {
|
|
391
|
+
const token = await tokenProvider();
|
|
392
|
+
// Use persistent Android device ID (survives factory reset & app data clear)
|
|
393
|
+
const bridge = window._nativeBridge;
|
|
394
|
+
let deviceId = "android-unknown";
|
|
395
|
+
if (bridge?.app?.getAndroidId) {
|
|
396
|
+
try {
|
|
397
|
+
const androidId = await bridge.app.getAndroidId();
|
|
398
|
+
deviceId = `android-${androidId.substring(0, 12)}`;
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
deviceId = `android-${getDeviceId().substring(0, 8)}`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Read existing clients.jsonc
|
|
405
|
+
const q = encodeURIComponent(`name='clients.jsonc' and '${folderId}' in parents and trashed=false`);
|
|
406
|
+
const listRes = await fetch(`https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id)`, { headers: { "Authorization": `Bearer ${token}` } });
|
|
407
|
+
if (!listRes.ok) {
|
|
408
|
+
console.warn(`[gdrive] clients.jsonc list failed: ${listRes.status}`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const listData = await listRes.json();
|
|
412
|
+
const fileId = listData.files?.[0]?.id;
|
|
413
|
+
let clients = {};
|
|
414
|
+
if (fileId) {
|
|
415
|
+
const readRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, { headers: { "Authorization": `Bearer ${token}` } });
|
|
416
|
+
if (readRes.ok) {
|
|
417
|
+
try {
|
|
418
|
+
clients = JSON.parse(await readRes.text());
|
|
419
|
+
}
|
|
420
|
+
catch { /* */ }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
clients[deviceId] = {
|
|
424
|
+
hostname: deviceId,
|
|
425
|
+
platform: "android",
|
|
426
|
+
accounts: accountIds,
|
|
427
|
+
lastSeen: new Date().toISOString(),
|
|
428
|
+
version: window._nativeBridge?.info?.version || "?",
|
|
429
|
+
};
|
|
430
|
+
const content = JSON.stringify(clients, null, 2);
|
|
431
|
+
if (fileId) {
|
|
432
|
+
const upRes = await fetch(`https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`, {
|
|
433
|
+
method: "PATCH",
|
|
434
|
+
headers: {
|
|
435
|
+
"Authorization": `Bearer ${token}`,
|
|
436
|
+
"Content-Type": "application/json",
|
|
437
|
+
},
|
|
438
|
+
body: content,
|
|
439
|
+
});
|
|
440
|
+
if (upRes.ok)
|
|
441
|
+
console.log(`[android] Registered device in clients.jsonc as ${deviceId}`);
|
|
442
|
+
else
|
|
443
|
+
console.warn(`[gdrive] clients.jsonc update failed: ${upRes.status}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch (e) {
|
|
447
|
+
console.warn(`[android] Device registration failed: ${e.message}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
367
450
|
async function findGDriveMailxFolder(tokenProvider) {
|
|
368
451
|
const token = await tokenProvider();
|
|
369
452
|
const q = encodeURIComponent("name='mailx' and mimeType='application/vnd.google-apps.folder' and trashed=false");
|
|
@@ -425,16 +508,37 @@ export async function initAndroid() {
|
|
|
425
508
|
if (gmailTokenProvider) {
|
|
426
509
|
setGDriveTokenProvider(gmailTokenProvider);
|
|
427
510
|
try {
|
|
511
|
+
console.log("[android] Looking up GDrive mailx folder...");
|
|
428
512
|
const folderId = await findGDriveMailxFolder(gmailTokenProvider);
|
|
429
|
-
if (folderId) {
|
|
513
|
+
if (!folderId) {
|
|
514
|
+
console.warn("[android] GDrive mailx folder not found");
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
430
517
|
setGDriveFolderId(folderId);
|
|
431
518
|
console.log(`[android] GDrive mailx folder: ${folderId}`);
|
|
432
|
-
//
|
|
519
|
+
// DEBUG: list all files in the folder
|
|
520
|
+
try {
|
|
521
|
+
const tk = await gmailTokenProvider();
|
|
522
|
+
const lr = await fetch(`https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } });
|
|
523
|
+
if (lr.ok) {
|
|
524
|
+
const ld = await lr.json();
|
|
525
|
+
const names = (ld.files || []).map((f) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
|
|
526
|
+
console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
console.warn(`[android] List folder failed: ${lr.status}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch (e) {
|
|
533
|
+
console.warn(`[android] List debug: ${e.message}`);
|
|
534
|
+
}
|
|
535
|
+
// Read accounts directly from GDrive (bypass IndexedDB cache)
|
|
536
|
+
console.log("[android] Reading accounts.jsonc from GDrive...");
|
|
433
537
|
const gdriveAccounts = await loadAccountsFromCloud();
|
|
434
|
-
|
|
435
|
-
|
|
538
|
+
console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
|
|
539
|
+
if (gdriveAccounts.length > 0) {
|
|
540
|
+
// Use canonical GDrive accounts (upsert handles overwrites)
|
|
436
541
|
accounts = gdriveAccounts;
|
|
437
|
-
// Register any new accounts
|
|
438
542
|
for (const account of accounts) {
|
|
439
543
|
if (!account.enabled)
|
|
440
544
|
continue;
|
|
@@ -444,7 +548,10 @@ export async function initAndroid() {
|
|
|
444
548
|
}
|
|
445
549
|
await syncManager.addAccount(account);
|
|
446
550
|
}
|
|
551
|
+
console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
|
|
447
552
|
}
|
|
553
|
+
// Register this Android device in clients.jsonc
|
|
554
|
+
await registerDeviceInGDrive(gmailTokenProvider, folderId, accounts.map(a => a.id));
|
|
448
555
|
}
|
|
449
556
|
}
|
|
450
557
|
catch (e) {
|
|
@@ -501,7 +501,8 @@ export class WebMailxDB {
|
|
|
501
501
|
from: { name: r.from_name, address: r.from_address },
|
|
502
502
|
to: JSON.parse(r.to_json), cc: JSON.parse(r.cc_json),
|
|
503
503
|
flags: JSON.parse(r.flags_json), size: r.size,
|
|
504
|
-
hasAttachments: !!r.has_attachments, preview: r.preview
|
|
504
|
+
hasAttachments: !!r.has_attachments, preview: r.preview,
|
|
505
|
+
bodyPath: r.body_path || ""
|
|
505
506
|
};
|
|
506
507
|
}
|
|
507
508
|
}
|
|
@@ -19,6 +19,8 @@ export declare class GmailApiWebProvider implements MailProvider {
|
|
|
19
19
|
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
20
20
|
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
21
21
|
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
22
|
+
/** Fetch a message directly by Gmail provider ID — fast path, no listing needed */
|
|
23
|
+
fetchById(providerId: string, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
22
24
|
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
23
25
|
getUids(folder: string): Promise<number[]>;
|
|
24
26
|
close(): Promise<void>;
|
|
@@ -135,16 +135,14 @@ export class GmailApiWebProvider {
|
|
|
135
135
|
const headers = msg.payload?.headers || [];
|
|
136
136
|
let source = "";
|
|
137
137
|
if (options.source && msg.raw) {
|
|
138
|
-
// URL-safe base64 → standard base64 →
|
|
138
|
+
// URL-safe base64 → standard base64 → UTF-8 string
|
|
139
|
+
// atob() returns a binary string (1 byte per char). Must decode bytes as UTF-8
|
|
140
|
+
// to handle multi-byte characters correctly (e.g. smart quotes).
|
|
139
141
|
const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// Handle padding issues
|
|
145
|
-
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
146
|
-
source = atob(padded);
|
|
147
|
-
}
|
|
142
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
143
|
+
const binary = atob(padded);
|
|
144
|
+
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
145
|
+
source = new TextDecoder("utf-8").decode(bytes);
|
|
148
146
|
}
|
|
149
147
|
const fromRaw = getHeader(headers, "From");
|
|
150
148
|
const toRaw = getHeader(headers, "To");
|
|
@@ -189,6 +187,24 @@ export class GmailApiWebProvider {
|
|
|
189
187
|
const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
|
|
190
188
|
return this.batchFetch(matchingIds, options);
|
|
191
189
|
}
|
|
190
|
+
/** Fetch a message directly by Gmail provider ID — fast path, no listing needed */
|
|
191
|
+
async fetchById(providerId, options = {}) {
|
|
192
|
+
const format = options.source ? "raw" : "metadata";
|
|
193
|
+
const params = new URLSearchParams({ format });
|
|
194
|
+
if (format === "metadata") {
|
|
195
|
+
for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
|
|
196
|
+
params.append("metadataHeaders", h);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const msg = await this.apiFetch(`/messages/${providerId}?${params}`);
|
|
201
|
+
return this.parseMessage(msg, options);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
console.warn(`[gmail] fetchById ${providerId} failed: ${e.message}`);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
192
208
|
async fetchOne(folder, uid, options = {}) {
|
|
193
209
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
194
210
|
const ids = await this.listMessageIds(query, 1000);
|
|
@@ -72,6 +72,7 @@ export interface MessageEnvelope {
|
|
|
72
72
|
size: number;
|
|
73
73
|
hasAttachments: boolean;
|
|
74
74
|
preview: string; /** First ~200 chars of body text */
|
|
75
|
+
bodyPath?: string; /** Local body location: "idb:..." or "gmail:<id>" */
|
|
75
76
|
}
|
|
76
77
|
/** Full message with body content */
|
|
77
78
|
export interface Message extends MessageEnvelope {
|