@bobfrankston/mailx 1.0.181 → 1.0.183

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":1047,"width":1844,"x":339,"y":168}
1
+ {"height":1344,"width":1438,"x":216,"y":107}
package/client/app.js CHANGED
@@ -265,12 +265,31 @@ async function openCompose(mode) {
265
265
  references: [],
266
266
  accounts: accounts.map((a) => ({ id: a.id, name: a.name, email: a.email })),
267
267
  };
268
+ // Auto-detect reply From: if the message was delivered to an identity domain,
269
+ // reply from that address instead of the default account address.
270
+ // Identity domains configured per-account in accounts.jsonc (identityDomains array).
271
+ // Default identity domains for bob.ma account:
272
+ const identityDomains = ["bob.ma", "frankston.com"];
273
+ function detectReplyFrom() {
274
+ if (!msg)
275
+ return undefined;
276
+ // Check deliveredTo first (most reliable), then To addresses
277
+ const candidates = [msg.deliveredTo, ...(msg.to || []).map((a) => a.address)].filter(Boolean);
278
+ for (const addr of candidates) {
279
+ const domain = addr.split("@")[1]?.toLowerCase();
280
+ if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
281
+ return addr;
282
+ }
283
+ }
284
+ return undefined;
285
+ }
268
286
  if (msg && mode === "reply") {
269
287
  init.to = [msg.from];
270
288
  init.subject = `Re: ${cleanSubject}`;
271
289
  init.bodyHtml = quoteBody(msg);
272
290
  init.inReplyTo = msg.messageId;
273
291
  init.references = [...(msg.references || []), msg.messageId];
292
+ init.fromAddress = detectReplyFrom();
274
293
  }
275
294
  else if (msg && mode === "replyAll") {
276
295
  init.to = [msg.from, ...msg.to.filter((a) => a.address !== msg.from.address)];
@@ -279,6 +298,7 @@ async function openCompose(mode) {
279
298
  init.bodyHtml = quoteBody(msg);
280
299
  init.inReplyTo = msg.messageId;
281
300
  init.references = [...(msg.references || []), msg.messageId];
301
+ init.fromAddress = detectReplyFrom();
282
302
  }
283
303
  else if (msg && mode === "forward") {
284
304
  init.subject = `Fwd: ${cleanSubject}`;
@@ -260,6 +260,13 @@ function parseAddrs(s) {
260
260
  function applyInit(init) {
261
261
  // Populate From dropdown
262
262
  populateFromSelect(init.accounts, init.accountId);
263
+ // Auto-detect reply From: if fromAddress is set (identity domain match),
264
+ // use it as the From address via the "Other..." custom field
265
+ if (init.fromAddress) {
266
+ fromSelect.value = "__custom__";
267
+ fromCustom.hidden = false;
268
+ fromCustom.value = init.fromAddress;
269
+ }
263
270
  toInput.value = formatAddrs(init.to);
264
271
  ccInput.value = formatAddrs(init.cc);
265
272
  subjectInput.value = init.subject;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.181",
3
+ "version": "1.0.183",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.2",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.21",
27
- "@bobfrankston/msger": "^0.1.231",
27
+ "@bobfrankston/msger": "^0.1.233",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -178,7 +178,7 @@ export declare class ImapManager extends EventEmitter {
178
178
  private readonly hostname;
179
179
  /** Ensure Outbox folder exists, create if needed */
180
180
  private ensureOutbox;
181
- /** Save a debug copy of outgoing mail to the sending directory */
181
+ /** Save a copy of outgoing mail label is a subdirectory (editing/queued/sent) */
182
182
  private saveSendingCopy;
183
183
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
184
184
  queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
@@ -1629,20 +1629,19 @@ export class ImapManager extends EventEmitter {
1629
1629
  outbox = this.findFolder(accountId, "outbox");
1630
1630
  return outbox?.path || "Outbox";
1631
1631
  }
1632
- /** Save a debug copy of outgoing mail to the sending directory */
1632
+ /** Save a copy of outgoing mail label is a subdirectory (editing/queued/sent) */
1633
1633
  saveSendingCopy(accountId, rawMessage, label) {
1634
1634
  try {
1635
- const sendingDir = path.join(getConfigDir(), "sending", accountId);
1636
- fs.mkdirSync(sendingDir, { recursive: true });
1635
+ const dir = path.join(getConfigDir(), "sending", accountId, label);
1636
+ fs.mkdirSync(dir, { recursive: true });
1637
1637
  const now = new Date();
1638
1638
  const pad2 = (n) => String(n).padStart(2, "0");
1639
1639
  const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
1640
- const filename = `${ts}-${label}.eml`;
1641
- fs.writeFileSync(path.join(sendingDir, filename), rawMessage);
1642
- console.log(` [sending] Saved debug copy: ${filename}`);
1640
+ fs.writeFileSync(path.join(dir, `${ts}.eml`), rawMessage);
1641
+ console.log(` [sending] ${label}/${ts}.eml`);
1643
1642
  }
1644
1643
  catch (e) {
1645
- console.error(` [sending] Failed to save debug copy: ${e.message}`);
1644
+ console.error(` [sending] Failed to save copy: ${e.message}`);
1646
1645
  }
1647
1646
  }
1648
1647
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
@@ -4,7 +4,9 @@
4
4
  * Both the Express API (mailx-api) and the Android bridge call these functions.
5
5
  */
6
6
  import * as dns from "node:dns/promises";
7
- import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
8
10
  import { simpleParser } from "mailparser";
9
11
  // ── Quoted-printable encoding (readable in debug .eml files) ──
10
12
  function encodeQuotedPrintable(text) {
@@ -106,7 +108,7 @@ export class MailxService {
106
108
  for (const cfg of settings.accounts) {
107
109
  const a = dbAccounts.find(d => d.id === cfg.id);
108
110
  if (a)
109
- ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false });
111
+ ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [] });
110
112
  }
111
113
  // Append any DB accounts not in settings
112
114
  for (const a of dbAccounts) {
@@ -339,7 +341,9 @@ export class MailxService {
339
341
  const account = settings.accounts.find(a => a.id === msg.from);
340
342
  if (!account)
341
343
  throw new Error(`Unknown account: ${msg.from}`);
342
- const fromHeader = msg.fromAddress || `${account.name} <${account.email}>`;
344
+ // Use custom From address if set (identity domain reply), but always wrap with account display name
345
+ const fromAddr = msg.fromAddress || account.email;
346
+ const fromHeader = `${account.name} <${fromAddr}>`;
343
347
  const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
344
348
  const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
345
349
  const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
@@ -536,6 +540,21 @@ export class MailxService {
536
540
  `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
537
541
  ].filter(h => h !== null).join("\r\n");
538
542
  const raw = `${headers}\r\n\r\n${bodyEncoded}`;
543
+ // Save local editing copy — crash recovery, keep last 3
544
+ try {
545
+ const editingDir = path.join(getConfigDir(), "sending", accountId, "editing");
546
+ fs.mkdirSync(editingDir, { recursive: true });
547
+ const pad2 = (n) => String(n).padStart(2, "0");
548
+ const now = new Date();
549
+ const ts = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`;
550
+ fs.writeFileSync(path.join(editingDir, `${ts}.eml`), raw);
551
+ // Keep only last 3
552
+ const files = fs.readdirSync(editingDir).filter(f => f.endsWith(".eml")).sort();
553
+ while (files.length > 3) {
554
+ fs.unlinkSync(path.join(editingDir, files.shift()));
555
+ }
556
+ }
557
+ catch { /* ignore */ }
539
558
  const uid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
540
559
  return { uid, draftId: id };
541
560
  }