@bobfrankston/mailx 1.0.174 → 1.0.176

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.
@@ -0,0 +1 @@
1
+ {"height":1047,"width":1844,"x":531,"y":264}
@@ -8,9 +8,12 @@ function createQuillEditor(container) {
8
8
  placeholder: "Write your message...",
9
9
  modules: {
10
10
  toolbar: [
11
+ [{ font: [] }, { size: ["small", false, "large", "huge"] }],
11
12
  [{ header: [1, 2, 3, false] }],
12
13
  ["bold", "italic", "underline", "strike"],
14
+ [{ color: [] }, { background: [] }],
13
15
  [{ list: "ordered" }, { list: "bullet" }],
16
+ [{ align: [] }],
14
17
  ["blockquote", "link", "image"],
15
18
  ["clean"]
16
19
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.174",
3
+ "version": "1.0.176",
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.1",
25
25
  "@bobfrankston/miscinfo": "^1.0.7",
26
26
  "@bobfrankston/oauthsupport": "^1.0.20",
27
- "@bobfrankston/msger": "^0.1.223",
27
+ "@bobfrankston/msger": "^0.1.225",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -184,6 +184,8 @@ export declare class ImapManager extends EventEmitter {
184
184
  queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
185
185
  /** Process local file queue — move to IMAP Outbox when server is reachable */
186
186
  private processLocalQueue;
187
+ /** Send a raw RFC 2822 message via SMTP for a given account */
188
+ private sendRawViaSMTP;
187
189
  /** Process Outbox — send pending messages with flag-based interlock */
188
190
  processOutbox(accountId: string): Promise<void>;
189
191
  /** Start background Outbox worker — runs immediately then every 10 seconds */
@@ -1624,7 +1624,7 @@ export class ImapManager extends EventEmitter {
1624
1624
  /** Save a debug copy of outgoing mail to the sending directory */
1625
1625
  saveSendingCopy(accountId, rawMessage, label) {
1626
1626
  try {
1627
- const sendingDir = path.join(import.meta.dirname, "..", "..", "sending", accountId);
1627
+ const sendingDir = path.join(getConfigDir(), "sending", accountId);
1628
1628
  fs.mkdirSync(sendingDir, { recursive: true });
1629
1629
  const now = new Date();
1630
1630
  const pad2 = (n) => String(n).padStart(2, "0");
@@ -1665,7 +1665,7 @@ export class ImapManager extends EventEmitter {
1665
1665
  console.error(` [outbox] IMAP queue failed: ${e.message} — saving locally`);
1666
1666
  }
1667
1667
  // Fallback: save to local file queue
1668
- const localQueue = path.join(import.meta.dirname, "..", "..", "outbox", accountId);
1668
+ const localQueue = path.join(getConfigDir(), "outbox", accountId);
1669
1669
  fs.mkdirSync(localQueue, { recursive: true });
1670
1670
  const now = new Date();
1671
1671
  const pad2 = (n) => String(n).padStart(2, "0");
@@ -1675,12 +1675,29 @@ export class ImapManager extends EventEmitter {
1675
1675
  }
1676
1676
  /** Process local file queue — move to IMAP Outbox when server is reachable */
1677
1677
  async processLocalQueue(accountId) {
1678
- const localQueue = path.join(import.meta.dirname, "..", "..", "outbox", accountId);
1678
+ const localQueue = path.join(getConfigDir(), "outbox", accountId);
1679
1679
  if (!fs.existsSync(localQueue))
1680
1680
  return;
1681
1681
  const files = fs.readdirSync(localQueue).filter(f => f.endsWith(".ltr"));
1682
1682
  if (files.length === 0)
1683
1683
  return;
1684
+ // Gmail/API accounts: send directly via SMTP from local queue (no IMAP outbox)
1685
+ if (this.isGmailAccount(accountId)) {
1686
+ for (const file of files) {
1687
+ const filePath = path.join(localQueue, file);
1688
+ const raw = fs.readFileSync(filePath, "utf-8");
1689
+ try {
1690
+ await this.sendRawViaSMTP(accountId, raw);
1691
+ fs.unlinkSync(filePath);
1692
+ console.log(` [outbox] Sent local ${file} via SMTP`);
1693
+ }
1694
+ catch (e) {
1695
+ console.error(` [outbox] Send failed for ${file}: ${e.message}`);
1696
+ }
1697
+ }
1698
+ return;
1699
+ }
1700
+ // IMAP accounts: append to IMAP Outbox for multi-machine interlock
1684
1701
  try {
1685
1702
  const outboxPath = await this.ensureOutbox(accountId);
1686
1703
  const client = await this.createClientWithLimit(accountId);
@@ -1704,6 +1721,48 @@ export class ImapManager extends EventEmitter {
1704
1721
  // IMAP still unreachable — leave files for next attempt
1705
1722
  }
1706
1723
  }
1724
+ /** Send a raw RFC 2822 message via SMTP for a given account */
1725
+ async sendRawViaSMTP(accountId, raw) {
1726
+ const settings = loadSettings();
1727
+ const account = settings.accounts.find(a => a.id === accountId);
1728
+ if (!account?.smtp)
1729
+ throw new Error(`No SMTP config for ${accountId}`);
1730
+ let smtpAuth;
1731
+ if (account.smtp.auth === "password") {
1732
+ smtpAuth = { user: account.smtp.user, pass: account.smtp.password };
1733
+ }
1734
+ else if (account.smtp.auth === "oauth2") {
1735
+ const accessToken = await this.getOAuthToken(accountId);
1736
+ if (!accessToken)
1737
+ throw new Error("OAuth token not available");
1738
+ smtpAuth = { type: "OAuth2", user: account.smtp.user, accessToken };
1739
+ }
1740
+ const { createTransport } = await import("nodemailer");
1741
+ const transport = createTransport({
1742
+ host: account.smtp.host,
1743
+ port: account.smtp.port,
1744
+ secure: account.smtp.port === 465,
1745
+ auth: smtpAuth,
1746
+ tls: { rejectUnauthorized: false },
1747
+ });
1748
+ const parseAddrs = (s) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
1749
+ const toMatch = raw.match(/^To:\s*(.+)$/mi);
1750
+ const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
1751
+ const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
1752
+ const fromMatch = raw.match(/^From:\s*(.+)$/mi);
1753
+ const recipients = [
1754
+ ...(toMatch ? parseAddrs(toMatch[1]) : []),
1755
+ ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
1756
+ ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
1757
+ ];
1758
+ const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
1759
+ if (recipients.length === 0)
1760
+ throw new Error("No recipients");
1761
+ const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
1762
+ this.saveSendingCopy(accountId, rawToSend, "sent");
1763
+ await transport.sendMail({ envelope: { from: sender, to: recipients }, raw: rawToSend });
1764
+ console.log(` [smtp] ${accountId}: sent to ${recipients.join(", ")}`);
1765
+ }
1707
1766
  /** Process Outbox — send pending messages with flag-based interlock */
1708
1767
  async processOutbox(accountId) {
1709
1768
  const outboxFolder = this.findFolder(accountId, "outbox");
@@ -1712,7 +1771,7 @@ export class ImapManager extends EventEmitter {
1712
1771
  // Skip if this account's sync is failing — don't pile up connections
1713
1772
  if (this.connectionBackoff.has(accountId) && Date.now() < (this.connectionBackoff.get(accountId) || 0))
1714
1773
  return;
1715
- // Gmail uses SMTP for sending (not IMAP outbox) skip IMAP outbox check
1774
+ // Gmail: skip IMAP outbox check — sending handled by processLocalQueue which sends directly via SMTP
1716
1775
  if (this.isGmailAccount(accountId))
1717
1776
  return;
1718
1777
  const settings = loadSettings();
@@ -6,6 +6,42 @@
6
6
  import * as dns from "node:dns/promises";
7
7
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo } from "@bobfrankston/mailx-settings";
8
8
  import { simpleParser } from "mailparser";
9
+ // ── Quoted-printable encoding (readable in debug .eml files) ──
10
+ function encodeQuotedPrintable(text) {
11
+ const bytes = Buffer.from(text, "utf-8");
12
+ let line = "";
13
+ let result = "";
14
+ for (let i = 0; i < bytes.length; i++) {
15
+ const b = bytes[i];
16
+ let encoded;
17
+ if (b === 0x0D && bytes[i + 1] === 0x0A) {
18
+ // CRLF — output as-is
19
+ result += line + "\r\n";
20
+ line = "";
21
+ i++; // skip LF
22
+ continue;
23
+ }
24
+ else if (b === 0x0A) {
25
+ // Bare LF — normalize to CRLF
26
+ result += line + "\r\n";
27
+ line = "";
28
+ continue;
29
+ }
30
+ else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
31
+ encoded = String.fromCharCode(b);
32
+ }
33
+ else {
34
+ encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
35
+ }
36
+ if (line.length + encoded.length > 75) {
37
+ result += line + "=\r\n";
38
+ line = "";
39
+ }
40
+ line += encoded;
41
+ }
42
+ result += line;
43
+ return result;
44
+ }
9
45
  // ── Email provider detection (MX-based) ──
10
46
  const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
11
47
  const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
@@ -308,7 +344,7 @@ export class MailxService {
308
344
  const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
309
345
  const bcc = msg.bcc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
310
346
  const body = msg.bodyHtml || msg.bodyText || "";
311
- const bodyBase64 = Buffer.from(body, "utf-8").toString("base64").replace(/(.{76})/g, "$1\r\n");
347
+ const bodyEncoded = encodeQuotedPrintable(body);
312
348
  // Generate a unique Message-ID (required for threading, dedup, and RFC compliance)
313
349
  const domain = account.email.split("@")[1] || "mailx.local";
314
350
  const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
@@ -319,9 +355,9 @@ export class MailxService {
319
355
  `Message-ID: ${messageId}`,
320
356
  msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
321
357
  msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
322
- `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: base64`,
358
+ `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
323
359
  ].filter(h => h !== null).join("\r\n");
324
- const rawMessage = `${headers}\r\n\r\n${bodyBase64}`;
360
+ const rawMessage = `${headers}\r\n\r\n${bodyEncoded}`;
325
361
  this.imapManager.queueOutgoingLocal(account.id, rawMessage);
326
362
  console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
327
363
  for (const addr of msg.to)
@@ -491,15 +527,15 @@ export class MailxService {
491
527
  // Generate or reuse a stable draft ID for dedup
492
528
  const id = draftId || `mailx-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
493
529
  const body = bodyHtml || bodyText || "";
494
- const bodyBase64 = Buffer.from(body, "utf-8").toString("base64").replace(/(.{76})/g, "$1\r\n");
530
+ const bodyEncoded = encodeQuotedPrintable(body);
495
531
  const headers = [
496
532
  `From: ${account.name} <${account.email}>`,
497
533
  to ? `To: ${to}` : null, cc ? `Cc: ${cc}` : null,
498
534
  `Subject: ${subject || "(no subject)"}`, `Date: ${new Date().toUTCString()}`,
499
535
  `X-Mailx-Draft-ID: ${id}`,
500
- `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: base64`,
536
+ `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
501
537
  ].filter(h => h !== null).join("\r\n");
502
- const raw = `${headers}\r\n\r\n${bodyBase64}`;
538
+ const raw = `${headers}\r\n\r\n${bodyEncoded}`;
503
539
  const uid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
504
540
  return { uid, draftId: id };
505
541
  }