@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}
|
package/client/compose/editor.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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:
|
|
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${
|
|
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
|
|
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:
|
|
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${
|
|
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
|
}
|