@bobfrankston/rmfmail 1.2.301 → 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,29 +1,49 @@
1
- Placing the caret in the body is the same act as taking focus
2
-
3
- Ctrl+N opens compose with the cursor in the message body instead of the To
4
- field (Bob 2026-09-03: "^n (and forward?) still start me in the body rather
5
- than the to field" — and yes, forward too).
6
-
7
- applyInit made the decision twice. First it seeded the body and called
8
- `editor.setCursor(0)`; twenty lines later it picked the first empty field and
9
- focused it. Reading top to bottom the second one wins, and it does not:
10
- `setCursor` is not a caret-placement call, it is a focus call. rmf-tiny's
11
- implementation ends with `editor.focus()` and then re-runs the whole placement
12
- inside `requestAnimationFrame` deliberately, to survive the nested-iframe
13
- focus race that produced the old "have to click in and try again" symptom so
14
- it lands after anything applyInit does synchronously afterwards. Quill's
15
- `setSelection` focuses as well. Whatever order the two lines were written in,
16
- the editor was always going to win.
17
-
18
- So the two are now one decision, made once and up front: `focusTarget` is the
19
- first field the user still has to fill, To Subject body, and `setCursor` is
20
- asked for only when the answer is the body. On a reply that is still the body
21
- To and Subject are filled, and the call also scrolls the quote out of view so
22
- the empty line is what's on screen, which is the behaviour it exists for. On a
23
- forward and on new mail it is the To field, untouched.
24
-
25
- Forward has been doing this since it grew a quoted body. Ctrl+N joined it in
26
- v1.2.298, three hours ago: before the account signature reached compose,
27
- `bodyToRender` was empty for new mail and the branch holding `setCursor` never
28
- ran at all. The first message anyone composed with a signature was also the
29
- first one that opened with the cursor in the wrong place.
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
  }