@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. For new mode: just signature. For reply/forward: appended
453
- // after the quoted block. Drafts are skippedthe signature is already
454
- // baked into the saved body. Editing existing draft also skipped.
452
+ // the body. Two sources, in priority order:
453
+ // 1. New `sig: { text, html? }` objectapplied 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 sig = acct?.signature || "";
458
- if (sig && init.mode !== "draft" && !init.draftUid) {
459
- const sigBlock = `<br><br>--<br>${sig}`;
460
- bodyToRender = init.mode === "reply" || init.mode === "replyAll" || init.mode === "forward"
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.428",
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.361",
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.361",
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 all message senders in the DB */
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 all message senders in the DB */
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
- const rows = this.db.prepare(`SELECT from_name, from_address, COUNT(*) as cnt, MAX(date) as last
1105
- FROM messages
1106
- WHERE from_address != ''
1107
- GROUP BY from_address`).all();
1108
- let added = 0;
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
- // Skip invalid addresses so contact autocomplete never proposes non-emails
1111
- if (!r.from_address || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(r.from_address))
1112
- continue;
1113
- const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(r.from_address);
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
- this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)").run(r.from_name || "", r.from_address, r.last, r.cnt, now);
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 from this account. Plain text or HTML allowed. */
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";