<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 王磊</title>
    <description>The latest articles on DEV Community by 王磊 (@_172c9b2760d6c5bc1e8966).</description>
    <link>https://dev.to/_172c9b2760d6c5bc1e8966</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3964002%2Fd67ecd17-1334-49df-abff-4386876a8a76.png</url>
      <title>DEV Community: 王磊</title>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_172c9b2760d6c5bc1e8966"/>
    <language>en</language>
    <item>
      <title>Why My iPhone File Picker Greyed Out Every PST File I Asked For</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Mon, 02 Nov 2026 02:06:50 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/why-my-iphone-file-picker-greyed-out-every-pst-file-i-asked-for-46k9</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/why-my-iphone-file-picker-greyed-out-every-pst-file-i-asked-for-46k9</guid>
      <description>&lt;p&gt;&lt;em&gt;WebKit maps &lt;code&gt;accept&lt;/code&gt; extensions to MIME types, drops the ones it doesn't know, and then limits the Files app to whatever is left. For a mail archive tool, that is almost everything that matters.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;I run a browser tool that opens Outlook &lt;code&gt;.pst&lt;/code&gt; and &lt;code&gt;.ost&lt;/code&gt; files without Outlook. For months the homepage said it worked on a Mac, a PC, a Chromebook, or an Android phone. Google was suggesting "open pst file on ipad" next to "on android" and "on chromebook". Bing showed tablet-flavoured queries. The site said nothing about an iPad or an iPhone.&lt;/p&gt;

&lt;p&gt;That gap was not just marketing. On iOS the file picker itself can quietly refuse the files the page is built for. Here is what I measured, why the picker behaves that way, and what the page now does about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, does the engine even open a PST?
&lt;/h2&gt;

&lt;p&gt;Before writing any "works on iPad" claim, I ran the live site in Playwright's WebKit 26.6 with the iPad Pro 11 and iPhone 15 profiles. That is Apple's engine in an emulated viewport, not a physical iPad's memory or its Files UI, so the claim has to stay within what that run shows.&lt;/p&gt;

&lt;p&gt;Results with the public Enron test PST (14 MB, read whole) and a 108 MB copy (read in pages):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;11 folders and 71 messages opened in about 0.8 seconds&lt;/li&gt;
&lt;li&gt;search returned hits&lt;/li&gt;
&lt;li&gt;tapping a message opened it&lt;/li&gt;
&lt;li&gt;no page errors, no sideways scroll&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the readers work in WebKit. PST, OST and MBOX are read in pages rather than held whole in memory, which is the same path Android and desktop use for large files. That is the engine half of the story. The picker is the other half.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;&amp;lt;input accept=".pst,.ost,…"&amp;gt;&lt;/code&gt; does on iOS
&lt;/h2&gt;

&lt;p&gt;On iPhone and iPad every browser is WebKit. When a page sets the file input's &lt;code&gt;accept&lt;/code&gt; attribute to a list of extensions, WebKit does &lt;strong&gt;not&lt;/strong&gt; pass those extensions through to the Files app as filenames. It looks each one up in the system's extension-to-MIME table and &lt;strong&gt;drops&lt;/strong&gt; any extension that has no entry.&lt;/p&gt;

&lt;p&gt;Of the archive types this tool accepts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extension&lt;/th&gt;
&lt;th&gt;iOS has a MIME mapping?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.eml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes (&lt;code&gt;message/rfc822&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.zip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes (&lt;code&gt;application/zip&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.pst&lt;/code&gt;, &lt;code&gt;.ost&lt;/code&gt;, &lt;code&gt;.olm&lt;/code&gt;, &lt;code&gt;.mbox&lt;/code&gt;, &lt;code&gt;.msg&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If &lt;em&gt;anything&lt;/em&gt; survives that lookup, the Files picker opens &lt;strong&gt;limited to the survivors&lt;/strong&gt;. A homepage that listed &lt;code&gt;.pst,.ost,.eml,.mbox,…&lt;/code&gt; therefore opened a picker in which every archive type but &lt;code&gt;.eml&lt;/code&gt; and &lt;code&gt;.zip&lt;/code&gt; was greyed out — including the &lt;code&gt;.pst&lt;/code&gt; the visitor came to open.&lt;/p&gt;

&lt;p&gt;If &lt;em&gt;nothing&lt;/em&gt; survives, the picker allows every file. So the failure mode is not "no &lt;code&gt;accept&lt;/code&gt; breaks iOS". It is "a mixed list that includes one known type silently filters out every unknown type".&lt;/p&gt;

&lt;p&gt;(The WebKit side of this lives in &lt;code&gt;WKFileUploadPanel&lt;/code&gt; and the Cocoa MIME registry: extensions go through UTI / MIME lookup before the menu is shown. I am not claiming a new WebKit bug; I am describing the behaviour a page has to work around.)&lt;/p&gt;

&lt;p&gt;This matched older analytics too: across ninety days there had been iOS visitors and zero successful opens. The engine was fine. The picker was the wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule the page uses now
&lt;/h2&gt;

&lt;p&gt;The accept string is built by a small helper. On an Apple touch device it is kept only when &lt;strong&gt;every&lt;/strong&gt; listed extension is one iOS can type. Otherwise it is left off, and the content check that already runs after a pick tells a wrong file apart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IOS_TYPED_EXTENSIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlySet&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zip&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fileInputAccept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;appleTouch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;appleTouch&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;IOS_TYPED_EXTENSIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detecting "Apple touch" needs one extra check: modern iPadOS often sends a Macintosh user agent, and gives itself away by having a touch screen (&lt;code&gt;maxTouchPoints &amp;gt; 1&lt;/code&gt;). Matching only &lt;code&gt;/iPhone|iPad|iPod/&lt;/code&gt; misses those iPads.&lt;/p&gt;

&lt;p&gt;A page that accepts only &lt;code&gt;.eml&lt;/code&gt; and &lt;code&gt;.zip&lt;/code&gt; still gets an &lt;code&gt;accept&lt;/code&gt; attribute. A page that mixes in &lt;code&gt;.pst&lt;/code&gt; or &lt;code&gt;.mbox&lt;/code&gt; does not. Tests pin both the helper and the homepage copy to that set, so a future format list cannot drift from the FAQ.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the homepage now says
&lt;/h2&gt;

&lt;p&gt;The hero line names an iPad next to the platforms that were already there. A new FAQ answers "Can I open a PST file on an iPad or iPhone?" with the practical path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Yes, in Safari.&lt;/li&gt;
&lt;li&gt;Tap the drop area and choose the file in the Files app.&lt;/li&gt;
&lt;li&gt;The picker lists every file, because naming archive types by extension would grey most of them out.&lt;/li&gt;
&lt;li&gt;Whatever you choose is checked by its contents once picked.&lt;/li&gt;
&lt;li&gt;Large PST / OST / MBOX files are still read in pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same platform line went into &lt;code&gt;llms.txt&lt;/code&gt;, so answer engines that quote the site don't keep saying "Mac, PC, Chromebook, Android" and stop there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits I am not papering over
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The WebKit run is an engine-and-layout check. It is not a measurement of a specific iPad's free RAM, nor of every Files location (iCloud Drive, On My iPhone, shared folders).&lt;/li&gt;
&lt;li&gt;Leaving &lt;code&gt;accept&lt;/code&gt; off means the picker shows photos, PDFs and everything else. That is deliberate. The content sniffer rejects non-archives after the pick; the cost is one extra tap for someone who picks the wrong file.&lt;/li&gt;
&lt;li&gt;OLM, ZIP and MSG are still read whole into the tab. A phone runs out of memory for those sooner than a laptop. The iPad answer sticks to PST / OST / MBOX for the paging claim, which is what the code actually pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;On iOS, &lt;code&gt;accept&lt;/code&gt; is a MIME filter in disguise. Unknown extensions vanish; known ones become the only things you can tap.&lt;/li&gt;
&lt;li&gt;A mixed list is worse than no list, if your product's main format is in the unknown set.&lt;/li&gt;
&lt;li&gt;Omit &lt;code&gt;accept&lt;/code&gt; when you cannot name a typeable set, and validate by content after the pick.&lt;/li&gt;
&lt;li&gt;Detect iPadOS by touch points, not by the word "iPad" in the user agent alone.&lt;/li&gt;
&lt;li&gt;Don't claim a platform in the hero until the picker path on that platform has been walked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need to open a PST on a tablet without Outlook, the homepage walkthrough is on &lt;a href="https://pst.aivismonitor.com/" rel="noopener noreferrer"&gt;Mailward&lt;/a&gt;. &lt;strong&gt;Disclosure: I built it.&lt;/strong&gt; The file stays on the device; Safari opens it locally.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>html</category>
    </item>
    <item>
      <title>Putting Remote Email Images in a PDF From the Browser: CORS, Tracking Pixels, and Honest Failure Boxes</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Fri, 30 Oct 2026 02:58:56 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/putting-remote-email-images-in-a-pdf-from-the-browser-cors-tracking-pixels-and-honest-failure-1150</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/putting-remote-email-images-in-a-pdf-from-the-browser-cors-tracking-pixels-and-honest-failure-1150</guid>
      <description>&lt;p&gt;I added "save this email as a PDF" to a browser-based mail viewer. Everything runs locally: the message is parsed in a Web Worker and the PDF is built with &lt;code&gt;pdf-lib&lt;/code&gt;. Text, headers and attachments were easy. &lt;strong&gt;Images were the hard part.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Pictures an email carries inside itself (&lt;code&gt;cid:&lt;/code&gt; references and &lt;code&gt;data:&lt;/code&gt; URIs) are simple: the bytes are already in the message. Pictures an email &lt;em&gt;links to&lt;/em&gt; (&lt;code&gt;&amp;lt;img src="https://…"&amp;gt;&lt;/code&gt;) are another story. My first version skipped them on purpose. Then a newsletter printed with blank gaps where its pictures should be, and it read like a bug. So I changed my mind, with constraints. Here is what those constraints are and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just &lt;code&gt;fetch()&lt;/code&gt; every &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;Two reasons, one about privacy and one about the platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy.&lt;/strong&gt; Loading a remote image tells the sender's server that the mail was opened, and from where. That is how tracking pixels work. A tool that promises "your mail never leaves your device" must not turn into an open-tracker by accident. So the rules are strict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No proxy. The browser requests the image straight from the host that serves it; the request never goes through my site.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;credentials: "omit"&lt;/code&gt; and &lt;code&gt;referrerPolicy: "no-referrer"&lt;/code&gt;, so no cookies and no page address are sent.&lt;/li&gt;
&lt;li&gt;Images that are clearly beacons are never requested: &lt;code&gt;width&lt;/code&gt; or &lt;code&gt;height&lt;/code&gt; of 2px or less, or &lt;code&gt;display:none&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Requests to private-network IP literals and the local machine are refused outright. A mail should not be able to make the converter poke at &lt;code&gt;169.254.169.254&lt;/code&gt; or &lt;code&gt;localhost&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The platform.&lt;/strong&gt; From a page, &lt;code&gt;fetch()&lt;/code&gt; of a cross-origin image is only &lt;em&gt;readable&lt;/em&gt; if the host sends &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;. An &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tag can display such a picture, but code can't get its bytes to embed in a PDF without CORS permission.&lt;/p&gt;

&lt;h2&gt;
  
  
  CORS makes a dead host and a refusing host look the same
&lt;/h2&gt;

&lt;p&gt;When a CORS check fails, the browser gives your code a generic network error. You can't tell "this server is down" from "this server doesn't allow other sites to read its images" from "the picture moved". All look like a &lt;code&gt;TypeError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How common is it? I tried a set of popular image hosts on 2026-10-26. Roughly two thirds sent the header (GitHub, Substack, Amazon, LinkedIn, HubSpot, Klaviyo, Constant Contact, Shopify, Gravatar and googleusercontent did). Others did not: google.com/gstatic, SendGrid's S3 bucket, Campaign Monitor and apple.com. That is a small hand-picked sample, not a survey, but it is enough to say: &lt;strong&gt;a meaningful share of real newsletter images can't be embedded from a browser, and you can't fix that client-side.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So the design question became: what do you show in the PDF when a picture can't be had?&lt;/p&gt;

&lt;h2&gt;
  
  
  A box that says why, not a blank
&lt;/h2&gt;

&lt;p&gt;Instead of a gap, the PDF gets a grey box: "Picture not included", the alt text if there is any, and a reason. The reasons I can distinguish:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a network or CORS failure: "&lt;em&gt;host&lt;/em&gt; does not let other websites download its pictures, or is offline" (deliberately both, since the browser can't separate them);&lt;/li&gt;
&lt;li&gt;HTTP 404 or 410: "&lt;em&gt;host&lt;/em&gt; no longer has this picture";&lt;/li&gt;
&lt;li&gt;the reply is too large;&lt;/li&gt;
&lt;li&gt;the reply isn't an image;&lt;/li&gt;
&lt;li&gt;the format can't be put in a PDF;&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;cid:&lt;/code&gt; the message doesn't contain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two refinements keep the output tidy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Adjacent pictures that fail for the same reason share one box&lt;/strong&gt;, so a newsletter with twelve blocked banner slices doesn't become twelve grey rectangles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed picture with no size and no alt text is left out entirely.&lt;/strong&gt; That is almost certainly an open beacon, and a box for it would be noise.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The fetch rules in code
&lt;/h2&gt;

&lt;p&gt;The request options are the small part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cors&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;omit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;referrerPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-referrer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around it sit the guardrails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http:&lt;/code&gt; and protocol-relative URLs are upgraded to &lt;code&gt;https:&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A 10-second timeout and a 10 MB cap. The cap is enforced while &lt;em&gt;streaming&lt;/em&gt; the body, so an oversized response is abandoned rather than buffered first.&lt;/li&gt;
&lt;li&gt;The reply must carry an image content type &lt;strong&gt;or&lt;/strong&gt; start with image magic bytes. A server can answer with something that isn't a picture, so the check doesn't rely on one signal alone.&lt;/li&gt;
&lt;li&gt;At most 100 pictures per message, six requests in flight at a time.&lt;/li&gt;
&lt;li&gt;A host that fails at the network level isn't asked again within that worker, so one dead host doesn't cost a timeout per picture.&lt;/li&gt;
&lt;li&gt;Pictures up to 512 KB are cached by address, since the same logo appears many times in a mailbox.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Order matters when you parallelise
&lt;/h2&gt;

&lt;p&gt;Exporting a folder of newsletters means many messages, each waiting on its own pictures. Rendering them one after another makes the slowest host stall everything. The folder export renders four messages at once. The output order stays that of the folder, which is the part that's easy to break: results are collected by index, not by completion time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without trusting the network
&lt;/h2&gt;

&lt;p&gt;Unit tests exercise the rules without real servers: the address rules (&lt;code&gt;0.gravatar.com&lt;/code&gt; is allowed, &lt;code&gt;169.254.169.254&lt;/code&gt; is refused), the request options, each failure kind and its wording, the streamed size cap, one request per address and none after a host has failed, and that adjacent same-reason failures merge. For the real-browser behaviour, an end-to-end script in Chrome against a production build checks one CORS-friendly host (a GitHub avatar is drawn) and one that refuses (google.com's logo becomes a box with its reason), and asserts both requests went out without a referrer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you if you build something similar
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide your privacy rule &lt;em&gt;first&lt;/em&gt;, then see which features survive it. "No proxy" is what forces the grey boxes.&lt;/li&gt;
&lt;li&gt;Don't pretend you know why a CORS failure happened. Say the two things it could be.&lt;/li&gt;
&lt;li&gt;Filter beacons before requesting, not after.&lt;/li&gt;
&lt;li&gt;Cap size while streaming, and verify the content, not just the header.&lt;/li&gt;
&lt;li&gt;Keep output order independent of completion order when you parallelise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see how it behaves on your own &lt;code&gt;.eml&lt;/code&gt; files, the page is the &lt;a href="https://pst.aivismonitor.com/eml-to-pdf" rel="noopener noreferrer"&gt;EML to PDF converter&lt;/a&gt;. &lt;strong&gt;Disclosure: I built it.&lt;/strong&gt; The message is processed in your browser; the only requests it makes are for fonts and emoji pictures from the site itself, and for the pictures the message itself links to, which go to their own servers.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>privacy</category>
      <category>pdf</category>
    </item>
    <item>
      <title>Why My PST Reader Showed 24 of 160 Items in an Outlook OST (and Raised No Error)</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Thu, 29 Oct 2026 02:16:07 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/why-my-pst-reader-showed-24-of-160-items-in-an-outlook-ost-and-raised-no-error-8ig</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/why-my-pst-reader-showed-24-of-160-items-in-an-outlook-ost-and-raised-no-error-8ig</guid>
      <description>&lt;p&gt;&lt;em&gt;A debugging story about row matrices, an 8 KB assumption, and a loop that stopped without telling anyone.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Here is the kind of bug I like least: nothing crashes, nothing logs, and the output looks plausible. A folder simply has fewer items than it should.&lt;/p&gt;

&lt;p&gt;I work on a browser-based viewer for Outlook mail files. It reads &lt;code&gt;.pst&lt;/code&gt; and &lt;code&gt;.ost&lt;/code&gt; files locally with a JavaScript library called &lt;code&gt;pst-extractor&lt;/code&gt; and a good deal of my own code around it. While testing with an Outlook 2013+ OST (the offline cache Outlook keeps for an Exchange or Microsoft 365 mailbox), I opened a calendar folder named "United States holidays". The folder tree said 160 items. The list showed &lt;strong&gt;24&lt;/strong&gt;. No exception, no warning.&lt;/p&gt;

&lt;p&gt;Here is what I found and how I fixed it. If you ever parse a PST or OST, one of these details may save you an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layouts that share a name
&lt;/h2&gt;

&lt;p&gt;Outlook's personal-folder files come in more than one layout. The old ANSI format, the Unicode PST, and the Outlook 2013+ files that use &lt;strong&gt;4 KB pages&lt;/strong&gt; (version number 36 in the header, which is what my test file reports) are all "PST-family" files, but they don't store data blocks the same way.&lt;/p&gt;

&lt;p&gt;Folder contents in these files are stored in a &lt;strong&gt;Table Context&lt;/strong&gt;, which is, roughly, a table with a "row matrix": fixed-size rows laid out one after another. Each row starts with a 4-byte row ID. A separate row index maps row IDs to row numbers.&lt;/p&gt;

&lt;p&gt;To read row &lt;code&gt;r&lt;/code&gt;, a parser needs the byte offset of that row. The library I use computes it the way the Unicode PST layout dictates: data blocks of 8,176 bytes, with padding at the end of each block, so&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;offset(r) = floor(r / rowsPerBlock) × 8176 + (r mod rowsPerBlock) × rowSize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is correct for Unicode PSTs. It is wrong for a 4K OST.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 4K OST actually contains
&lt;/h2&gt;

&lt;p&gt;In my test file, the contents table of the 160-item calendar folder is &lt;strong&gt;a single deflate-compressed block of 54,240 bytes&lt;/strong&gt;. That is exactly 160 rows × 339 bytes per row, with no padding. The data blocks in this layout are larger than 8 KB.&lt;/p&gt;

&lt;p&gt;Now apply the 8 KB arithmetic. Row 24 should start at byte 8,136 (24 × 339), but the formula treats a block as ending at 8,176 and reads row 24 from byte 8,176 instead. It finds whatever bytes are there and reads a garbage row ID.&lt;/p&gt;

&lt;p&gt;The folder cursor (the code that pages through items) treats a row ID that doesn't make sense as the end of the table. It stops. No error. The library reported &lt;code&gt;rowCount&lt;/code&gt; as 159, and the list showed 24.&lt;/p&gt;

&lt;p&gt;That's the whole bug: one layout assumption, applied to a file format that breaks it, and a loop that fails quietly instead of loudly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: stop computing offsets, find them
&lt;/h2&gt;

&lt;p&gt;I didn't want to rewrite the whole reader. The fix patches the table class for the 4K layout only, and leaves Unicode and ANSI files on the library's original code path.&lt;/p&gt;

&lt;p&gt;The idea: &lt;strong&gt;don't compute where row &lt;code&gt;r&lt;/code&gt; is, locate it, and verify.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Row 0 starts at offset 0.&lt;/li&gt;
&lt;li&gt;Row &lt;code&gt;r&lt;/code&gt; starts where row &lt;code&gt;r-1&lt;/code&gt; ended.&lt;/li&gt;
&lt;li&gt;Check it: read the 4-byte row ID at that offset, look it up in the table's row index, and confirm that it maps to row number &lt;code&gt;r&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If it doesn't match, look further, within the next &lt;code&gt;rowSize - 1&lt;/code&gt; bytes. This covers the case where a row matrix spans several blocks and the writer left padding at the end of a block (the libpff documentation describes that padding).&lt;/li&gt;
&lt;li&gt;If the row still can't be found, &lt;strong&gt;end the table&lt;/strong&gt; rather than return a garbage row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;locateRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;readRowId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rowSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;rowIndexById&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReadonlyMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rowSize&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rowIndexById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rowSize&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rowSize&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rowIndexById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readRowId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;found&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;found&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rowSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check against the row index is the important part. It turns "I assume this offset is right" into "this offset is right, because the row at it says it is row &lt;code&gt;r&lt;/code&gt;." A wrong guess ends the table instead of producing wrong data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusing the library's own row parsing
&lt;/h2&gt;

&lt;p&gt;Locating rows is half of it. Decoding a row is still the library's job, and I wanted to keep it. The library reads row &lt;code&gt;r&lt;/code&gt; at &lt;code&gt;r × rowSize&lt;/code&gt; from the start of a node. So for each row, I hand it a node &lt;strong&gt;shifted&lt;/strong&gt; by the difference between where the row really is and where the library will look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;offsets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TCI_bm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowNodeInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;NodeInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startOffset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endOffset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pstNodeInputStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalGetItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the duration of the call the library's block size is set to a huge value, so its own padding step never kicks in, and the original node is restored in a &lt;code&gt;finally&lt;/code&gt;. The &lt;code&gt;rowCount&lt;/code&gt; getter is overridden the same way for 4K files. It is a monkey patch, but a contained one: it only applies when the file's type is the 4K one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "end of table" signal
&lt;/h2&gt;

&lt;p&gt;The library's folder cursor stops when it reads a row carrying a particular sentinel. When my locator has no more rows, the patched &lt;code&gt;getItems&lt;/code&gt; returns exactly that sentinel (a fake row with item index −1), so the cursor ends cleanly instead of reading past what was located.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing, and what I could not test
&lt;/h2&gt;

&lt;p&gt;The regression test uses the library's own test OST (MIT-licensed test data, gzipped in my fixtures). It asserts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the header is the 4K layout (&lt;code&gt;!BDN&lt;/code&gt; magic, version 36);&lt;/li&gt;
&lt;li&gt;the "United States holidays" folder lists all &lt;strong&gt;160&lt;/strong&gt; items, and row 24, the first one the 8 KB arithmetic misplaced, matches the full listing;&lt;/li&gt;
&lt;li&gt;paging works at the end of the table (asking for 50 items from row 150 returns 10);&lt;/li&gt;
&lt;li&gt;every message folder in that file lists the count the folder tree shows, and the result is the same through a paged reader with a small cache;&lt;/li&gt;
&lt;li&gt;a regular Unicode PST still lists the same 71 messages as before, so the patch doesn't touch it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One limit I want to be upfront about: I did not have a real 4K OST whose row matrix spans multiple blocks. The "look past the block's tail padding" path is covered only by tests on &lt;strong&gt;synthetic&lt;/strong&gt; data, where I build matrices with padding between blocks and check that the offsets come out right. The real-file case I verified is the single-block one. If you have a multi-block 4K sample, that's the case I'd most like to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A "file format" often means several layouts. Check which one your library's arithmetic was written for.&lt;/li&gt;
&lt;li&gt;Silent truncation is worse than a crash. If a loop ends early, cross-check it against a count you already have (here, the folder's 160 versus the 24 listed).&lt;/li&gt;
&lt;li&gt;Prefer &lt;strong&gt;locate and verify&lt;/strong&gt; over &lt;strong&gt;compute and trust&lt;/strong&gt; when a format has self-describing indexes.&lt;/li&gt;
&lt;li&gt;Keep a patch narrowly scoped (one layout), and leave the working path alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it on your own file, the tool's &lt;a href="https://pst.aivismonitor.com/ost-viewer" rel="noopener noreferrer"&gt;OST viewer page&lt;/a&gt; is where this lives. &lt;strong&gt;Disclosure: I built it.&lt;/strong&gt; It reads the file in your browser without uploading it.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Splitting an Audio Project at Label Marks, the Way Audacity Does It (and Batch-Exporting Many Projects)</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Wed, 28 Oct 2026 03:30:54 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/splitting-an-audio-project-at-label-marks-the-way-audacity-does-it-and-batch-exporting-many-lk4</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/splitting-an-audio-project-at-label-marks-the-way-audacity-does-it-and-batch-exporting-many-lk4</guid>
      <description>&lt;p&gt;If you edit podcasts or long recordings in Audacity, you probably use label tracks: little text marks on the timeline ("Intro", "Interview", "Outro"). Audacity can split a project into one file per label (&lt;em&gt;Export Multiple → Split files based on Labels&lt;/em&gt;). I recently added the same thing to a browser-based &lt;code&gt;.aup3&lt;/code&gt;/&lt;code&gt;.aup4&lt;/code&gt; tool, plus a batch mode for several projects at once. Reproducing the rule exactly took more care than I expected, so here is how it works and what tripped me up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where labels live
&lt;/h2&gt;

&lt;p&gt;A label track is stored in the project document as &lt;code&gt;&amp;lt;labeltrack&amp;gt;&lt;/code&gt; containing &lt;code&gt;&amp;lt;label t="…" t1="…" title="…"/&amp;gt;&lt;/code&gt; elements. &lt;code&gt;t&lt;/code&gt; is the start in seconds and &lt;code&gt;t1&lt;/code&gt; the end. I checked the writer (&lt;code&gt;LabelTrack::WriteXML&lt;/code&gt;) in Audacity 3.7.9 and 4.0.0 and the same names show up in &lt;code&gt;.aup3&lt;/code&gt;, &lt;code&gt;.aup4&lt;/code&gt; and the old XML-based 1.x/2.x &lt;code&gt;.aup&lt;/code&gt;, so one reader covers all three. Audacity 4.0 only adds an &lt;code&gt;isSelected&lt;/code&gt; attribute.&lt;/p&gt;

&lt;p&gt;Two label kinds share this structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;region label&lt;/strong&gt; has &lt;code&gt;t1 &amp;gt; t&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;point label&lt;/strong&gt; has &lt;code&gt;t1 == t&lt;/code&gt; (or a missing or backwards &lt;code&gt;t1&lt;/code&gt;, which I treat as a point).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distinction drives the whole split rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split rule
&lt;/h2&gt;

&lt;p&gt;I followed what Audacity's export dialog code does (&lt;code&gt;ExportAudioDialog::UpdateLabelExportSettings&lt;/code&gt;, read at the 4.0.0 tag):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every label starts a file.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;region&lt;/strong&gt; label ends its file at the region's own end.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;point&lt;/strong&gt; label ends its file at the &lt;em&gt;next label's start&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;last&lt;/strong&gt; point label runs to the end of the audio.&lt;/li&gt;
&lt;li&gt;Audio &lt;em&gt;before&lt;/em&gt; the first label becomes a file only if you ask for it ("Include audio before first label", off by default), and it is named after the project.&lt;/li&gt;
&lt;li&gt;An empty title becomes &lt;code&gt;untitled&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Files are numbered &lt;code&gt;01-Title&lt;/code&gt;, &lt;code&gt;02-Title&lt;/code&gt;, … so they sort.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The core is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isPoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isPoint&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;untitled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that a region label's end is &lt;em&gt;not&lt;/em&gt; clipped to the next label. Overlapping regions produce overlapping files, which is what Audacity's rule does too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Segments need clamping.&lt;/strong&gt; Label times come from a document a user may have edited, shortened, or hand-crafted. I clamp both ends into &lt;code&gt;[0, duration]&lt;/code&gt; and drop any segment shorter than one sample (&lt;code&gt;1/48000&lt;/code&gt; s). Otherwise you export empty files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File names are a mess.&lt;/strong&gt; Titles contain &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, quotes and control characters, and two labels can have the same title. I replace the characters file systems reject with &lt;code&gt;_&lt;/code&gt; (similar to what Audacity's &lt;code&gt;Internat::SanitiseFilename&lt;/code&gt; does), collapse whitespace, cap at 120 characters, and de-duplicate case-insensitively, since macOS and Windows treat &lt;code&gt;Intro&lt;/code&gt; and &lt;code&gt;intro&lt;/code&gt; as the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;fileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Audacity only looks at the first label track.&lt;/strong&gt; A project can have several. Audacity's split always uses the first. I let the user pick which one, since a project with one label track for chapters and another for edit notes is common.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each piece must be exactly its span of the mix.&lt;/strong&gt; I render each file as a window of the whole mixed-down audio, rather than re-mixing each piece separately. A test checks that a split file is exactly its span of the mix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving the pieces names players can read
&lt;/h2&gt;

&lt;p&gt;Split MP3s carry a title and track number, written as an ID3v2.3 tag in front of the encoded stream. I chose v2.3 over v2.4 for its wider player support. The consequence is that v2.3 has no UTF-8, so text frames are UTF-16 with a byte-order mark. Frame sizes are plain big-endian in 2.3, and only the tag header's size is "syncsafe" (7 bits per byte) — an easy detail to get backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch: many projects, one worker
&lt;/h2&gt;

&lt;p&gt;If you drop two or more &lt;code&gt;.aup3&lt;/code&gt;/&lt;code&gt;.aup4&lt;/code&gt; files (each with its own &lt;code&gt;-wal&lt;/code&gt; if it has one) or a folder of them, the page lists them instead of opening one. The design decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One project in memory at a time.&lt;/strong&gt; The batch opens and exports projects sequentially in the same Web Worker. Opening them in parallel would multiply memory use, and projects can be large.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One failure never stops the batch.&lt;/strong&gt; A project whose document doesn't decode is skipped and named in the report rather than aborting the run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything goes into one ZIP&lt;/strong&gt;, built in the browser, with a &lt;code&gt;batch report.txt&lt;/code&gt;. Per project it lists the files produced, any unreadable blocks, effects that weren't rendered, and what was skipped or failed and why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optionally split each project at its labels&lt;/strong&gt;, with a folder per project and each project's label file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deciding what a drop &lt;em&gt;is&lt;/em&gt; lives in a pure planning function that looks at the dropped file names and returns a kind (single project, batch, and so on), so it can be unit-tested without a browser. Recovery and downgrade pages keep one project at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is free and what isn't
&lt;/h2&gt;

&lt;p&gt;For transparency: reading labels and exporting Audacity's own label text file (start, end, title, tab-separated, six decimals — the format &lt;em&gt;File → Import → Labels&lt;/em&gt; reads back) or "m:ss Title" timestamps is free. Splitting at labels, the chaptered MP3 and batch export are paid features in the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;The unit tests pin the document reading, the split rule, both text formats, the ID3 bytes and the free/paid split. The fixtures are two existing test projects with label tracks added; the audio is untouched. An end-to-end script runs the feature with real pointer events on desktop and phone viewports. Its first run caught label names overlapping in the phone-width timeline lane, so a point label's text now gets only the room up to the next label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you want to see this without installing Audacity, the splitting feature is described at &lt;a href="https://aup4.aivismonitor.com/split-audacity-project-by-labels" rel="noopener noreferrer"&gt;Waveward's split-by-labels page&lt;/a&gt;. &lt;strong&gt;Disclosure: I built it.&lt;/strong&gt; Everything runs in your browser, and projects are not uploaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Copy the reference implementation's rule, including its odd corners, and note which version you read.&lt;/li&gt;
&lt;li&gt;Treat region and point labels differently; the next label's start is the only end a point label has.&lt;/li&gt;
&lt;li&gt;Clamp, drop empty segments, sanitize and de-duplicate names.&lt;/li&gt;
&lt;li&gt;Batch work: sequential, isolated failures, and a report that says what was skipped.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>audio</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Open Outlook PST and OST Files Without Outlook</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Tue, 27 Oct 2026 03:37:52 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/how-to-open-outlook-pst-and-ost-files-without-outlook-1g39</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/how-to-open-outlook-pst-and-ost-files-without-outlook-1g39</guid>
      <description>&lt;p&gt;Sooner or later someone hands you a &lt;code&gt;.pst&lt;/code&gt; file. Maybe it's an old mailbox export from a former employee, an archive from a laptop you retired years ago, or evidence you need to review. You don't have Outlook installed, you're on Linux or macOS, or you simply don't want to set up a mail profile just to read a few messages.&lt;/p&gt;

&lt;p&gt;This post covers what these files actually are and the practical ways to read them without Outlook, along with the privacy trade-offs of each approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PST and OST files are
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PST (Personal Storage Table)&lt;/strong&gt; is Microsoft's file format for storing mail, contacts, calendar items and tasks locally. Outlook uses it for archives and for POP3 accounts, and it's what you get when you use &lt;em&gt;File → Import/Export → Export to a file&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OST (Offline Storage Table)&lt;/strong&gt; is a local cache of a server mailbox (Exchange, Microsoft 365, and IMAP accounts in newer Outlook versions). It lets Outlook work offline and syncs back to the server.&lt;/p&gt;

&lt;p&gt;A few useful facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Both formats share the same underlying structure, documented by Microsoft as &lt;strong&gt;[MS-PST]&lt;/strong&gt;. Internally it's a set of B-trees (a node B-tree and a block B-tree) with a "messaging layer" on top that represents folders, messages, and attachments as property sets.&lt;/li&gt;
&lt;li&gt;There are two main variants: the old &lt;strong&gt;ANSI&lt;/strong&gt; format (Outlook 97–2002, 2 GB limit) and the &lt;strong&gt;Unicode&lt;/strong&gt; format (Outlook 2003 and later), which supports much larger files. Multi-gigabyte PSTs are common.&lt;/li&gt;
&lt;li&gt;PSTs can be password-protected, but the "password" is a weak check stored in the file, and the content is usually only obfuscated with a simple encoding, not strongly encrypted. Don't treat it as a security boundary.&lt;/li&gt;
&lt;li&gt;An OST is tied to the profile it was created for. Outlook itself won't open someone else's OST as a data file, but third-party parsers can often read it because the on-disk structure is the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the format is documented, there are good open-source parsers. You have real options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: Use Outlook anyway (if you have access)
&lt;/h2&gt;

&lt;p&gt;If you have a Windows machine with Outlook, it's still the most faithful viewer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;File → Open &amp;amp; Export → Open Outlook Data File&lt;/em&gt; to mount a PST alongside your mailbox, or&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;File → Open &amp;amp; Export → Import/Export → Import from another program or file&lt;/em&gt; to merge it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This doesn't work for OST files from another profile. And it requires a licensed Outlook install, which is exactly what many people lack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: libpff / pffexport (Linux, macOS)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/libyal/libpff" rel="noopener noreferrer"&gt;libpff&lt;/a&gt; by Joachim Metz is a forensic-grade library for PST, OST, and PAB files. It ships with command-line tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pffinfo&lt;/code&gt; shows file metadata (format type, encryption type).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pffexport&lt;/code&gt; extracts the contents into a directory tree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Debian/Ubuntu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;pff-tools
pffinfo mailbox.pst
pffexport &lt;span class="nt"&gt;-m&lt;/span&gt; all &lt;span class="nt"&gt;-t&lt;/span&gt; out mailbox.pst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get folders containing one directory per message, with headers, the body (plain text, HTML, or RTF), and attachments as separate files. It's great for forensic work or for grepping through mail, but it's not a mail client. libpff also handles OST files and can recover deleted items in some cases (&lt;code&gt;-m all&lt;/code&gt; includes recovered and orphaned items).&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: readpst → MBOX → Thunderbird
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;readpst&lt;/code&gt; comes from the &lt;strong&gt;libpst&lt;/strong&gt; project. Its main advantage is that it converts to standard formats that ordinary mail clients understand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;pst-utils
&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;libpst

&lt;span class="nb"&gt;mkdir &lt;/span&gt;out
readpst &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; out mailbox.pst      &lt;span class="c"&gt;# one MBOX per folder, preserving hierarchy&lt;/span&gt;
readpst &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; out mailbox.pst      &lt;span class="c"&gt;# one .eml file per message instead&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, to read the result comfortably:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install &lt;strong&gt;Thunderbird&lt;/strong&gt; and the &lt;strong&gt;ImportExportTools NG&lt;/strong&gt; add-on.&lt;/li&gt;
&lt;li&gt;Create a Local Folder, right-click → &lt;em&gt;ImportExportTools NG → Import mbox file&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Choose the MBOX files &lt;code&gt;readpst&lt;/code&gt; produced (the "with sub-folders" option keeps the structure).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now you have full search, threading, and attachment handling, all offline. This is my default recommendation for anyone comfortable with a terminal: everything stays on your machine, the tools are free and open source, and MBOX/EML are open formats you can keep long-term.&lt;/p&gt;

&lt;p&gt;Caveats: very large or damaged files sometimes trip up &lt;code&gt;readpst&lt;/code&gt;. If that happens, try &lt;code&gt;pffexport&lt;/code&gt;, which is often more tolerant. Calendar and contact items are exported too, but they're less pleasant to browse than mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4: Browser-based viewers
&lt;/h2&gt;

&lt;p&gt;There's a class of tools that let you open a PST in a web page. They differ a lot in &lt;strong&gt;where the parsing happens&lt;/strong&gt;, and that's the part you should check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upload-based converters&lt;/strong&gt; send your file to a server, process it there, and give you back a download. They're convenient, but think about what's in a typical mailbox: contracts, password-reset emails, HR conversations, customers' personal data. Uploading that to an unknown third party may violate your company's policy or data-protection obligations (GDPR, client confidentiality, legal hold). Also, multi-GB uploads are slow and often fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client-side viewers&lt;/strong&gt; parse the file in the browser using JavaScript or WebAssembly. The file never leaves your machine. You can verify this yourself: open DevTools → Network, load a file, and watch for requests carrying its contents. Or disconnect from the network after the page loads and check that it still works.&lt;/p&gt;

&lt;p&gt;One option in this category is Mailward (&lt;a href="https://pst.aivismonitor.com/" rel="noopener noreferrer"&gt;https://pst.aivismonitor.com/&lt;/a&gt;). &lt;strong&gt;Disclosure: I built Mailward.&lt;/strong&gt; It parses PST/OST files locally in a Web Worker with no upload, handles multi-GB files, and shows the folder tree, message bodies, and attachments, with full-text search. Reading and searching are free (with 3 exports per day), and a $19 one-time Pro license adds batch MBOX/EML export. If you're happy in a terminal, &lt;code&gt;readpst&lt;/code&gt; + Thunderbird above does a similar job for free.&lt;/p&gt;

&lt;p&gt;Whatever browser tool you pick, run the DevTools check before you drop in a sensitive mailbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step-by-step: which path should you take?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Identify the file.&lt;/strong&gt; Run &lt;code&gt;pffinfo file.pst&lt;/code&gt; (or check the extension and size). Note whether it's PST or OST, ANSI or Unicode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make a copy.&lt;/strong&gt; Always work on a copy, especially with evidence or with a file that might be damaged. Some tools open files read-write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide where the data is allowed to go.&lt;/strong&gt; If the mailbox contains anything sensitive, rule out upload-based services. Stick to local tools (libpff, readpst, a client-side viewer) or Outlook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a tool for the job:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Need to look up a few emails quickly:&lt;/em&gt; a local browser viewer or &lt;code&gt;pffexport&lt;/code&gt; + grep.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Need a long-term, searchable archive:&lt;/em&gt; &lt;code&gt;readpst&lt;/code&gt; → MBOX → Thunderbird.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Need forensic completeness or deleted items:&lt;/em&gt; &lt;code&gt;pffexport -m all&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Have Outlook and a PST from your own profile:&lt;/em&gt; just open it in Outlook.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the result.&lt;/strong&gt; Compare folder counts and spot-check a few messages with attachments and non-ASCII text (encoding issues show up there first).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean up.&lt;/strong&gt; Exported folders contain plaintext copies of everything. Delete them securely when you're done, or store them with the same care as the original.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;PST and OST look like proprietary black boxes, but the format is documented and well supported by open-source tools. On Linux and macOS, &lt;code&gt;readpst&lt;/code&gt; and &lt;code&gt;pffexport&lt;/code&gt; cover most needs. Thunderbird gives you a comfortable reading experience, and client-side browser viewers are a zero-install option. Whatever you choose, think about where your data goes before you think about convenience.&lt;/p&gt;

</description>
      <category>email</category>
      <category>linux</category>
      <category>opensource</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Parsing multi-gigabyte Outlook PST files in the browser</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Fri, 14 Aug 2026 01:30:36 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/parsing-multi-gigabyte-outlook-pst-files-in-the-browser-16oe</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/parsing-multi-gigabyte-outlook-pst-files-in-the-browser-16oe</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure up front: I build &lt;a href="https://pst.aivismonitor.com" rel="noopener noreferrer"&gt;Mailward&lt;/a&gt;, a browser-based PST/OST/OLM/MBOX/EML viewer. These are the implementation notes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A PST file is not a folder of emails. It is a single-file B-tree database — Microsoft documents the layout as [MS-PST] — and the mail inside exists only as sets of typed properties. To show one message you walk the B-trees, resolve the property set, and reconstruct an RFC 5322 message from scratch.&lt;/p&gt;

&lt;p&gt;Now do that in a browser tab, for a 4 GB file, without uploading it anywhere and without loading it into memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint that shapes everything
&lt;/h2&gt;

&lt;p&gt;The whole point of a browser-side viewer is privacy: the file never leaves the machine. That rules out "stream it to a server". And &lt;code&gt;FileReader.readAsArrayBuffer&lt;/code&gt; on the whole file rules itself out the moment archives pass a gigabyte — mobile Safari will simply kill the tab.&lt;/p&gt;

&lt;p&gt;So the parser must do &lt;strong&gt;random access&lt;/strong&gt; over a &lt;code&gt;File&lt;/code&gt; it never fully reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  One method to intercept
&lt;/h2&gt;

&lt;p&gt;We build on &lt;a href="https://github.com/epfromer/pst-extractor" rel="noopener noreferrer"&gt;pst-extractor&lt;/a&gt;, a faithful JS port of java-libpst. It has one architectural gift: every byte it reads funnels through a single method, &lt;code&gt;PSTFile.readSync(buffer, length, position)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means you can subclass &lt;code&gt;PSTFile&lt;/code&gt; and reroute all I/O without touching parser internals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PSTFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pst-extractor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pendingReader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RandomAccessReader&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReaderBackedPSTFile&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;PSTFile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;archiveReader&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RandomAccessReader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RandomAccessReader&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pendingReader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// super() reads the 514-byte header via readSync&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;pendingReader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;archiveReader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ReaderBackedPSTFile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;readSync&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;archiveReader&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;pendingReader&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two quirks worth explaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The override lives &lt;strong&gt;on the prototype, outside the class body&lt;/strong&gt;, because &lt;code&gt;PSTFile&lt;/code&gt;'s constructor immediately reads the 514-byte header — so the replacement must already be installed while &lt;code&gt;super()&lt;/code&gt; runs.&lt;/li&gt;
&lt;li&gt;Instance fields don't exist yet at that point, hence the module-scoped &lt;code&gt;pendingReader&lt;/code&gt; handoff. Workers are single-threaded, so this is safe.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The reader: 64 KB pages, LRU-capped
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RandomAccessReader&lt;/code&gt; is a paged view of the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;64 KB pages&lt;/strong&gt;, loaded on demand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LRU cache capped at 1024 pages&lt;/strong&gt; — 64 MB worst case, regardless of file size.&lt;/li&gt;
&lt;li&gt;Reads that span page boundaries are assembled from multiple pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inside a Web Worker the page loader is almost embarrassingly small, because &lt;code&gt;FileReaderSync&lt;/code&gt; gives you synchronous random access to a user-selected &lt;code&gt;File&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FileSlicePageLoader&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FileReaderSync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nf"&gt;loadPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readAsArrayBuffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;file.slice()&lt;/code&gt; doesn't copy — it's a view. We measured a 4 KB slice read at &lt;strong&gt;~200 µs in Chrome&lt;/strong&gt;, which makes B-tree walks feel instant. The same &lt;code&gt;RandomAccessReader&lt;/code&gt; interface is backed by a plain &lt;code&gt;Buffer&lt;/code&gt; in Node, so the entire parser is testable outside the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're in Node, you don't need any of this
&lt;/h2&gt;

&lt;p&gt;pst-extractor's constructor accepts &lt;strong&gt;either a Buffer or a filename&lt;/strong&gt;. The 2 GB failures people report almost always come from &lt;code&gt;fs.readFileSync&lt;/code&gt; → Buffer. Pass the path instead and the library reads through a file descriptor with no Buffer ceiling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PSTFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/path/to/large.pst&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// descriptor-backed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;Honesty section: none of this repairs a corrupted PST — if &lt;code&gt;scanpst&lt;/code&gt; can't open it, a reader can't either. And ANSI-era PSTs (Outlook 97–2002, the 2 GB-capped variant) parse through the same pipeline in theory, but genuine ANSI files written by 20-year-old Outlook are nearly impossible to test against, so we claim "likely works, fails loudly" rather than "supported".&lt;/p&gt;

&lt;p&gt;More format field notes (OST's account lock, OLM's timezone-less UTC timestamps, MBOX From-munging) live in this repo: &lt;a href="https://github.com/xsxs89757/email-archive-formats" rel="noopener noreferrer"&gt;email-archive-formats&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>email</category>
      <category>performance</category>
    </item>
    <item>
      <title>Shopify Markets CSV Price Updates: A Safer Way to Verify Catalog Imports</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Wed, 05 Aug 2026 01:40:32 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/shopify-markets-csv-price-updates-a-safer-way-to-verify-catalog-imports-5fkj</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/shopify-markets-csv-price-updates-a-safer-way-to-verify-catalog-imports-5fkj</guid>
      <description>&lt;p&gt;A Shopify CSV import can finish without an obvious error and still leave a market catalog with the wrong price or fewer variants than the base catalog.&lt;/p&gt;

&lt;p&gt;This is especially easy to miss after the change from Markets to Catalogs. A product import CSV and a catalog-specific price CSV may look similar, but they are not interchangeable workflows. The safest approach is to treat a market-price update as a small data-reconciliation job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a successful import is not proof
&lt;/h2&gt;

&lt;p&gt;There are three separate questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did Shopify parse the CSV?&lt;/li&gt;
&lt;li&gt;Did the intended Handle/SKU rows match the right variants?&lt;/li&gt;
&lt;li&gt;Did the target catalog apply the expected price and inclusion state?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first answer can be "yes" while the second or third is "no." A catalog can fall back to a base price, omit a variant, or keep an old inclusion value without producing the kind of row-level error merchants expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  A safer workflow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Save a baseline
&lt;/h3&gt;

&lt;p&gt;Export the current product state and record the target market/catalog, export date, currency, and number of products and variants. Keep this as a rollback reference. Do not start by editing the only copy of the export.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Use the target catalog's own template
&lt;/h3&gt;

&lt;p&gt;Open the specific catalog you intend to update and download its sample or export format when Shopify provides one. Do not assume that columns such as &lt;code&gt;Price / US&lt;/code&gt;, &lt;code&gt;Price / UK&lt;/code&gt;, or &lt;code&gt;Included / US&lt;/code&gt; belong in the ordinary product import flow.&lt;/p&gt;

&lt;p&gt;For a catalog-specific file, keep the smallest set of columns that the template documents. In many workflows that means an identity pair plus the catalog fields, for example:&lt;/p&gt;

&lt;p&gt;Example columns: Handle, SKU, Price, Included.&lt;/p&gt;

&lt;p&gt;Example row: blue-shirt, BLUE-S, 29.90, true.&lt;/p&gt;

&lt;p&gt;Use your actual exported headers and identifiers; the example is only a shape, not a universal Shopify template.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Normalize numbers before checking catalog behavior
&lt;/h3&gt;

&lt;p&gt;Prices should be numeric and consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use a decimal point, not a locale comma, unless the target template explicitly says otherwise.&lt;/li&gt;
&lt;li&gt;Remove currency symbols and thousands separators.&lt;/li&gt;
&lt;li&gt;Keep one currency per file and confirm the catalog currency before importing.&lt;/li&gt;
&lt;li&gt;Check for formulas that exported as blank strings or text.&lt;/li&gt;
&lt;li&gt;Make sure the same SKU is not repeated for two different variant rows unless that is intentional.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A browser-side &lt;a href="https://shopify-csv.aivismonitor.com/shopify-csv-price-format-fixer" rel="noopener noreferrer"&gt;numeric price-format check&lt;/a&gt; can catch malformed price cells before they create noise in the catalog test. It cannot prove that Shopify mapped a row to the correct market catalog, so treat it as the formatting step only.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Compare identities and counts
&lt;/h3&gt;

&lt;p&gt;Before importing, compare the Handles and SKUs in the proposed file with the baseline export. Record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;total rows in the proposed file;&lt;/li&gt;
&lt;li&gt;unique Handle + SKU pairs;&lt;/li&gt;
&lt;li&gt;missing identifiers;&lt;/li&gt;
&lt;li&gt;duplicate identity pairs;&lt;/li&gt;
&lt;li&gt;products with fewer or more variant rows than expected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This catches a common false positive: the file is valid, but the catalog receives only part of the intended variant set.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Test a small, multi-variant sample
&lt;/h3&gt;

&lt;p&gt;Choose a few products that cover the risky cases: one single-variant product, one product with several options, one sale-price change, and one product that should be excluded from the target catalog.&lt;/p&gt;

&lt;p&gt;After the import, check the catalog itself. Do not rely only on the import toast or the product page's base price.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Verify the result in the target catalog
&lt;/h3&gt;

&lt;p&gt;For each sample, confirm the market price, currency display, included/excluded state, and variant count. Then compare the post-import export with the baseline. If one market shows fewer variants or the base price appears in place of the market price, stop the full rollout and isolate that catalog's template and identity rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Keep a rollback path
&lt;/h3&gt;

&lt;p&gt;Store the original export, the exact file you imported, a short change summary, and the post-import verification result. For a large catalog, keep an exception sheet rather than repeatedly re-importing the whole file while guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical boundary
&lt;/h2&gt;

&lt;p&gt;A general CSV validator can help with headers, encoding, row identity, file size, and numeric price formatting. It cannot read Shopify's private catalog state or guarantee that an import changed every market correctly.&lt;/p&gt;

&lt;p&gt;That boundary matters: "CSV accepted" is an input check; "catalog prices and variant coverage match the plan" is a post-import reconciliation check. Keeping those two claims separate makes international price updates much safer.&lt;/p&gt;

</description>
      <category>shopify</category>
    </item>
    <item>
      <title>Bulk Update Shopify Product Descriptions and Images Without Blank Overwrites</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Mon, 03 Aug 2026 01:39:47 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/bulk-update-shopify-product-descriptions-and-images-without-blank-overwrites-mfc</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/bulk-update-shopify-product-descriptions-and-images-without-blank-overwrites-mfc</guid>
      <description>&lt;p&gt;Bulk catalog edits are easy to start and surprisingly easy to make dangerous.&lt;/p&gt;

&lt;p&gt;If you need to update hundreds of Shopify product descriptions and replace images, the safest workflow is usually a small, intentional CSV update—not a full export with every column left in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Start with a fresh Shopify export
&lt;/h2&gt;

&lt;p&gt;Export the products immediately before preparing the change. Keep that original file untouched as your rollback reference. Do not build a new file from memory: the export contains the exact Handles, option rows, and variant structure Shopify currently knows.&lt;/p&gt;

&lt;p&gt;For a description-only change, identify the columns you actually need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Handle&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Title&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Body (HTML)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are changing prices, SKUs, inventory, or variant options at the same time, treat that as a separate change set. Mixing unrelated edits makes it much harder to explain a partial import or recover from an overwrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Generate the repeated description in a spreadsheet
&lt;/h2&gt;

&lt;p&gt;A spreadsheet formula is enough when the template is stable. For example, if the product title is in column B, a Google Sheets formula can build a simple HTML description:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;="&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;"&amp;amp;B2&amp;amp;"&amp;lt;/strong&amp;gt; is handcrafted and ships free in 2 days. 30-day returns on every "&amp;amp;B2&amp;amp;".&amp;lt;/p&amp;gt;"&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Adjust the wording for your store, fill the formula down, then copy the result and paste it back as values into &lt;code&gt;Body (HTML)&lt;/code&gt;. Values-only paste prevents a later formula change from silently changing the import file.&lt;/p&gt;

&lt;p&gt;Keep the original &lt;code&gt;Title&lt;/code&gt; column while you work. It is useful for reviewing the generated text, even though the description is the field you intend to update.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Avoid the blank-column trap
&lt;/h2&gt;

&lt;p&gt;For an existing-product update, an omitted column and an included blank column do not mean the same thing. A blank column can clear existing data, while an omitted column leaves that field alone.&lt;/p&gt;

&lt;p&gt;Before importing, remove every column you are not deliberately updating. Preserve all variant rows for each Handle if your file still contains variant-level fields. Never sort rows in a way that breaks the product's Handle grouping, and never remove option columns from a variant update without first checking how Shopify will match the variants.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Treat images as a separate decision
&lt;/h2&gt;

&lt;p&gt;A Shopify product CSV carries image references, not files sitting on your laptop. If new images are already hosted, use direct public &lt;code&gt;https://&lt;/code&gt; URLs in &lt;code&gt;Image Src&lt;/code&gt; and keep the Handle and image position mapping consistent. If the files are local, upload them to a location that produces stable public URLs before importing.&lt;/p&gt;

&lt;p&gt;Do not mix a large image replacement with a description update unless you have a clear reason. A description-only pilot is easier to verify, and an image-only pilot makes URL or row-mapping failures much easier to isolate.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Pilot before the full catalog
&lt;/h2&gt;

&lt;p&gt;Import one or two representative products first. Confirm the description, variant count, image order, and unchanged fields in Shopify admin. Keep the pre-import export and the exact CSV you uploaded.&lt;/p&gt;

&lt;p&gt;After the pilot, export the affected products again and compare the before/after values. Only then expand to the rest of the catalog. If the catalog is large, split the work into batches that each have a clear product range and a simple rollback point.&lt;/p&gt;

&lt;p&gt;For a faster preflight of identity matching, changed columns, blank overwrites, and update-only risk, I use this browser-side &lt;a href="https://shopify-csv.aivismonitor.com/shopify-csv-existing-product-update-audit" rel="noopener noreferrer"&gt;Shopify existing-product CSV update audit&lt;/a&gt;. It does not import into Shopify or replace the pilot; it helps make the file's intended change set visible before you upload it.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>A Safer Way to Bulk-Update Shopify Product Descriptions by CSV</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Sat, 01 Aug 2026 01:39:09 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/a-safer-way-to-bulk-update-shopify-product-descriptions-by-csv-13mg</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/a-safer-way-to-bulk-update-shopify-product-descriptions-by-csv-13mg</guid>
      <description>&lt;p&gt;Bulk-editing 200 Shopify product descriptions sounds like a spreadsheet task. It is, but the dangerous part is usually not the formula. It is sending a full export back to Shopify with columns you did not intend to change.&lt;/p&gt;

&lt;p&gt;A safer workflow separates content generation, import scope, and post-import verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Start with a dated baseline
&lt;/h2&gt;

&lt;p&gt;Export the products before making edits and keep that file unchanged. Treat it as the rollback reference, not as the file you will immediately import.&lt;/p&gt;

&lt;p&gt;Record which products are in scope and keep the export date in the filename. If an app or another team member changes the catalog while you work, the baseline can become stale, so re-export before a large production update when possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Generate descriptions in a separate column
&lt;/h2&gt;

&lt;p&gt;In Excel or Google Sheets, build the new Body (HTML) value from the existing Title column and your approved template. Review a few short, long, and unusual titles manually before filling the formula down.&lt;/p&gt;

&lt;p&gt;Keep the original description in a separate working column until the new text has been reviewed. Do not paste spreadsheet formulas into Shopify; paste values into Body (HTML) after the formula is checked.&lt;/p&gt;

&lt;p&gt;This is also the point to decide whether HTML is really needed. Unescaped quotes, copied rich text, and spreadsheet formatting can create a description that looks acceptable in the sheet but behaves differently after import.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Trim the import file
&lt;/h2&gt;

&lt;p&gt;For a description-only update, the file should normally contain only the stable identifier and the fields you intentionally changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handle&lt;/li&gt;
&lt;li&gt;Title, if you are using it for review&lt;/li&gt;
&lt;li&gt;Body (HTML)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An included blank cell can clear an existing value. A column that is absent is different: Shopify cannot update what you did not include. Remove unused price, inventory, option, image, and metafield columns instead of leaving them blank.&lt;/p&gt;

&lt;p&gt;If you are changing variants, keep the relevant option and identity columns together and test one multi-variant product first. A description update should not accidentally become a variant update.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Treat images as a separate job
&lt;/h2&gt;

&lt;p&gt;A product CSV carries image URLs; it does not upload image files from your computer. For new images, prepare stable public URLs first, then review Image Src, Image Position, and the repeated Handle rows together.&lt;/p&gt;

&lt;p&gt;Check that each URL is reachable and that the image order is intentional. Do not assume a reachable URL proves that Shopify will associate every Variant Image reference correctly. If the images are only local files, upload them through an appropriate Shopify workflow before preparing the CSV.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Run a preflight before production
&lt;/h2&gt;

&lt;p&gt;Use a small representative batch first, then compare the exported result with the baseline. Look specifically for changed Handles, missing variant rows, unexpected blank cells, and fields that changed outside the requested description scope.&lt;/p&gt;

&lt;p&gt;A browser-side checker such as the Shopify CSV Existing Product Update Audit can flag missing identifiers and risky blank cells, then produce a smaller update file for manual review: &lt;a href="https://shopify-csv.aivismonitor.com/shopify-csv-existing-product-update-audit" rel="noopener noreferrer"&gt;https://shopify-csv.aivismonitor.com/shopify-csv-existing-product-update-audit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The checker does not write to Shopify or guarantee a rollback. That boundary is useful: the merchant still decides which rows to import and verifies the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Verify after import
&lt;/h2&gt;

&lt;p&gt;Open a few updated products and confirm the rendered description, HTML structure, image count, image order, options, and variant identities. Compare a fresh export with the pre-import baseline. If anything outside the intended fields changed, stop the next batch and investigate before continuing.&lt;/p&gt;

&lt;p&gt;The repeatable rule is simple: generate in a working sheet, import the smallest intentional file, and verify the exported result before scaling up.&lt;/p&gt;

</description>
      <category>shopify</category>
    </item>
    <item>
      <title>Shopify CSV Cost per Item: Audit It Before You Create a Purchase Order</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Thu, 30 Jul 2026 01:41:57 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/shopify-csv-cost-per-item-audit-it-before-you-create-a-purchase-order-3pf7</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/shopify-csv-cost-per-item-audit-it-before-you-create-a-purchase-order-3pf7</guid>
      <description>&lt;p&gt;A native Shopify Purchase Order can leave line-item costs blank even when a variant already has a Cost per item value. That is a workflow gap, not necessarily a bad CSV.&lt;/p&gt;

&lt;p&gt;A CSV can still help you prepare the numbers safely, but it cannot reach into a native Purchase Order and fill its lines. Treat the CSV step as a controlled cost review before you create or receive the PO.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Start with a fresh product export
&lt;/h2&gt;

&lt;p&gt;Keep the original export unchanged. Work from a copy that includes the fields you need to identify each variant and compare the economics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Handle&lt;/li&gt;
&lt;li&gt;Title&lt;/li&gt;
&lt;li&gt;Variant SKU&lt;/li&gt;
&lt;li&gt;Variant Price&lt;/li&gt;
&lt;li&gt;Cost per item&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your catalog uses barcodes or another stable identity, keep that reference too. Do not delete the baseline: it is the evidence you need if a later import changes more rows than intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Check identity before checking the numbers
&lt;/h2&gt;

&lt;p&gt;A cost value is only useful when it is attached to the right variant. Before reviewing costs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Look for blank or repeated identifiers.&lt;/li&gt;
&lt;li&gt;Confirm that the SKU or other identity maps to one intended variant.&lt;/li&gt;
&lt;li&gt;Keep option values when several variants share a product Handle.&lt;/li&gt;
&lt;li&gt;Separate a supplier SKU from Shopify's own Variant SKU instead of silently treating them as interchangeable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This prevents a clean-looking cost column from being applied to the wrong size, color, or pack.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Review Cost per item beside Variant Price
&lt;/h2&gt;

&lt;p&gt;For each variant, check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cost per item is present and numeric.&lt;/li&gt;
&lt;li&gt;Variant Price is present and numeric.&lt;/li&gt;
&lt;li&gt;Cost is lower than price when a sale is expected to be profitable.&lt;/li&gt;
&lt;li&gt;A blank, zero, or unusually large value is reviewed instead of being accepted automatically.&lt;/li&gt;
&lt;li&gt;A supplier cost increase does not quietly turn a previously healthy margin into a loss.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://shopify-csv.aivismonitor.com/shopify-product-csv-margin-audit" rel="noopener noreferrer"&gt;Shopify product CSV margin audit&lt;/a&gt; can calculate the margin amount and percentage locally from those columns and surface missing cost, invalid price, and low-margin rows for review.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Separate standing cost from the PO cost
&lt;/h2&gt;

&lt;p&gt;Cost per item is a product-level reference. A Purchase Order is a specific supplier agreement at a specific time. If two suppliers quote different prices, keep the agreed PO cost in your purchasing worksheet and decide deliberately whether the standing product cost should change.&lt;/p&gt;

&lt;p&gt;A practical worksheet can include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Supplier and supplier SKU&lt;/li&gt;
&lt;li&gt;Shopify Variant SKU&lt;/li&gt;
&lt;li&gt;Ordered quantity and location&lt;/li&gt;
&lt;li&gt;Agreed unit cost and currency&lt;/li&gt;
&lt;li&gt;Freight, duty, or other adjustments&lt;/li&gt;
&lt;li&gt;The date and reason for any standing-cost change&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This preserves the historical price you actually agreed to instead of rewriting it later when a supplier sends a new list.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Test the smallest safe change
&lt;/h2&gt;

&lt;p&gt;If you need to update product costs through Shopify's CSV workflow, use one representative product or a very small variant batch first. Compare the result with the original export, then expand only after the identifiers and cost values are confirmed.&lt;/p&gt;

&lt;p&gt;Do not assume that a successful product CSV import means the native Purchase Order was populated. Verify the PO lines separately in Shopify and record any manual cost entry that remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this workflow does not do
&lt;/h2&gt;

&lt;p&gt;A browser-side CSV review cannot access a merchant's admin, read native Purchase Orders, call the Admin API, or write costs back automatically. It prepares evidence and a safer review path; the final Shopify import and PO entry remain manual actions.&lt;/p&gt;

&lt;p&gt;That boundary is useful: it keeps supplier and store data local, makes the cost decision explicit, and avoids presenting a worksheet as an integration that Shopify does not expose.&lt;/p&gt;

</description>
      <category>inventory</category>
    </item>
    <item>
      <title>Shopify CSV Files Over 15MB: Split by Product Handle, Not Row Count</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Tue, 28 Jul 2026 01:40:08 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/shopify-csv-files-over-15mb-split-by-product-handle-not-row-count-17b3</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/shopify-csv-files-over-15mb-split-by-product-handle-not-row-count-17b3</guid>
      <description>&lt;p&gt;When a Shopify product CSV grows past 15 MB, making it smaller is only half the job. The split must keep each product together, or Shopify may receive variants and image rows without the parent context they need.&lt;/p&gt;

&lt;h2&gt;
  
  
  A safer batching checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Keep the untouched Shopify export as your rollback reference. Make a working copy before removing or reordering anything.&lt;/li&gt;
&lt;li&gt;Group rows by the exact &lt;code&gt;Handle&lt;/code&gt;. Do not split after an arbitrary line number: one product can occupy several rows.&lt;/li&gt;
&lt;li&gt;Keep every variant row for a Handle in the same batch. Check Option values, Variant SKU, price, inventory, and barcode together.&lt;/li&gt;
&lt;li&gt;Keep the product's image rows with the same Handle. Moving an Image Src row into another file can attach media to the wrong product or make the import harder to audit.&lt;/li&gt;
&lt;li&gt;Check each batch independently for headers, UTF-8 text, prices, and duplicate Handles before importing.&lt;/li&gt;
&lt;li&gt;Import a small representative batch first. Confirm product count, variant count, prices, inventory, and images before continuing with the remaining files.&lt;/li&gt;
&lt;li&gt;Keep a simple batch log: file name, Handle range, row count, import time, and any Shopify warning. This makes a partial retry much safer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 15 MB limit is a file-size boundary, not a promise that a large catalog will import cleanly. Long descriptions and image URLs can push a file over the limit, while a row-count split can quietly break variant groups. A handle-aware split plus a post-import spot check gives you a better audit trail.&lt;/p&gt;

&lt;p&gt;For a browser-side check and handle-aware batching guide, see &lt;a href="https://shopify-csv.aivismonitor.com/split-shopify-csv-under-15mb" rel="noopener noreferrer"&gt;the Shopify CSV size checker&lt;/a&gt;. It prepares review guidance locally; it does not upload files to Shopify or change products in your admin.&lt;/p&gt;

</description>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Shopify CSV Prices That Look Right but Still Fail: A Pre-Import Checklist</title>
      <dc:creator>王磊</dc:creator>
      <pubDate>Sat, 25 Jul 2026 01:43:09 +0000</pubDate>
      <link>https://dev.to/_172c9b2760d6c5bc1e8966/shopify-csv-prices-that-look-right-but-still-fail-a-pre-import-checklist-249f</link>
      <guid>https://dev.to/_172c9b2760d6c5bc1e8966/shopify-csv-prices-that-look-right-but-still-fail-a-pre-import-checklist-249f</guid>
      <description>&lt;p&gt;A Shopify CSV can look perfectly normal in a spreadsheet and still fail, skip values, or change the wrong variants after import. Price columns are a common reason: spreadsheet locale settings, currency symbols, thousands separators, and variant-row matching can all change what Shopify receives.&lt;/p&gt;

&lt;p&gt;Here is a practical pre-import checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Start from a Shopify export
&lt;/h2&gt;

&lt;p&gt;Use a recent product export as your baseline. Keep the original file untouched, then make a working copy for edits. Confirm that the file uses Shopify's expected headers, especially &lt;code&gt;Handle&lt;/code&gt;, &lt;code&gt;Option1 Value&lt;/code&gt;, &lt;code&gt;Variant SKU&lt;/code&gt;, &lt;code&gt;Variant Price&lt;/code&gt;, and, when relevant, &lt;code&gt;Variant Compare At Price&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Do not rebuild a large catalog from a blank spreadsheet unless you have a strong reason. The export preserves the row structure that Shopify uses to group products and variants.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Make the price format unambiguous
&lt;/h2&gt;

&lt;p&gt;Before saving the CSV, check that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;prices use a decimal point, not a locale-specific decimal comma;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;currency symbols are not embedded in the cell value;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;thousands separators are not left in values such as &lt;code&gt;1,299.00&lt;/code&gt; unless your cleaning step removes them safely;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;empty cells are intentional, especially on continuation rows;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;compare-at prices are reviewed together with the selling price.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the spreadsheet displays a value as currency, inspect the raw cell value before exporting. A display format can hide characters that end up in the CSV.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Verify variant identity before changing prices
&lt;/h2&gt;

&lt;p&gt;A correct price on the wrong variant is still a bad import. For each product, check that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;every variant row keeps the same &lt;code&gt;Handle&lt;/code&gt;;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;option names and values stay paired;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;SKU or another stable identifier matches the baseline export;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the update file does not accidentally include blank columns that could overwrite existing data;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;the number of variant rows is unchanged unless you intentionally add or remove variants.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a large catalog, test one representative product with multiple options before preparing the full file.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Scan the working copy
&lt;/h2&gt;

&lt;p&gt;Run the working CSV through a local checker and review critical findings before exporting a cleaned copy. Pay special attention to malformed prices, unexpected headers, encoding warnings, duplicate handles, and file-size warnings. Treat the report as a preflight review; it cannot know whether a particular sale price is commercially correct.&lt;/p&gt;

&lt;p&gt;For a quick browser-side check, use the &lt;a href="https://shopify-csv.aivismonitor.com/shopify-csv-price-format-fixer" rel="noopener noreferrer"&gt;Shopify CSV price format fixer&lt;/a&gt; to review price cells and export a cleaned copy when the correction is unambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Import a tiny batch and compare
&lt;/h2&gt;

&lt;p&gt;Import a small set of products first. After Shopify finishes, compare the result against the baseline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;selling price and compare-at price;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;variant SKU and option values;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;inventory policy and availability;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;product count and handle count.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the result differs from the plan, stop before uploading the full catalog. Keep the original export and the tested CSV so you can explain exactly what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final caution
&lt;/h2&gt;

&lt;p&gt;A CSV preflight does not replace Shopify's importer, change market-specific pricing, or guarantee that a theme or app will display prices as expected. It helps narrow down file-level risks so the next import is easier to verify.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
