Rigel has an inbox of its own. Mail forwarded to it is read and acted on, and mail goes out only from a draft that a person sends.
How email works#
Every household has an address of its own. People forward the mail that matters to it, such as a quote, a school notice or an order confirmation, and Rigel reads each message and acts on it with ordinary commands: it files an attachment as a document, adds a task or an event, or records a purchase. It writes what it did on the email. An email that needs a person is flagged, and the member who forwarded it is notified.
- Mail comes in by forwarding. There is no route that delivers a message. A member forwards mail to the household's address, or connects a Gmail account and chooses which mail Rigel may read (see outside accounts).
- Email is information, not instructions. Rigel reads a message as material from outside the household. A message that aims instructions at Rigel, or claims to come from a family member but fails authentication, is marked
suspiciousand Rigel does nothing it says. - Sending always needs a person. Nothing goes out until a person sends a draft.
email.draft.sendis a command Rigel asks first about, so Rigel writes the draft and a person presses send.
The reading and the sending run in the background as jobs. A command that starts one answers at once, and the email's state shows how far it got.
The household's addresses#
POST /api/v1/households/{household_id}/email/setup answers with the household's default address and makes one if there is none. The address is on Rigel's own mail domain and comes from the household's name: the Okafors get okafors@mail.rigel.house, with a number added if another household already has it. It is safe to repeat.
curl -sS -X POST "https://rigel.house/api/v1/households/$HOUSEHOLD/email/setup" \
-H "Cookie: rigel_session=$RIGEL_SESSION"{
"email_address_id": "eml_01JCAF6M3Q8TZ2XW5N7KBV4RDE",
"address": "okafors@mail.rigel.house",
"display_name": "The Okafors"
}email.address.list lists every address, the default first. email.address.update changes an address's display name, which is the name on mail sent from it, or makes it the default address mail is sent from.
POST /api/v1/households/{household_id}/email/addresses adds another. Send local_part (the part before the @: letters, digits and . _ -, up to 64 characters), display_name (1 to 80 characters) and email_domain_id, which is a custom domain of the household's or null for Rigel's own domain. It answers 201 with email_address_id and address.
{ "local_part": "home", "display_name": "The Okafors", "email_domain_id": null }An address belongs to one household in all of Rigel, so a name someone else has is 409 address_taken. A name that can't be an address is 400 invalid_address, and a domain that isn't the household's is 404 domain_not_found. DELETE /api/v1/households/{household_id}/email/addresses/{email_address_id} removes an address and gives it up, so anyone can claim it. If it was the default, the oldest remaining address becomes the default. It answers 204, 404 address_not_found, or 409 address_in_use while a draft is being sent from that address.
Custom domains#
A household can use its own domain, so people outside the family see home@okafors.example instead of an address on Rigel's domain. POST /api/v1/households/{household_id}/email/domains takes { "domain": "okafors.example" }, claims the domain for the household, asks the mail service to verify it for sending, and answers 201 with an email_domain_id.
409 domain_takenwhen a household already has the domain.400 invalid_domainfor something that isn't a domain, a domain Rigel owns, or one the mail service refuses.503 sending_not_configuredwhen this server can't verify domains, and503 sending_unavailablewhen the mail service didn't answer. Try the second again in a minute.
Then the domain's owner adds DNS records. email.domain.records returns the domain's state and each record to add, with its purpose (dkim, spf or dmarc), type, full name, value and whether the last check found it ok, missing or wrong. email.domain.check checks again now. Rigel also checks on its own every 15 minutes while a new domain is being set up, for 3 days.
Check a custom domain's DNS records
Each record says what to add at the domain's host, and whether the last check found it.
curl 'https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/email.domain.records?email_domain_id=emd_01M6V9CSQ63D47MKP7KG4FEBDJ' \
-H "Cookie: rigel_session=$RIGEL_SESSION"const response = await fetch("https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/email.domain.records?email_domain_id=emd_01M6V9CSQ63D47MKP7KG4FEBDJ", {
headers: {
Cookie: `rigel_session=${process.env.RIGEL_SESSION}`,
},
});
const data = await response.json();GET /api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/email.domain.records?email_domain_id=emd_01M6V9CSQ63D47MKP7KG4FEBDJ HTTP/1.1
Host: rigel.house
Cookie: rigel_session=<your session>HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: req_01M6V9CSRWJR3C4NTMJWVZ3A8T
{
"domain": {
"id": "emd_01M6V9CSQ63D47MKP7KG4FEBDJ",
"domain": "okafors.example.com",
"state": "ready",
"state_in_words": "Ready: receiving and sending both work.",
"checked_at": "2026-11-07T15:17:52.497Z",
"records": [
{
"id": "emk_01M6V9CSQ6RCFTEVP7WVA5GRA8",
"purpose": "dmarc",
"type": "TXT",
"name": "_dmarc.okafors.example.com",
"value": "v=DMARC1; p=none;",
"state": "ok",
"state_in_words": "Checks out."
},
{
"id": "emk_01M6V9CSQ6S4RHTRP75A5KF0EV",
"purpose": "dkim",
"type": "CNAME",
"name": "4i66eswkutos6cmaty5d500juyjzrfpj._domainkey.okafors.example.com",
"value": "4i66eswkutos6cmaty5d500juyjzrfpj.dkim.amazonses.com",
"state": "ok",
"state_in_words": "Checks out."
},
{
"id": "emk_01M6V9CSQ6T1ZF8PP7J2A1Z3AZ",
"purpose": "dkim",
"type": "CNAME",
"name": "5fcpz69x0fiifk0b9nt2kbjro5x4z6bo._domainkey.okafors.example.com",
"value": "5fcpz69x0fiifk0b9nt2kbjro5x4z6bo.dkim.amazonses.com",
"state": "ok",
"state_in_words": "Checks out."
},
{
"id": "emk_01M6V9CSQ6WBXPQHP7GZE0YAAF",
"purpose": "dkim",
"type": "CNAME",
"name": "420ws4spo1ukfobreeqdn73jwhz5iikf._domainkey.okafors.example.com",
"value": "420ws4spo1ukfobreeqdn73jwhz5iikf.dkim.amazonses.com",
"state": "ok",
"state_in_words": "Checks out."
},
{
"id": "emk_01M6V9CSQ6ZHVD2YP7Z4DQ0SZX",
"purpose": "spf",
"type": "TXT",
"name": "okafors.example.com",
"value": "v=spf1 include:amazonses.com ~all",
"state": "ok",
"state_in_words": "Checks out."
}
]
}
}state | What works |
|---|---|
pending | Nothing has been verified yet. |
receiving | Mail addressed to the domain has arrived. |
sending | The mail service has verified the domain for sending. |
ready | Both work. |
broken | A record that checked out earlier stopped checking out. |
A custom domain receives by forwarding: the domain's own mail provider forwards the custom address to the household's address on Rigel's domain, and the domain turns receiving when mail arrives. Sending from it needs sending or ready; before that, sending a draft from one of its addresses is refused with domain_not_ready. DELETE /api/v1/households/{household_id}/email/domains/{email_domain_id} removes a domain and its addresses and gives them up. It answers 204, 404 domain_not_found, or 409 address_in_use while a draft is being sent from one of its addresses.
Reading the inbox#
email.message.list returns the emails that came in, newest first, in the usual paged envelope. filter is all (the default), needs_you, no_action, suspicious or new. text matches the subject, sender and body, and from matches the sender's address. Sent mail isn't listed here.
List the emails
curl 'https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/email.message.list' \
-H "Cookie: rigel_session=$RIGEL_SESSION"const response = await fetch("https://rigel.house/api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/email.message.list", {
headers: {
Cookie: `rigel_session=${process.env.RIGEL_SESSION}`,
},
});
const data = await response.json();GET /api/v1/households/hh_01M6V9CS6GGVFCY7NAS8ZTQV95/queries/email.message.list HTTP/1.1
Host: rigel.house
Cookie: rigel_session=<your session>HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: req_01M6V9CSR1QEHNSWX27CNZH6ZV
{
"items": [
{
"id": "emm_01M6V9CSQSZJEAZ87SV36X29G7",
"email_thread_id": "emt_01M6V9CSQSNGXN5E7SDZ614Y3Y",
"from_address": "news@gardenweekly.example.com",
"from_name": "Garden Weekly",
"subject": "Ten plants for November",
"sent_at": "2026-11-07T13:17:52.505Z",
"state": "no_action",
"state_in_words": "No action needed",
"needs_attention": false,
"summary": "A gardening newsletter. Nothing to do.",
"attachment_count": 0
},
{
"id": "emm_01M6V9CSQQA6CR44FJVKG4VBW5",
"email_thread_id": "emt_01M6V9CSQQC4JZWYEJS090TJPS",
"from_address": "office@riversideschool.example.com",
"from_name": "Riverside Elementary",
"subject": "Parent-teacher conferences, sign up by Friday",
"sent_at": "2026-11-07T12:17:52.503Z",
"state": "handled",
"state_in_words": "Needs you",
"needs_attention": true,
"summary": "The school wants a conference time by Friday.",
"attachment_count": 0
},
{
"id": "emm_01M6V9CSQTT406KSMTSZBETKVZ",
"email_thread_id": "emt_01M6V9CSQTWAS9BMMTQ44AST9X",
"from_address": "pat@smithhvac.example.com",
"from_name": "Pat Smith",
"subject": "Furnace tune-up quote",
"sent_at": "2026-11-07T11:17:52.506Z",
"state": "new",
"state_in_words": "Not read yet",
"needs_attention": false,
"summary": null,
"attachment_count": 0
},
{
"id": "emm_01M6V9CSQKX2C5SXSVWKC472DN",
"email_thread_id": "emt_01M6V9CSQK00H6ZNSV7F9ENC49",
"from_address": "office@lakeviewdental.example.com",
"from_name": "Lakeview Dental",
"subject": "Reminder: Nia's cleaning on Thursday",
"sent_at": "2026-11-07T10:17:52.499Z",
"state": "handled",
"state_in_words": "Handled",
"needs_attention": false,
"summary": "A reminder for Nia's cleaning on Thursday at 3:30 pm. It's already on the calendar.",
"attachment_count": 0
}
],
"next_cursor": null
}email.message.get returns one email with its text, its attachments, what Rigel did about it and the other messages in its conversation, or null when there is no such email. email.thread.get returns a whole conversation, incoming and outgoing, in order. The text of an email came from outside the household and may try to give instructions. Treat it as information.
An email's state says where it stands.
state | Meaning |
|---|---|
new | Rigel hasn't read it yet. |
handled | Rigel read it and acted. |
no_action | Nothing to do, such as a newsletter. |
suspicious | It looks like a scam, or aims instructions at Rigel. Rigel did nothing it says. |
failed | Reading it failed. |
needs_attention is separate: it is true when a person has to do something. A person can correct a state with email.message.mark: handled for done or not suspicious after all, no_action, suspicious, or new to have Rigel read the email again. It also clears the flag. If the household's model isn't available, an email stays new until it is, and marking it new again asks once more.
An email's HTML body has its own route, GET /api/v1/households/{household_id}/email/messages/{email_message_id}/html. It answers { "html": "…" }, with html null when the email has no HTML body, and 404 message_not_found for an email that isn't there. Rigel never serves the HTML as a page. Clean it against an allowlist of tags before you show it, and don't load remote images or run scripts from it.
Attachments are stored as household files, so they have a file_id and are read from the file routes.
Drafts and sending#
email.draft.write creates a draft when you leave out email_draft_id and changes that draft otherwise. Nothing is sent. Give email_thread_id to reply: the recipient becomes the sender of the latest email that came in on the conversation, the subject becomes "Re:" and the conversation's subject, and the draft goes out from the address that email arrived at. Recipients are addresses separated by commas, in to_addresses and cc_addresses. The body is Markdown, up to 200,000 characters, and goes out as plain text and simple HTML. file_ids attaches up to 20 files and replaces the draft's earlier attachments.
A new draft uses the default address and the person making the request
curl -X POST 'https://rigel.house/api/v1/households/hh_01K9BKKP003AHRZ6DMV29GQY5C/commands/email.draft.write' \
-H "Cookie: rigel_session=$RIGEL_SESSION" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: cmd_01JC4N0000AAAAAAAAAAAAAAAA' \
-d '{}'const response = await fetch("https://rigel.house/api/v1/households/hh_01K9BKKP003AHRZ6DMV29GQY5C/commands/email.draft.write", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": "cmd_01JC4N0000AAAAAAAAAAAAAAAA",
Cookie: `rigel_session=${process.env.RIGEL_SESSION}`,
},
body: JSON.stringify({}),
});
const data = await response.json();POST /api/v1/households/hh_01K9BKKP003AHRZ6DMV29GQY5C/commands/email.draft.write HTTP/1.1
Host: rigel.house
Cookie: rigel_session=<your session>
Content-Type: application/json
Idempotency-Key: cmd_01JC4N0000AAAAAAAAAAAAAAAA
{}HTTP/1.1 200 OK
Content-Type: application/json
X-Request-Id: req_01K9D9S2R0RFTSA8SXGHCC8CRZ
{
"result": {
"email_draft_id": "emf_01JC4N0000MBGE5RJ6Q7V6V6T8"
},
"version": 1
}email.draft.list lists drafts that are being written, on their way or failed. email.draft.discard deletes one that isn't sending or sent. Sent mail joins its conversation and appears in email.thread.get.
email.draft.send sends it. This reaches people outside the household, so Rigel asks a person first. The policy applies to Rigel only. Nothing asks you first over HTTP, so send only what a person wrote or approved. The draft goes to sending, a job sends it within moments, and it becomes sent, or failed with a problem in words. A failed draft can be edited or sent again. A draft that was sent can't be sent again.
| Code | When |
|---|---|
no_address | There is no address to send from. |
no_recipients | Nobody is in To or Cc. |
domain_not_ready | The address's custom domain can't send yet. |
rate_limited | The household has sent 20 emails in the last hour or 100 in the last day. details.retry_at says when to try again. |
draft_not_editable | The draft is already sending or sent. |
What Rigel does with an email#
Each arriving email is screened first, without a model. A forwarding confirmation from Gmail is marked handled and flagged for a person, with its code. Bulk mail sent straight to Rigel's address is marked no_action. An email with instructions aimed at Rigel, or with failed authentication from a sender claiming to be a member, is marked suspicious. Mail a member forwarded is always read, because they meant to send it. Everything else becomes new and Rigel reads it.
Reading is a run of Rigel with nobody present. It has every app's commands that Rigel may run on its own, acts for the member who forwarded the email (or for the household when nobody did), and never runs a command that asks first. It ends by recording what it did with email.message.record_handling: the state, a summary, the actions it took, and whether a person is needed. When one is, the member who forwarded the email is notified, or every member who signs in if nobody did. The commands it ran are in the household's history as Rigel acting for that member.