<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Lei Vertri's Blog]]></title><description><![CDATA[Lei Vertri's Blog]]></description><link>https://leivertri.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 23:02:28 GMT</lastBuildDate><atom:link href="https://leivertri.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Turning Audio Into an MP4 in the Browser: Writing the Muxer, and Why I Left the Edit List Out]]></title><description><![CDATA[WebCodecs gives you AAC packets and H.264 frames. Getting them into a file that YouTube, phones and ffmpeg all accept is the part nobody hands you.]]></description><link>https://leivertri.hashnode.dev/turning-audio-into-an-mp4-in-the-browser-writing-the-muxer-and-why-i-left-the-edit-list-out</link><guid isPermaLink="true">https://leivertri.hashnode.dev/turning-audio-into-an-mp4-in-the-browser-writing-the-muxer-and-why-i-left-the-edit-list-out</guid><category><![CDATA[video]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[#WebCodecs ]]></category><category><![CDATA[mp4]]></category><category><![CDATA[audio]]></category><dc:creator><![CDATA[Lei Vertri]]></dc:creator><pubDate>Mon, 02 Nov 2026 02:09:28 GMT</pubDate><content:encoded><![CDATA[<p>Some places only take video. Video sites, a few messaging apps and plenty of phones will happily play an audio file and refuse to upload it. People who edit audio end up asking for the same thing: "can I get this as an MP4?"</p>
<p>I added exactly that to a browser audio tool: the mixed-down audio plus a single still picture, written as an <code>.mp4</code>. It is not a video editor, and the picture never changes. Still, producing a file that every player and uploader accepts turned out to be mostly a file-format problem, not an encoding problem. This post is about that part.</p>
<h2>What WebCodecs gives you, and what it doesn't</h2>
<p>In Chrome, <code>AudioEncoder</code> produces raw AAC packets (1024 samples each) and <code>VideoEncoder</code> produces H.264 frames. Neither writes a file, so the MP4 container is my job: boxes, sample tables, offsets.</p>
<p>The plan:</p>
<ol>
<li>Encode the audio to AAC. (An M4A writer already existed, so this reuses its encoder path.)</li>
<li>Draw one picture on an <code>OffscreenCanvas</code> and encode it as H.264.</li>
<li>Write an MP4 with two tracks, an audio track and a video track.</li>
</ol>
<h2>Layout: index first, data after, nothing held in memory</h2>
<p>I wanted a "fast start" file, with the <code>moov</code> index before the <code>mdat</code> data, so a browser can start playing before the download finishes. But the index depends on sizes of everything that comes after it, and the audio can be long. I don't want to hold an hour of AAC in memory just to write a header.</p>
<p>So the file is assembled as:</p>
<pre><code>ftyp | moov | mdat header | all audio packets | all video samples
</code></pre>
<p>The packets are written to the output as they are encoded, and once their sizes are all known, the head (<code>ftyp</code> + <code>moov</code> + the <code>mdat</code> header) is built and put in front. Because the sample table stores each track's data as a single chunk, the whole audio track is one chunk and the whole video track is one chunk. That keeps the index small.</p>
<h2>The chicken-and-egg offset</h2>
<p>The sample table needs to say <em>where</em> each chunk starts (<code>stco</code> / <code>co64</code>). That offset is the size of <code>ftyp</code> + <code>moov</code> + the <code>mdat</code> header, but <code>moov</code> contains the offsets. Its own size depends on them.</p>
<p>The trick I used is dull and works: build <code>moov</code> twice. The offset boxes have a fixed width, so the size doesn't change between passes.</p>
<pre><code class="language-ts">function assemble(ftyp, dataBytes, build) {
  const header = mdatHeader(dataBytes);
  let moov = build(0);                       // wrong offsets, right size
  moov = build(ftyp.length + moov.length + header.length); // right offsets
  /* concatenate ftyp, moov, header */
}
</code></pre>
<p>The one place it could go wrong is the offset box width: past 4 GB, 32-bit <code>stco</code> has to become 64-bit <code>co64</code>, and so does the <code>mdat</code> header (a size field of 1 followed by a 64-bit length). Both are chosen by the data size, which is known before the first pass, so the width is the same in both passes.</p>
<h2>Decision: no edit list</h2>
<p>AAC encoders emit a few "priming" samples at the start; the decoder is supposed to skip them. For a plain <code>.m4a</code>, I write an <strong>edit list</strong> (<code>elst</code>) that trims the priming at the start and the padding at the end, so the decoded length is exactly the mix's length.</p>
<p>For the MP4 I left the edit list out. YouTube's recommended upload settings say "No Edit Lists (or the video might not get processed correctly)", and this file is meant for uploads. There is a cost, and it is not hidden:</p>
<ul>
<li>The sound plays every decoded sample, priming included. On the encoder I tested, that lead-in was 2,112 samples, about 48 ms at 44.1 kHz. That is the encoder's number, not a universal constant.</li>
<li>To keep the two tracks the same length, the picture's last frame is shortened so the video ends with the sound.</li>
</ul>
<p>In a check against the WAV of the same mix, the sound matched after shifting by exactly that lead-in (the comparison came out at about 47 dB). I'm describing what I measured on one setup, not a guarantee for every browser's encoder. And 48 ms is inaudible for a still-picture upload, but it is a real offset, and I'd rather state it than pretend the file is sample-exact.</p>
<h2>The video: one frame a second</h2>
<p>A picture that never changes doesn't need 30 frames per second. The track is 1280×720, <strong>one frame per second</strong>, with a keyframe every 10 seconds. Fewer frames means less encoder time and a smaller file. Keyframes still have to exist at regular intervals so players can seek.</p>
<p>The sample table reflects that: <code>stts</code> gives every sample the same duration, <code>stss</code> lists the keyframes, and <code>ctts</code> (composition offsets) is written only if an encoder actually reorders frames. If any offset is negative, the box is written signed.</p>
<p>Codec profiles are a fallback chain, because browsers differ: try H.264 High, then Main, then Baseline, whichever the encoder reports as supported. In Chrome the software encoder I hit only offered Baseline. The export option is offered at all only if the browser has <em>both</em> an AAC encoder and an H.264 encoder; otherwise the control says it isn't available there.</p>
<h2>How I checked it</h2>
<p>A muxer can produce something that plays in one player and breaks in another, so a single "it plays" proves little. For a 10-minute project in Chrome 156 I checked:</p>
<ul>
<li><code>ffprobe</code>: H.264 High, 1280×720, 1 fps, yuv420p, plus AAC, with equal track lengths;</li>
<li>the box order (<code>ftyp</code>, <code>moov</code>, <code>mdat</code>) and no edit-list box anywhere;</li>
<li>a full <code>ffmpeg</code> decode with no errors;</li>
<li>macOS AVFoundation reading it, and Chrome's <code>&lt;video&gt;</code> element playing it;</li>
<li>unit tests that parse the boxes back and check offsets, <code>stts</code>, <code>stss</code>, <code>ctts</code> and <code>co64</code>.</li>
</ul>
<p>That run produced 601 frames and a 13.0 MB file in 18.1 seconds on that machine. It's one data point on one machine, not a benchmark.</p>
<h2>Limits</h2>
<ul>
<li>The picture is static. This is not a way to make a real video.</li>
<li>No edit list means a small, known lead-in on the sound.</li>
<li>Both encoders must exist in the browser; some will not offer it.</li>
<li>The checks above are Chrome plus a handful of players. They are not a claim that every uploader on earth accepts the file.</li>
</ul>
<h2>Takeaways</h2>
<ul>
<li>WebCodecs gives you encoded chunks; the container is a separate job.</li>
<li>For a fast-start file that doesn't buffer, write the data first and build the head afterwards, building the index twice to resolve its own offsets.</li>
<li>Choose <code>stco</code> vs <code>co64</code> and the <code>mdat</code> header form from the data size before the first pass, so sizes don't shift.</li>
<li>An edit list is a tradeoff: exact length versus compatibility with what the destination recommends. Write down which one you chose.</li>
<li>Verify with more than one consumer, and report measurements as measurements.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[When Windows Hides Extensions, Users Drop the Wrong File — and Your Error Message Makes It Worse]]></title><description><![CDATA[Four visitors in sixty days dropped a SQLite write-ahead log alone. The fix was not smarter detection — it was naming the file they actually need.]]></description><link>https://leivertri.hashnode.dev/when-windows-hides-extensions-users-drop-the-wrong-file-and-your-error-message-makes-it-worse</link><guid isPermaLink="true">https://leivertri.hashnode.dev/when-windows-hides-extensions-users-drop-the-wrong-file-and-your-error-message-makes-it-worse</guid><category><![CDATA[UX]]></category><category><![CDATA[Windows]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[error handling]]></category><dc:creator><![CDATA[Lei Vertri]]></dc:creator><pubDate>Sat, 31 Oct 2026 02:10:49 GMT</pubDate><content:encoded><![CDATA[<p>I build a browser tool that opens Audacity projects. Modern projects (<code>.aup3</code> / <code>.aup4</code>) are SQLite databases. While Audacity has one open, SQLite keeps a write-ahead log beside it: <code>&lt;name&gt;.aup4-wal</code>. That log can hold the newest edits. The project file alone is not always enough.</p>
<p>For sixty days, analytics showed the same pattern four times: someone dropped a lone <code>-wal</code> file. All four were on Windows. Three of them dropped the real project about a minute later. One left.</p>
<p>The sniffing already knew what they had dropped. The message said so: <em>this is the write-ahead log, not the project; drop it together with the project of the same name without <code>-wal</code></em>. That wording is accurate. It still failed half of them on the first try, and one of them for good. Here is why, and what I changed.</p>
<h2>What File Explorer shows by default</h2>
<p>Windows File Explorer ships with <strong>"Hide extensions for known file types"</strong> turned on. For a registered type, Explorer lists the file by its stem. For a type Windows does not know, it shows the whole name.</p>
<p>Audacity projects are often associated with Audacity, so a project named <code>My Song.aup4</code> can appear in Explorer as just:</p>
<pre><code>My Song
</code></pre>
<p>The write-ahead log is <em>not</em> a registered type. It keeps its full name:</p>
<pre><code>My Song.aup4-wal
</code></pre>
<p>Put those two side by side and the only entry that visibly contains <code>.aup4</code> is the log. A careful user looking for "the <code>.aup4</code> file" will pick the wrong one. That is not a misunderstanding of Audacity. It is a misunderstanding of what the file list is showing.</p>
<p>I did not invent this from a theory. It matches the four drops: every one of them was Windows, and three people corrected themselves once they got a second look at the folder. The wording "the project of the same name without <code>-wal</code>" is true, but on a screen that already hides <code>.aup4</code>, it does not point at anything visible.</p>
<h2>The fix is in the sentence, not the detector</h2>
<p>The detector was fine. A <code>-wal</code> is recognised by SQLite's WAL header, not by its name, and saying "this is not a project" was already correct. What was missing was a concrete name.</p>
<p>When the dropped file is called <code>My Song.aup4-wal</code>, the companion project is recoverable from that string alone:</p>
<pre><code class="language-ts">function companionProject(
  name: string | undefined,
  suffix: "-wal" | "-shm",
): { file: string; stem: string } | null {
  const match = name
    ? new RegExp(`^(.+)\\.(aup[34])${suffix}$`, "i").exec(name)
    : null;
  return match
    ? { file: `${match[1]}.${match[2]}`, stem: match[1] }
    : null;
}
</code></pre>
<p>For <code>My Song.aup4-wal</code> that yields <code>{ file: "My Song.aup4", stem: "My Song" }</code>. The error message can then say both forms:</p>
<blockquote>
<p>Drop it together with the project <strong>My Song.aup4</strong>, the file of the same name without <code>-wal</code> (Windows may list it as just <strong>"My Song"</strong> when "Hide extensions for known file types" is on)…</p>
</blockquote>
<p>The same pattern applies to <code>-shm</code> (the WAL index). That file holds no audio at all, and Windows visitors have dropped it alone for the same reason: it is the only name in the folder that still shows an Audacity-looking suffix.</p>
<p>If the dropped name does not match (<code>renamed.bin</code>, <code>song.wav-wal</code>, a bare <code>.aup4-wal</code>), the message falls back to the general wording. Guessing a project name from a rename would be worse than saying nothing specific.</p>
<h2>One privacy constraint on the file name</h2>
<p>The dropped file's name is useful in the message the user sees. It is not useful in analytics. The name is passed into the message builder and nowhere else — it is never reported as a metric. That keeps the error personal without turning file names into telemetry.</p>
<p>The same care applies to <em>when</em> the hint is shown. The "what you actually dropped" sentence only appears when the file was rejected for <em>what it is</em> (not SQLite, or SQLite but not an Audacity project). A genuine project that fails to open for another reason — corruption, a bad journal, out of memory — must not get a "this is a write-ahead log" speech. That would send the user looking for a companion file that does not exist.</p>
<h2>What this is not</h2>
<p>This is not a Windows bug report, and it is not a call to force extensions visible. Explorer's default has been this way for a long time, and users are not going to change it for one tool. The tool has to meet them where the file list already is.</p>
<p>It is also not a claim that every lone <code>-wal</code> drop is caused by hidden extensions. Three of four people recovered on their own; one left. Four events in sixty days is a small sample. The change is cheap, tested, and matches a real Windows UI behaviour, so I shipped it. I would not call it proven causation.</p>
<h2>Takeaways</h2>
<ul>
<li>When users repeatedly drop a <em>companion</em> file instead of the main one, look at how the file list renders names, not only at how your format works.</li>
<li>"Drop the file of the same name without the suffix" is precise and still useless if the suffix was never on screen.</li>
<li>Derive the missing name from the dropped name when the pattern is unambiguous; fall back when it is not.</li>
<li>Keep that name in the user-facing sentence. Do not put it in telemetry.</li>
<li>Show "what you dropped" hints only when the rejection is about identity, not about a later open failure.</li>
</ul>
<p>The surprising part, for me, was how little code it took. The hard part was noticing that a correct sentence can still point at the wrong icon.</p>
]]></content:encoded></item><item><title><![CDATA[Parsing winmail.dat in TypeScript: What the TNEF Spec Doesn't Warn You About]]></title><description><![CDATA[If you have ever received an email with a single attachment called winmail.dat and nothing else, you have met TNEF (Transport Neutral Encapsulation Format…]]></description><link>https://leivertri.hashnode.dev/parsing-winmail-dat-in-typescript-what-the-tnef-spec-doesn-t-warn-you-about</link><guid isPermaLink="true">https://leivertri.hashnode.dev/parsing-winmail-dat-in-typescript-what-the-tnef-spec-doesn-t-warn-you-about</guid><dc:creator><![CDATA[Lei Vertri]]></dc:creator><pubDate>Tue, 27 Oct 2026 07:23:14 GMT</pubDate><content:encoded><![CDATA[<p>If you have ever received an email with a single attachment called <code>winmail.dat</code> and nothing else, you have met TNEF (Transport Neutral Encapsulation Format, documented as [MS-OXTNEF]). Outlook produces it when it sends a Rich Text message to a recipient that doesn't speak MAPI. The PDF, the spreadsheet and the formatted body are all still there, packed inside that one opaque file.</p>
<p>I recently wrote a TNEF reader in TypeScript that runs in the browser. The file format is small, but several details caught me out. Here is what they were and how the parser handles them.</p>
<h2>The shape of the file</h2>
<p>A TNEF stream starts with a 4-byte signature, <code>0x223E9F78</code> (little-endian), followed by a 2-byte key. After that it is a flat run of <strong>attributes</strong>:</p>
<pre><code>level    (1 byte)   0x01 = message, 0x02 = attachment
id       (4 bytes)
length   (4 bytes)
data     (length bytes)
checksum (2 bytes)
</code></pre>
<p>That is 11 bytes of overhead per attribute. Some attributes carry simple things like the subject, sender or dates. Two of them, <code>attMsgProps</code> (0x9003) and <code>attAttachment</code> (0x9005), carry whole <strong>MAPI property lists</strong>, and most of the useful data lives there: the long attachment names, the compressed RTF body, and the recipients.</p>
<p>There is no nesting for attachments. <code>attAttachRenddata</code> (0x9002) <em>opens</em> a new attachment, and every attachment-level attribute after it (title, data, MAPI props) belongs to that attachment until the next <code>attAttachRenddata</code>. So the parser is a small state machine: "current attachment = the last one opened".</p>
<h2>Gotcha 1: match attribute ids on the low word only</h2>
<p>The 32-bit attribute id is documented as a type in the high word plus an id in the low word. In practice, writers don't agree on the high word. Matching the full 32-bit value will silently miss attributes from some producers. The fix is to switch on <code>id &amp; 0xFFFF</code>:</p>
<pre><code class="language-ts">const ATT_SUBJECT = 0x8004;
const ATT_ATTACH_DATA = 0x800f;
const ATT_ATTACH_TITLE = 0x8010;
const ATT_ATTACH_RENDDATA = 0x9002;
const ATT_MSG_PROPS = 0x9003;
const ATT_ATTACHMENT = 0x9005;
const ATT_OEM_CODEPAGE = 0x9007;

switch (rawId &amp; 0xffff) { /* ... */ }
</code></pre>
<h2>Gotcha 2: 8-bit strings have no encoding of their own</h2>
<p>TNEF predates universal Unicode. Plain attributes like the subject or the attachment title are bytes in the <strong>OEM code page</strong> named by <code>attOemCodepage</code>. MAPI <code>PT_STRING8</code> values are in the <strong>message's</strong> code page, which can be a different one. If you decode with UTF-8 or Latin-1 while parsing, a Japanese or Cyrillic file name comes out as mojibake, and the damage can't be undone.</p>
<p>So the parser doesn't decode at all. It keeps those strings as raw bytes:</p>
<pre><code class="language-ts">/** An 8-bit string, still in whatever code page its writer used. */
export interface AnsiString {
  ansi: Uint8Array;
}
</code></pre>
<p>Only the layer above, which knows which code page applies to which field, turns them into text. <code>PT_UNICODE</code> values are the exception. They are UTF-16LE and can be decoded right away (strip trailing NULs).</p>
<h2>Gotcha 3: MAPI property lists are padded, and named properties carry extra baggage</h2>
<p>A property list is a count, followed by entries of <code>type (u16)</code>, <code>id (u16)</code>, an optional name, and the value or values. Two details matter here:</p>
<ul>
<li><strong>Padding.</strong> Each variable-length value (<code>PT_STRING8</code>, <code>PT_UNICODE</code>, <code>PT_BINARY</code>, <code>PT_OBJECT</code>) is <code>u32 length</code> + bytes, padded to a multiple of 4. Fixed-size values are padded too. Forget this once and every property after it is misaligned.</li>
<li><strong>Named properties</strong> (<code>id &gt;= 0x8000</code>) are followed by a 16-byte property-set GUID, a kind, and then either a 4-byte numeric id or a length-prefixed (and padded) UTF-16 name. Their ids are assigned per store and mean nothing without the name, so I step over them and keep only the standard properties:</li>
</ul>
<pre><code class="language-ts">if (id &gt;= 0x8000) {
  cursor.skip(16);              // property set GUID
  const kind = cursor.u32();
  if (kind === 0) cursor.skip(4);
  else {
    const n = cursor.u32();
    cursor.skip(n);
    cursor.pad(n);
  }
}
</code></pre>
<p>Multi-valued types (flag <code>0x1000</code>) and all variable-length types start with a value count. A corrupt file can claim four billion values, so the parser checks that count against what could physically fit in the remaining bytes before looping, since each value takes at least four:</p>
<pre><code class="language-ts">if (n &gt; (cursor.bytes.length - cursor.pos) / 4)
  throw new RangeError("MAPI value count past the end");
</code></pre>
<h2>Gotcha 4: bounds-check every read, but keep what you already have</h2>
<p>Every read goes through a tiny <code>Cursor</code> whose <code>need(n)</code> throws when a value would run past the end. That alone stops out-of-bounds reads. What to <em>do</em> with the error is the more interesting decision.</p>
<p>A winmail.dat that was cut short (a failed download, a mail gateway that truncated it) usually still holds complete attachments before the cut. Those attachments are exactly why the person opened the file. So the parser doesn't throw the whole message away. It records <code>truncated: true</code> and returns what it parsed. Property lists write each property into the map as soon as it's read, so a list that fails halfway still hands its first half to the caller. Only two cases are hard failures: a bad signature, or a file with nothing usable in it.</p>
<h2>Gotcha 5: which name is the real file name?</h2>
<p>An attachment can carry up to three names:</p>
<ol>
<li><code>attAttachTitle</code>, often an 8.3 short name like <code>QUARTE~1.PDF</code>, in the OEM code page.</li>
<li><code>attAttachTransportFilename</code>.</li>
<li><code>PR_ATTACH_LONG_FILENAME</code> (0x3707) in the attachment's MAPI properties, which is the name the sender actually saw.</li>
</ol>
<p>Prefer the long MAPI name and fall back to the title. Otherwise users get 8.3 names back and assume the file is broken.</p>
<h2>The body: compressed RTF, and sometimes HTML in disguise</h2>
<p>The message body is usually <code>PR_RTF_COMPRESSED</code> (0x1009): RTF compressed with Microsoft's LZFu scheme, or stored uncompressed in the "MELA" form. I didn't write my own decompressor. <code>@kenjiuno/decompressrtf</code> handles both, and it was already in the dependency tree through an MSG reader.</p>
<p>What comes out of decompression has one of two shapes:</p>
<ul>
<li><strong>Encapsulated HTML</strong> ([MS-OXRTFEX]). If the RTF contains <code>\fromhtml1</code>, the original message was HTML. The tags sit in <code>{\*\htmltag …}</code> groups, and RTF-only filler sits between <code>\htmlrtf</code> and <code>\htmlrtf0</code>. Walking the groups and keeping only the HTML parts gives you the original HTML back character for character, so there's no need to render RTF at all.</li>
<li><strong>Native Rich Text.</strong> For a reading pane you only need the text: characters, paragraph breaks and tabs. You skip destinations that are never body text (<code>fonttbl</code>, <code>colortbl</code>, <code>stylesheet</code>, <code>pict</code>, <code>header</code>/<code>footer</code> and so on), honour <code>\ucN</code> for Unicode escapes, and map a handful of symbol words (<code>\emdash</code>, <code>\ldblquote</code> …). The detail that matters is that <code>\'xx</code> hex escapes are bytes in the <strong>current font's charset</strong>, so you have to parse the font table and map <code>\fcharsetN</code> to a code page (128 → 932, 134 → 936, 204 → 1251, …). Without that, CJK Rich Text turns into garbage.</li>
</ul>
<h2>Routing: trust bytes over names</h2>
<p>People rename things. A TNEF stream can arrive as <code>winmail.dat</code>, <code>ATT00001.dat</code>, or with no extension at all, and a <code>.dat</code> file isn't always TNEF. My routing works like this:</p>
<ul>
<li>a <code>.dat</code> name goes to the TNEF reader by default;</li>
<li>TNEF content under any other name is recognised by its first four bytes;</li>
<li>a <code>.dat</code> whose bytes are actually PST, EML, MBOX or an OLE compound file goes to <em>that</em> reader.</li>
</ul>
<p>When the signature check fails, the error includes what the first bytes <em>do</em> look like, so the message can say "this is actually a ZIP" instead of a generic "invalid file".</p>
<h2>Testing against real files</h2>
<p>Hand-made fixtures only prove that you agree with yourself. The tests run on Apache POI's HMEF sample files (Apache-2.0). The extracted attachments must be <strong>byte-identical</strong> to POI's originals, and the decompressed RTF identical to POI's <code>message.rtf</code>. Two Exchange Online samples check sender, recipient, HTML body and binary attachments. For the cases no public sample covers (embedded messages, CJK Rich Text, named properties, files cut at every offset), I wrote a tiny TNEF <em>writer</em> in the test suite and round-trip through it.</p>
<h2>Takeaways</h2>
<ul>
<li>Match TNEF attribute ids on the low 16 bits.</li>
<li>Keep 8-bit strings as bytes until you know their code page.</li>
<li>Respect 4-byte padding in MAPI property lists and skip named properties' GUID and name.</li>
<li>Sanity-check counts against remaining bytes before looping.</li>
<li>On truncation, return what you parsed. The attachments are the point.</li>
<li>Prefer <code>PR_ATTACH_LONG_FILENAME</code> over the 8.3 title.</li>
<li>For the body, extract encapsulated HTML when it's there, and use per-font code pages for Rich Text.</li>
</ul>
]]></content:encoded></item></channel></rss>