Skip to the main content
mkfifo.com

Email, and the sign-in

Updated planned discovered changed fixed

An email comes in as a stream. For two days, mkfifo.com also sent the email of each FIFO; then your own email service took that job. The sign-in of mkfifo.com became a FIFO, with one-time codes and signed cookies. Then each account got a home: a locked FIFO, with FIFOs in it. Before the next features, I checked the homes again, and closed the holes that I found.

Email in, as a stream

An email to NAME@mkfifo.com is a Request. I read it as a stream, so a large attachment uses almost no memory.

The parse

  • MIME, the encoded words of RFC 2047, and the charsets UTF-8, US-ASCII, ISO-8859-1 and ISO-8859-15. ISO-8859-1 is Windows-1252, as in a browser.
  • The body of the Request is the first plain text part, else the first HTML part.
  • A multipart body goes through a streaming parser. Its model, specs/Multipart.tla, looks at all the bodies of up to 9 symbols and all their splits into chunks.
  • Forms use the same parser: each field of a form becomes one line name: value of the body.

A backslash

A property test found that a backslash in the name of a field of multipart/form-data lost the name. A browser does not escape a backslash there, so the parser must not read it as an escape. Fixed.

Who sent it

Anybody can write any From. Cloudflare Email Routing checks SPF, DKIM and DMARC, and the Worker puts the DMARC result in X-MkFIFO-DMARC. A pattern on From and X-MkFIFO-DMARC: ^pass$ together prove the sender. Only the Worker sets that field: mkfifo.com removes it from each other Request.

No bounce for an empty name

Any name can get envelopes, with no sign-up. So a name with no envelopes is not a missing FIFO: it is an empty one. Until now, an email to it went back to the sender, and an HTTP Request got 404. A shop, or the check that Gmail sends before it forwards, can mark an address as bad after one bounce, before you write the first envelope.

Now an empty FIFO drops a Request, as a FIFO drops one that no envelope matches: 204, and no bounce. The cost is that an email to a mistyped address disappears quietly. A name that cannot be a FIFO still gets 404.

On the way, I found that the editor said "No ingress envelope matches" for an envelope that matched but had nothing to send. It now says "Matched, with no egress envelope to send".

Asking before sending email

A FIFO can send email to an address that somebody typed. Without a check, that is a machine for spam. So mkfifo.com asks the holder of the address first.

The model found the scanner

I wrote specs/Confirm.tla before the code. Four rules prevent four faults, and TLC finds each fault when its rule is not there.

Then I added a mail scanner to the model. Many mail services open each link of an email to check it. If the link itself confirms, the scanner confirms with no consent. So the link opens a page, and only the button of that page confirms. A link is valid for 7 days, and an address gets at most one link in one hour.

The stop link

Each email has a stop link with one click (RFC 8058). TLC found a fault before I wrote it: after a stop, the save of an old page of the editor started the emails again. Now a confirmed line stays confirmed only when the FIFO has that address confirmed now.

How email goes out

At first the VM sent through the API of Cloudflare Email Service with the secrets of my account. I changed that: each email goes to an email API with a token, as to any third party. The Worker has such an API for Cloudflare Email Service, and SendGrid, Mailgun, Postmark or Resend work too.

Two days later

I removed the confirmation and the stop link. mkfifo.com sends email only for the FIFOs of the site now, and your FIFO sends email with your own email service (see "Your email service sends your email" below).

Your email service sends your email

mkfifo.com does not send email for your FIFO now. Your own email service sends it. I removed the confirmation of an address, the stop link and the reply address.

Why

The confirmation made each email to a new address wait for a click. That stopped spam, but it also stopped good uses: a contact form that replies to the person who wrote, or a sign-in link to the address of a form. Each of these sends to an address that a stranger typed. With the email service of mkfifo.com, I must ask that stranger first. With your email service, that is between you and your service.

So I turned it around. An email is an HTTP request to the API of your email service, with your key in a sealed field:

To: https://api.mail.example/send
Authorization: <seal:Bearer KEY>
Content-Type: application/json

{"to": "{{email}}", "subject": "Thanks", "text": "We got your message."}

The variables get the escapes of JSON, so a capture cannot add a field. The editor shows only <sealed:TOKEN> in place of the key.

What went away

  • The confirmation link, its page, and the limit of one link in one hour. The TLA+ model of these rules (specs/Confirm.tla) went too: there is no consent to keep now.
  • The stop link. Your email service has its own.
  • The reply address reply+UUID@mkfifo.com. With no email from mkfifo.com, there is nothing to reply to. A Request from one FIFO to the next has Received: by mkfifo.com; DATE now, and still no name of the FIFO before it.
  • The two kinds of seal. A seal was an address or a field. Now each seal is a field, and a sealed To opens as its destination.

What stays

The FIFOs of the site still send email from noreply@mkfifo.com, with the email service of mkfifo.com. The letters of /contact come to me in this way. A FIFO of the site can also send to the address of a capture, To: {{email}}: the base of a sign-in link later.

The templates changed too. Each template that sent an email now sends JSON to an example API, with a note that tells what to change: the URL, the key and the body.

Later

An account that owns a FIFO could keep the data of its email service. Then To could be an email address again, and that service sends it.

One-time codes and cookies

I want a sign-in for mkfifo.com, but I want it as a FIFO, not as code beside the FIFOs. A FIFO can already take a form and send a link. It could not make a code, check a code, or remember a person. Now it can do all three.

A pattern can use a capture

A pattern can have a variable now: {{email}} is the capture of a different line of the same envelope. The lines with variables go last, after the captures of the other lines.

The filter totp makes it useful. {{email | totp}} is a one-time code of the capture: 8 digits, as the codes of an authenticator app (RFC 6238). In an egress envelope it puts the code in a link. In a pattern it accepts that code:

Email: ^(?<email>[^@\s]+@[^@\s]+)$
Code: ^{{email | totp}}$

The key of a code holds the secret of mkfifo.com, the FIFO and the value. A code is valid for at most 15 minutes, and a Request that agrees uses it. After 5 wrong codes for one address in 15 minutes, its codes stop.

The code that came back

My first version marked each used code. A test sent two links in one step of 7.5 minutes, and both had the same code: that is how TOTP works. So the second link had a code that was used already, and did not work.

Now the key also has a generation of the value. A use moves the value to its next generation: the used code, and each older code of that address, stop, and the next link has a new code. Two Requests with one code at the same time could both read the generation before one of them moves it. So the use is one insert_new in a table in memory, and a TLA+ model (specs/Codes.tla) shows that only one of them agrees. Without insert_new, TLC finds two.

But the table is in memory, and each deploy starts a new VM. After a start, each generation was 0 again, so a used code agreed one more time, until its 15 minutes ended. A person with an old link, from the history of a browser for example, could sign in with it. Now each start makes a random value, and the key of each code has it: after a start, each code from before it is wrong. A person who asked for a code just before a deploy asks for a new one. A test stops the process of the table, and checks that the used code does not agree again. The code also compares in constant time now.

Signed cookies

An answer of a FIFO of the site can set a cookie now:

Set-Cookie: auth={{email}}; Path=/; Max-Age=5184000

mkfifo.com signs the name and the value. The browser keeps only the signature. When the browser sends the cookie back, the FIFO gets the plain value in Cookie, and a client cannot write a cookie that agrees.

This changed the rule of cookies from this morning. A Request lost only the session of mkfifo.com then. Now it keeps only the cookies of its own FIFO, also a Request from a different FIFO: else a FIFO could send a false Cookie to a FIFO of the site.

A letter with an answer

LiveView sends a letter over its socket, so the browser never sees the answer of the Request: not its page, not its redirect, and not its cookies. Now the letter of a FIFO with an answer egress goes with a usual form of the browser, also with JavaScript. The page in its sandbox shows, and the cookies arrive.

Sign in with a FIFO

/auth is the sign-in of mkfifo.com, and it is a FIFO. Its editor is open to read, like /contact: the envelopes there are all of the sign-in. There is no table of persons.

Three envelopes

  1. A letter with an email address and no code. Its egress sends an email with a link and a code, {{email | totp}}, and its answer brings you back to the form with your address.
  2. A letter with the address and the code: Code: ^{{email | totp}}$. Its answer sets the signed cookie auth for 60 days, and goes to the home page.
  3. /logout takes each POST, and its answer removes the cookie.

The link in the email is /auth?form&email=...&code=.... I made ?form put the values of the query in the fields of the letter, so the link opens the form already filled. You still push "Submit": many mail services open each link of an email to check it, and a link that signed in by itself would sign in the scanner too.

The header of each page shows your address and "Sign out", or "Sign in". A plug reads the cookie on each page, and keeps a copy in the session, because a LiveView cannot read a cookie after it connects.

A limit for the emails

This FIFO sends an email to any address that somebody types. That is the old problem of spam, the reason for the confirmation that I removed this morning, with a smaller door: only a FIFO of the site can do it. So an address of a capture gets at most 3 emails in one hour now, from all the FIFOs. mkfifo.com counts them in memory, with a hash of the address. The letter also has a honeypot field.

Email to each address

Until 9 October, the emails of the site went only to the verified addresses of the Cloudflare account: Cloudflare sends to any address only from a domain that is onboarded in Email Sending, and only on the plan Workers Paid. Now mkfifo.com is onboarded. Cloudflare added the records of cf-bounce.mkfifo.com (MX, SPF and DKIM) and a DMARC record with p=reject, and it did not change the MX records of the incoming email. The code did not change: sendmail.js and the binding were ready.

The first link of the sign-in opened the form with the code, and the field Code had the mark "does not agree". The letter took its marks from the envelope with the most lines that agree, and that was the first envelope: it takes a letter with no code. The second envelope agreed, but its line of the code has a variable, which a test before the send cannot use, so it had fewer lines in the test. Now the marks come first from an envelope that agrees.

The link also showed too much: the full letter, with each field, its mark, and the result of each envelope. Now the link opens a short dialog with the address and one button, "Sign in". The button sends the same letter in hidden fields, so the server did not change, and a mail scanner that opens the link still does not sign in. "Sign in with a different address" opens the letter.

Plus addresses

In a query, + is a space. Thus a link with ann+tag@example.com as plain text would fill a different address. The email is Markdown, and Markdown puts each capture in the URL of a link with percent-encoding, so the HTML part has ann%2Btag%40example.com. The answer of the first letter goes back to the form with Location, which also encodes the capture. A test in Chromium signed in with a plus address, with and without JavaScript.

Accounts, again

The first accounts came before a plan, and went away the same day. They came back with a plan, and with less code: the sign-in above did not change.

  • When a browser starts a session with a valid cookie auth, mkfifo.com keeps the sign-in, and the first one makes the account. An account is an email address and two times: the first and the last sign-in. There is no password, and no table of sessions: the signed cookie is the session.
  • An account keeps lists of FIFOs. The editor of a FIFO has "Add to a list", and your account shows the lists, with "Remove" and "Delete my account". Each form works with no JavaScript.
  • A list does not make a FIFO yours: the name is still the key. A lock for a subscriber comes later.
  • The tables were still in the live database, from the first version. The migration came back with the same version, and now makes each table only if it is not there, as the migration of the graph does. A new database gets the same tables.
  • The privacy page tells what an account keeps.

Homes

The lists were there for a few hours. mkfifo.com keeps its data as a graph of nodes and edges, so new tables for the accounts and the lists were the wrong choice. Now the account and its home are in the graph too, and links take the place of the lists.

  • Your home is a FIFO. At the first sign-in, mkfifo.com makes a node for the account, with your address and the times of your sign-ins, and a FIFO whose real name is a new UUID of version 7. /~ opens it.
  • Each path in your home is a FIFO. There is no mkdir: open /~/work/my-fifo, and the FIFO my-fifo is there, in the FIFO ~/work. Save an envelope in it, and it stays. There is no rmdir: mkfifo.com stores envelopes, not FIFOs, so a FIFO of a home with nothing in it goes away one day later.
  • The FIFOs of your home are locked to you, and they are never public. This is the first lock of mkfifo.com, and it costs nothing, because it locks no name: a real name is a UUID. For each other person, they are not there: the real name, a Request, a PUT and a PROPFIND all get 404.
  • The FIFOs of your home send Requests to each other: To: ~/inbox, or $($body | ~/inbox). The save keeps the real name in the graph. A script of sh or eval in an envelope cannot send to them, because a Request can give the script.
  • A link is a mirror. Write Bcc: my-shop in an envelope of ~/shop, and the envelope gets the Requests to the public FIFO my-shop, as a symbolic link. Only you see the link: the editor of my-shop does not show it.
  • A home holds no FIFO. A hold from a FIFO of a home would lock a name, and a lock of a name is for a subscriber.
  • In the terminal, ~ is your home: printf hi > ~/work sends a Request to your FIFO ~/work. Only the terminal does this. A Request never expands ~, because a different site can make your browser send a Request.
  • The lists did not move: a link is an envelope now, and the old tables went away.

A check of the homes

Homes and guests change who can do what, so I stopped the new features, and read the code again as a person who wants to break it. These are the holes that I found and closed. Each fix has a test.

  • A link took the answer. The answer egress of a link became the answer of the public FIFO: its status, its page, and also a redirect to a different site. A link also used the steps and the messages of the Request of the FIFO. Now a link examines a Request after the envelopes of the FIFO, with its own steps and messages, and gives no answer. The client gets only the results of the envelopes of the FIFO.
  • The lock was in the pages, not in the store. A copy could go into a FIFO of a home, and an editor that was open could save after "Delete my account". Now the store examines the owner in the lock of each save, note and delete.
  • A delete that stops. "Delete my account" could stop in the middle, and leave FIFOs with no owner. Now the account gets a mark first, then each envelope goes, and the next start of mkfifo.com goes on with a delete that stopped. The start also repairs the old Cc and Bcc edges, and the notes with no envelope.
  • Real names in errors. An error of a loop could show the real name of a FIFO of a different home. Now it shows "a FIFO of a home", and your own FIFOs as ~/PATH. A name in the form of a UUID with no home gets the same answers as a FIFO of a home, so a real name tells nothing.
  • A slow pattern. match_limit of PCRE applies to each start position, so [a-z]*[0-9] on one long line took minutes, and the limit did not stop it. Now each search counts the reductions of the BEAM, which need no clock, and a search on a long text runs in a process that stops when the Request has no more steps. In WebAssembly, the worst search stops after about 6 seconds, as the slowest search of the NFA of grep.
  • One-time codes. The memory of totp has a limit now, ~ keeps no codes, and the uppercase and lowercase letters of an address give no more tries.
  • Cookies from a different site. A page of a different site could sign a person out. Now an answer to a request from a different site sets no cookie.
  • The HTTP egress sends nothing to this computer or to a private network. On Cloudflare, the network blocked them already, but a different host would not.
  • Reads in a home. An envelope of a home cannot read a FIFO whose name a variable gives: a Request through a link must not choose the FIFO. A sealed key comes only from the text of an envelope.
  • An empty FIFO of a home now goes away one day after its last open, not after it was made. Thus a FIFO that is open in an editor stays.