@bobfrankston/mailx 1.0.339 → 1.0.348
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 +87 -7
- package/client/app.js +413 -32
- package/client/components/address-book.js +199 -0
- package/client/components/calendar.js +217 -0
- package/client/components/folder-tree.js +62 -16
- package/client/components/message-list.js +9 -0
- package/client/components/message-viewer.js +41 -8
- package/client/components/outbox-view.js +104 -0
- package/client/components/tasks.js +256 -0
- package/client/compose/compose.html +2 -2
- package/client/compose/compose.js +87 -39
- package/client/compose/editor.js +67 -0
- package/client/index.html +8 -6
- package/client/lib/api-client.js +21 -0
- package/client/lib/mailxapi.js +15 -0
- package/client/styles/components.css +354 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +24 -0
- package/packages/mailx-imap/index.js +132 -6
- package/packages/mailx-service/index.d.ts +25 -0
- package/packages/mailx-service/index.js +142 -5
- package/packages/mailx-service/jsonrpc.js +20 -1
- package/packages/mailx-settings/index.js +18 -3
- package/packages/mailx-store/db.d.ts +17 -0
- package/packages/mailx-store/db.js +122 -4
- package/packages/mailx-types/index.d.ts +1 -0
package/client/index.html
CHANGED
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
<label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
|
|
43
43
|
<hr class="tb-menu-sep">
|
|
44
44
|
<button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc">Edit config files...</button>
|
|
45
|
+
<button class="tb-menu-item" id="btn-open-mailx-dir" title="Open ~/.mailx in file explorer">Open mailx folder...</button>
|
|
46
|
+
<button class="tb-menu-item" id="btn-open-log" title="Open today's log file">Open log...</button>
|
|
45
47
|
<button class="tb-menu-item" id="btn-about" title="Show version and build info">About mailx...</button>
|
|
46
48
|
</div>
|
|
47
49
|
</div>
|
|
@@ -76,9 +78,9 @@
|
|
|
76
78
|
<button class="rail-btn" id="rail-compose" title="Compose (Ctrl+N)" aria-label="Compose">✏</button>
|
|
77
79
|
<button class="rail-btn" id="rail-inbox" title="Inbox" aria-label="Inbox" data-active="true">✉</button>
|
|
78
80
|
<button class="rail-btn" id="rail-unified" title="All Inboxes" aria-label="All Inboxes">⌘</button>
|
|
79
|
-
<button class="rail-btn" id="rail-contacts" title="Contacts
|
|
80
|
-
<button class="rail-btn" id="rail-calendar" title="Calendar
|
|
81
|
-
<button class="rail-btn" id="rail-tasks" title="Tasks
|
|
81
|
+
<button class="rail-btn" id="rail-contacts" title="Contacts / Address book" aria-label="Contacts">👤</button>
|
|
82
|
+
<button class="rail-btn" id="rail-calendar" title="Calendar" aria-label="Calendar">📅</button>
|
|
83
|
+
<button class="rail-btn" id="rail-tasks" title="Tasks" aria-label="Tasks">☑</button>
|
|
82
84
|
</div>
|
|
83
85
|
<div class="rail-bottom">
|
|
84
86
|
<button class="rail-btn" id="rail-settings" title="Settings" aria-label="Settings">⚙</button>
|
|
@@ -99,12 +101,12 @@
|
|
|
99
101
|
<main class="main-area">
|
|
100
102
|
<section class="message-list" id="message-list">
|
|
101
103
|
<search class="search-bar ml-search">
|
|
102
|
-
<select id="search-scope" title="
|
|
104
|
+
<select id="search-scope" title="Local search scope">
|
|
103
105
|
<option value="all">All folders</option>
|
|
104
106
|
<option value="current">This folder</option>
|
|
105
|
-
<option value="server">IMAP server</option>
|
|
106
107
|
</select>
|
|
107
|
-
<input type="search" id="search-input" placeholder="Search... (/regex/)" autocomplete="off" title="Search messages. /pattern/ for regex. Qualifiers: from: to: subject:">
|
|
108
|
+
<input type="search" id="search-input" placeholder="Search... (/regex/)" autocomplete="off" title="Search messages. /pattern/ for regex. Qualifiers: from: to: subject: date: after: before: has:attachment is:flagged|unread|answered|draft folder:">
|
|
109
|
+
<label class="search-server-check" title="Also search the IMAP server (slower; spans all folders on all accounts)"><input type="checkbox" id="search-server-too"> Server</label>
|
|
108
110
|
</search>
|
|
109
111
|
<div class="ml-folder-title" id="ml-folder-title"></div>
|
|
110
112
|
<div class="ml-header">
|
package/client/lib/api-client.js
CHANGED
|
@@ -62,9 +62,30 @@ export function reauthenticate(accountId) {
|
|
|
62
62
|
export function getSyncPending() {
|
|
63
63
|
return ipc().getSyncPending();
|
|
64
64
|
}
|
|
65
|
+
export function getOutboxStatus() {
|
|
66
|
+
return ipc().getOutboxStatus();
|
|
67
|
+
}
|
|
68
|
+
export function listQueuedOutgoing() {
|
|
69
|
+
return ipc().listQueuedOutgoing();
|
|
70
|
+
}
|
|
71
|
+
export function cancelQueuedOutgoing(p) {
|
|
72
|
+
return ipc().cancelQueuedOutgoing(p);
|
|
73
|
+
}
|
|
65
74
|
export function searchContacts(query) {
|
|
66
75
|
return ipc().searchContacts(query);
|
|
67
76
|
}
|
|
77
|
+
export function listContacts(query, page = 1, pageSize = 100) {
|
|
78
|
+
return ipc().listContacts(query, page, pageSize);
|
|
79
|
+
}
|
|
80
|
+
export function upsertContact(name, email) {
|
|
81
|
+
return ipc().upsertContact(name, email);
|
|
82
|
+
}
|
|
83
|
+
export function deleteContact(email) {
|
|
84
|
+
return ipc().deleteContact(email);
|
|
85
|
+
}
|
|
86
|
+
export function openLocalPath(which) {
|
|
87
|
+
return ipc().openLocalPath(which);
|
|
88
|
+
}
|
|
68
89
|
export function allowRemoteContent(type, value) {
|
|
69
90
|
return ipc().allowRemoteContent(type, value);
|
|
70
91
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -135,11 +135,26 @@
|
|
|
135
135
|
searchContacts: function(query) {
|
|
136
136
|
return callNode("searchContacts", { query: query });
|
|
137
137
|
},
|
|
138
|
+
listContacts: function(query, page, pageSize) {
|
|
139
|
+
return callNode("listContacts", { query: query || "", page: page || 1, pageSize: pageSize || 100 });
|
|
140
|
+
},
|
|
141
|
+
upsertContact: function(name, email) {
|
|
142
|
+
return callNode("upsertContact", { name: name, email: email });
|
|
143
|
+
},
|
|
144
|
+
deleteContact: function(email) {
|
|
145
|
+
return callNode("deleteContact", { email: email });
|
|
146
|
+
},
|
|
147
|
+
openLocalPath: function(which) {
|
|
148
|
+
return callNode("openLocalPath", { which: which });
|
|
149
|
+
},
|
|
138
150
|
|
|
139
151
|
// Sync
|
|
140
152
|
syncAll: function() { return callNode("syncAll"); },
|
|
141
153
|
syncAccount: function(accountId) { return callNode("syncAccount", { accountId: accountId }); },
|
|
142
154
|
getSyncPending: function() { return callNode("getSyncPending"); },
|
|
155
|
+
getOutboxStatus: function() { return callNode("getOutboxStatus"); },
|
|
156
|
+
listQueuedOutgoing: function() { return callNode("listQueuedOutgoing"); },
|
|
157
|
+
cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
|
|
143
158
|
reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
|
|
144
159
|
|
|
145
160
|
// Bulk operations
|
|
@@ -924,6 +924,29 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
924
924
|
color: var(--color-text);
|
|
925
925
|
font-weight: 600;
|
|
926
926
|
}
|
|
927
|
+
.mv-details-row {
|
|
928
|
+
display: flex;
|
|
929
|
+
align-items: center;
|
|
930
|
+
gap: 6px;
|
|
931
|
+
padding: 2px 0;
|
|
932
|
+
}
|
|
933
|
+
.mv-details-value {
|
|
934
|
+
flex: 1;
|
|
935
|
+
font-family: var(--font-mono);
|
|
936
|
+
font-size: 0.85em;
|
|
937
|
+
word-break: break-all;
|
|
938
|
+
}
|
|
939
|
+
.mv-details-copy {
|
|
940
|
+
background: none;
|
|
941
|
+
border: none;
|
|
942
|
+
cursor: pointer;
|
|
943
|
+
color: var(--color-text-muted);
|
|
944
|
+
font-size: 1rem;
|
|
945
|
+
padding: 0 4px;
|
|
946
|
+
border-radius: 3px;
|
|
947
|
+
|
|
948
|
+
&:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
949
|
+
}
|
|
927
950
|
.mv-action-primary {
|
|
928
951
|
background: var(--color-brand-dark) !important;
|
|
929
952
|
color: white !important;
|
|
@@ -1062,6 +1085,337 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
1062
1085
|
.status-action:hover { background: oklch(0.65 0.15 25); color: #fff; }
|
|
1063
1086
|
.status-action:disabled { opacity: 0.5; cursor: default; }
|
|
1064
1087
|
|
|
1088
|
+
/* ── Address Book ── */
|
|
1089
|
+
.ab-toolbar {
|
|
1090
|
+
display: flex;
|
|
1091
|
+
gap: var(--gap-sm);
|
|
1092
|
+
align-items: center;
|
|
1093
|
+
}
|
|
1094
|
+
.ab-toolbar input {
|
|
1095
|
+
flex: 1;
|
|
1096
|
+
}
|
|
1097
|
+
.ab-count {
|
|
1098
|
+
font-size: var(--font-size-sm);
|
|
1099
|
+
color: var(--color-text-muted);
|
|
1100
|
+
}
|
|
1101
|
+
.ab-list {
|
|
1102
|
+
flex: 1;
|
|
1103
|
+
overflow: auto;
|
|
1104
|
+
border: 1px solid var(--color-border);
|
|
1105
|
+
border-radius: var(--radius-sm);
|
|
1106
|
+
min-height: 240px;
|
|
1107
|
+
}
|
|
1108
|
+
.ab-row {
|
|
1109
|
+
display: grid;
|
|
1110
|
+
grid-template-columns: 180px minmax(220px, 1fr) 80px 50px 80px 96px;
|
|
1111
|
+
gap: var(--gap-sm);
|
|
1112
|
+
padding: 4px 8px;
|
|
1113
|
+
align-items: center;
|
|
1114
|
+
border-bottom: 1px solid var(--color-border-faint, rgba(0,0,0,0.05));
|
|
1115
|
+
font-size: var(--font-size-sm);
|
|
1116
|
+
|
|
1117
|
+
&:hover { background: var(--color-bg-hover); }
|
|
1118
|
+
&.ab-header {
|
|
1119
|
+
font-weight: 600;
|
|
1120
|
+
background: var(--color-bg-surface);
|
|
1121
|
+
position: sticky;
|
|
1122
|
+
top: 0;
|
|
1123
|
+
z-index: 1;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
.ab-name, .ab-email { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1127
|
+
.ab-name-input { width: 100%; box-sizing: border-box; }
|
|
1128
|
+
.ab-source { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; }
|
|
1129
|
+
.ab-count-cell { text-align: right; color: var(--color-text-muted); }
|
|
1130
|
+
.ab-last { color: var(--color-text-muted); font-size: 0.85em; }
|
|
1131
|
+
.ab-actions { display: flex; gap: 2px; justify-content: flex-end; }
|
|
1132
|
+
.ab-actions button {
|
|
1133
|
+
background: none;
|
|
1134
|
+
border: none;
|
|
1135
|
+
cursor: pointer;
|
|
1136
|
+
padding: 2px 6px;
|
|
1137
|
+
border-radius: 3px;
|
|
1138
|
+
font-size: 0.9rem;
|
|
1139
|
+
|
|
1140
|
+
&:hover { background: var(--color-bg-hover); }
|
|
1141
|
+
}
|
|
1142
|
+
.ab-empty {
|
|
1143
|
+
padding: 24px;
|
|
1144
|
+
text-align: center;
|
|
1145
|
+
color: var(--color-text-muted);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/* ── Calendar ── */
|
|
1149
|
+
.cal-grid {
|
|
1150
|
+
display: grid;
|
|
1151
|
+
grid-template-columns: 380px 1fr;
|
|
1152
|
+
gap: var(--gap-md);
|
|
1153
|
+
flex: 1;
|
|
1154
|
+
min-height: 0;
|
|
1155
|
+
}
|
|
1156
|
+
.cal-month, .cal-upcoming {
|
|
1157
|
+
display: flex;
|
|
1158
|
+
flex-direction: column;
|
|
1159
|
+
min-height: 0;
|
|
1160
|
+
}
|
|
1161
|
+
.cal-upcoming { overflow: auto; }
|
|
1162
|
+
.cal-nav {
|
|
1163
|
+
display: flex;
|
|
1164
|
+
align-items: center;
|
|
1165
|
+
gap: var(--gap-sm);
|
|
1166
|
+
padding: 4px 0;
|
|
1167
|
+
}
|
|
1168
|
+
.cal-nav-title { flex: 1; font-weight: 600; }
|
|
1169
|
+
.cal-nav-btn {
|
|
1170
|
+
background: none;
|
|
1171
|
+
border: 1px solid var(--color-border);
|
|
1172
|
+
border-radius: var(--radius-sm);
|
|
1173
|
+
padding: 2px 8px;
|
|
1174
|
+
cursor: pointer;
|
|
1175
|
+
font-size: 0.9rem;
|
|
1176
|
+
|
|
1177
|
+
&:hover { background: var(--color-bg-hover); }
|
|
1178
|
+
}
|
|
1179
|
+
.cal-month-grid {
|
|
1180
|
+
display: grid;
|
|
1181
|
+
grid-template-columns: repeat(7, 1fr);
|
|
1182
|
+
gap: 2px;
|
|
1183
|
+
}
|
|
1184
|
+
.cal-dow {
|
|
1185
|
+
text-align: center;
|
|
1186
|
+
font-size: 0.75rem;
|
|
1187
|
+
color: var(--color-text-muted);
|
|
1188
|
+
padding: 4px 0;
|
|
1189
|
+
}
|
|
1190
|
+
.cal-day {
|
|
1191
|
+
aspect-ratio: 1;
|
|
1192
|
+
border: 1px solid transparent;
|
|
1193
|
+
background: none;
|
|
1194
|
+
cursor: pointer;
|
|
1195
|
+
border-radius: var(--radius-sm);
|
|
1196
|
+
font-size: 0.85rem;
|
|
1197
|
+
color: var(--color-text);
|
|
1198
|
+
|
|
1199
|
+
&.cal-day-blank { visibility: hidden; cursor: default; }
|
|
1200
|
+
&:hover:not(.cal-day-blank) { background: var(--color-bg-hover); }
|
|
1201
|
+
&.cal-day-today { font-weight: 700; color: var(--color-accent); }
|
|
1202
|
+
&.cal-day-selected { background: var(--color-accent); color: #fff; }
|
|
1203
|
+
&.cal-day-has-event::after {
|
|
1204
|
+
content: "•";
|
|
1205
|
+
display: block;
|
|
1206
|
+
margin-top: -4px;
|
|
1207
|
+
font-size: 0.6rem;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
.cal-section-title {
|
|
1211
|
+
font-weight: 600;
|
|
1212
|
+
margin-bottom: var(--gap-sm);
|
|
1213
|
+
color: var(--color-text-muted);
|
|
1214
|
+
}
|
|
1215
|
+
.cal-event {
|
|
1216
|
+
border-left: 3px solid var(--color-accent);
|
|
1217
|
+
padding: 6px 8px;
|
|
1218
|
+
margin-bottom: 6px;
|
|
1219
|
+
background: var(--color-bg-surface);
|
|
1220
|
+
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
|
1221
|
+
position: relative;
|
|
1222
|
+
}
|
|
1223
|
+
.cal-event-time { font-size: 0.75rem; color: var(--color-text-muted); }
|
|
1224
|
+
.cal-event-title { font-weight: 500; }
|
|
1225
|
+
.cal-event-loc { font-size: 0.85rem; color: var(--color-text-muted); }
|
|
1226
|
+
.cal-event-del {
|
|
1227
|
+
position: absolute;
|
|
1228
|
+
right: 4px;
|
|
1229
|
+
top: 4px;
|
|
1230
|
+
background: none;
|
|
1231
|
+
border: none;
|
|
1232
|
+
cursor: pointer;
|
|
1233
|
+
color: var(--color-text-muted);
|
|
1234
|
+
font-size: 1.1rem;
|
|
1235
|
+
|
|
1236
|
+
&:hover { color: oklch(0.65 0.2 25); }
|
|
1237
|
+
}
|
|
1238
|
+
.cal-empty { color: var(--color-text-muted); padding: 12px 0; }
|
|
1239
|
+
.cal-source-note { font-size: 0.75rem; color: var(--color-text-muted); }
|
|
1240
|
+
|
|
1241
|
+
/* ── Tasks ── */
|
|
1242
|
+
.tk-toolbar { padding-bottom: var(--gap-sm); }
|
|
1243
|
+
.tk-toolbar input { width: 100%; box-sizing: border-box; }
|
|
1244
|
+
.tk-filters {
|
|
1245
|
+
display: flex;
|
|
1246
|
+
gap: 4px;
|
|
1247
|
+
align-items: center;
|
|
1248
|
+
padding-bottom: var(--gap-sm);
|
|
1249
|
+
border-bottom: 1px solid var(--color-border);
|
|
1250
|
+
}
|
|
1251
|
+
.tk-filter-btn {
|
|
1252
|
+
background: none;
|
|
1253
|
+
border: 1px solid transparent;
|
|
1254
|
+
padding: 4px 10px;
|
|
1255
|
+
cursor: pointer;
|
|
1256
|
+
border-radius: var(--radius-sm);
|
|
1257
|
+
color: var(--color-text-muted);
|
|
1258
|
+
font-size: var(--font-size-sm);
|
|
1259
|
+
|
|
1260
|
+
&:hover { background: var(--color-bg-hover); }
|
|
1261
|
+
&.tk-filter-active {
|
|
1262
|
+
background: var(--color-bg-surface);
|
|
1263
|
+
border-color: var(--color-border);
|
|
1264
|
+
color: var(--color-text);
|
|
1265
|
+
font-weight: 500;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
.tk-spacer { flex: 1; }
|
|
1269
|
+
.tk-count { font-size: var(--font-size-sm); color: var(--color-text-muted); }
|
|
1270
|
+
.tk-list {
|
|
1271
|
+
flex: 1;
|
|
1272
|
+
overflow: auto;
|
|
1273
|
+
min-height: 240px;
|
|
1274
|
+
}
|
|
1275
|
+
.tk-row {
|
|
1276
|
+
display: grid;
|
|
1277
|
+
grid-template-columns: 24px 24px minmax(180px, 1fr) auto 80px 28px 28px;
|
|
1278
|
+
gap: var(--gap-sm);
|
|
1279
|
+
padding: 6px 4px;
|
|
1280
|
+
align-items: center;
|
|
1281
|
+
border-bottom: 1px solid var(--color-border-faint, rgba(0,0,0,0.05));
|
|
1282
|
+
|
|
1283
|
+
&:hover { background: var(--color-bg-hover); }
|
|
1284
|
+
&.tk-done .tk-title { text-decoration: line-through; color: var(--color-text-muted); }
|
|
1285
|
+
}
|
|
1286
|
+
.tk-prio {
|
|
1287
|
+
text-align: center;
|
|
1288
|
+
font-weight: 700;
|
|
1289
|
+
|
|
1290
|
+
&.tk-prio-3 { color: oklch(0.65 0.2 25); }
|
|
1291
|
+
&.tk-prio-2 { color: oklch(0.7 0.18 60); }
|
|
1292
|
+
&.tk-prio-1 { color: var(--color-text-muted); }
|
|
1293
|
+
}
|
|
1294
|
+
.tk-title {
|
|
1295
|
+
outline: none;
|
|
1296
|
+
padding: 2px 4px;
|
|
1297
|
+
border-radius: 3px;
|
|
1298
|
+
|
|
1299
|
+
&:focus { background: var(--color-bg-surface); box-shadow: inset 0 0 0 1px var(--color-accent); }
|
|
1300
|
+
}
|
|
1301
|
+
.tk-tags {
|
|
1302
|
+
color: var(--color-accent);
|
|
1303
|
+
font-size: 0.85em;
|
|
1304
|
+
}
|
|
1305
|
+
.tk-due {
|
|
1306
|
+
text-align: right;
|
|
1307
|
+
color: var(--color-text-muted);
|
|
1308
|
+
font-size: 0.85em;
|
|
1309
|
+
|
|
1310
|
+
&.tk-overdue { color: oklch(0.65 0.2 25); font-weight: 500; }
|
|
1311
|
+
}
|
|
1312
|
+
.tk-snooze, .tk-del {
|
|
1313
|
+
background: none;
|
|
1314
|
+
border: none;
|
|
1315
|
+
cursor: pointer;
|
|
1316
|
+
color: var(--color-text-muted);
|
|
1317
|
+
padding: 2px 4px;
|
|
1318
|
+
border-radius: 3px;
|
|
1319
|
+
|
|
1320
|
+
&:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
1321
|
+
}
|
|
1322
|
+
.tk-empty {
|
|
1323
|
+
padding: 24px;
|
|
1324
|
+
text-align: center;
|
|
1325
|
+
color: var(--color-text-muted);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/* Server-search orthogonal checkbox */
|
|
1329
|
+
.search-server-check {
|
|
1330
|
+
display: inline-flex;
|
|
1331
|
+
align-items: center;
|
|
1332
|
+
gap: 4px;
|
|
1333
|
+
margin-left: 6px;
|
|
1334
|
+
padding: 2px 6px;
|
|
1335
|
+
font-size: 0.85rem;
|
|
1336
|
+
color: var(--color-text-muted);
|
|
1337
|
+
cursor: pointer;
|
|
1338
|
+
border-radius: var(--radius-sm);
|
|
1339
|
+
|
|
1340
|
+
&:has(input:checked) {
|
|
1341
|
+
color: var(--color-accent);
|
|
1342
|
+
font-weight: 500;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/* ── Outbox / Pink-row view ── */
|
|
1347
|
+
.ob-info {
|
|
1348
|
+
color: var(--color-text-muted);
|
|
1349
|
+
font-size: var(--font-size-sm);
|
|
1350
|
+
padding-bottom: var(--gap-sm);
|
|
1351
|
+
}
|
|
1352
|
+
.ob-list {
|
|
1353
|
+
flex: 1;
|
|
1354
|
+
overflow: auto;
|
|
1355
|
+
min-height: 200px;
|
|
1356
|
+
}
|
|
1357
|
+
.ob-row {
|
|
1358
|
+
border: 1px solid var(--color-border);
|
|
1359
|
+
border-radius: var(--radius-sm);
|
|
1360
|
+
padding: 8px 12px;
|
|
1361
|
+
margin-bottom: 8px;
|
|
1362
|
+
position: relative;
|
|
1363
|
+
font-size: 0.9rem;
|
|
1364
|
+
}
|
|
1365
|
+
.ob-pink {
|
|
1366
|
+
/* Visible-reconciliation-state: local-only not yet on the server. */
|
|
1367
|
+
background: color-mix(in oklch, oklch(0.75 0.15 350) 15%, var(--color-bg-surface));
|
|
1368
|
+
border-color: oklch(0.7 0.15 350);
|
|
1369
|
+
}
|
|
1370
|
+
.ob-row-hdr {
|
|
1371
|
+
display: flex;
|
|
1372
|
+
gap: 8px;
|
|
1373
|
+
align-items: baseline;
|
|
1374
|
+
flex-wrap: wrap;
|
|
1375
|
+
margin-bottom: 4px;
|
|
1376
|
+
}
|
|
1377
|
+
.ob-acct {
|
|
1378
|
+
font-size: 0.75rem;
|
|
1379
|
+
color: var(--color-text-muted);
|
|
1380
|
+
background: var(--color-bg);
|
|
1381
|
+
padding: 1px 6px;
|
|
1382
|
+
border-radius: 3px;
|
|
1383
|
+
text-transform: uppercase;
|
|
1384
|
+
}
|
|
1385
|
+
.ob-subject { font-weight: 600; flex: 1; }
|
|
1386
|
+
.ob-created { color: var(--color-text-muted); font-size: 0.8rem; }
|
|
1387
|
+
.ob-badge {
|
|
1388
|
+
font-size: 0.75rem;
|
|
1389
|
+
padding: 1px 6px;
|
|
1390
|
+
border-radius: 10px;
|
|
1391
|
+
background: var(--color-bg);
|
|
1392
|
+
color: var(--color-text-muted);
|
|
1393
|
+
}
|
|
1394
|
+
.ob-claimed { background: oklch(0.7 0.15 200); color: #fff; }
|
|
1395
|
+
.ob-retry { background: oklch(0.75 0.15 60); color: #fff; }
|
|
1396
|
+
.ob-row-meta { font-size: 0.85rem; color: var(--color-text-muted); }
|
|
1397
|
+
.ob-row-path {
|
|
1398
|
+
font-family: var(--font-mono);
|
|
1399
|
+
font-size: 0.75rem;
|
|
1400
|
+
color: var(--color-text-muted);
|
|
1401
|
+
margin-top: 4px;
|
|
1402
|
+
word-break: break-all;
|
|
1403
|
+
}
|
|
1404
|
+
.ob-row-actions { margin-top: 6px; }
|
|
1405
|
+
.ob-cancel {
|
|
1406
|
+
background: none;
|
|
1407
|
+
border: 1px solid oklch(0.65 0.2 25);
|
|
1408
|
+
color: oklch(0.65 0.2 25);
|
|
1409
|
+
padding: 3px 10px;
|
|
1410
|
+
border-radius: var(--radius-sm);
|
|
1411
|
+
cursor: pointer;
|
|
1412
|
+
font-size: 0.85rem;
|
|
1413
|
+
|
|
1414
|
+
&:hover:not(:disabled) { background: oklch(0.65 0.2 25); color: #fff; }
|
|
1415
|
+
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
1416
|
+
}
|
|
1417
|
+
.ob-empty { padding: 24px; text-align: center; color: var(--color-text-muted); }
|
|
1418
|
+
|
|
1065
1419
|
/* ── Startup Overlay ── */
|
|
1066
1420
|
|
|
1067
1421
|
.startup-overlay {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.348",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
27
|
"@bobfrankston/msger": "^0.1.344",
|
|
28
|
-
"@bobfrankston/mailx-host": "^0.1.
|
|
28
|
+
"@bobfrankston/mailx-host": "^0.1.4",
|
|
29
29
|
"@capacitor/android": "^8.3.0",
|
|
30
30
|
"@capacitor/cli": "^8.3.0",
|
|
31
31
|
"@capacitor/core": "^8.3.0",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"@bobfrankston/miscinfo": "^1.0.9",
|
|
90
90
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
91
91
|
"@bobfrankston/msger": "^0.1.344",
|
|
92
|
-
"@bobfrankston/mailx-host": "^0.1.
|
|
92
|
+
"@bobfrankston/mailx-host": "^0.1.4",
|
|
93
93
|
"@capacitor/android": "^8.3.0",
|
|
94
94
|
"@capacitor/cli": "^8.3.0",
|
|
95
95
|
"@capacitor/core": "^8.3.0",
|
|
@@ -23,6 +23,24 @@ export interface ImapManagerEvents {
|
|
|
23
23
|
* the UI flip a row's "not-downloaded" indicator without re-rendering. */
|
|
24
24
|
bodyCached: (accountId: string, uid: number) => void;
|
|
25
25
|
syncActionFailed: (accountId: string, action: string, uid: number, error: string) => void;
|
|
26
|
+
/** Fired whenever the outbox queue depth or state changes (file added,
|
|
27
|
+
* file sent and removed, retry attempted). Lets the UI show a persistent
|
|
28
|
+
* queue-status indicator without polling. Aggregate status across all
|
|
29
|
+
* accounts is included so the listener doesn't have to reassemble it. */
|
|
30
|
+
outboxStatus: (status: OutboxStatus) => void;
|
|
31
|
+
}
|
|
32
|
+
/** Per-account outbox queue breakdown, plus totals for the UI. */
|
|
33
|
+
export interface OutboxStatus {
|
|
34
|
+
total: number;
|
|
35
|
+
retrying: number;
|
|
36
|
+
claimed: number;
|
|
37
|
+
oldestAgeSec: number;
|
|
38
|
+
maxAttempts: number;
|
|
39
|
+
perAccount: Record<string, {
|
|
40
|
+
total: number;
|
|
41
|
+
retrying: number;
|
|
42
|
+
claimed: number;
|
|
43
|
+
}>;
|
|
26
44
|
}
|
|
27
45
|
export declare class ImapManager extends EventEmitter {
|
|
28
46
|
private configs;
|
|
@@ -231,6 +249,12 @@ export declare class ImapManager extends EventEmitter {
|
|
|
231
249
|
* sync_actions "send" branch was removed because it duplicated the same
|
|
232
250
|
* work and risked double-send when both paths fired on the same message. */
|
|
233
251
|
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
252
|
+
/** Scan the local outbox + sending/queued dirs and return counts + age.
|
|
253
|
+
* Cheap — a handful of readdir + head-read per file. Called by both the
|
|
254
|
+
* polling UI (status bar) and emitted as an event after queue mutations. */
|
|
255
|
+
getOutboxStatus(): OutboxStatus;
|
|
256
|
+
/** Emit outboxStatus now. Call after any queue mutation. */
|
|
257
|
+
private emitOutboxStatus;
|
|
234
258
|
/** Guard against concurrent processSendActions for the same account */
|
|
235
259
|
private sendingAccounts;
|
|
236
260
|
/** Process local send actions — APPEND to Outbox, which the outbox worker then sends */
|
|
@@ -303,8 +303,25 @@ export class ImapManager extends EventEmitter {
|
|
|
303
303
|
* logout() is wrapped as a no-op so legacy callers don't close it. */
|
|
304
304
|
async getOpsClient(accountId) {
|
|
305
305
|
let client = this.opsClients.get(accountId);
|
|
306
|
-
if (client)
|
|
307
|
-
|
|
306
|
+
if (client) {
|
|
307
|
+
// C38: health-check the cached client before returning. If the
|
|
308
|
+
// underlying socket is dead (Dovecot silently dropped IDLE after
|
|
309
|
+
// the inactivity timeout, or we lost connectivity), the next
|
|
310
|
+
// command would fail with "Not connected" — and nothing would
|
|
311
|
+
// recover it until an explicit reconnectOps was called from the
|
|
312
|
+
// catch handler. Cheap pre-check here catches it earlier.
|
|
313
|
+
const sock = client?.native?.transport?.socket;
|
|
314
|
+
const dead = sock?.destroyed || sock?.readyState === "closed" || client?._dead;
|
|
315
|
+
if (!dead)
|
|
316
|
+
return client;
|
|
317
|
+
try {
|
|
318
|
+
await (client._realLogout || client.logout)();
|
|
319
|
+
}
|
|
320
|
+
catch { /* */ }
|
|
321
|
+
this.opsClients.delete(accountId);
|
|
322
|
+
console.log(` [conn] ${accountId}: stale ops client detected in getOpsClient — reconnecting`);
|
|
323
|
+
client = undefined;
|
|
324
|
+
}
|
|
308
325
|
client = this.newClient(accountId, "ops");
|
|
309
326
|
// Wrap logout as no-op — this is a persistent connection. The
|
|
310
327
|
// newClient wrapper's close-counter runs on `_realLogout`.
|
|
@@ -2453,15 +2470,122 @@ export class ImapManager extends EventEmitter {
|
|
|
2453
2470
|
* sync_actions "send" branch was removed because it duplicated the same
|
|
2454
2471
|
* work and risked double-send when both paths fired on the same message. */
|
|
2455
2472
|
queueOutgoingLocal(accountId, rawMessage) {
|
|
2473
|
+
// Loud logging so a "vanished message" report is diagnosable from the log alone.
|
|
2456
2474
|
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
2457
|
-
|
|
2475
|
+
try {
|
|
2476
|
+
fs.mkdirSync(outboxDir, { recursive: true });
|
|
2477
|
+
}
|
|
2478
|
+
catch (e) {
|
|
2479
|
+
console.error(` [outbox] FAIL mkdirSync ${outboxDir}: ${e?.message || e}`);
|
|
2480
|
+
throw new Error(`Cannot create outbox dir ${outboxDir}: ${e?.message || e}`);
|
|
2481
|
+
}
|
|
2458
2482
|
const now = new Date();
|
|
2459
2483
|
const pad2 = (n) => String(n).padStart(2, "0");
|
|
2460
2484
|
const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
|
|
2461
2485
|
const filePath = path.join(outboxDir, filename);
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2486
|
+
try {
|
|
2487
|
+
fs.writeFileSync(filePath, rawMessage);
|
|
2488
|
+
}
|
|
2489
|
+
catch (e) {
|
|
2490
|
+
console.error(` [outbox] FAIL writeFileSync ${filePath}: ${e?.message || e}`);
|
|
2491
|
+
throw new Error(`Cannot write outbox file ${filePath}: ${e?.message || e}`);
|
|
2492
|
+
}
|
|
2493
|
+
// Immediate readback verification — if this DOESN'T print, the user's
|
|
2494
|
+
// "neither in outbox nor file system" report has a real explanation.
|
|
2495
|
+
const written = fs.existsSync(filePath);
|
|
2496
|
+
const size = written ? fs.statSync(filePath).size : 0;
|
|
2497
|
+
console.log(` [outbox] WROTE ${filePath} (${size} bytes, exists=${written})`);
|
|
2498
|
+
this.emitOutboxStatus();
|
|
2499
|
+
// CRITICAL: defer to next tick. processLocalQueue runs ~30+ lines
|
|
2500
|
+
// of synchronous fs work BEFORE its first await — calling it inline
|
|
2501
|
+
// blocks the IPC ack on all that work.
|
|
2502
|
+
setImmediate(() => {
|
|
2503
|
+
this.processLocalQueue(accountId)
|
|
2504
|
+
.catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`))
|
|
2505
|
+
.finally(() => this.emitOutboxStatus());
|
|
2506
|
+
});
|
|
2507
|
+
}
|
|
2508
|
+
/** Scan the local outbox + sending/queued dirs and return counts + age.
|
|
2509
|
+
* Cheap — a handful of readdir + head-read per file. Called by both the
|
|
2510
|
+
* polling UI (status bar) and emitted as an event after queue mutations. */
|
|
2511
|
+
getOutboxStatus() {
|
|
2512
|
+
const configDir = getConfigDir();
|
|
2513
|
+
const perAccount = {};
|
|
2514
|
+
let total = 0;
|
|
2515
|
+
let retrying = 0;
|
|
2516
|
+
let claimed = 0;
|
|
2517
|
+
let oldestMs = 0;
|
|
2518
|
+
let maxAttempts = 0;
|
|
2519
|
+
const now = Date.now();
|
|
2520
|
+
const scan = (accountId, dir) => {
|
|
2521
|
+
if (!fs.existsSync(dir))
|
|
2522
|
+
return;
|
|
2523
|
+
for (const f of fs.readdirSync(dir)) {
|
|
2524
|
+
const isClaim = /\.sending-[^-]+-\d+$/.test(f);
|
|
2525
|
+
const isActive = isClaim || f.endsWith(".ltr") || f.endsWith(".eml");
|
|
2526
|
+
if (!isActive)
|
|
2527
|
+
continue;
|
|
2528
|
+
total++;
|
|
2529
|
+
const acctSlot = perAccount[accountId] ||= { total: 0, retrying: 0, claimed: 0 };
|
|
2530
|
+
acctSlot.total++;
|
|
2531
|
+
if (isClaim) {
|
|
2532
|
+
claimed++;
|
|
2533
|
+
acctSlot.claimed++;
|
|
2534
|
+
}
|
|
2535
|
+
const fp = path.join(dir, f);
|
|
2536
|
+
try {
|
|
2537
|
+
const st = fs.statSync(fp);
|
|
2538
|
+
const age = now - st.mtimeMs;
|
|
2539
|
+
if (age > oldestMs)
|
|
2540
|
+
oldestMs = age;
|
|
2541
|
+
// Only read header region to count retry attempts — tiny I/O.
|
|
2542
|
+
const fd = fs.openSync(fp, "r");
|
|
2543
|
+
try {
|
|
2544
|
+
const buf = Buffer.alloc(4096);
|
|
2545
|
+
const n = fs.readSync(fd, buf, 0, 4096, 0);
|
|
2546
|
+
const head = buf.slice(0, n).toString("utf-8");
|
|
2547
|
+
const info = parseRetryInfo(head);
|
|
2548
|
+
if (info.attemptCount > 0) {
|
|
2549
|
+
retrying++;
|
|
2550
|
+
acctSlot.retrying++;
|
|
2551
|
+
}
|
|
2552
|
+
if (info.attemptCount > maxAttempts)
|
|
2553
|
+
maxAttempts = info.attemptCount;
|
|
2554
|
+
}
|
|
2555
|
+
finally {
|
|
2556
|
+
fs.closeSync(fd);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
catch { /* ignore per-file errors */ }
|
|
2560
|
+
}
|
|
2561
|
+
};
|
|
2562
|
+
const outboxRoot = path.join(configDir, "outbox");
|
|
2563
|
+
const sendingRoot = path.join(configDir, "sending");
|
|
2564
|
+
try {
|
|
2565
|
+
if (fs.existsSync(outboxRoot)) {
|
|
2566
|
+
for (const acct of fs.readdirSync(outboxRoot))
|
|
2567
|
+
scan(acct, path.join(outboxRoot, acct));
|
|
2568
|
+
}
|
|
2569
|
+
if (fs.existsSync(sendingRoot)) {
|
|
2570
|
+
for (const acct of fs.readdirSync(sendingRoot)) {
|
|
2571
|
+
scan(acct, path.join(sendingRoot, acct, "queued"));
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
catch { /* */ }
|
|
2576
|
+
return {
|
|
2577
|
+
total, retrying, claimed,
|
|
2578
|
+
oldestAgeSec: Math.floor(oldestMs / 1000),
|
|
2579
|
+
maxAttempts,
|
|
2580
|
+
perAccount,
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
/** Emit outboxStatus now. Call after any queue mutation. */
|
|
2584
|
+
emitOutboxStatus() {
|
|
2585
|
+
try {
|
|
2586
|
+
this.emit("outboxStatus", this.getOutboxStatus());
|
|
2587
|
+
}
|
|
2588
|
+
catch { /* */ }
|
|
2465
2589
|
}
|
|
2466
2590
|
/** Guard against concurrent processSendActions for the same account */
|
|
2467
2591
|
sendingAccounts = new Set();
|
|
@@ -2980,6 +3104,8 @@ export class ImapManager extends EventEmitter {
|
|
|
2980
3104
|
}
|
|
2981
3105
|
}
|
|
2982
3106
|
}
|
|
3107
|
+
// After each full tick, refresh the UI indicator.
|
|
3108
|
+
this.emitOutboxStatus();
|
|
2983
3109
|
};
|
|
2984
3110
|
setTimeout(() => processAll(), 3000);
|
|
2985
3111
|
this.outboxInterval = setInterval(processAll, 10000);
|