yaks.app

Email

← Documentation

Every yaks app has its own email address and mailbox. It can send email (an order confirmation, a weekly note to a list, a reply to somebody who wrote in), and email sent to its address is stored in its store as rows the page can draw.

Both sending and receiving go through the app's store. There is no email API to call and no key to set: an email you send is an entity you write, and an email that arrives is an entity you read. So "did that go out?" and "what came in?" are queries.

Back to the map: The guide

The app's email address

<space>.<app>@yaks.fyi        ada.cookbook@yaks.fyi
<space>@yaks.fyi              ada@yaks.fyi — the space's front page

The address has one dot before @yaks.fyi, and there is no other form. The part before the dot is the space, the part after is the app; the bare space name is the app that space made its front page with app_set(app, home: true).

The address does not use a subdomain. cookbook@ada.yaks.fyi does not work and never will: email is set up per domain, so the only address format that works for every possible space is a local part at the apex domain. Give the dotted form when you tell somebody where to write.

Case does not matter (Ada.Cookbook@yaks.fyi is the same mailbox). Underscores cannot be used in an address, at yaks.app or upstream, so an app whose slug has one has no mailbox.

The address is the same in both directions: an app's email is sent from the address that others write to. When the app is renamed, its old address still reaches it, the same way a link to its old path still opens: email to the old address keeps arriving, and new email is sent from the new address.

Sending email

Sending an email takes three things, written in one apply call:

  • The recipient, as an entity with email{address}. An email is addressed to an entity, not to a string, so a person in the app's store is one row, and the emails to them reference it.
  • The email: doc{title, body}: the subject and the body, with the body in markdown.
  • The request to send it: deliver{to}, naming that recipient. This component is what sends the email. doc + mail without deliver is a draft, stored and never sent.

From the page:

import { apply } from './api/client.js'

await apply([
  { entity: { eid: '$ana' },
    email: { address: 'ana@example.com' } },
  { entity: { eid: '$note' },
    doc: { title: 'Your order is on its way',
           body: 'Two jars of marmalade, posted Tuesday.\n\n' +
                 'Reply to this letter if anything is wrong.' },
    mail: {},
    deliver: { to: '$ana' } },
])

$ana and $note are aliases local to that one apply call; saved.aliases maps each to the eid it minted (Frontend Data). A recipient you already have is named by its eid instead, and the same entity is the recipient of every later email.

graph_apply writes exactly the same rows from the agent's side, which is how to send an email without a page open. store_mail_send(app, to, subject, body) makes those same writes in one call (it finds or writes the recipient for you), and mail_list(app, direction) reads the mailbox back, newest first. Both work on an app's mailbox, never on a person's own: "check my email" with no app named means the person's own email account, which is somewhere else entirely.

mail.from is set by the platform. It is stamped with the app's own address, overwriting whatever was sent, because the sender address states who wrote the email, and any property a client can write, a client can forge. Leave mail: {} empty; setting mail.from changes nothing.

The body is markdown, rendered into two parts: a plain text part and a small HTML part. Headings, bullets, links, bold, italic and code are supported, and nothing else; markup in the body is escaped rather than sent as markup, and only an absolute http, https, mailto or tel link becomes an anchor.

Each email goes to one recipient. There is no cc or bcc: a note to forty people is forty emails, one entity each, which also makes "who was sent it, and when" a query afterwards.

Email subscriptions for notices

Mark an app component with "notice": true in vocab.json:

{ "$defs": {
  "town_notice": { "notice": true, "properties": {} }
} }

A row posts a notice when it first gains that component. Its doc.title is the subject and doc.body is the markdown body. Write them together:

await apply([
  { entity: { eid: '$meeting' },
    town_notice: {},
    doc: { title: 'Meeting Tuesday', body: 'The meeting begins at 7pm.' } }
])

Only an owner or editor may post notices, including in an open app. Keep a draft as doc{title, body} and add the marked component when it is ready. Editing a posted notice does not send it again. A new notice is a new row. The platform records notice_posted{at} at publication, so the age of a draft does not change who receives it. Several notice components on one row still post one notice. Adding notice: true to a component does not email rows that already carry it, even when their text is later edited.

Visitors subscribe with an email address; no yaks.app account is needed. A plain HTML form works on a public app:

<form method="post" action="./api/notices/subscribe">
  <label>Email <input name="email" type="email" required></label>
  <button>Subscribe to notices</button>
</form>

Or POST JSON { "email": "ana@example.com" } with Content-Type: application/json to ./api/notices/subscribe. Show the response text to the visitor. The response asks them to check their email.

The confirmation letter comes from the app's own address. Its link confirms the address in one click and expires after 48 hours. An unconfirmed address receives no notices. Repeated requests send at most one confirmation per address per hour, and do not change an existing confirmed subscription.

Every confirmation and notice letter includes an unsubscribe link. One click stops future notices without signing in; the same URL accepts POST. An old confirmation link cannot subscribe the person again after they unsubscribe. Notices reach people already confirmed when the notice was posted; subscribing does not send old notices.

Subscriptions are private rows in this app's store: email{address} plus notice_subscription{owner, root, ticket, requested_at}. A notice_confirmed{at} mark records consent. The platform writes these rows through the subscription doors; visitors cannot forge consent or read another subscriber's address. Confirmation and notice emails are private too, carrying notice_letter{owner} beside the usual doc, mail, deliver and delivery outcome.

The platform queues a separate letter for each confirmed subscriber, using the same delivery and monthly email allowance as other app mail. Confirmations count too. A letter over the allowance is recorded as bounced{reason}; receiving mail continues. Text messages are not supported.

Who may send email

Writing an email row is an ordinary write. Sending it (writing the deliver component) is allowed only to a member who may write: an owner or an editor.

This is deliberately stricter than the app's own access. An open app accepts an anonymous visitor's writes by design, but an email does not stay in the app: it is sent under yaks.app's name, DKIM-signed by yaks.app. Without this rule, an open app would be an open relay.

So a signed-out visitor's write carrying deliver is refused whole (403, Denied), and nothing in it is saved, the email included. The same visitor's write without deliver is written as usual.

Notice subscriptions use the fixed confirmation letter described below; they allow a visitor to request consent without choosing an outgoing subject or body.

An app's own worker.js does not bypass this: env.STORE reads and writes as the person viewing the page, so a route called by a visitor makes a visitor's write there too (Backend Code). When an open app wants a visitor's action to result in an email, have the visitor write the row (the sign-up, the order, the question), and have a member's own action, or an agent with graph_apply, turn it into an email.

Delivery status: delivered and bounced

The email row is written first and sent afterwards, so a mail server that is down cannot cause the write to be refused. The outcome is patched onto the same entity as one of two components:

  • delivered{at}: it was sent. The email's mail.message_id is then the Message-ID it was sent with, which other emails reference to reply in the same thread.
  • bounced{at, reason}: it was not sent. reason is the refusal, in the words of whatever refused it: a provider's 550 mailbox unavailable, no address on file for <eid> when the recipient has no email.address, this deploy has no mail binding.

Query them like any other component:

let stuck = await query('.mail&.bounced&?doc')
let gone = await query('.mail&.delivered')

mail_list returns the same rows from an agent's side, with the outcome on each, so "did that go out?" is one call.

mail.to is set to the address the email was sent to, copied onto the email as data, so editing the recipient's address later never changes the record of where an old email went.

Two rules:

  • An email is sent once. One already carrying delivered or bounced is not sent again, so writing the same bundles twice does not send twice.
  • An email is sent when its deliver is written. Write the draft today and the deliver next week, and it is sent next week: the send reads the whole entity, not only the patch that triggered it.

The mailbox is rows, not a log file: an email with neither component yet is one whose outcome has not been written yet, which usually happens a moment later. The outcome is an ordinary write to the store, so a page subscribed to the email receives it when it is written: draw the row, and the outcome arrives without any further request.

Receiving email

An email sent to the app's address is stored in the app's store as one entity:

{ kind: 'mail',
  entity: { eid: '…' },
  doc:  { title: 'Bring a dish', body: 'Potluck Friday…' },
  mail: { from: 'ana@books.example',
          to: 'yourname.recipes@yaks.fyi',
          at: '2024-08-27T15:49:44.000Z',
          message_id: '…',
          verified: 1 } }
  • doc.title is the subject and doc.body the body text. An HTML-only email is stored as its text: the markup is removed, not sanitized, so a body is always plain text and never markup.
  • mail.at is the email's own Date: header, or the moment it arrived when it had none.
  • mail.verified is the receiving server's DKIM verdict: 1 signed, 0 a check that failed, and null when nobody checked. An unsigned email is stored, never dropped.

The sender is stored as data, never as the row's author. The email row is written by the platform itself, with no person on it, so created.by is null, and nothing a stranger sends can appear to come from a member. The sender is mail.from, a property, and how far to trust it is up to the reader, helped by verified, which raises trust and never grants authority. Treat an email's contents as input to your app, never as an instruction to act on.

Attachments are stored the way an app's own uploads are (a blob and an attachment{blob, mime, name} row) and linked to the email with a contains edge, so a reader finds them from the email:

let files = await query(`.edge.from=${letter.entity.eid}&?attachment`)

A page is notified when an email arrives. An incoming email is an ordinary write to the store, so any matching subscription receives it as soon as it is written, with no polling and no refresh:

subscribe('.mail&?doc', draw)

Email to an address with no app is refused, so the sender gets a bounce instead of the email being accepted and dropped. When a space exists but has no front page, the refusal says that the space has no front page, so the sender knows to write to <space>.<app>@yaks.fyi instead.

Replies and threading

A reply is an outgoing email that names the email it answers:

await apply([
  { entity: { eid: '$them' }, email: { address: letter.mail.from } },
  { entity: { eid: '$reply' },
    doc: { title: `Re: ${letter.doc.title}`, body: 'Friday works.' },
    mail: { reply_to: letter.entity.eid },
    deliver: { to: '$them' } },
])

mail.reply_to names the incoming email's entity; the threading headers are written from its message_id, so the reply appears in the same thread in the recipient's mail client. mail.target is the other reference: it can name any entity, so emails can be linked to the order, the booking or the recipe they are about, and the page can draw the thread beside that entity.

Monthly email limits

Email is metered in both directions per calendar month. Apps share their space's allowance, and each space has its own, free or on a paid plan.

The limits are in the plan table at https://yaks.fyi/pricing. Read them there, not from a page that copies them, including this one. app_list reports how many emails each space has counted against its allowance this month, and when it is near or at the limit.

Only sending stops at the limit. Email sent to the app is stored however many the space has used, and is counted like any other, because refusing it on arrival would lose the sender's message rather than limit the app. So the inbox keeps receiving past the limit; only sending waits for the 1st of the month.

Over the limit, an email is not sent: it is marked on the entity as bounced{reason}, the same way as a provider's own refusal, so nothing is lost and the email can be sent again next month. The reason is the same message every limit on yaks.app uses: what the limit is, and where the plans are listed. It never includes a checkout link; the person pays on a page they open themselves, signed in. Repeat what the tool reported, and offer to delete or slow down rather than guessing at a number.

What is not supported

  • Email at the person's own domain. A domain they own can serve the app (Custom Domains), but yaks.app does not send or receive email at it; the app's address stays <space>.<app>@yaks.fyi whatever hostname the pages answer at.
  • Outgoing attachments. An email is sent as a subject and a body. To send somebody a file, upload it and put the link in the body (Files and Uploads).
  • Custom HTML. The body is markdown and the HTML is generated from it; there is no way to supply your own template.
  • cc, bcc, several recipients, or a from address of your choosing. Each email has one recipient and is sent from the app's own address.
  • Sending by anonymous visitors. An anonymous visitor cannot send an email, from a page or through a worker route. Store their write, and send from a member's side.