@bobfrankston/rmfmail 1.2.300 → 1.2.302

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/.commitmsg CHANGED
@@ -1,41 +1,49 @@
1
- The chip claimed every rule was a bulk-mail test without looking at them
2
-
3
- Altis Hotels' welcome mail (Bob 2026-09-03: "marked as spam but is valid",
4
- `mailxstore/bobma/90/90fd2404….eml`). The banner on it is already right —
5
- `info`, no headline, "Your mail server scored this as spam, but altishotels.com
6
- proved it sent this", and the "Stop warning about marketing@altishotels.com"
7
- button underneath. What was wrong sat one element above: the score chip's
8
- tooltip read "Scored by bulk-mail or blocklist rules; the sending domain passed
9
- DMARC, so the sender is proved" — and then listed, on the next line of the same
10
- tooltip, `3.2 KAM_ACCOUNTPHISH (Spam that tries to get account information)`,
11
- which is 3.2 of the message's 8.3 and is neither a bulk-mail nor a blocklist
12
- rule. A reader who reads both lines has been told two different things about
13
- the same message by the same tooltip, and the app is the one that is wrong.
14
-
15
- The claim was never checked. `benign = proved || kind === "bulk"` picked the
16
- neutral chip correctly, that part is about the sender and the tooltip then
17
- described the RULES from that same flag. `proved` is a fact about the headers
18
- and says nothing about any rule; `kind` is the WORST class present and says
19
- nothing about the rest. Neither one can support a sentence beginning "every
20
- rule that scored".
21
-
22
- `analyzeServerSpam` now computes `bulkOnly` — every rule that scored is `bulk`
23
- or `neutral` and `spamScoreOf` carries it to the chip. The bulk-only sentence
24
- is said only when it is true. Otherwise the tooltip says what IS established
25
- and nothing more: the proof, by name, plus "not every rule that scored is a
26
- bulk-mail test they are listed below", or for an unproved sender "no forgery
27
- rule scored", which `kind === "bulk"` does establish. The `info` banner for a
28
- bulk verdict loses its "— nothing that scored tests who sent it" clause on the
29
- same condition, since it was making the same unchecked claim.
30
-
31
- Measured over the 800 newest messages in the store: 138 raise the neutral chip,
32
- and 132 of those were being told the score was bulk-mail rules only. Most are
33
- unflagged mail scoring above half the threshold, which carries no
34
- X-Spam-Report at all there the individual scores are unknown, which is
35
- precisely when a claim about all of them cannot be made.
36
-
37
- Not changed, on purpose: KAM_ACCOUNTPHISH stays unclassified. Its description
38
- says it tests what the message asks for, not who sent it, but "other" is the
39
- honest answer for a rule this file has no basis to classify, and the cautious
40
- default is what "other" is for. The fix is to stop asserting past it, not to
41
- guess at it.
1
+ Opening a PDF on the phone: a stub, and bytes laundered through UTF-8
2
+
3
+ "I tried opening a pdf attachment on Android and it said 'opening' but nothing
4
+ happened" (Bob 2026-09-04). In the Android log, verbatim:
5
+
6
+ Couldn't open "260901-THE-AI-COMPUTE-RACE-…-PRIVATE-DRAFT.pdf":
7
+ Not implemented on Android: getAttachment
8
+
9
+ Two independent bugs, and only the first was reachable.
10
+
11
+ **getAttachment was a `notImpl` stub.** It sat one line above a comment that
12
+ read "Android opens attachments via the native bridge getAttachment + base64
13
+ hand-off covers it": a correct description of the design, immediately below the
14
+ throw that made the design impossible. The viewer's Android branch does exactly
15
+ what the comment says fetch the bytes, hand them to
16
+ `_nativeBridge.openAttachment`so the throw landed in the chip's catch, which
17
+ posts a banner and restores the label after 600ms. From the phone that is
18
+ "Opening…", and then the chip goes back to normal. It is now implemented: read
19
+ the body through the sync manager, parse, index the attachment the same way
20
+ getMessage numbered the chips, return base64 in the shape the desktop jsonrpc
21
+ returns.
22
+
23
+ **The parser was destroying the bytes anyway.** The attachment branch called
24
+ `decodeBody()` which decodes the transfer encoding to bytes and then decodes
25
+ THOSE as UTF-8 text and re-encoded the result with `TextEncoder`. Every byte
26
+ sequence that is not valid UTF-8 became U+FFFD and came back as the three bytes
27
+ EF BF BD. A PDF, an image or a zip would have arrived corrupt and LONGER than
28
+ it started, with `size` wrong to match, the moment the stub was filled in. The
29
+ transfer-encoding step is now `decodeBodyBytes()` and stops at bytes;
30
+ `decodeBody` is that plus the charset step, so the encodings still have one
31
+ implementation. Attachments take the bytes. Text parts are unaffected.
32
+
33
+ Verified against the actual message: the web parser and mailparser now produce
34
+ byte-identical output for that PDF 3,307,996 bytes, `%PDF-` to `%%EOF`.
35
+ tests/attachment-bytes.test.ts covers it with a fixture built from bytes no
36
+ UTF-8 decoder can round-trip, asserting the whole buffer rather than a prefix,
37
+ because the broken version also started with "%PDF".
38
+
39
+ **One more thing that had never run.** `app/openattachment` in MainPage.xaml.cs
40
+ is the only place in the bridge that reads a RETURN VALUE from JS; every other
41
+ path pushes C#→JS. Since the JS threw first, that code has never once executed.
42
+ Android's evaluateJavascript hands back a JSON-encoded value, so the base64
43
+ arrives quoted and with `=` liable to appear as = — undone before decode,
44
+ and a FormatException now logs the length and first characters of what actually
45
+ arrived instead of dying as "invalid base64".
46
+
47
+ Untested from here: the 3.3 MB hand-off itself. The bytes are correct and the
48
+ call is wired; whether a payload that size survives one EvaluateJavaScriptAsync
49
+ can only be answered on the device. If it does not, the new log line names it.
@@ -6841,13 +6841,13 @@ function parseMimeParts(body, boundary, topHeaders) {
6841
6841
  }
6842
6842
  if (disposition.includes("attachment") || partType.includes("application/") && !partType.includes("text/")) {
6843
6843
  const filenameMatch = disposition.match(/filename="?([^";\r\n]+)"?/i) || partType.match(/name="?([^";\r\n]+)"?/i);
6844
- const decoded = decodeBody(partBody, partEncoding);
6844
+ const content = decodeBodyBytes(partBody, partEncoding);
6845
6845
  attachments.push({
6846
6846
  filename: filenameMatch?.[1]?.trim() || `attachment-${attachments.length}`,
6847
6847
  contentType: partType.split(";")[0].trim(),
6848
- size: decoded.length,
6848
+ size: content.length,
6849
6849
  contentId: (partHeaders.get("content-id") || "").replace(/[<>]/g, ""),
6850
- content: new TextEncoder().encode(decoded)
6850
+ content
6851
6851
  });
6852
6852
  } else if (partType.includes("text/html")) {
6853
6853
  const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
@@ -6859,14 +6859,21 @@ function parseMimeParts(body, boundary, topHeaders) {
6859
6859
  }
6860
6860
  return { html, text, headers: topHeaders, attachments };
6861
6861
  }
6862
- function decodeBody(body, encoding, charset = "utf-8") {
6862
+ function base64FromBytes(bytes) {
6863
+ const CHUNK = 32768;
6864
+ let binary = "";
6865
+ for (let i = 0; i < bytes.length; i += CHUNK)
6866
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
6867
+ return btoa(binary);
6868
+ }
6869
+ function decodeBodyBytes(body, encoding) {
6863
6870
  let bytes;
6864
6871
  if (encoding === "base64") {
6865
6872
  try {
6866
6873
  const binary = atob(body.replace(/\s/g, ""));
6867
6874
  bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
6868
6875
  } catch {
6869
- return body;
6876
+ bytes = Uint8Array.from(body, (c) => c.charCodeAt(0) & 255);
6870
6877
  }
6871
6878
  } else if (encoding === "quoted-printable") {
6872
6879
  const cleaned = body.replace(/=\r?\n/g, "");
@@ -6884,8 +6891,14 @@ function decodeBody(body, encoding, charset = "utf-8") {
6884
6891
  } else if (encoding === "7bit" || encoding === "8bit" || encoding === "" || encoding === "binary") {
6885
6892
  bytes = Uint8Array.from(body, (c) => c.charCodeAt(0) & 255);
6886
6893
  } else {
6887
- return body;
6894
+ bytes = Uint8Array.from(body, (c) => c.charCodeAt(0) & 255);
6888
6895
  }
6896
+ return bytes;
6897
+ }
6898
+ function decodeBody(body, encoding, charset = "utf-8") {
6899
+ if (!["base64", "quoted-printable", "7bit", "8bit", "", "binary"].includes(encoding))
6900
+ return body;
6901
+ const bytes = decodeBodyBytes(body, encoding);
6889
6902
  try {
6890
6903
  const normalized = charset.toLowerCase().replace("windows-", "windows-").replace("iso-", "iso-");
6891
6904
  return new TextDecoder(normalized).decode(bytes);
@@ -7486,11 +7499,49 @@ var WebMailxService = class _WebMailxService {
7486
7499
  async getMessageSource(_accountId, _uid, _folderId) {
7487
7500
  return this.notImpl("getMessageSource");
7488
7501
  }
7489
- async getAttachment(_accountId, _uid, _attachmentId, _folderId) {
7490
- return this.notImpl("getAttachment");
7502
+ /** The bytes of one attachment, base64 for the wire — the same shape the
7503
+ * desktop jsonrpc `getAttachment` case returns, because the same viewer
7504
+ * code consumes both.
7505
+ *
7506
+ * 2026-09-04 — Claude Code (Opus 5), at Bob's direction ("I tried opening
7507
+ * a pdf attachment on Android and it said 'opening' but nothing
7508
+ * happened"). This was a `notImpl` stub sitting immediately above a
7509
+ * comment explaining that Android opens attachments through the native
7510
+ * bridge "— getAttachment + base64 hand-off covers it". Both statements
7511
+ * were in the file at once, and the second was describing this method.
7512
+ * The viewer's Android branch does what that comment says: it calls
7513
+ * getAttachment for the bytes and hands them to
7514
+ * `_nativeBridge.openAttachment`. The throw landed in the chip's catch,
7515
+ * which posts a banner and restores the label — so from the phone it read
7516
+ * as "Opening…" and then nothing. Log line, verbatim:
7517
+ * `Couldn't open "…rev159-PRIVATE-DRAFT.pdf": Not implemented on Android:
7518
+ * getAttachment`.
7519
+ *
7520
+ * `attachmentId` is the chip's id, which getMessage assigns as the index
7521
+ * into this same parser's attachment list — so the two must stay the same
7522
+ * parse. Re-reading the body rather than caching it keeps that true even
7523
+ * if the message is re-fetched between opening it and tapping the chip. */
7524
+ async getAttachment(accountId, uid, attachmentId, folderId) {
7525
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
7526
+ if (!envelope)
7527
+ throw new Error("Message not found");
7528
+ const raw = await this.syncManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
7529
+ if (!raw)
7530
+ throw new Error("Message body isn't downloaded yet \u2014 open the message, then try the attachment again.");
7531
+ const parsed = parseEmailSource(new TextDecoder().decode(raw));
7532
+ const att = parsed.attachments?.[attachmentId];
7533
+ if (!att)
7534
+ throw new Error(`Attachment ${attachmentId} not found in this message`);
7535
+ return {
7536
+ content: base64FromBytes(att.content),
7537
+ contentType: att.contentType || "application/octet-stream",
7538
+ filename: (att.filename || "attachment").replace(/"/g, "")
7539
+ };
7491
7540
  }
7492
7541
  // Android opens attachments via the native bridge (_nativeBridge.openAttachment),
7493
- // not this service path getAttachment + base64 hand-off covers it.
7542
+ // which the viewer reaches directly with the bytes getAttachment returns
7543
+ // above; there is no service-side "save it and ask the OS to open it" on a
7544
+ // phone.
7494
7545
  async openAttachment(_accountId, _uid, _attachmentId, _folderId) {
7495
7546
  return this.notImpl("openAttachment");
7496
7547
  }