@bobfrankston/mailx 1.0.184 → 1.0.185

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/app.js CHANGED
@@ -268,16 +268,15 @@ async function openCompose(mode) {
268
268
  // Auto-detect reply From: if the message was delivered to an identity domain,
269
269
  // reply from that address instead of the default account address.
270
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"];
271
+ const account = accounts.find((a) => a.id === accountId);
272
+ const identityDomains = account?.identityDomains || [];
273
273
  function detectReplyFrom() {
274
- if (!msg)
274
+ if (!msg || identityDomains.length === 0)
275
275
  return undefined;
276
- // Check deliveredTo first (most reliable), then To addresses
277
276
  const candidates = [msg.deliveredTo, ...(msg.to || []).map((a) => a.address)].filter(Boolean);
278
277
  for (const addr of candidates) {
279
278
  const domain = addr.split("@")[1]?.toLowerCase();
280
- if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
279
+ if (domain && identityDomains.some((d) => domain === d.toLowerCase())) {
281
280
  return addr;
282
281
  }
283
282
  }
@@ -585,10 +584,16 @@ onWsEvent((event) => {
585
584
  break;
586
585
  }
587
586
  case "folderCountsChanged": {
588
- // Update folder badges only never reload the message list or touch the viewer.
589
- // The list refreshes when the user clicks a folder or presses Sync.
587
+ // Update folder badges + silently refresh message list (preserves selection and viewer)
590
588
  updateFolderCounts();
591
589
  updateNewMessageCount();
590
+ // Debounced silent reload — preserves scroll position, selection, and viewer
591
+ if (reloadDebounceTimer)
592
+ clearTimeout(reloadDebounceTimer);
593
+ reloadDebounceTimer = setTimeout(() => {
594
+ reloadDebounceTimer = null;
595
+ reloadCurrentFolder();
596
+ }, 2000);
592
597
  // Sync finished — re-enable sync button
593
598
  const syncBtn = document.getElementById("btn-sync");
594
599
  if (syncBtn) {
@@ -258,14 +258,24 @@ function parseAddrs(s) {
258
258
  });
259
259
  }
260
260
  function applyInit(init) {
261
- // Populate From dropdown
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;
261
+ // If identity domain matched, add as first option in dropdown
262
+ const replyAddr = init.fromAddress;
263
+ const account = init.accounts.find(a => a.id === init.accountId);
264
+ const displayName = account?.name || "";
265
+ if (replyAddr) {
266
+ // Insert identity address as first option, selected
267
+ const idOpt = document.createElement("option");
268
+ idOpt.value = init.accountId;
269
+ idOpt.textContent = `${displayName} <${replyAddr}>`;
270
+ idOpt.dataset.email = replyAddr;
271
+ idOpt.dataset.name = displayName;
272
+ idOpt.selected = true;
273
+ // Populate rest of dropdown, then prepend identity option
274
+ populateFromSelect(init.accounts, "");
275
+ fromSelect.prepend(idOpt);
276
+ }
277
+ else {
278
+ populateFromSelect(init.accounts, init.accountId);
269
279
  }
270
280
  toInput.value = formatAddrs(init.to);
271
281
  ccInput.value = formatAddrs(init.cc);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.184",
3
+ "version": "1.0.185",
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.234",
27
+ "@bobfrankston/msger": "^0.1.236",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -12,8 +12,10 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { simpleParser } from "mailparser";
14
14
  import { GmailApiProvider } from "./providers/gmail-api.js";
15
- import { createTransport } from "nodemailer";
16
15
  import * as os from "node:os";
16
+ // Well-known ports — no magic numbers
17
+ const SMTP_PORT_STARTTLS = 587;
18
+ const SMTP_PORT_IMPLICIT_TLS = 465;
17
19
  /** Extract full error detail with provenance */
18
20
  function imapError(err) {
19
21
  const msg = err.message || err.reason || err.code || (typeof err === "string" ? err : "");
@@ -1734,21 +1736,27 @@ export class ImapManager extends EventEmitter {
1734
1736
  const account = settings.accounts.find(a => a.id === accountId);
1735
1737
  if (!account?.smtp)
1736
1738
  throw new Error(`No SMTP config for ${accountId}`);
1739
+ // SMTP auth: use explicit SMTP credentials, fall back to IMAP credentials
1737
1740
  let smtpAuth;
1738
- if (account.smtp.auth === "password") {
1739
- smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
1741
+ const smtpAuthType = account.smtp.auth || (account.imap?.password ? "password" : undefined);
1742
+ if (smtpAuthType === "password") {
1743
+ smtpAuth = {
1744
+ user: account.smtp.user || account.imap?.user || account.email,
1745
+ pass: account.smtp.password || account.imap?.password,
1746
+ };
1740
1747
  }
1741
- else if (account.smtp.auth === "oauth2") {
1748
+ else if (smtpAuthType === "oauth2") {
1742
1749
  const accessToken = await this.getOAuthToken(accountId);
1743
1750
  if (!accessToken)
1744
1751
  throw new Error("OAuth token not available");
1745
- smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
1752
+ smtpAuth = { type: "OAuth2", user: account.smtp.user || account.imap?.user || account.email, accessToken };
1746
1753
  }
1747
1754
  const { createTransport } = await import("nodemailer");
1755
+ const smtpPort = account.smtp.port || SMTP_PORT_STARTTLS;
1748
1756
  const transport = createTransport({
1749
- host: account.smtp.host,
1750
- port: account.smtp.port,
1751
- secure: account.smtp.port === 465,
1757
+ host: account.smtp.host || account.imap?.host,
1758
+ port: smtpPort,
1759
+ secure: smtpPort === SMTP_PORT_IMPLICIT_TLS, // 465 = implicit TLS, 587 = STARTTLS
1752
1760
  auth: smtpAuth,
1753
1761
  tls: { rejectUnauthorized: false },
1754
1762
  });
@@ -1824,52 +1832,10 @@ export class ImapManager extends EventEmitter {
1824
1832
  await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
1825
1833
  continue;
1826
1834
  }
1827
- // Send via SMTP
1835
+ // Send via shared SMTP method
1828
1836
  try {
1829
- let smtpAuth;
1830
- if (account.smtp.auth === "password") {
1831
- smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
1832
- }
1833
- else if (account.smtp.auth === "oauth2") {
1834
- const accessToken = await this.getOAuthToken(accountId);
1835
- if (!accessToken)
1836
- throw new Error("OAuth token not available — re-authenticate");
1837
- smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
1838
- }
1839
- const transport = createTransport({
1840
- host: account.smtp.host,
1841
- port: account.smtp.port,
1842
- secure: account.smtp.port === 465,
1843
- auth: smtpAuth,
1844
- tls: { rejectUnauthorized: false },
1845
- });
1846
- // Parse recipients from raw message headers for SMTP envelope
1847
- const toMatch = msg.source.match(/^To:\s*(.+)$/mi);
1848
- const ccMatch = msg.source.match(/^Cc:\s*(.+)$/mi);
1849
- const bccMatch = msg.source.match(/^Bcc:\s*(.+)$/mi);
1850
- const fromMatch = msg.source.match(/^From:\s*(.+)$/mi);
1851
- const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
1852
- const recipients = [
1853
- ...(toMatch ? parseAddrs(toMatch[1]) : []),
1854
- ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
1855
- ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
1856
- ];
1857
- const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
1858
- if (recipients.length === 0) {
1859
- console.error(` [outbox] No recipients in UID ${uid} — permanent failure`);
1860
- await client.removeFlags(outboxFolder.path, uid, [sendingFlag]);
1861
- await client.addFlags(outboxFolder.path, uid, ["$PermanentFailure"]);
1862
- continue;
1863
- }
1864
- // Strip Bcc header from raw message before sending
1865
- const rawToSend = msg.source.replace(/^Bcc:.*\r?\n/mi, "");
1866
- // Save debug copy before sending
1867
- this.saveSendingCopy(accountId, rawToSend, `sent-${uid}`);
1868
- await transport.sendMail({
1869
- raw: rawToSend,
1870
- envelope: { from: sender, to: recipients },
1871
- });
1872
- console.log(` [outbox] Sent UID ${uid} → ${recipients.join(", ")}`);
1837
+ await this.sendRawViaSMTP(accountId, msg.source);
1838
+ console.log(` [outbox] Sent UID ${uid}`);
1873
1839
  // Delete from Outbox FIRST to prevent double-send if move-to-Sent fails.
1874
1840
  // The message is already sent via SMTP — worst case we lose the Sent copy,
1875
1841
  // which is better than sending the message twice.