@bobfrankston/mailx 1.0.378 → 1.0.382
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 +18 -0
- package/client/app.js +107 -1
- package/client/components/calendar-sidebar.js +45 -79
- package/client/components/folder-tree.js +25 -0
- package/client/components/message-list.js +75 -1
- package/client/components/message-viewer.js +7 -0
- package/client/index.html +10 -5
- package/client/lib/api-client.js +31 -2
- package/client/lib/mailxapi.js +13 -0
- package/client/styles/components.css +124 -0
- package/client/styles/layout.css +34 -10
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +31 -2
- package/packages/mailx-service/google-sync.d.ts +83 -0
- package/packages/mailx-service/google-sync.js +140 -0
- package/packages/mailx-service/index.d.ts +44 -2
- package/packages/mailx-service/index.js +218 -3
- package/packages/mailx-service/jsonrpc.js +25 -0
- package/packages/mailx-store/db.d.ts +49 -0
- package/packages/mailx-store/db.js +255 -6
- package/packages/mailx-store-web/db.js +54 -0
- package/packages/mailx-store-web/web-service.js +19 -10
- package/packages/mailx-types/index.d.ts +15 -3
- package/packages/mailx-types/index.js +90 -2
package/client/lib/mailxapi.js
CHANGED
|
@@ -109,6 +109,19 @@
|
|
|
109
109
|
getThreadMessages: function(accountId, threadId) {
|
|
110
110
|
return callNode("getThreadMessages", { accountId: accountId, threadId: threadId });
|
|
111
111
|
},
|
|
112
|
+
// Calendar / Tasks two-way cache. Reads return local DB immediately;
|
|
113
|
+
// writes commit locally and queue a push-to-Google action.
|
|
114
|
+
getCalendarEvents: function(fromMs, toMs) {
|
|
115
|
+
return callNode("getCalendarEvents", { fromMs: fromMs, toMs: toMs });
|
|
116
|
+
},
|
|
117
|
+
createCalendarEvent: function(ev) { return callNode("createCalendarEvent", ev); },
|
|
118
|
+
updateCalendarEvent: function(uuid, patch) { return callNode("updateCalendarEvent", { uuid: uuid, patch: patch }); },
|
|
119
|
+
deleteCalendarEvent: function(uuid) { return callNode("deleteCalendarEvent", { uuid: uuid }); },
|
|
120
|
+
getTasks: function(includeCompleted) { return callNode("getTasks", { includeCompleted: !!includeCompleted }); },
|
|
121
|
+
createTask: function(t) { return callNode("createTask", t); },
|
|
122
|
+
updateTask: function(uuid, patch) { return callNode("updateTask", { uuid: uuid, patch: patch }); },
|
|
123
|
+
deleteTask: function(uuid) { return callNode("deleteTask", { uuid: uuid }); },
|
|
124
|
+
drainStoreSync: function() { return callNode("drainStoreSync"); },
|
|
112
125
|
readJsoncFile: function(name) {
|
|
113
126
|
return callNode("readJsoncFile", { name: name });
|
|
114
127
|
},
|
|
@@ -205,6 +205,15 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
205
205
|
|
|
206
206
|
.ft-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
207
207
|
|
|
208
|
+
/* Case-duplicate warning — another folder exists with the same name in a
|
|
209
|
+
different case on the server. Hover for the full explanation via title. */
|
|
210
|
+
.ft-folder-duplicate .ft-folder-name::after {
|
|
211
|
+
content: " ⚠";
|
|
212
|
+
color: oklch(0.65 0.2 65);
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
margin-left: 4px;
|
|
215
|
+
}
|
|
216
|
+
|
|
208
217
|
.ft-badge {
|
|
209
218
|
font-size: 0.7rem;
|
|
210
219
|
padding: 0.1rem 0.4rem;
|
|
@@ -332,6 +341,18 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
332
341
|
user-select: none;
|
|
333
342
|
|
|
334
343
|
.ml-col { cursor: pointer; &:hover { color: var(--color-text); } }
|
|
344
|
+
.ml-col-sortable { position: relative; padding-right: 14px; }
|
|
345
|
+
.ml-col-sort-asc::after,
|
|
346
|
+
.ml-col-sort-desc::after {
|
|
347
|
+
position: absolute;
|
|
348
|
+
right: 2px;
|
|
349
|
+
top: 50%;
|
|
350
|
+
transform: translateY(-50%);
|
|
351
|
+
font-size: 10px;
|
|
352
|
+
color: var(--color-accent);
|
|
353
|
+
}
|
|
354
|
+
.ml-col-sort-asc::after { content: "▲"; }
|
|
355
|
+
.ml-col-sort-desc::after { content: "▼"; }
|
|
335
356
|
}
|
|
336
357
|
|
|
337
358
|
/* Narrow-mode folder title above the list — hidden on wide where the window
|
|
@@ -603,6 +624,64 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
603
624
|
white-space: pre;
|
|
604
625
|
tab-size: 2;
|
|
605
626
|
}
|
|
627
|
+
|
|
628
|
+
/* JSONC editor with line-number gutter. Gutter + textarea share the same
|
|
629
|
+
line-height so numbers stay aligned; textarea owns the scroll and the
|
|
630
|
+
gutter syncs via JS. Error line is highlighted red in the gutter so the
|
|
631
|
+
"Line N, col M" error points at a visible marker. */
|
|
632
|
+
.jsonc-editor-wrap {
|
|
633
|
+
flex: 1;
|
|
634
|
+
display: flex;
|
|
635
|
+
min-height: 200px;
|
|
636
|
+
border: 1px solid var(--color-border);
|
|
637
|
+
border-radius: var(--radius-sm);
|
|
638
|
+
overflow: hidden;
|
|
639
|
+
background: var(--color-bg-surface);
|
|
640
|
+
}
|
|
641
|
+
.jsonc-gutter {
|
|
642
|
+
flex: 0 0 auto;
|
|
643
|
+
min-width: 3em;
|
|
644
|
+
padding: 6px 6px 6px 10px;
|
|
645
|
+
background: oklch(0.94 0.005 250);
|
|
646
|
+
border-right: 1px solid var(--color-border);
|
|
647
|
+
color: var(--color-text-muted);
|
|
648
|
+
font-family: var(--font-mono);
|
|
649
|
+
font-size: 13px;
|
|
650
|
+
line-height: 1.5;
|
|
651
|
+
text-align: right;
|
|
652
|
+
user-select: none;
|
|
653
|
+
overflow: hidden;
|
|
654
|
+
white-space: pre;
|
|
655
|
+
tab-size: 2;
|
|
656
|
+
}
|
|
657
|
+
.jsonc-gutter-line {
|
|
658
|
+
line-height: 1.5;
|
|
659
|
+
font-variant-numeric: tabular-nums;
|
|
660
|
+
}
|
|
661
|
+
.jsonc-gutter-error {
|
|
662
|
+
background: oklch(0.65 0.2 25);
|
|
663
|
+
color: #fff;
|
|
664
|
+
font-weight: 600;
|
|
665
|
+
border-radius: 2px;
|
|
666
|
+
padding: 0 4px;
|
|
667
|
+
margin: 0 -4px;
|
|
668
|
+
}
|
|
669
|
+
.jsonc-editor-wrap .jsonc-textarea {
|
|
670
|
+
flex: 1;
|
|
671
|
+
border: 0;
|
|
672
|
+
border-radius: 0;
|
|
673
|
+
line-height: 1.5;
|
|
674
|
+
resize: none;
|
|
675
|
+
/* The outer wrap draws the focus ring so gutter + area move together */
|
|
676
|
+
}
|
|
677
|
+
.jsonc-editor-wrap:focus-within {
|
|
678
|
+
outline: 2px solid var(--color-accent);
|
|
679
|
+
outline-offset: -1px;
|
|
680
|
+
}
|
|
681
|
+
.jsonc-editor-wrap:has(.mailx-modal-input-error) {
|
|
682
|
+
outline: 2px solid oklch(0.65 0.2 25);
|
|
683
|
+
outline-offset: -1px;
|
|
684
|
+
}
|
|
606
685
|
.mailx-modal-input:focus {
|
|
607
686
|
outline: 2px solid var(--color-accent);
|
|
608
687
|
outline-offset: -1px;
|
|
@@ -816,6 +895,21 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
816
895
|
opacity: 0.9;
|
|
817
896
|
}
|
|
818
897
|
|
|
898
|
+
/* Filter: only this conversation — rows not in the current thread hide.
|
|
899
|
+
The selected row stays visible regardless so toggling doesn't leave the
|
|
900
|
+
list empty when the selection is a singleton thread. */
|
|
901
|
+
.ml-row.thread-filter-hidden { display: none; }
|
|
902
|
+
|
|
903
|
+
/* Offline indicator — sits in the status bar; amber tone so it's visible
|
|
904
|
+
but doesn't scream (being offline is normal local-first behavior, not
|
|
905
|
+
an error). */
|
|
906
|
+
.status-offline {
|
|
907
|
+
color: oklch(0.65 0.18 65);
|
|
908
|
+
font-weight: 600;
|
|
909
|
+
font-size: var(--font-size-sm);
|
|
910
|
+
padding: 0 6px;
|
|
911
|
+
}
|
|
912
|
+
|
|
819
913
|
/* S51 — calendar sidebar (Thunderbird Lightning Events & Tasks pane).
|
|
820
914
|
Right-docked, fixed width. Visible by default; user hides via View menu.
|
|
821
915
|
Hides automatically on narrow screens (< 1100px) — Android uses the
|
|
@@ -926,11 +1020,41 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
|
|
|
926
1020
|
align-items: center;
|
|
927
1021
|
gap: 6px;
|
|
928
1022
|
padding: 2px 0;
|
|
1023
|
+
|
|
1024
|
+
.cal-side-task-delete {
|
|
1025
|
+
margin-left: auto;
|
|
1026
|
+
opacity: 0;
|
|
1027
|
+
background: transparent;
|
|
1028
|
+
border: 0;
|
|
1029
|
+
cursor: pointer;
|
|
1030
|
+
color: var(--color-text-muted);
|
|
1031
|
+
padding: 0 4px;
|
|
1032
|
+
font-size: 14px;
|
|
1033
|
+
line-height: 1;
|
|
1034
|
+
transition: opacity 0.15s, color 0.15s;
|
|
1035
|
+
}
|
|
1036
|
+
&:hover .cal-side-task-delete { opacity: 1; }
|
|
1037
|
+
.cal-side-task-delete:hover { color: oklch(0.55 0.22 25); }
|
|
1038
|
+
}
|
|
1039
|
+
.cal-side-task-title {
|
|
1040
|
+
flex: 1;
|
|
1041
|
+
overflow: hidden;
|
|
1042
|
+
text-overflow: ellipsis;
|
|
1043
|
+
white-space: nowrap;
|
|
929
1044
|
}
|
|
930
1045
|
.cal-side-task-title.done {
|
|
931
1046
|
text-decoration: line-through;
|
|
932
1047
|
color: var(--color-text-muted);
|
|
933
1048
|
}
|
|
1049
|
+
.cal-side-task-header-row {
|
|
1050
|
+
display: flex;
|
|
1051
|
+
align-items: center;
|
|
1052
|
+
gap: var(--gap-xs);
|
|
1053
|
+
padding: 2px 0;
|
|
1054
|
+
|
|
1055
|
+
label { flex: 1; }
|
|
1056
|
+
.cal-side-new { width: auto; padding: 2px 8px; font-size: 0.85em; }
|
|
1057
|
+
}
|
|
934
1058
|
|
|
935
1059
|
.ml-empty {
|
|
936
1060
|
grid-column: 1 / -1;
|
package/client/styles/layout.css
CHANGED
|
@@ -44,15 +44,17 @@ body.calendar-sidebar-on {
|
|
|
44
44
|
.main-area { grid-area: main; }
|
|
45
45
|
.status-bar { grid-area: status; }
|
|
46
46
|
|
|
47
|
-
/* Vertical icon rail
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
/* Vertical icon rail — Thunderbird Supernova style: dark background with
|
|
48
|
+
light icons so the rail reads as "chrome" and contrasts visibly against
|
|
49
|
+
the content area (whether the app's in light or dark theme). Always
|
|
50
|
+
visible on wide+medium tiers; hidden on narrow (icons fold into
|
|
51
|
+
hamburger — TBD). */
|
|
50
52
|
.icon-rail {
|
|
51
53
|
display: flex;
|
|
52
54
|
flex-direction: column;
|
|
53
55
|
justify-content: space-between;
|
|
54
|
-
background:
|
|
55
|
-
border-right: 1px solid
|
|
56
|
+
background: oklch(0.25 0.01 250);
|
|
57
|
+
border-right: 1px solid oklch(0.18 0.01 250);
|
|
56
58
|
padding: 6px 0;
|
|
57
59
|
overflow: hidden;
|
|
58
60
|
}
|
|
@@ -71,16 +73,38 @@ body.calendar-sidebar-on {
|
|
|
71
73
|
background: transparent;
|
|
72
74
|
cursor: pointer;
|
|
73
75
|
font-size: 16px;
|
|
74
|
-
color:
|
|
76
|
+
color: oklch(0.88 0.01 250);
|
|
75
77
|
border-left: 3px solid transparent;
|
|
76
|
-
transition: background 0.12s, border-color 0.12s;
|
|
78
|
+
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
77
79
|
}
|
|
78
80
|
.rail-btn:hover:not([disabled]) {
|
|
79
|
-
background:
|
|
81
|
+
background: oklch(0.32 0.02 250);
|
|
82
|
+
color: oklch(0.96 0.01 250);
|
|
80
83
|
}
|
|
81
84
|
.rail-btn[data-active="true"] {
|
|
82
|
-
background:
|
|
83
|
-
|
|
85
|
+
background: oklch(0.35 0.03 250);
|
|
86
|
+
color: oklch(0.98 0.01 250);
|
|
87
|
+
border-left-color: var(--color-accent, #4a9cff);
|
|
88
|
+
}
|
|
89
|
+
.rail-btn[disabled] {
|
|
90
|
+
color: oklch(0.55 0.01 250);
|
|
91
|
+
}
|
|
92
|
+
.rail-btn { position: relative; }
|
|
93
|
+
.rail-badge {
|
|
94
|
+
position: absolute;
|
|
95
|
+
top: 3px;
|
|
96
|
+
right: 6px;
|
|
97
|
+
min-width: 18px;
|
|
98
|
+
padding: 1px 4px;
|
|
99
|
+
border-radius: 9px;
|
|
100
|
+
background: oklch(0.65 0.22 25);
|
|
101
|
+
color: #fff;
|
|
102
|
+
font-size: 10px;
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
line-height: 1.4;
|
|
105
|
+
text-align: center;
|
|
106
|
+
font-variant-numeric: tabular-nums;
|
|
107
|
+
pointer-events: none;
|
|
84
108
|
}
|
|
85
109
|
.rail-btn[disabled] {
|
|
86
110
|
opacity: 0.35;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.382",
|
|
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.7",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.347",
|
|
28
28
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
29
29
|
"@capacitor/android": "^8.3.0",
|
|
30
30
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"@bobfrankston/iflow-node": "^0.1.7",
|
|
89
89
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
90
90
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
91
|
-
"@bobfrankston/msger": "^0.1.
|
|
91
|
+
"@bobfrankston/msger": "^0.1.347",
|
|
92
92
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
93
93
|
"@capacitor/android": "^8.3.0",
|
|
94
94
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -564,7 +564,11 @@ export class ImapManager extends EventEmitter {
|
|
|
564
564
|
const hasToken = fs.existsSync(path.join(tokenDir, "oauth-token.json"));
|
|
565
565
|
const TOKEN_FETCH_TIMEOUT_MS = hasToken ? 30_000 : 120_000;
|
|
566
566
|
const authPromise = authenticateOAuth(credPath, {
|
|
567
|
-
|
|
567
|
+
// Scope set covers two-way sync of all mailx-managed local
|
|
568
|
+
// stores: mail (mail.google.com), contacts (full, not
|
|
569
|
+
// readonly — we write edits back), calendar (full), tasks
|
|
570
|
+
// (full), drive (for shared accounts.jsonc).
|
|
571
|
+
scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks https://www.googleapis.com/auth/drive",
|
|
568
572
|
tokenDirectory: tokenDir,
|
|
569
573
|
credentialsKey: "installed",
|
|
570
574
|
loginHint: account.imap.user,
|
|
@@ -2295,8 +2299,33 @@ export class ImapManager extends EventEmitter {
|
|
|
2295
2299
|
await api.setFlags(folder.path, action.uid, action.flags || []);
|
|
2296
2300
|
console.log(` [api] ${accountId}: flags synced UID ${action.uid}`);
|
|
2297
2301
|
}
|
|
2302
|
+
else if ((action.action === "delete" || action.action === "trash") && api.trashMessage) {
|
|
2303
|
+
await api.trashMessage(folder.path, action.uid);
|
|
2304
|
+
console.log(` [api] ${accountId}: trashed UID ${action.uid} from ${folder.path}`);
|
|
2305
|
+
}
|
|
2306
|
+
else if (action.action === "move" && api.moveMessage) {
|
|
2307
|
+
const target = folders.find(f => f.id === action.targetFolderId);
|
|
2308
|
+
if (!target) {
|
|
2309
|
+
// Unreachable target — drop the action rather than loop.
|
|
2310
|
+
console.error(` [api] ${accountId}: move target folder ${action.targetFolderId} missing — dropping UID ${action.uid}`);
|
|
2311
|
+
this.db.completeSyncAction(action.id);
|
|
2312
|
+
continue;
|
|
2313
|
+
}
|
|
2314
|
+
await api.moveMessage(folder.path, action.uid, target.path);
|
|
2315
|
+
console.log(` [api] ${accountId}: moved UID ${action.uid} ${folder.path} → ${target.path}`);
|
|
2316
|
+
}
|
|
2298
2317
|
else {
|
|
2299
|
-
//
|
|
2318
|
+
// Unsupported action on Gmail. After 5 retries, drop it
|
|
2319
|
+
// so stale rows don't mark messages pending-reconcile
|
|
2320
|
+
// forever. Previously "continue" here caused the pink
|
|
2321
|
+
// rows that shouldn't have been pink.
|
|
2322
|
+
if (action.attempts >= 5) {
|
|
2323
|
+
console.warn(` [api] ${accountId}: dropping stale action "${action.action}" UID ${action.uid} after ${action.attempts} attempts (unsupported on Gmail API path)`);
|
|
2324
|
+
this.db.completeSyncAction(action.id);
|
|
2325
|
+
}
|
|
2326
|
+
else {
|
|
2327
|
+
this.db.failSyncAction(action.id, `unsupported Gmail action: ${action.action}`);
|
|
2328
|
+
}
|
|
2300
2329
|
continue;
|
|
2301
2330
|
}
|
|
2302
2331
|
this.db.completeSyncAction(action.id);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar / Tasks / People (Contacts) two-way sync helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by MailxService to push local edits to Google and pull server
|
|
5
|
+
* changes into the local cache. All functions take a `getToken` function
|
|
6
|
+
* and a fetch implementation so they stay platform-agnostic (Node uses
|
|
7
|
+
* global `fetch` on Node 18+; browsers use window.fetch).
|
|
8
|
+
*
|
|
9
|
+
* Error handling: throws on network / HTTP errors. Caller catches and
|
|
10
|
+
* either retries via the store_sync drainer or surfaces to the UI.
|
|
11
|
+
*/
|
|
12
|
+
type TokenProvider = () => Promise<string>;
|
|
13
|
+
export interface GCalEvent {
|
|
14
|
+
id: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
start: {
|
|
17
|
+
dateTime?: string;
|
|
18
|
+
date?: string;
|
|
19
|
+
};
|
|
20
|
+
end: {
|
|
21
|
+
dateTime?: string;
|
|
22
|
+
date?: string;
|
|
23
|
+
};
|
|
24
|
+
location?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
etag?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function listCalendarEvents(tokenProvider: TokenProvider, fromMs: number, toMs: number, calendarId?: string): Promise<GCalEvent[]>;
|
|
29
|
+
export declare function createCalendarEvent(tokenProvider: TokenProvider, event: any, calendarId?: string): Promise<GCalEvent>;
|
|
30
|
+
export declare function updateCalendarEvent(tokenProvider: TokenProvider, eventId: string, event: any, calendarId?: string): Promise<GCalEvent>;
|
|
31
|
+
export declare function deleteCalendarEvent(tokenProvider: TokenProvider, eventId: string, calendarId?: string): Promise<void>;
|
|
32
|
+
export interface GTask {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
notes?: string;
|
|
36
|
+
due?: string;
|
|
37
|
+
completed?: string;
|
|
38
|
+
status?: "needsAction" | "completed";
|
|
39
|
+
etag?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function listTasks(tokenProvider: TokenProvider, listId?: string, showCompleted?: boolean): Promise<GTask[]>;
|
|
42
|
+
export declare function createTask(tokenProvider: TokenProvider, task: any, listId?: string): Promise<GTask>;
|
|
43
|
+
export declare function updateTask(tokenProvider: TokenProvider, taskId: string, task: any, listId?: string): Promise<GTask>;
|
|
44
|
+
export declare function deleteTask(tokenProvider: TokenProvider, taskId: string, listId?: string): Promise<void>;
|
|
45
|
+
export declare function createContact(tokenProvider: TokenProvider, person: any): Promise<any>;
|
|
46
|
+
export declare function updateContact(tokenProvider: TokenProvider, resourceName: string, updatePersonFields: string, person: any): Promise<any>;
|
|
47
|
+
export declare function deleteContact(tokenProvider: TokenProvider, resourceName: string): Promise<void>;
|
|
48
|
+
export declare function calendarEventToLocal(ev: GCalEvent, accountId: string): {
|
|
49
|
+
providerId: string;
|
|
50
|
+
accountId: string;
|
|
51
|
+
title: string;
|
|
52
|
+
startMs: number;
|
|
53
|
+
endMs: number;
|
|
54
|
+
allDay: boolean;
|
|
55
|
+
location: string;
|
|
56
|
+
notes: string;
|
|
57
|
+
etag: string;
|
|
58
|
+
};
|
|
59
|
+
export declare function localToCalendarEvent(local: {
|
|
60
|
+
title: string;
|
|
61
|
+
startMs: number;
|
|
62
|
+
endMs: number;
|
|
63
|
+
allDay?: boolean;
|
|
64
|
+
location?: string;
|
|
65
|
+
notes?: string;
|
|
66
|
+
}): any;
|
|
67
|
+
export declare function taskToLocal(t: GTask, accountId: string): {
|
|
68
|
+
providerId: string;
|
|
69
|
+
accountId: string;
|
|
70
|
+
title: string;
|
|
71
|
+
notes: string;
|
|
72
|
+
dueMs: number | undefined;
|
|
73
|
+
completedMs: number | undefined;
|
|
74
|
+
etag: string;
|
|
75
|
+
};
|
|
76
|
+
export declare function localToTask(local: {
|
|
77
|
+
title: string;
|
|
78
|
+
notes?: string;
|
|
79
|
+
dueMs?: number;
|
|
80
|
+
completedMs?: number;
|
|
81
|
+
}): any;
|
|
82
|
+
export {};
|
|
83
|
+
//# sourceMappingURL=google-sync.d.ts.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Calendar / Tasks / People (Contacts) two-way sync helpers.
|
|
3
|
+
*
|
|
4
|
+
* Used by MailxService to push local edits to Google and pull server
|
|
5
|
+
* changes into the local cache. All functions take a `getToken` function
|
|
6
|
+
* and a fetch implementation so they stay platform-agnostic (Node uses
|
|
7
|
+
* global `fetch` on Node 18+; browsers use window.fetch).
|
|
8
|
+
*
|
|
9
|
+
* Error handling: throws on network / HTTP errors. Caller catches and
|
|
10
|
+
* either retries via the store_sync drainer or surfaces to the UI.
|
|
11
|
+
*/
|
|
12
|
+
async function googleFetch(tokenProvider, url, init = {}) {
|
|
13
|
+
const token = await tokenProvider();
|
|
14
|
+
const headers = new Headers(init.headers || {});
|
|
15
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
16
|
+
if (init.body && !headers.has("Content-Type"))
|
|
17
|
+
headers.set("Content-Type", "application/json");
|
|
18
|
+
const res = await fetch(url, { ...init, headers });
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const body = await res.text().catch(() => "");
|
|
21
|
+
throw new Error(`Google API ${res.status} ${res.statusText}: ${body.slice(0, 300)}`);
|
|
22
|
+
}
|
|
23
|
+
return res;
|
|
24
|
+
}
|
|
25
|
+
export async function listCalendarEvents(tokenProvider, fromMs, toMs, calendarId = "primary") {
|
|
26
|
+
const from = new Date(fromMs).toISOString();
|
|
27
|
+
const to = new Date(toMs).toISOString();
|
|
28
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`
|
|
29
|
+
+ `?timeMin=${encodeURIComponent(from)}&timeMax=${encodeURIComponent(to)}`
|
|
30
|
+
+ `&singleEvents=true&orderBy=startTime&maxResults=250`;
|
|
31
|
+
const all = [];
|
|
32
|
+
let pageToken;
|
|
33
|
+
do {
|
|
34
|
+
const pagedUrl = pageToken ? `${url}&pageToken=${encodeURIComponent(pageToken)}` : url;
|
|
35
|
+
const res = await googleFetch(tokenProvider, pagedUrl);
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
for (const ev of (data.items || []))
|
|
38
|
+
all.push(ev);
|
|
39
|
+
pageToken = data.nextPageToken;
|
|
40
|
+
} while (pageToken);
|
|
41
|
+
return all;
|
|
42
|
+
}
|
|
43
|
+
export async function createCalendarEvent(tokenProvider, event, calendarId = "primary") {
|
|
44
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`;
|
|
45
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(event) });
|
|
46
|
+
return res.json();
|
|
47
|
+
}
|
|
48
|
+
export async function updateCalendarEvent(tokenProvider, eventId, event, calendarId = "primary") {
|
|
49
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
|
|
50
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(event) });
|
|
51
|
+
return res.json();
|
|
52
|
+
}
|
|
53
|
+
export async function deleteCalendarEvent(tokenProvider, eventId, calendarId = "primary") {
|
|
54
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
|
|
55
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
56
|
+
}
|
|
57
|
+
export async function listTasks(tokenProvider, listId = "@default", showCompleted = false) {
|
|
58
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks`
|
|
59
|
+
+ `?showCompleted=${showCompleted}&maxResults=100`;
|
|
60
|
+
const res = await googleFetch(tokenProvider, url);
|
|
61
|
+
const data = await res.json();
|
|
62
|
+
return data.items || [];
|
|
63
|
+
}
|
|
64
|
+
export async function createTask(tokenProvider, task, listId = "@default") {
|
|
65
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks`;
|
|
66
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(task) });
|
|
67
|
+
return res.json();
|
|
68
|
+
}
|
|
69
|
+
export async function updateTask(tokenProvider, taskId, task, listId = "@default") {
|
|
70
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`;
|
|
71
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(task) });
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
export async function deleteTask(tokenProvider, taskId, listId = "@default") {
|
|
75
|
+
const url = `https://tasks.googleapis.com/tasks/v1/lists/${encodeURIComponent(listId)}/tasks/${encodeURIComponent(taskId)}`;
|
|
76
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
77
|
+
}
|
|
78
|
+
// ── Contacts (People API) ──
|
|
79
|
+
export async function createContact(tokenProvider, person) {
|
|
80
|
+
const url = `https://people.googleapis.com/v1/people:createContact`;
|
|
81
|
+
const res = await googleFetch(tokenProvider, url, { method: "POST", body: JSON.stringify(person) });
|
|
82
|
+
return res.json();
|
|
83
|
+
}
|
|
84
|
+
export async function updateContact(tokenProvider, resourceName, updatePersonFields, person) {
|
|
85
|
+
const url = `https://people.googleapis.com/v1/${resourceName}:updateContact`
|
|
86
|
+
+ `?updatePersonFields=${encodeURIComponent(updatePersonFields)}`;
|
|
87
|
+
const res = await googleFetch(tokenProvider, url, { method: "PATCH", body: JSON.stringify(person) });
|
|
88
|
+
return res.json();
|
|
89
|
+
}
|
|
90
|
+
export async function deleteContact(tokenProvider, resourceName) {
|
|
91
|
+
const url = `https://people.googleapis.com/v1/${resourceName}:deleteContact`;
|
|
92
|
+
await googleFetch(tokenProvider, url, { method: "DELETE" });
|
|
93
|
+
}
|
|
94
|
+
// ── Conversion helpers ──
|
|
95
|
+
export function calendarEventToLocal(ev, accountId) {
|
|
96
|
+
const allDay = !!ev.start.date && !ev.start.dateTime;
|
|
97
|
+
const startMs = allDay
|
|
98
|
+
? Date.parse(ev.start.date)
|
|
99
|
+
: Date.parse(ev.start.dateTime);
|
|
100
|
+
const endMs = allDay
|
|
101
|
+
? Date.parse(ev.end.date)
|
|
102
|
+
: Date.parse(ev.end.dateTime);
|
|
103
|
+
return {
|
|
104
|
+
providerId: ev.id, accountId,
|
|
105
|
+
title: ev.summary || "(no title)",
|
|
106
|
+
startMs, endMs, allDay,
|
|
107
|
+
location: ev.location || "", notes: ev.description || "",
|
|
108
|
+
etag: ev.etag || "",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function localToCalendarEvent(local) {
|
|
112
|
+
const toIso = (ms) => new Date(ms).toISOString();
|
|
113
|
+
const toDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
114
|
+
return {
|
|
115
|
+
summary: local.title,
|
|
116
|
+
start: local.allDay ? { date: toDate(local.startMs) } : { dateTime: toIso(local.startMs) },
|
|
117
|
+
end: local.allDay ? { date: toDate(local.endMs) } : { dateTime: toIso(local.endMs) },
|
|
118
|
+
location: local.location || undefined,
|
|
119
|
+
description: local.notes || undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function taskToLocal(t, accountId) {
|
|
123
|
+
return {
|
|
124
|
+
providerId: t.id, accountId, title: t.title || "",
|
|
125
|
+
notes: t.notes || "",
|
|
126
|
+
dueMs: t.due ? Date.parse(t.due) : undefined,
|
|
127
|
+
completedMs: t.completed ? Date.parse(t.completed) : undefined,
|
|
128
|
+
etag: t.etag || "",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function localToTask(local) {
|
|
132
|
+
return {
|
|
133
|
+
title: local.title,
|
|
134
|
+
notes: local.notes || undefined,
|
|
135
|
+
due: local.dueMs ? new Date(local.dueMs).toISOString() : undefined,
|
|
136
|
+
completed: local.completedMs ? new Date(local.completedMs).toISOString() : undefined,
|
|
137
|
+
status: local.completedMs ? "completed" : "needsAction",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=google-sync.js.map
|
|
@@ -47,6 +47,46 @@ export declare class MailxService {
|
|
|
47
47
|
* Called without `feature` it returns the catch-all primary — same
|
|
48
48
|
* semantics as the original single-flag version for back-compat. */
|
|
49
49
|
getPrimaryAccount(feature?: string): any;
|
|
50
|
+
private primaryTokenProvider;
|
|
51
|
+
/** Return cal events visible in [fromMs..toMs), refreshing from Google
|
|
52
|
+
* in the background if enough time has passed. Caller displays local
|
|
53
|
+
* results immediately; events updated from Google emit an event. */
|
|
54
|
+
getCalendarEvents(fromMs: number, toMs: number): Promise<any[]>;
|
|
55
|
+
private refreshCalendarEvents;
|
|
56
|
+
createCalendarEventLocal(ev: {
|
|
57
|
+
title: string;
|
|
58
|
+
startMs: number;
|
|
59
|
+
endMs: number;
|
|
60
|
+
allDay?: boolean;
|
|
61
|
+
location?: string;
|
|
62
|
+
notes?: string;
|
|
63
|
+
}): Promise<string>;
|
|
64
|
+
updateCalendarEventLocal(uuid: string, patch: {
|
|
65
|
+
title?: string;
|
|
66
|
+
startMs?: number;
|
|
67
|
+
endMs?: number;
|
|
68
|
+
allDay?: boolean;
|
|
69
|
+
location?: string;
|
|
70
|
+
notes?: string;
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
deleteCalendarEventLocal(uuid: string): Promise<void>;
|
|
73
|
+
getTasks(includeCompleted?: boolean): Promise<any[]>;
|
|
74
|
+
private refreshTasks;
|
|
75
|
+
createTaskLocal(t: {
|
|
76
|
+
title: string;
|
|
77
|
+
notes?: string;
|
|
78
|
+
dueMs?: number;
|
|
79
|
+
}): Promise<string>;
|
|
80
|
+
updateTaskLocal(uuid: string, patch: {
|
|
81
|
+
title?: string;
|
|
82
|
+
notes?: string;
|
|
83
|
+
dueMs?: number;
|
|
84
|
+
completedMs?: number;
|
|
85
|
+
}): Promise<void>;
|
|
86
|
+
deleteTaskLocal(uuid: string): Promise<void>;
|
|
87
|
+
/** Drain the store_sync queue — calendar / tasks / contacts push-to-server.
|
|
88
|
+
* Called on every local edit, and on a periodic tick from the outbox worker. */
|
|
89
|
+
drainStoreSync(): Promise<void>;
|
|
50
90
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
51
91
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
52
92
|
listQueuedOutgoing(): any[];
|
|
@@ -95,11 +135,13 @@ export declare class MailxService {
|
|
|
95
135
|
addContact(name: string, email: string): boolean;
|
|
96
136
|
/** Address-book listing — paginated, filterable. */
|
|
97
137
|
listContacts(query: string, page?: number, pageSize?: number): any;
|
|
98
|
-
/** Upsert a contact from the address book UI (edit name).
|
|
138
|
+
/** Upsert a contact from the address book UI (edit name). Two-way cache:
|
|
139
|
+
* commits locally, queues a Google People push. */
|
|
99
140
|
upsertContact(name: string, email: string): {
|
|
100
141
|
ok: true;
|
|
101
142
|
};
|
|
102
|
-
/** Delete a contact from the address book.
|
|
143
|
+
/** Delete a contact from the address book. Also pushes the deletion to
|
|
144
|
+
* Google People if the contact had a resourceName (i.e. was synced). */
|
|
103
145
|
deleteContact(email: string): {
|
|
104
146
|
ok: true;
|
|
105
147
|
};
|