@bobfrankston/mailx 1.0.428 → 1.0.430
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.
|
@@ -449,15 +449,30 @@ function applyInit(init) {
|
|
|
449
449
|
}
|
|
450
450
|
}
|
|
451
451
|
// C42: append the account's signature (if configured) BEFORE rendering
|
|
452
|
-
// the body.
|
|
453
|
-
//
|
|
454
|
-
//
|
|
452
|
+
// the body. Two sources, in priority order:
|
|
453
|
+
// 1. New `sig: { text, html? }` object — applied to NEW messages only.
|
|
454
|
+
// `text` is HTML-escaped (newlines → <br>) unless `html: true` is set
|
|
455
|
+
// (reserved for future use; currently `html` is ignored and text is
|
|
456
|
+
// always escaped).
|
|
457
|
+
// 2. Legacy `signature: string` — HTML, applied to new + reply + forward.
|
|
458
|
+
//
|
|
459
|
+
// Drafts are skipped — the signature is already baked into the saved body.
|
|
460
|
+
// Editing an existing draft also skipped.
|
|
455
461
|
let bodyToRender = init.bodyHtml || "";
|
|
456
462
|
const acct = init.accounts.find(a => a.id === init.accountId);
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
463
|
+
const isNew = init.mode !== "reply" && init.mode !== "replyAll"
|
|
464
|
+
&& init.mode !== "forward" && init.mode !== "draft" && !init.draftUid;
|
|
465
|
+
const isReplyForward = init.mode === "reply" || init.mode === "replyAll"
|
|
466
|
+
|| init.mode === "forward";
|
|
467
|
+
if (isNew && acct?.sig?.text) {
|
|
468
|
+
const sigText = acct.sig.html
|
|
469
|
+
? acct.sig.text // future: trust as raw HTML
|
|
470
|
+
: escapeHtml(acct.sig.text).replace(/\n/g, "<br>");
|
|
471
|
+
bodyToRender = `${bodyToRender}<br><br>-- <br>${sigText}`;
|
|
472
|
+
}
|
|
473
|
+
else if (acct?.signature && init.mode !== "draft" && !init.draftUid) {
|
|
474
|
+
const sigBlock = `<br><br>--<br>${acct.signature}`;
|
|
475
|
+
bodyToRender = isReplyForward
|
|
461
476
|
? `<br>${sigBlock}<br>${bodyToRender}` // sig above the quote
|
|
462
477
|
: `${bodyToRender}${sigBlock}`; // sig at the end for new
|
|
463
478
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.430",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@bobfrankston/iflow-node": "^0.1.8",
|
|
37
37
|
"@bobfrankston/miscinfo": "^1.0.10",
|
|
38
38
|
"@bobfrankston/oauthsupport": "^1.0.25",
|
|
39
|
-
"@bobfrankston/msger": "^0.1.
|
|
39
|
+
"@bobfrankston/msger": "^0.1.362",
|
|
40
40
|
"@bobfrankston/mailx-host": "^0.1.8",
|
|
41
41
|
"@capacitor/android": "^8.3.0",
|
|
42
42
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"@bobfrankston/iflow-node": "^0.1.8",
|
|
101
101
|
"@bobfrankston/miscinfo": "^1.0.10",
|
|
102
102
|
"@bobfrankston/oauthsupport": "^1.0.25",
|
|
103
|
-
"@bobfrankston/msger": "^0.1.
|
|
103
|
+
"@bobfrankston/msger": "^0.1.362",
|
|
104
104
|
"@bobfrankston/mailx-host": "^0.1.8",
|
|
105
105
|
"@capacitor/android": "^8.3.0",
|
|
106
106
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -395,6 +395,8 @@ function normalizeAccount(acct, globalName) {
|
|
|
395
395
|
// blocking the build. Once mailx-types has rebuilt once (post-field)
|
|
396
396
|
// this cast can be removed.
|
|
397
397
|
...(acct.signature ? { signature: acct.signature } : {}),
|
|
398
|
+
...(acct.sig && typeof acct.sig === "object" && typeof acct.sig.text === "string"
|
|
399
|
+
? { sig: { text: acct.sig.text, html: !!acct.sig.html } } : {}),
|
|
398
400
|
};
|
|
399
401
|
}
|
|
400
402
|
// ── Defaults ──
|
|
@@ -196,7 +196,18 @@ export declare class MailxDB {
|
|
|
196
196
|
rollbackTransaction(): void;
|
|
197
197
|
/** Record an address used in sent mail */
|
|
198
198
|
recordSentAddress(name: string, email: string): void;
|
|
199
|
-
/** Seed contacts from
|
|
199
|
+
/** Seed contacts from every address that appears in any cached message —
|
|
200
|
+
* From, To, Cc, Bcc — across all folders. The original implementation
|
|
201
|
+
* only scanned `from_address`, so addresses you'd sent to (To/Cc on
|
|
202
|
+
* outgoing mail) never made it into autocomplete unless they had also
|
|
203
|
+
* written back to you. That left compose unable to suggest people you
|
|
204
|
+
* email regularly but who never reply. This pass walks the JSON arrays
|
|
205
|
+
* too. Dedup by lowercased email; per-address `use_count` is the total
|
|
206
|
+
* occurrences across the corpus, `last_used` is the most recent message
|
|
207
|
+
* date in any field. Existing rows have their counters bumped (instead of
|
|
208
|
+
* the old skip-if-exists behavior) so periodic re-seeds keep recency
|
|
209
|
+
* fresh — `received`-tagged rows get usage data from observed traffic;
|
|
210
|
+
* `sent` and `google` rows are left alone (they're authoritative). */
|
|
200
211
|
seedContactsFromMessages(): number;
|
|
201
212
|
/** Search contacts by name or email prefix */
|
|
202
213
|
searchContacts(query: string, limit?: number): {
|
|
@@ -1098,24 +1098,83 @@ export class MailxDB {
|
|
|
1098
1098
|
this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('sent', ?, ?, ?, 1, ?)").run(name, email, now, now);
|
|
1099
1099
|
}
|
|
1100
1100
|
}
|
|
1101
|
-
/** Seed contacts from
|
|
1101
|
+
/** Seed contacts from every address that appears in any cached message —
|
|
1102
|
+
* From, To, Cc, Bcc — across all folders. The original implementation
|
|
1103
|
+
* only scanned `from_address`, so addresses you'd sent to (To/Cc on
|
|
1104
|
+
* outgoing mail) never made it into autocomplete unless they had also
|
|
1105
|
+
* written back to you. That left compose unable to suggest people you
|
|
1106
|
+
* email regularly but who never reply. This pass walks the JSON arrays
|
|
1107
|
+
* too. Dedup by lowercased email; per-address `use_count` is the total
|
|
1108
|
+
* occurrences across the corpus, `last_used` is the most recent message
|
|
1109
|
+
* date in any field. Existing rows have their counters bumped (instead of
|
|
1110
|
+
* the old skip-if-exists behavior) so periodic re-seeds keep recency
|
|
1111
|
+
* fresh — `received`-tagged rows get usage data from observed traffic;
|
|
1112
|
+
* `sent` and `google` rows are left alone (they're authoritative). */
|
|
1102
1113
|
seedContactsFromMessages() {
|
|
1114
|
+
const VALID = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
|
|
1103
1115
|
const now = Date.now();
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1116
|
+
// Per-email aggregator. `name` keeps the first non-empty display name
|
|
1117
|
+
// we see so autocomplete shows "Alice <alice@…>" not just the address.
|
|
1118
|
+
const agg = new Map();
|
|
1119
|
+
const bump = (name, address, date) => {
|
|
1120
|
+
const email = (address || "").trim().toLowerCase();
|
|
1121
|
+
if (!email || !VALID.test(email))
|
|
1122
|
+
return;
|
|
1123
|
+
const e = agg.get(email);
|
|
1124
|
+
if (e) {
|
|
1125
|
+
e.cnt++;
|
|
1126
|
+
if (date > e.last)
|
|
1127
|
+
e.last = date;
|
|
1128
|
+
if (!e.name && name)
|
|
1129
|
+
e.name = name;
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
agg.set(email, { name: name || "", cnt: 1, last: date || 0 });
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
const rows = this.db.prepare("SELECT from_name, from_address, to_json, cc_json, bcc_json, date FROM messages").all();
|
|
1109
1136
|
for (const r of rows) {
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1137
|
+
const date = r.date || 0;
|
|
1138
|
+
bump(r.from_name, r.from_address, date);
|
|
1139
|
+
for (const field of [r.to_json, r.cc_json, r.bcc_json]) {
|
|
1140
|
+
if (!field)
|
|
1141
|
+
continue;
|
|
1142
|
+
let parsed;
|
|
1143
|
+
try {
|
|
1144
|
+
parsed = JSON.parse(field);
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
if (!Array.isArray(parsed))
|
|
1150
|
+
continue;
|
|
1151
|
+
for (const a of parsed) {
|
|
1152
|
+
if (!a)
|
|
1153
|
+
continue;
|
|
1154
|
+
bump(a.name || "", a.address || a.email || "", date);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
let added = 0;
|
|
1159
|
+
let bumped = 0;
|
|
1160
|
+
const insStmt = this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)");
|
|
1161
|
+
const updStmt = this.db.prepare("UPDATE contacts SET use_count = ?, last_used = max(last_used, ?), name = CASE WHEN name = '' AND ? != '' THEN ? ELSE name END, updated_at = ? WHERE email = ? AND source = 'received'");
|
|
1162
|
+
for (const [email, info] of agg) {
|
|
1163
|
+
const existing = this.db.prepare("SELECT id, source FROM contacts WHERE email = ?").get(email);
|
|
1114
1164
|
if (!existing) {
|
|
1115
|
-
|
|
1165
|
+
insStmt.run(info.name, email, info.last, info.cnt, now);
|
|
1116
1166
|
added++;
|
|
1117
1167
|
}
|
|
1168
|
+
else if (existing.source === "received") {
|
|
1169
|
+
updStmt.run(info.cnt, info.last, info.name, info.name, now, email);
|
|
1170
|
+
bumped++;
|
|
1171
|
+
}
|
|
1172
|
+
// 'sent' and 'google' rows are authoritative — leave their
|
|
1173
|
+
// use_count alone. recordSentAddress bumps 'sent' rows on actual
|
|
1174
|
+
// sends, syncGoogleContacts owns 'google' rows.
|
|
1118
1175
|
}
|
|
1176
|
+
if (bumped > 0)
|
|
1177
|
+
console.log(` [contacts] seed: ${added} new + ${bumped} refreshed`);
|
|
1119
1178
|
return added;
|
|
1120
1179
|
}
|
|
1121
1180
|
/** Search contacts by name or email prefix */
|
|
@@ -39,7 +39,14 @@ export interface AccountConfig {
|
|
|
39
39
|
deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
|
|
40
40
|
identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
|
|
41
41
|
spam?: string; /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
|
|
42
|
-
signature?: string; /** HTML signature appended to outgoing messages
|
|
42
|
+
signature?: string; /** Legacy: HTML signature appended to all outgoing messages (new + reply + forward). Plain text or HTML allowed. Superseded by `sig`. */
|
|
43
|
+
sig?: AccountSignature; /** Per-account signature object. Initially appended only to NEW messages; later options will cover replies/forwards. */
|
|
44
|
+
}
|
|
45
|
+
/** Signature configuration in accounts.jsonc. Initial shape carries `text`
|
|
46
|
+
* only; `html: true` reserved for future support of raw HTML signatures. */
|
|
47
|
+
export interface AccountSignature {
|
|
48
|
+
text: string; /** Plain-text signature body. Newlines preserved. Appended to NEW messages with the standard "-- " RFC 3676 separator. */
|
|
49
|
+
html?: boolean; /** Future flag: when true, `text` is treated as raw HTML rather than escaped plain text. Currently ignored. */
|
|
43
50
|
}
|
|
44
51
|
/** Standard IMAP special-use folder types */
|
|
45
52
|
export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
|