@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/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 (coming soon)" aria-label="Contacts" disabled>👤</button>
80
- <button class="rail-btn" id="rail-calendar" title="Calendar (Phase 4)" aria-label="Calendar" disabled>📅</button>
81
- <button class="rail-btn" id="rail-tasks" title="Tasks (Phase 4)" aria-label="Tasks" disabled>☑</button>
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="Search scope">
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">
@@ -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
  }
@@ -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.339",
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.3",
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.3",
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
- return client;
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
- fs.mkdirSync(outboxDir, { recursive: true });
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
- fs.writeFileSync(filePath, rawMessage);
2463
- console.log(` [outbox] Queued ${filePath}`);
2464
- this.processLocalQueue(accountId).catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`));
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);