@bobfrankston/mailx 1.0.219 → 1.0.220

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.
@@ -1 +1 @@
1
- {"height":1344,"width":2151,"x":194,"y":22}
1
+ {"height":1344,"width":2151,"x":1060,"y":329}
package/client/app.js CHANGED
@@ -192,6 +192,24 @@ document.getElementById("folder-tree")?.addEventListener("click", (e) => {
192
192
  document.querySelector(".folder-panel")?.classList.remove("open");
193
193
  }
194
194
  });
195
+ // Close folder overlay when user clicks outside it (narrow mode OR
196
+ // medium-width mode where the folder panel slides in as an overlay).
197
+ // Uses capture phase so it beats any child handler that might stopPropagation.
198
+ document.addEventListener("pointerdown", (e) => {
199
+ const panel = document.querySelector(".folder-panel");
200
+ if (!panel || !panel.classList.contains("open"))
201
+ return;
202
+ const target = e.target;
203
+ // Ignore clicks inside the panel itself and on the hamburger toggle
204
+ if (target.closest(".folder-panel") || target.closest("#btn-menu"))
205
+ return;
206
+ // Only auto-dismiss when we're in overlay mode (small or medium screens).
207
+ // On wide screens the panel is a permanent column and the "open" class
208
+ // is irrelevant.
209
+ if (window.innerWidth <= 1100 || window.innerHeight <= 600) {
210
+ panel.classList.remove("open");
211
+ }
212
+ }, true);
195
213
  // ── Toolbar actions ──
196
214
  document.getElementById("btn-sync")?.addEventListener("click", async () => {
197
215
  const btn = document.getElementById("btn-sync");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.219",
3
+ "version": "1.0.220",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,7 +20,7 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow-direct": "^0.1.8",
23
+ "@bobfrankston/iflow-direct": "^0.1.9",
24
24
  "@bobfrankston/iflow-node": "^0.1.2",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.22",
@@ -74,7 +74,7 @@
74
74
  },
75
75
  ".transformedSnapshot": {
76
76
  "dependencies": {
77
- "@bobfrankston/iflow-direct": "^0.1.8",
77
+ "@bobfrankston/iflow-direct": "^0.1.9",
78
78
  "@bobfrankston/iflow-node": "^0.1.2",
79
79
  "@bobfrankston/miscinfo": "^1.0.8",
80
80
  "@bobfrankston/oauthsupport": "^1.0.22",
@@ -341,11 +341,34 @@ export class MailxService {
341
341
  const account = settings.accounts.find(a => a.id === msg.from);
342
342
  if (!account)
343
343
  throw new Error(`Unknown account: ${msg.from}`);
344
+ // Vet every recipient address — refuse to send if any field contains a
345
+ // non-email (e.g. "Bob Frankston <Bob Frankston>" from a bad contact
346
+ // autocomplete). This catches garbage BEFORE it hits SMTP, where the
347
+ // server would either accept-and-bounce or reject the whole envelope.
348
+ const emailRe = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
349
+ const validateList = (label, list) => {
350
+ if (!list)
351
+ return;
352
+ for (const a of list) {
353
+ const addr = (a?.address || "").trim();
354
+ if (!addr)
355
+ throw new Error(`${label} has an empty address`);
356
+ if (!emailRe.test(addr))
357
+ throw new Error(`${label} has an invalid address: "${addr}"${a?.name ? ` (displayed as "${a.name}")` : ""}`);
358
+ }
359
+ };
360
+ validateList("To", msg.to);
361
+ validateList("Cc", msg.cc);
362
+ validateList("Bcc", msg.bcc);
363
+ if (!msg.to?.length)
364
+ throw new Error("No To recipients");
344
365
  // Extract bare email from fromAddress (may be "Name <addr>" or just "addr")
345
366
  let fromAddr = msg.fromAddress || account.email;
346
367
  const angleMatch = fromAddr.match(/<([^>]+)>/);
347
368
  if (angleMatch)
348
369
  fromAddr = angleMatch[1];
370
+ if (!emailRe.test(fromAddr))
371
+ throw new Error(`From address is not a valid email: "${fromAddr}"`);
349
372
  const fromHeader = `${account.name} <${fromAddr}>`;
350
373
  const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
351
374
  const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
@@ -369,6 +369,11 @@ export class MailxDB {
369
369
  // ── Contacts ──
370
370
  /** Record an address used in sent mail */
371
371
  recordSentAddress(name, email) {
372
+ // Don't pollute the contacts table with non-addresses. Anything without
373
+ // an @ or without a TLD-ish tail would show up in autocomplete and end
374
+ // up back in To/Cc headers as "Name <not an email>".
375
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
376
+ return;
372
377
  const now = Date.now();
373
378
  const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email);
374
379
  if (existing) {
@@ -387,6 +392,9 @@ export class MailxDB {
387
392
  GROUP BY from_address`).all();
388
393
  let added = 0;
389
394
  for (const r of rows) {
395
+ // Skip invalid addresses so contact autocomplete never proposes non-emails
396
+ if (!r.from_address || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(r.from_address))
397
+ continue;
390
398
  const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(r.from_address);
391
399
  if (!existing) {
392
400
  this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)").run(r.from_name || "", r.from_address, r.last, r.cnt, now);