< the skull

HOW I BUILT A PUBLIC FAX WALL
=============================
2026-07-18

https://fax.1208.pro [1]

There is a working fax number out in the world and it belongs to me. If you send
a page to it, that page may end up hanging on a web page, in black and white,
for any stranger to look at. No signup, no login, no app. Just a phone number
and an old machine on the other end of an imaginary line.

The idea came out of a small grudge. "Just send the link" has become the beige
wallpaper of modern life. Everything is instant, everything is editable,
everything disappears. Fax isn't like that. Fax is stubborn, far too physical
for 2026, and somehow still alive — running like a haunted appliance with union
protection. I wanted to see what would happen if I left that channel open to the
world and published whatever came through without much curation.

This is about how the thing was built. I'll walk through each piece in the order
a sheet of paper travels through the system.

The rule that shaped the architecture
-------------------------------------

Before the first file existed, I had one personal constraint: no application
server.

No backend running around the clock, no database to back up, no container to
patch at three in the morning because somebody published a CVE. This is a toy
project. If it demands maintenance, it dies in three months. They all do.

So the decision was: the final site is static HTML. One file. Nginx serves it
and that's the end of it. Everything dynamic happens far away, on infrastructure
somebody else maintains for free, and the result lands as a dumb JSON file on
disk.

That sounds like laziness. It is, but it's laziness with a design behind it.
Every other choice in the system falls out of this rule.

Piece 1: getting a real fax number
----------------------------------

I don't have a phone line and I'm not buying a machine. I used a fax-to-email
service — fax.plus, in this case. You rent a number, and when somebody transmits
to it, the service answers the call, decodes the signal, and emails you the
document as an attachment, PDF or TIFF.

The number is published on the site, because that's the entire point: +1 762 475
9826.

Here's the trick that made the project viable: the moment a fax becomes an email
with an attachment, it stops being telephony and becomes a Gmail inbox. And a
Gmail inbox is something I know how to automate without paying anyone.

Piece 2: Google Apps Script playing the part of a backend
---------------------------------------------------------

The whole core of the system lives in a single `Código.gs` file running on
Google Apps Script. It's JavaScript hosted by Google with native access to
Gmail, Drive, and Sheets, time-based triggers, and a public HTTP endpoint if you
want one. It costs nothing and I administer none of it.

It has two main functions that run in sequence.

`ingestFaxes()` — fishing out the new faxes
-------------------------------------------

The function sweeps Gmail with a specific query: messages from the fax service's
notification sender, within the last 30 days. The search is paginated 100
threads at a time in a loop, because the Apps Script API hands results back in
batches, and ignoring that is the classic way to silently lose older messages
once volume grows.

For each message, three decisions:

Have I seen this one? Before anything else, I compare the Gmail message ID
against the IDs already on record. This is the heart of the system's
idempotency. The trigger fires periodically and always looks at a rolling 30-day
window, which means it reprocesses the same messages dozens of times. Without a
reliable dedup key, the wall would turn into a wall of duplicates. Using the
message ID rather than the subject or the date was the right call: subjects
repeat, dates collide, IDs don't.

Which attachment is the fax? A notification email arrives with a logo, a footer,
an inline image, sometimes a receipt. I wrote a function that filters for
plausible candidates — `.pdf`, `.tif`, `.tiff`, or the matching content types —
and then scores the survivors: PDF is worth 3, TIFF is worth 2, and file size
enters as a tiebreaker divided by ten million. Highest score wins.

There's one ugly case in there that I left on purpose: if an attachment shows up
as `application/octet-stream` and is larger than 1 KB, I accept it. Fax gateways
are notorious for sending generic content types. Rejecting on technical purity
would mean dropping legitimate faxes, and the cost of the opposite error is low
— worst case, Cloudinary refuses the file downstream and the row simply ends up
without an image.

Keep the original. The chosen attachment is copied into a Drive folder under a
deterministic name: `fax_20260410_143022_<messageId>`. That's the raw file,
untouched. If I break every other stage of the pipeline, the source material is
still sitting there.

Then a row goes into the spreadsheet with its own UUID, the received date, the
sender, the subject, the Drive file ID, the attachment name and type, and a
short public slug derived from the first eight characters of the UUID.

The spreadsheet as a database
-----------------------------

Yes, the database is a fifteen-column Google Sheet. I recommend this far more
often than people expect for projects this size. It comes with a free admin
interface, revision history, permissions, and export, and I can fix a bad record
from my phone on the bus without touching SSH.

One thing I did that saved me pain: a function that checks and repairs the
sheet's header row every single time the script runs. It verifies the fifteen
columns are where they should be, inserts columns if any are missing, and
rewrites the header row if it doesn't match. A spreadsheet is human-editable,
and humans drag columns around. Assuming the schema is intact is optimism. The
code never reads a column by fixed position — it always builds a name-to-index
map from row 1.

Every row is born with status `approved`. The schema supports moderation — the
field is there, and the publishing function only looks at approved rows — but in
practice the gate is wide open. That was a deliberate choice about the spirit of
the site, not an oversight. The switch exists for the day I need it.

`publishApproved()` — turning a PDF into a publishable image
------------------------------------------------------------

Second function. It looks for approved rows that don't have a published image
yet, pulls the original file from Drive, and pushes it to Cloudinary under a
predictable public ID.

And this is where my favorite part of the whole project lives, because it's work
I didn't have to do.

The real problem was: how do you turn a fax PDF into an image that fits on a web
page? The obvious answer involves rasterizing PDF, which means ImageMagick or
Ghostscript, which means a server, which breaks rule number one.

Cloudinary does it in the URL. After the upload, the public image is assembled
from four transformations chained into the path:

  * `pg_1` — take only the first page of the PDF
  * `dn_200` — rasterize at 200 DPI, fax density
  * `e_blackwhite` — force pure black and white, no halftone
  * `q_auto` — let Cloudinary pick the compression

No processing happens on my side at all. I upload the raw PDF and request an
image by URL. The conversion runs on their CDN, on demand, and gets cached. The
result looks exactly the way I wanted — fax grain, high contrast, none of that
tasteful gray.

The image URL and the public ID go back into the spreadsheet, and the row is
live.

Piece 3: privacy, or at least the facade of it
----------------------------------------------

The wall displays pages that strangers sent me. I have no idea what's going to
arrive. So there's a hygiene layer working at two levels.

On the server side, the sender runs through a function that tries to extract a
human name from the email's `From` field. It handles the `"Some Person"
<person@domain.com>` format, strips outer quotes, and then applies two rejection
tests: if what's left looks like an email address, discard it; if it looks like
a phone or fax number — seven or more digits, nothing but phone characters —
discard it. Anything suspicious becomes `Unknown sender`.

This matters more than it looks. Fax carries the originating number in its
header, and the service frequently drops that number straight into the sender
field. Publishing it would mean leaking the phone number of whoever sent me a
joke.

On the client side I went further: the page doesn't render the sender name at
all. The public JSON carries the field, the front-end simply never draws it.
Every entry on the wall shows the date, the image, and an optional note. That's
it. As the site's own FAQ puts it — the machine knows, obviously, but it is not
a snitch.

Piece 4: how the data leaves Google and reaches my VPS
------------------------------------------------------

The Apps Script publishes a Web App with three output formats, selected by URL
parameter: a rough HTML gallery, `format=json`, and `format=rss`. The last two
are the ones that matter. Both accept a per-item filter and a count limit.

Now, I could have just pointed the site at Google's endpoint and called it done.
I didn't, for three reasons: the URL is ugly and advertises the implementation,
Apps Script has execution quotas, and I don't want my site's availability to
depend on a Google redirect resolving during a visitor's request.

So there's an eighty-line shell script running hourly on the VPS via cron. It's
small, but there are four deliberate decisions inside it.

Cascading configuration. The script loads config files in order — repository
first, then `~/.config` — and environment variables override everything. That
gives me versioned defaults plus machine-local overrides without a single `if`.

Explicit failure. It opens with `set -euo pipefail` and aborts with a clear
message if the Web App URL isn't configured. A silent cron job is the worst
category of bug: the site just freezes in time and nobody notices for three
weeks.

URL rewriting. Every link pointing at Google's domain inside the payload gets
swapped for the public domain, using `perl` with both ends passed in through
environment variables instead of interpolated into the regex. That keeps a slash
or a question mark in the URL from turning into a metacharacter and destroying
the file.

Atomic writes. This is the most important one and the easiest to forget. `curl`
downloads to `faxes.json.tmp`, and only then does `mv` move it over the real
file. `mv` within the same filesystem is an atomic rename, which means nginx
never — not for a single instant — serves a half-written JSON. If you download
straight over the file being served, sooner or later a visitor catches the
transfer mid-flight and gets a parse error.

Nginx serves both files with `Cache-Control: max-age=3600`, deliberately aligned
to the cron interval. There's no point caching for longer than the refresh, or
for shorter.

Piece 5: the page
-----------------

The front-end is a single HTML file. No build step, no framework, no
dependencies, no `npm install`. It loads the JSON with `fetch` and assembles the
list.

The look is monospace, light gray background, blue underlined links in the
browser's original default. That's not aesthetic laziness — that is the
aesthetic. A fax wall shouldn't look like a SaaS product.

A few details worth mentioning:

The favicon is a fax emoji embedded as an SVG data URL. Zero extra requests, no
`.ico` file.

The image containers have `aspect-ratio: 210 / 297` locked in before any image
loads. That's the A4 ratio. The space is reserved at the correct size up front,
so nothing jumps around when the images arrive.

The first image loads with `loading="eager"` and `fetchpriority="high"`; every
other one is `lazy`. There's a `preconnect` to Cloudinary's domain in the
`head`, so the TLS handshake is already underway before the JSON finishes
downloading.

The JSON parser is intentionally forgiving: if the response is an array, use it;
if it's an object, look for `items`, `faxes`, `records`, `data`, or `entries`.
It cost six lines and lets me change the backend's shape without breaking the
live page.

And everything coming out of the JSON gets HTML-escaped before it touches the
DOM. I am rendering content that anonymous strangers transmitted over a phone
line. Concatenating that straight into `innerHTML` would be an open invitation
for the first clever visitor to write the rest of my site for me.

Piece 6: the doorman
--------------------

Sitting in front of all of it is Anubis, a proxy that demands proof-of-work from
the browser before it lets the page through. It's there because AI training
crawlers started burning through small-VPS bandwidth as if it were infinite.

I found out it was stricter than I remembered when I tried to fetch my own site
with an automated tool and got an "access denied" to the face. Working as
designed, I suppose.

The whole flow, in one line
---------------------------

Somebody dials the number → the service converts the call into an email with an
attachment → Apps Script finds the email, dedupes on the message ID, picks the
right attachment, archives the original in Drive, and writes a row to the
spreadsheet → it uploads the PDF to Cloudinary and requests page one in black
and white at 200 DPI → it publishes a JSON feed → the VPS cron pulls that JSON
hourly and atomically swaps it over the old one → nginx serves it → static HTML
draws it.

Not one process of mine runs continuously. The most complex part of the system —
rasterizing PDF — is a parameter in a URL. The part that most resembles a
database is a spreadsheet I can open on my phone.

What I'd do differently
-----------------------

Two things bother me when I reread the repository.

First: the Web App URL is committed to the environment file inside the repo, and
the README itself says it should live outside of it. It's a read-only endpoint,
so it isn't catastrophic, but it's exactly the kind of thing you swear you'll
fix later and never do. The right move is to purge it from history, publish a
new Web App version, and keep the value only in `~/.config` on the VPS.

Second: the `data/faxes.json` sitting in the repo is empty. It's a placeholder —
the real file is generated on the VPS by cron — but a committed empty file is a
trap waiting for someone to deploy it over the good one.

Beyond that, I'd keep all of it. Especially the part where there's no server to
maintain.

Links:
  [1] https://fax.1208.pro


< the skull