@bobfrankston/mailx 1.0.12 → 1.0.14

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.
Files changed (172) hide show
  1. package/bin/mailx.js +52 -28
  2. package/client/app.js +113 -30
  3. package/client/components/folder-tree.js +84 -3
  4. package/client/components/message-list.js +164 -10
  5. package/client/components/message-viewer.js +130 -13
  6. package/client/compose/compose.html +4 -4
  7. package/client/compose/compose.js +53 -34
  8. package/client/index.html +50 -21
  9. package/client/lib/api-client.js +112 -31
  10. package/client/lib/mailxapi.js +123 -0
  11. package/client/package.json +1 -1
  12. package/client/styles/components.css +206 -16
  13. package/client/styles/layout.css +2 -1
  14. package/killmail.cmd +6 -0
  15. package/launch.ps1 +47 -5
  16. package/launcher/bin/mailx-app-linux +0 -0
  17. package/launcher/bin/mailx-app.exe +0 -0
  18. package/launcher/builder/build-config.json +11 -0
  19. package/launcher/builder/postinstall.js +81 -0
  20. package/package.json +2 -4
  21. package/packages/mailx-api/index.js +125 -29
  22. package/packages/mailx-core/index.d.ts +129 -0
  23. package/packages/mailx-core/index.js +323 -0
  24. package/packages/mailx-core/ipc.d.ts +13 -0
  25. package/packages/mailx-core/ipc.js +56 -0
  26. package/packages/mailx-core/package.json +18 -0
  27. package/packages/mailx-imap/index.d.ts +7 -1
  28. package/packages/mailx-imap/index.js +89 -14
  29. package/packages/mailx-server/index.js +42 -31
  30. package/packages/mailx-server/package.json +1 -2
  31. package/packages/mailx-settings/index.d.ts +1 -1
  32. package/packages/mailx-settings/index.js +21 -12
  33. package/packages/mailx-store/db.d.ts +6 -2
  34. package/packages/mailx-store/db.js +78 -16
  35. package/packages/mailx-store/file-store.d.ts +2 -8
  36. package/packages/mailx-store/file-store.js +7 -31
  37. package/packages/mailx-types/index.d.ts +3 -1
  38. package/.tswalk.json +0 -7396
  39. package/launcher/release.cmd +0 -4
  40. package/mailx.json +0 -9
  41. package/packages/mailx-api/node_modules/nodemailer/.ncurc.js +0 -9
  42. package/packages/mailx-api/node_modules/nodemailer/.prettierignore +0 -8
  43. package/packages/mailx-api/node_modules/nodemailer/.prettierrc +0 -12
  44. package/packages/mailx-api/node_modules/nodemailer/.prettierrc.js +0 -10
  45. package/packages/mailx-api/node_modules/nodemailer/.release-please-config.json +0 -9
  46. package/packages/mailx-api/node_modules/nodemailer/LICENSE +0 -16
  47. package/packages/mailx-api/node_modules/nodemailer/README.md +0 -86
  48. package/packages/mailx-api/node_modules/nodemailer/SECURITY.txt +0 -22
  49. package/packages/mailx-api/node_modules/nodemailer/eslint.config.js +0 -88
  50. package/packages/mailx-api/node_modules/nodemailer/lib/addressparser/index.js +0 -383
  51. package/packages/mailx-api/node_modules/nodemailer/lib/base64/index.js +0 -139
  52. package/packages/mailx-api/node_modules/nodemailer/lib/dkim/index.js +0 -253
  53. package/packages/mailx-api/node_modules/nodemailer/lib/dkim/message-parser.js +0 -155
  54. package/packages/mailx-api/node_modules/nodemailer/lib/dkim/relaxed-body.js +0 -154
  55. package/packages/mailx-api/node_modules/nodemailer/lib/dkim/sign.js +0 -117
  56. package/packages/mailx-api/node_modules/nodemailer/lib/fetch/cookies.js +0 -281
  57. package/packages/mailx-api/node_modules/nodemailer/lib/fetch/index.js +0 -280
  58. package/packages/mailx-api/node_modules/nodemailer/lib/json-transport/index.js +0 -82
  59. package/packages/mailx-api/node_modules/nodemailer/lib/mail-composer/index.js +0 -629
  60. package/packages/mailx-api/node_modules/nodemailer/lib/mailer/index.js +0 -441
  61. package/packages/mailx-api/node_modules/nodemailer/lib/mailer/mail-message.js +0 -316
  62. package/packages/mailx-api/node_modules/nodemailer/lib/mime-funcs/index.js +0 -625
  63. package/packages/mailx-api/node_modules/nodemailer/lib/mime-funcs/mime-types.js +0 -2113
  64. package/packages/mailx-api/node_modules/nodemailer/lib/mime-node/index.js +0 -1316
  65. package/packages/mailx-api/node_modules/nodemailer/lib/mime-node/last-newline.js +0 -33
  66. package/packages/mailx-api/node_modules/nodemailer/lib/mime-node/le-unix.js +0 -43
  67. package/packages/mailx-api/node_modules/nodemailer/lib/mime-node/le-windows.js +0 -52
  68. package/packages/mailx-api/node_modules/nodemailer/lib/nodemailer.js +0 -157
  69. package/packages/mailx-api/node_modules/nodemailer/lib/punycode/index.js +0 -460
  70. package/packages/mailx-api/node_modules/nodemailer/lib/qp/index.js +0 -227
  71. package/packages/mailx-api/node_modules/nodemailer/lib/sendmail-transport/index.js +0 -210
  72. package/packages/mailx-api/node_modules/nodemailer/lib/ses-transport/index.js +0 -234
  73. package/packages/mailx-api/node_modules/nodemailer/lib/shared/index.js +0 -754
  74. package/packages/mailx-api/node_modules/nodemailer/lib/smtp-connection/data-stream.js +0 -108
  75. package/packages/mailx-api/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js +0 -143
  76. package/packages/mailx-api/node_modules/nodemailer/lib/smtp-connection/index.js +0 -1870
  77. package/packages/mailx-api/node_modules/nodemailer/lib/smtp-pool/index.js +0 -652
  78. package/packages/mailx-api/node_modules/nodemailer/lib/smtp-pool/pool-resource.js +0 -259
  79. package/packages/mailx-api/node_modules/nodemailer/lib/smtp-transport/index.js +0 -421
  80. package/packages/mailx-api/node_modules/nodemailer/lib/stream-transport/index.js +0 -135
  81. package/packages/mailx-api/node_modules/nodemailer/lib/well-known/index.js +0 -47
  82. package/packages/mailx-api/node_modules/nodemailer/lib/well-known/services.json +0 -611
  83. package/packages/mailx-api/node_modules/nodemailer/lib/xoauth2/index.js +0 -427
  84. package/packages/mailx-api/node_modules/nodemailer/package.json +0 -47
  85. package/packages/mailx-imap/node_modules/nodemailer/.ncurc.js +0 -9
  86. package/packages/mailx-imap/node_modules/nodemailer/.prettierignore +0 -8
  87. package/packages/mailx-imap/node_modules/nodemailer/.prettierrc +0 -12
  88. package/packages/mailx-imap/node_modules/nodemailer/.prettierrc.js +0 -10
  89. package/packages/mailx-imap/node_modules/nodemailer/.release-please-config.json +0 -9
  90. package/packages/mailx-imap/node_modules/nodemailer/LICENSE +0 -16
  91. package/packages/mailx-imap/node_modules/nodemailer/README.md +0 -86
  92. package/packages/mailx-imap/node_modules/nodemailer/SECURITY.txt +0 -22
  93. package/packages/mailx-imap/node_modules/nodemailer/eslint.config.js +0 -88
  94. package/packages/mailx-imap/node_modules/nodemailer/lib/addressparser/index.js +0 -383
  95. package/packages/mailx-imap/node_modules/nodemailer/lib/base64/index.js +0 -139
  96. package/packages/mailx-imap/node_modules/nodemailer/lib/dkim/index.js +0 -253
  97. package/packages/mailx-imap/node_modules/nodemailer/lib/dkim/message-parser.js +0 -155
  98. package/packages/mailx-imap/node_modules/nodemailer/lib/dkim/relaxed-body.js +0 -154
  99. package/packages/mailx-imap/node_modules/nodemailer/lib/dkim/sign.js +0 -117
  100. package/packages/mailx-imap/node_modules/nodemailer/lib/fetch/cookies.js +0 -281
  101. package/packages/mailx-imap/node_modules/nodemailer/lib/fetch/index.js +0 -280
  102. package/packages/mailx-imap/node_modules/nodemailer/lib/json-transport/index.js +0 -82
  103. package/packages/mailx-imap/node_modules/nodemailer/lib/mail-composer/index.js +0 -629
  104. package/packages/mailx-imap/node_modules/nodemailer/lib/mailer/index.js +0 -441
  105. package/packages/mailx-imap/node_modules/nodemailer/lib/mailer/mail-message.js +0 -316
  106. package/packages/mailx-imap/node_modules/nodemailer/lib/mime-funcs/index.js +0 -625
  107. package/packages/mailx-imap/node_modules/nodemailer/lib/mime-funcs/mime-types.js +0 -2113
  108. package/packages/mailx-imap/node_modules/nodemailer/lib/mime-node/index.js +0 -1316
  109. package/packages/mailx-imap/node_modules/nodemailer/lib/mime-node/last-newline.js +0 -33
  110. package/packages/mailx-imap/node_modules/nodemailer/lib/mime-node/le-unix.js +0 -43
  111. package/packages/mailx-imap/node_modules/nodemailer/lib/mime-node/le-windows.js +0 -52
  112. package/packages/mailx-imap/node_modules/nodemailer/lib/nodemailer.js +0 -157
  113. package/packages/mailx-imap/node_modules/nodemailer/lib/punycode/index.js +0 -460
  114. package/packages/mailx-imap/node_modules/nodemailer/lib/qp/index.js +0 -227
  115. package/packages/mailx-imap/node_modules/nodemailer/lib/sendmail-transport/index.js +0 -210
  116. package/packages/mailx-imap/node_modules/nodemailer/lib/ses-transport/index.js +0 -234
  117. package/packages/mailx-imap/node_modules/nodemailer/lib/shared/index.js +0 -754
  118. package/packages/mailx-imap/node_modules/nodemailer/lib/smtp-connection/data-stream.js +0 -108
  119. package/packages/mailx-imap/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js +0 -143
  120. package/packages/mailx-imap/node_modules/nodemailer/lib/smtp-connection/index.js +0 -1870
  121. package/packages/mailx-imap/node_modules/nodemailer/lib/smtp-pool/index.js +0 -652
  122. package/packages/mailx-imap/node_modules/nodemailer/lib/smtp-pool/pool-resource.js +0 -259
  123. package/packages/mailx-imap/node_modules/nodemailer/lib/smtp-transport/index.js +0 -421
  124. package/packages/mailx-imap/node_modules/nodemailer/lib/stream-transport/index.js +0 -135
  125. package/packages/mailx-imap/node_modules/nodemailer/lib/well-known/index.js +0 -47
  126. package/packages/mailx-imap/node_modules/nodemailer/lib/well-known/services.json +0 -611
  127. package/packages/mailx-imap/node_modules/nodemailer/lib/xoauth2/index.js +0 -427
  128. package/packages/mailx-imap/node_modules/nodemailer/package.json +0 -47
  129. package/packages/mailx-send/node_modules/nodemailer/.ncurc.js +0 -9
  130. package/packages/mailx-send/node_modules/nodemailer/.prettierignore +0 -8
  131. package/packages/mailx-send/node_modules/nodemailer/.prettierrc +0 -12
  132. package/packages/mailx-send/node_modules/nodemailer/.prettierrc.js +0 -10
  133. package/packages/mailx-send/node_modules/nodemailer/.release-please-config.json +0 -9
  134. package/packages/mailx-send/node_modules/nodemailer/LICENSE +0 -16
  135. package/packages/mailx-send/node_modules/nodemailer/README.md +0 -86
  136. package/packages/mailx-send/node_modules/nodemailer/SECURITY.txt +0 -22
  137. package/packages/mailx-send/node_modules/nodemailer/eslint.config.js +0 -88
  138. package/packages/mailx-send/node_modules/nodemailer/lib/addressparser/index.js +0 -383
  139. package/packages/mailx-send/node_modules/nodemailer/lib/base64/index.js +0 -139
  140. package/packages/mailx-send/node_modules/nodemailer/lib/dkim/index.js +0 -253
  141. package/packages/mailx-send/node_modules/nodemailer/lib/dkim/message-parser.js +0 -155
  142. package/packages/mailx-send/node_modules/nodemailer/lib/dkim/relaxed-body.js +0 -154
  143. package/packages/mailx-send/node_modules/nodemailer/lib/dkim/sign.js +0 -117
  144. package/packages/mailx-send/node_modules/nodemailer/lib/fetch/cookies.js +0 -281
  145. package/packages/mailx-send/node_modules/nodemailer/lib/fetch/index.js +0 -280
  146. package/packages/mailx-send/node_modules/nodemailer/lib/json-transport/index.js +0 -82
  147. package/packages/mailx-send/node_modules/nodemailer/lib/mail-composer/index.js +0 -629
  148. package/packages/mailx-send/node_modules/nodemailer/lib/mailer/index.js +0 -441
  149. package/packages/mailx-send/node_modules/nodemailer/lib/mailer/mail-message.js +0 -316
  150. package/packages/mailx-send/node_modules/nodemailer/lib/mime-funcs/index.js +0 -625
  151. package/packages/mailx-send/node_modules/nodemailer/lib/mime-funcs/mime-types.js +0 -2113
  152. package/packages/mailx-send/node_modules/nodemailer/lib/mime-node/index.js +0 -1316
  153. package/packages/mailx-send/node_modules/nodemailer/lib/mime-node/last-newline.js +0 -33
  154. package/packages/mailx-send/node_modules/nodemailer/lib/mime-node/le-unix.js +0 -43
  155. package/packages/mailx-send/node_modules/nodemailer/lib/mime-node/le-windows.js +0 -52
  156. package/packages/mailx-send/node_modules/nodemailer/lib/nodemailer.js +0 -157
  157. package/packages/mailx-send/node_modules/nodemailer/lib/punycode/index.js +0 -460
  158. package/packages/mailx-send/node_modules/nodemailer/lib/qp/index.js +0 -227
  159. package/packages/mailx-send/node_modules/nodemailer/lib/sendmail-transport/index.js +0 -210
  160. package/packages/mailx-send/node_modules/nodemailer/lib/ses-transport/index.js +0 -234
  161. package/packages/mailx-send/node_modules/nodemailer/lib/shared/index.js +0 -754
  162. package/packages/mailx-send/node_modules/nodemailer/lib/smtp-connection/data-stream.js +0 -108
  163. package/packages/mailx-send/node_modules/nodemailer/lib/smtp-connection/http-proxy-client.js +0 -143
  164. package/packages/mailx-send/node_modules/nodemailer/lib/smtp-connection/index.js +0 -1870
  165. package/packages/mailx-send/node_modules/nodemailer/lib/smtp-pool/index.js +0 -652
  166. package/packages/mailx-send/node_modules/nodemailer/lib/smtp-pool/pool-resource.js +0 -259
  167. package/packages/mailx-send/node_modules/nodemailer/lib/smtp-transport/index.js +0 -421
  168. package/packages/mailx-send/node_modules/nodemailer/lib/stream-transport/index.js +0 -135
  169. package/packages/mailx-send/node_modules/nodemailer/lib/well-known/index.js +0 -47
  170. package/packages/mailx-send/node_modules/nodemailer/lib/well-known/services.json +0 -611
  171. package/packages/mailx-send/node_modules/nodemailer/lib/xoauth2/index.js +0 -427
  172. package/packages/mailx-send/node_modules/nodemailer/package.json +0 -47
@@ -3,10 +3,24 @@
3
3
  * Loads more messages on scroll.
4
4
  */
5
5
  import { getMessages, getUnifiedInbox, searchMessages } from "../lib/api-client.js";
6
+ function clearFilter() {
7
+ const f = document.getElementById("ml-filter-input");
8
+ if (f)
9
+ f.value = "";
10
+ }
11
+ /** Clear the message viewer when no message is selected */
12
+ function clearViewer() {
13
+ const bodyEl = document.getElementById("mv-body");
14
+ const headerEl = document.getElementById("mv-header");
15
+ if (bodyEl)
16
+ bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
17
+ if (headerEl)
18
+ headerEl.hidden = true;
19
+ }
6
20
  let onMessageSelect;
7
21
  let currentAccountId;
8
22
  let currentFolderId;
9
- let selectedRow;
23
+ let lastClickedRow = null;
10
24
  let currentPage;
11
25
  let totalMessages;
12
26
  let loading = false;
@@ -14,11 +28,62 @@ let unifiedMode = false;
14
28
  let searchMode = false;
15
29
  let currentSearchQuery = "";
16
30
  let showToInsteadOfFrom = false;
31
+ /** Get all selected message rows */
32
+ export function getSelectedMessages() {
33
+ const body = document.getElementById("ml-body");
34
+ if (!body)
35
+ return [];
36
+ const rows = body.querySelectorAll(".ml-row.selected");
37
+ return Array.from(rows).map(r => ({
38
+ accountId: r.dataset.accountId || "",
39
+ uid: Number(r.dataset.uid),
40
+ folderId: Number(r.dataset.folderId),
41
+ }));
42
+ }
43
+ function clearSelection() {
44
+ const body = document.getElementById("ml-body");
45
+ if (body)
46
+ body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
47
+ }
48
+ function selectRange(from, to) {
49
+ const body = document.getElementById("ml-body");
50
+ if (!body)
51
+ return;
52
+ const rows = Array.from(body.querySelectorAll(".ml-row"));
53
+ const fromIdx = rows.indexOf(from);
54
+ const toIdx = rows.indexOf(to);
55
+ if (fromIdx < 0 || toIdx < 0)
56
+ return;
57
+ const lo = Math.min(fromIdx, toIdx);
58
+ const hi = Math.max(fromIdx, toIdx);
59
+ for (let i = lo; i <= hi; i++)
60
+ rows[i].classList.add("selected");
61
+ }
17
62
  const timeFmt = { hour: "2-digit", minute: "2-digit", hour12: false };
18
63
  const dateFmt = { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
19
64
  const dateFmtSameYear = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
20
65
  export function initMessageList(handler) {
21
66
  onMessageSelect = handler;
67
+ // Client-side filter
68
+ const filterInput = document.getElementById("ml-filter-input");
69
+ if (filterInput) {
70
+ filterInput.addEventListener("input", () => {
71
+ const query = filterInput.value.toLowerCase();
72
+ const body = document.getElementById("ml-body");
73
+ if (!body)
74
+ return;
75
+ for (const row of body.querySelectorAll(".ml-row")) {
76
+ const text = row.textContent?.toLowerCase() || "";
77
+ row.classList.toggle("filter-hidden", query.length > 0 && !text.includes(query));
78
+ }
79
+ });
80
+ filterInput.addEventListener("keydown", (e) => {
81
+ if (e.key === "Escape") {
82
+ filterInput.value = "";
83
+ filterInput.dispatchEvent(new Event("input"));
84
+ }
85
+ });
86
+ }
22
87
  // Infinite scroll
23
88
  const body = document.getElementById("ml-body");
24
89
  if (body) {
@@ -46,6 +111,7 @@ export function reloadCurrentFolder() {
46
111
  }
47
112
  /** Load unified inbox (all accounts) */
48
113
  export async function loadUnifiedInbox() {
114
+ clearFilter();
49
115
  unifiedMode = true;
50
116
  currentPage = 1;
51
117
  totalMessages = 0;
@@ -58,17 +124,24 @@ export async function loadUnifiedInbox() {
58
124
  totalMessages = result.total;
59
125
  if (result.items.length === 0) {
60
126
  body.innerHTML = `<div class="ml-empty">No messages</div>`;
127
+ clearViewer();
61
128
  return;
62
129
  }
63
130
  body.innerHTML = "";
64
131
  appendMessages(body, "", result.items);
132
+ const firstRow = body.querySelector(".ml-row");
133
+ if (firstRow)
134
+ firstRow.click();
65
135
  }
66
136
  catch (e) {
137
+ if (e.name === "AbortError")
138
+ return;
67
139
  body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
68
140
  }
69
141
  }
70
142
  /** Load search results */
71
- export async function loadSearchResults(query) {
143
+ export async function loadSearchResults(query, scope = "all", accountId = "", folderId = 0) {
144
+ clearFilter();
72
145
  searchMode = true;
73
146
  unifiedMode = false;
74
147
  currentSearchQuery = query;
@@ -79,7 +152,35 @@ export async function loadSearchResults(query) {
79
152
  return;
80
153
  body.innerHTML = `<div class="ml-empty">Searching...</div>`;
81
154
  try {
82
- const result = await searchMessages(query, 1);
155
+ // Regex search: filter client-side
156
+ if (query.startsWith("/") && query.endsWith("/") && query.length > 2) {
157
+ const pattern = query.slice(1, -1);
158
+ let regex;
159
+ try {
160
+ regex = new RegExp(pattern, "i");
161
+ }
162
+ catch {
163
+ body.innerHTML = `<div class="ml-empty">Invalid regex</div>`;
164
+ return;
165
+ }
166
+ // Get all messages from current context and filter
167
+ const source = scope === "current" && accountId
168
+ ? await getMessages(accountId, folderId, 1, 10000)
169
+ : await searchMessages("*", 1, 10000, "all");
170
+ const matches = source.items.filter((m) => regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || ""));
171
+ totalMessages = matches.length;
172
+ if (matches.length === 0) {
173
+ body.innerHTML = `<div class="ml-empty">No regex matches</div>`;
174
+ return;
175
+ }
176
+ body.innerHTML = "";
177
+ appendMessages(body, "", matches);
178
+ const firstRow = body.querySelector(".ml-row");
179
+ if (firstRow)
180
+ firstRow.click();
181
+ return;
182
+ }
183
+ const result = await searchMessages(query, 1, 50, scope, accountId, folderId);
83
184
  totalMessages = result.total;
84
185
  if (result.items.length === 0) {
85
186
  body.innerHTML = `<div class="ml-empty">No results for "${query}"</div>`;
@@ -93,6 +194,7 @@ export async function loadSearchResults(query) {
93
194
  }
94
195
  }
95
196
  export async function loadMessages(accountId, folderId, page = 1, specialUse = "") {
197
+ clearFilter();
96
198
  searchMode = false;
97
199
  unifiedMode = false;
98
200
  showToInsteadOfFrom = ["sent", "drafts", "outbox"].includes(specialUse) ||
@@ -114,12 +216,19 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
114
216
  totalMessages = result.total;
115
217
  if (result.items.length === 0) {
116
218
  body.innerHTML = `<div class="ml-empty">No messages</div>`;
219
+ clearViewer();
117
220
  return;
118
221
  }
119
222
  body.innerHTML = "";
120
223
  appendMessages(body, accountId, result.items);
224
+ // Auto-select first message
225
+ const firstRow = body.querySelector(".ml-row");
226
+ if (firstRow)
227
+ firstRow.click();
121
228
  }
122
229
  catch (e) {
230
+ if (e.name === "AbortError")
231
+ return; // Superseded by newer request
123
232
  body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
124
233
  }
125
234
  }
@@ -146,13 +255,18 @@ async function loadMoreMessages() {
146
255
  }
147
256
  function appendMessages(body, accountId, items) {
148
257
  for (const msg of items) {
258
+ // Use per-message accountId for unified inbox, fallback to list-level accountId
259
+ const msgAccountId = msg.accountId || accountId;
149
260
  const row = document.createElement("div");
150
261
  row.className = "ml-row";
262
+ row.draggable = true;
151
263
  if (!msg.flags.includes("\\Seen"))
152
264
  row.classList.add("unread");
153
265
  if (msg.flags.includes("\\Flagged"))
154
266
  row.classList.add("flagged");
155
267
  row.dataset.uid = String(msg.uid);
268
+ row.dataset.accountId = msgAccountId;
269
+ row.dataset.folderId = String(msg.folderId);
156
270
  const flag = document.createElement("span");
157
271
  flag.className = "ml-flag";
158
272
  flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606"; // ★ or ☆
@@ -185,7 +299,7 @@ function appendMessages(body, accountId, items) {
185
299
  ? currentFlags.filter((f) => f !== "\\Flagged")
186
300
  : [...currentFlags, "\\Flagged"];
187
301
  try {
188
- await fetch(`/api/message/${accountId}/${msg.uid}/flags`, {
302
+ await fetch(`/api/message/${msgAccountId}/${msg.uid}/flags`, {
189
303
  method: "PATCH",
190
304
  headers: { "Content-Type": "application/json" },
191
305
  body: JSON.stringify({ flags: newFlags }),
@@ -200,14 +314,54 @@ function appendMessages(body, accountId, items) {
200
314
  row.appendChild(from);
201
315
  row.appendChild(date);
202
316
  row.appendChild(subject);
203
- row.addEventListener("click", () => {
204
- if (selectedRow)
205
- selectedRow.classList.remove("selected");
206
- row.classList.add("selected");
207
- selectedRow = row;
317
+ row.addEventListener("click", (e) => {
318
+ if (e.shiftKey && lastClickedRow) {
319
+ // Shift+click: range select from last clicked to this row
320
+ clearSelection();
321
+ selectRange(lastClickedRow, row);
322
+ }
323
+ else if (e.ctrlKey || e.metaKey) {
324
+ // Ctrl+click: toggle this row
325
+ row.classList.toggle("selected");
326
+ }
327
+ else {
328
+ // Plain click: single select
329
+ clearSelection();
330
+ row.classList.add("selected");
331
+ }
332
+ lastClickedRow = row;
208
333
  row.classList.remove("unread");
209
- onMessageSelect(accountId, msg.uid);
334
+ onMessageSelect(msgAccountId, msg.uid, msg.folderId);
335
+ });
336
+ row.addEventListener("dragstart", (e) => {
337
+ // If dragging a non-selected row, select it first
338
+ if (!row.classList.contains("selected")) {
339
+ clearSelection();
340
+ row.classList.add("selected");
341
+ lastClickedRow = row;
342
+ }
343
+ const selected = getSelectedMessages();
344
+ e.dataTransfer.setData("application/x-mailx-messages", JSON.stringify(selected));
345
+ // Legacy single-message format for backwards compat
346
+ e.dataTransfer.setData("application/x-mailx-message", JSON.stringify({
347
+ accountId: msgAccountId,
348
+ uid: msg.uid,
349
+ folderId: msg.folderId,
350
+ subject: msg.subject,
351
+ }));
352
+ e.dataTransfer.effectAllowed = "copyMove";
353
+ row.classList.add("dragging");
354
+ // Show drag count
355
+ if (selected.length > 1) {
356
+ const badge = document.createElement("div");
357
+ badge.textContent = `${selected.length} messages`;
358
+ badge.style.cssText = "position:absolute;top:-1000px;background:#333;color:white;padding:4px 8px;border-radius:4px;font-size:12px";
359
+ document.body.appendChild(badge);
360
+ e.dataTransfer.setDragImage(badge, 0, 0);
361
+ setTimeout(() => badge.remove(), 0);
362
+ }
210
363
  });
364
+ row.addEventListener("dragend", () => row.classList.remove("dragging"));
211
365
  body.appendChild(row);
212
366
  }
213
367
  }
@@ -5,12 +5,14 @@ import { getMessage, updateFlags } from "../lib/api-client.js";
5
5
  /** Currently displayed message (for reply/forward) */
6
6
  let currentMessage = null;
7
7
  let currentAccountId = "";
8
+ let showMessageGeneration = 0; // Cancel stale fetches
8
9
  export function getCurrentMessage() {
9
10
  if (!currentMessage)
10
11
  return null;
11
12
  return { accountId: currentAccountId, message: currentMessage };
12
13
  }
13
- export async function showMessage(accountId, uid) {
14
+ export async function showMessage(accountId, uid, folderId, specialUse) {
15
+ const gen = ++showMessageGeneration;
14
16
  const headerEl = document.getElementById("mv-header");
15
17
  const bodyEl = document.getElementById("mv-body");
16
18
  const attEl = document.getElementById("mv-attachments");
@@ -18,9 +20,10 @@ export async function showMessage(accountId, uid) {
18
20
  headerEl.hidden = true;
19
21
  attEl.hidden = true;
20
22
  try {
21
- // Timeout after 15 seconds
22
- const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout loading message")), 15000));
23
- const msg = await Promise.race([getMessage(accountId, uid), timeout]);
23
+ const msg = await getMessage(accountId, uid, false, folderId);
24
+ // Stale response a newer showMessage was called while we were fetching
25
+ if (gen !== showMessageGeneration)
26
+ return;
24
27
  currentMessage = msg;
25
28
  currentAccountId = accountId;
26
29
  // Mark as read
@@ -37,18 +40,114 @@ export async function showMessage(accountId, uid) {
37
40
  }
38
41
  headerEl.querySelector(".mv-subject").textContent = msg.subject;
39
42
  headerEl.querySelector(".mv-date").textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
40
- // Remote content banner
43
+ // Unsubscribe button (upper right of header)
44
+ const unsubBtn = document.getElementById("mv-unsubscribe");
45
+ const headerUnsub = msg.listUnsubscribe || "";
46
+ const headerUnsubUrl = headerUnsub.match(/<(https?:\/\/[^>]+)>/)?.[1]
47
+ || headerUnsub.match(/<(mailto:[^>]+)>/)?.[1] || "";
48
+ if (unsubBtn) {
49
+ if (headerUnsubUrl) {
50
+ unsubBtn.hidden = false;
51
+ unsubBtn.href = headerUnsubUrl;
52
+ unsubBtn.target = "_blank";
53
+ unsubBtn.rel = "noopener noreferrer";
54
+ }
55
+ else {
56
+ unsubBtn.hidden = true;
57
+ }
58
+ }
59
+ // View Source button — shows .eml file path
60
+ const srcBtn = document.getElementById("mv-view-source");
61
+ if (srcBtn) {
62
+ if (msg.emlPath) {
63
+ srcBtn.hidden = false;
64
+ srcBtn.title = msg.emlPath;
65
+ srcBtn.onclick = () => {
66
+ // Copy path to clipboard and show in status bar
67
+ navigator.clipboard.writeText(msg.emlPath).then(() => {
68
+ const status = document.getElementById("status-sync");
69
+ if (status)
70
+ status.textContent = `Path copied: ${msg.emlPath}`;
71
+ }).catch(() => {
72
+ prompt("EML file path:", msg.emlPath);
73
+ });
74
+ };
75
+ }
76
+ else {
77
+ srcBtn.hidden = true;
78
+ }
79
+ }
80
+ // Edit Draft / Send from Outbox button
81
+ const editBtn = document.getElementById("mv-edit-draft");
82
+ if (editBtn) {
83
+ const isDraft = specialUse === "drafts" || specialUse === "outbox";
84
+ if (isDraft) {
85
+ editBtn.hidden = false;
86
+ editBtn.textContent = specialUse === "outbox" ? "Edit & Send" : "Edit Draft";
87
+ editBtn.onclick = () => {
88
+ // Open compose window pre-filled with this draft
89
+ const init = {
90
+ mode: "draft",
91
+ accountId,
92
+ to: msg.to || [],
93
+ cc: msg.cc || [],
94
+ subject: msg.subject || "",
95
+ bodyHtml: msg.bodyHtml || "",
96
+ inReplyTo: msg.inReplyTo || "",
97
+ references: msg.references || [],
98
+ accounts: [],
99
+ draftUid: msg.uid,
100
+ draftFolderId: msg.folderId,
101
+ };
102
+ sessionStorage.setItem("composeInit", JSON.stringify(init));
103
+ window.open("/compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
104
+ };
105
+ }
106
+ else {
107
+ editBtn.hidden = true;
108
+ }
109
+ }
110
+ // Remote content banner (collapsible dropdown with sender/recipient details)
41
111
  bodyEl.innerHTML = "";
42
112
  if (msg.hasRemoteContent) {
43
113
  const senderAddr = msg.from?.address || "";
114
+ const senderName = msg.from?.name || "";
44
115
  const senderDomain = senderAddr.split("@")[1] || "";
116
+ const deliveredTo = msg.deliveredTo || "";
117
+ const toAddr = msg.to?.[0]?.address || "";
118
+ const returnPath = msg.returnPath || "";
45
119
  const banner = document.createElement("div");
46
120
  banner.className = "mv-remote-banner";
47
- banner.innerHTML = `Remote content blocked from <b>${senderAddr}</b>. ` +
48
- `<button id="btn-load-remote">Load this time</button> ` +
49
- `<button id="btn-allow-sender">Always from ${senderAddr}</button> ` +
50
- (senderDomain ? `<button id="btn-allow-domain">Always from ${senderDomain}</button>` : "");
121
+ banner.innerHTML =
122
+ `<div class="mv-rb-summary">` +
123
+ `<span class="mv-rb-toggle">&#x25B8;</span>` +
124
+ `<span>Remote content blocked</span>` +
125
+ `<span class="mv-rb-buttons">` +
126
+ `<button id="btn-load-remote">Load once</button>` +
127
+ `<button id="btn-allow-sender" title="${escapeText(senderAddr)}">Always: ${escapeText(senderAddr)}</button>` +
128
+ (senderDomain ? `<button id="btn-allow-domain" title="*@${escapeText(senderDomain)}">Always: *@${escapeText(senderDomain)}</button>` : "") +
129
+ `</span>` +
130
+ `</div>` +
131
+ `<div class="mv-rb-details" hidden>` +
132
+ `<div class="mv-rb-info">` +
133
+ `<div><span class="mv-rb-label">From:</span> ${escapeText(senderName ? `${senderName} <${senderAddr}>` : senderAddr)}</div>` +
134
+ (deliveredTo ? `<div><span class="mv-rb-label">Delivered-To:</span> ${escapeText(deliveredTo)}</div>` : "") +
135
+ (toAddr && toAddr !== deliveredTo ? `<div><span class="mv-rb-label">To:</span> ${escapeText(toAddr)}</div>` : "") +
136
+ (returnPath && returnPath !== senderAddr ? `<div><span class="mv-rb-label">Return-Path:</span> ${escapeText(returnPath)}</div>` : "") +
137
+ `</div>` +
138
+ (deliveredTo || toAddr ? `<div class="mv-rb-actions"><button id="btn-allow-to">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : "") +
139
+ `</div>`;
51
140
  bodyEl.appendChild(banner);
141
+ // Toggle dropdown — click arrow or text to expand details
142
+ const summary = banner.querySelector(".mv-rb-summary");
143
+ const details = banner.querySelector(".mv-rb-details");
144
+ const toggle = banner.querySelector(".mv-rb-toggle");
145
+ summary.addEventListener("click", (e) => {
146
+ if (e.target.tagName === "BUTTON")
147
+ return;
148
+ details.hidden = !details.hidden;
149
+ toggle.textContent = details.hidden ? "\u25B8" : "\u25BE";
150
+ });
52
151
  const loadRemote = async () => {
53
152
  banner.remove();
54
153
  const full = await getMessage(accountId, uid, true);
@@ -78,6 +177,17 @@ export async function showMessage(accountId, uid) {
78
177
  });
79
178
  loadRemote();
80
179
  });
180
+ banner.querySelector("#btn-allow-to")?.addEventListener("click", async () => {
181
+ const addr = deliveredTo || toAddr;
182
+ if (!addr)
183
+ return;
184
+ await fetch("/api/settings/allow-remote", {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/json" },
187
+ body: JSON.stringify({ type: "recipient", value: addr }),
188
+ });
189
+ loadRemote();
190
+ });
81
191
  }
82
192
  // Body in sandboxed iframe
83
193
  if (msg.bodyHtml) {
@@ -85,7 +195,8 @@ export async function showMessage(accountId, uid) {
85
195
  iframe.sandbox.add("allow-same-origin");
86
196
  iframe.sandbox.add("allow-popups");
87
197
  iframe.sandbox.add("allow-popups-to-escape-sandbox");
88
- iframe.srcdoc = wrapHtmlBody(msg.bodyHtml);
198
+ iframe.sandbox.add("allow-top-navigation-by-user-activation");
199
+ iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
89
200
  bodyEl.appendChild(iframe);
90
201
  }
91
202
  else if (msg.bodyText) {
@@ -118,6 +229,11 @@ function formatAddr(addr) {
118
229
  return `${addr.name} <${addr.address}>`;
119
230
  return addr.address;
120
231
  }
232
+ function escapeText(s) {
233
+ const div = document.createElement("div");
234
+ div.textContent = s;
235
+ return div.innerHTML;
236
+ }
121
237
  function formatSize(bytes) {
122
238
  if (bytes < 1024)
123
239
  return `${bytes} B`;
@@ -126,9 +242,10 @@ function formatSize(bytes) {
126
242
  return `${(bytes / 1048576).toFixed(1)} MB`;
127
243
  }
128
244
  function wrapHtmlBody(html, allowRemote = false) {
245
+ // CSP blocks remote resource loading (tracking pixels, external CSS) but allows link clicks
129
246
  const csp = allowRemote
130
- ? "" // no CSP restriction when remote content allowed
131
- : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data: cid:;">`;
247
+ ? ""
248
+ : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data: cid:; form-action 'none';">`;
132
249
  return `<!DOCTYPE html>
133
250
  <html><head>
134
251
  <meta charset="UTF-8">
@@ -136,7 +253,7 @@ ${csp}
136
253
  <style>
137
254
  body {
138
255
  font-family: system-ui, sans-serif;
139
- font-size: 14px;
256
+ font-size: 17.5px;
140
257
  line-height: 1.5;
141
258
  color: #1a1a2e;
142
259
  background: #fff;
@@ -13,10 +13,10 @@
13
13
  </head>
14
14
  <body>
15
15
  <div class="compose-header">
16
- <div class="compose-field">
17
- <label for="compose-from">From</label>
18
- <input type="text" id="compose-from" list="compose-from-list" autocomplete="off">
19
- <datalist id="compose-from-list"></datalist>
16
+ <div class="compose-field compose-from-field">
17
+ <label for="compose-from-select">From</label>
18
+ <select id="compose-from-select"></select>
19
+ <input type="text" id="compose-from-custom" placeholder="Custom address..." hidden>
20
20
  </div>
21
21
  <div class="compose-field">
22
22
  <label for="compose-to">To</label>
@@ -18,27 +18,59 @@ const editor = new Quill("#compose-editor", {
18
18
  }
19
19
  });
20
20
  // ── Populate from init data ──
21
- const fromInput = document.getElementById("compose-from");
22
- const fromDatalist = document.getElementById("compose-from-list");
21
+ const fromSelect = document.getElementById("compose-from-select");
22
+ const fromCustom = document.getElementById("compose-from-custom");
23
23
  const toInput = document.getElementById("compose-to");
24
24
  const ccInput = document.getElementById("compose-cc");
25
25
  const bccInput = document.getElementById("compose-bcc");
26
26
  const subjectInput = document.getElementById("compose-subject");
27
- /** Extract account ID from the From field value, or return the raw value for custom addresses */
27
+ /** Populate the From dropdown with accounts */
28
+ function populateFromSelect(accounts, selectedId) {
29
+ fromSelect.innerHTML = "";
30
+ for (const acct of accounts) {
31
+ const opt = document.createElement("option");
32
+ opt.value = acct.id;
33
+ const displayLabel = acct.label || acct.name;
34
+ opt.textContent = `${displayLabel}: ${acct.name} <${acct.email}>`;
35
+ opt.dataset.email = acct.email;
36
+ opt.dataset.name = acct.name;
37
+ if (acct.defaultSend)
38
+ opt.dataset.defaultSend = "true";
39
+ if (acct.id === selectedId)
40
+ opt.selected = true;
41
+ fromSelect.appendChild(opt);
42
+ }
43
+ // "Other..." option for custom address
44
+ const other = document.createElement("option");
45
+ other.value = "__custom__";
46
+ other.textContent = "Other...";
47
+ fromSelect.appendChild(other);
48
+ }
49
+ fromSelect.addEventListener("change", () => {
50
+ if (fromSelect.value === "__custom__") {
51
+ fromCustom.hidden = false;
52
+ fromCustom.focus();
53
+ }
54
+ else {
55
+ fromCustom.hidden = true;
56
+ fromCustom.value = "";
57
+ }
58
+ });
59
+ /** Extract account ID from the From field */
28
60
  function getFromAccountId() {
29
- const val = fromInput.value;
30
- // Check datalist options for a matching account
31
- for (const opt of fromDatalist.options) {
32
- if (opt.value === val && opt.dataset.accountId)
33
- return opt.dataset.accountId;
61
+ if (fromSelect.value === "__custom__") {
62
+ // Custom address use default send account, fallback to first
63
+ const defaultOpt = Array.from(fromSelect.options).find(o => o.dataset.defaultSend === "true");
64
+ return defaultOpt?.value || fromSelect.options[0]?.value || "";
34
65
  }
35
- // No match — return first account ID as the sending account (custom From address)
36
- const first = fromDatalist.options[0];
37
- return first?.dataset.accountId || val;
66
+ return fromSelect.value;
38
67
  }
39
68
  /** Get the From address string for the message headers */
40
69
  function getFromAddress() {
41
- return fromInput.value;
70
+ if (fromSelect.value === "__custom__")
71
+ return fromCustom.value;
72
+ const opt = fromSelect.selectedOptions[0];
73
+ return opt ? `${opt.dataset.name} <${opt.dataset.email}>` : "";
42
74
  }
43
75
  /** Smart tab — skip to next empty field, ending at body */
44
76
  function smartTab(current) {
@@ -145,8 +177,7 @@ function setupAutocomplete(input) {
145
177
  e.preventDefault();
146
178
  const idx = activeIndex >= 0 ? activeIndex : 0;
147
179
  items[idx].dispatchEvent(new MouseEvent("mousedown"));
148
- // After selecting, smart-tab to next empty field
149
- setTimeout(() => smartTab(input), 50);
180
+ // Stay in field — user may want to add more addresses
150
181
  return;
151
182
  }
152
183
  }
@@ -176,16 +207,7 @@ function parseAddrs(s) {
176
207
  }
177
208
  function applyInit(init) {
178
209
  // Populate From dropdown
179
- fromDatalist.innerHTML = "";
180
- for (const acct of init.accounts) {
181
- const opt = document.createElement("option");
182
- opt.value = `${acct.name} <${acct.email}>`;
183
- opt.dataset.accountId = acct.id;
184
- fromDatalist.appendChild(opt);
185
- }
186
- const selectedAcct = init.accounts.find((a) => a.id === init.accountId) || init.accounts[0];
187
- if (selectedAcct)
188
- fromInput.value = `${selectedAcct.name} <${selectedAcct.email}>`;
210
+ populateFromSelect(init.accounts, init.accountId);
189
211
  toInput.value = formatAddrs(init.to);
190
212
  ccInput.value = formatAddrs(init.cc);
191
213
  subjectInput.value = init.subject;
@@ -193,6 +215,10 @@ function applyInit(init) {
193
215
  editor.clipboard.dangerouslyPasteHTML(init.bodyHtml);
194
216
  editor.setSelection(0, 0);
195
217
  }
218
+ // If resuming a draft, track its UID for deletion after send
219
+ if (init.draftUid) {
220
+ draftUid = init.draftUid;
221
+ }
196
222
  document.title = init.subject ? `${init.subject} - Compose` : "Compose - mailx";
197
223
  // Focus first empty field: To → Subject → body
198
224
  if (!toInput.value)
@@ -213,19 +239,11 @@ else {
213
239
  toInput.focus();
214
240
  }
215
241
  // If From dropdown is empty (new compose without init, or init had no accounts), fetch from API
216
- if (fromDatalist.children.length === 0) {
242
+ if (fromSelect.options.length === 0) {
217
243
  fetch("/api/accounts")
218
244
  .then(r => r.json())
219
245
  .then((accounts) => {
220
- for (const acct of accounts) {
221
- const opt = document.createElement("option");
222
- opt.value = `${acct.name} <${acct.email}>`;
223
- opt.dataset.accountId = acct.id;
224
- fromDatalist.appendChild(opt);
225
- }
226
- if (!fromInput.value && accounts.length > 0) {
227
- fromInput.value = `${accounts[0].name} <${accounts[0].email}>`;
228
- }
246
+ populateFromSelect(accounts);
229
247
  })
230
248
  .catch(e => console.error("Failed to load accounts:", e));
231
249
  }
@@ -270,6 +288,7 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
270
288
  btn.textContent = "Sending...";
271
289
  const body = {
272
290
  from: getFromAccountId(),
291
+ fromAddress: getFromAddress(),
273
292
  to: parseAddrs(toInput.value),
274
293
  cc: parseAddrs(ccInput.value),
275
294
  bcc: parseAddrs(bccInput.value),