Receive email
Your app can have its own email address. Keelson stores every email that arrives as one row in a table in the app’s database and saves its attachments in Files. Your app reads them with SQL; it does not need code that receives mail. To process each email after it is saved, name a background task in on_receive.
Typical uses are a shared support inbox and reading order or invoice emails. Keelson does not receive mail for your own domain. Forward mail to the app’s address from Gmail, Google Workspace, or Microsoft 365 instead (Forward mail to the app).
Tell your AI agent what should happen to each email, and it can write the keelson.yaml settings and the processing task.
Store the order emails forwarded from our sales@ group and register each one as an order. Processing the same email twice must not create a duplicate order.
Enable receiving
Section titled “Enable receiving”Add email.inbound to keelson.yaml and deploy. Receiving needs Managed SQLite (db.mode: libsql).
slug: order-inboxruntime: python-slimcommand: "uvicorn main:app --host 0.0.0.0 --port $PORT"db: mode: libsql # requiredemail: inbound: enabled: true on_receive: process-mail # optional: a task declared under taskstasks: - name: process-mail command: "python process_mail.py"| Field | Meaning |
|---|---|
email.inbound.enabled | true turns receiving on. The deploy issues the receiving address and creates the table keelson_inbound_emails |
email.inbound.on_receive | Optional. The name of a task under tasks. Keelson starts it after each email is saved |
The deploy is rejected in these cases:
| Error | Cause |
|---|---|
email_inbound_requires_libsql | enabled: true without db.mode: libsql |
email_on_receive_requires_enabled | on_receive is set but enabled is not true |
email_on_receive_unknown_task | on_receive does not match any tasks[].name |
With on_receive, an app that has tasks needs neither command nor crons; receiving is what starts the task. The location of attachments is fixed and cannot be configured.
Receiving address
Section titled “Receiving address”The receiving address appears on the app’s Email settings page in the console. It has the form in+<random>@inbound.keelson.run. The random part is not derived from the slug, so renaming the app does not change the address.
- Rotate: you can issue a new address from the console. The old address is retired immediately, and mail sent to it is refused.
- Turn receiving off: deploying without
email.inbound(or withenabled: false) retires the address permanently. Emails not yet saved, or held because they could not be saved, are discarded. Rows and attachments already saved stay in place. - Turn it on again: a new address is issued. Update the forwarding settings to the new address.
A retired address is never reused, by your app or any other.
How emails are stored
Section titled “How emails are stored”Each email becomes one row in keelson_inbound_emails. The table is in the app’s own database, so the app reads it with the same connection as its other tables.
| Column | Contents |
|---|---|
email_id | Primary key. Fixed when the email is received |
schema_version | Version of the column layout when the row was written. Currently 1 |
received_at | Time received, ISO 8601 UTC |
envelope_to | The app’s receiving address the email was delivered to |
envelope_from | The sender address used for delivery (the envelope sender) |
from_address | The sender’s address: X-Original-From if present, otherwise From. Not authenticated (below) |
from_name | The sender’s display name, from the same header |
header_from | The raw From header, after any rewriting by a forwarding group |
reply_to | Reply-To header |
to_addresses | JSON array of the To addresses (for forwarded mail, your shared inbox) |
cc_addresses | JSON array of the Cc addresses |
subject | Subject |
message_id | Message-ID header |
in_reply_to | In-Reply-To header |
references_header | References header |
list_id | List-Id header |
text_body | Plain-text body, decoded to UTF-8 |
html_body | HTML body, decoded to UTF-8 |
body_truncated | 1 if either body was longer than 1 MiB and was cut |
attachments | JSON array of attachments ([] if none) |
spam_score | Simple spam score. A marker only (Spam) |
auth_spf / auth_dkim / auth_dmarc | SPF / DKIM / DMARC results recorded by the receiving server |
Headers that are missing are NULL. Encoded subjects and file names (including Japanese) are decoded.
to_addresses and cc_addresses are arrays of addresses (name is null when absent):
Each item in attachments:
[{"attachment_id": "att_01", "filename": "order.pdf", "content_type": "application/pdf", "content_id": null, "size_bytes": 182344, "files_key": "inbound/<email_id>/att_01", "sha256": "<hex>"}]content_id is set for inline images referenced from the HTML body.
Read emails with SQL
Section titled “Read emails with SQL”-- Latest 20 emailsSELECT email_id, received_at, from_address, subjectFROM keelson_inbound_emailsORDER BY received_at DESCLIMIT 20;
-- Attachments of one emailSELECT json_extract(a.value, '$.filename') AS filename, json_extract(a.value, '$.files_key') AS files_keyFROM keelson_inbound_emails e, json_each(e.attachments) aWHERE e.email_id = ?;Read attachments
Section titled “Read attachments”Attachments are saved in Files under the key inbound/<email_id>/<attachment_id>, which is the files_key in attachments. Read them with the Files SDK.
import jsonfrom keelson import files
(attachments,) = conn.execute( "SELECT attachments FROM keelson_inbound_emails WHERE email_id = ?", (email_id,)).fetchone()for att in json.loads(attachments): data = files.read(att["files_key"]) # bytes | Noneimport * as files from "@keelsonhq/files";
const { rows } = await db.execute({ sql: "SELECT attachments FROM keelson_inbound_emails WHERE email_id = ?", args: [emailId],});for (const att of JSON.parse(rows[0].attachments)) { const data = await files.read(att.files_key); // Uint8Array | null}An attachment can be larger than the Files write limit (10 MiB). Reading it works. Attachments count toward the workspace storage; the rows do not.
Rules for the table
Section titled “Rules for the table”- Keelson creates
keelson_inbound_emailson the deploy that turns receiving on. Do not create it yourself in migrations or app code. If a table with the same name already exists and Keelson did not create it, the deploy stops. - The table is yours: the app can read, update, and delete rows. Keep your own state, such as “handled” or “assignee,” in a separate table keyed by
email_id. - Keelson only inserts rows. It never rewrites existing rows or recreates the table.
- Do not remove the
email_idprimary key, drop columns, or add triggers to the table, and do not save your own files under the Files keyinbound/. New emails then cannot be saved and are held (Emails that could not be saved). - Restoring the database from a backup also restores this table to that point.
Process each email (on_receive)
Section titled “Process each email (on_receive)”When on_receive names a task, Keelson starts that task after each email is saved. The task’s payload is the email ID only:
{"attempt_no":1,"payload":{"email_id":"…"},"schema_version":1,"task_id":"…","task_name":"process-mail"}The task reads the row from keelson_inbound_emails by email_id. Other than how it is started, it is an ordinary background task (Background tasks).
The task is started at least once. The same email_id can be passed more than once, and failed attempts are retried. Record which emails you have handled, and write the record and the result in one transaction:
import jsonimport osimport sys
import libsql
email_id = json.loads(sys.stdin.readline())["payload"]["email_id"]
conn = libsql.connect( database=os.environ.get("KEELSON_DB_URL", "file:local.db"), auth_token=os.environ.get("KEELSON_DB_AUTH_TOKEN", ""),)conn.execute( "CREATE TABLE IF NOT EXISTS handled_emails (email_id TEXT PRIMARY KEY, handled_at TEXT NOT NULL)")
cur = conn.execute( "INSERT INTO handled_emails (email_id, handled_at) VALUES (?, datetime('now')) " "ON CONFLICT (email_id) DO NOTHING", (email_id,),)if cur.rowcount == 0: sys.exit(0) # already handled
(subject,) = conn.execute( "SELECT subject FROM keelson_inbound_emails WHERE email_id = ?", (email_id,),).fetchone()conn.execute("INSERT INTO orders (email_id, title) VALUES (?, ?)", (email_id, subject))conn.commit() # the handled record and the order are saved togetherIf the task fails before the commit, nothing is saved and the retry handles the email from the start. For external APIs, pass email_id as the idempotency key.
- From the console you can start
on_receiveagain for a saved email. Each start counts the same as an automatic one. - Tasks started by
on_receivedo not count toward Background Jobs runs. Their run time counts toward the monthly Background Jobs runtime. If the runtime allowance is used up, the task is not started; the console shows this, and it is not retried automatically. - Heavy processing can use up the runtime allowance and stop scheduled jobs too. Check the monthly usage in the console.
The sender is not authenticated
Section titled “The sender is not authenticated”from_address comes from the X-Original-From or From header. Anyone can write any address there. Do not use from_address to identify a user or to decide what an email is allowed to do, for example “emails from the CEO approve a payment.” Use it for display and for sorting mail only.
auth_spf, auth_dkim, and auth_dmarc record checks made by the receiving server. Forwarding often breaks them, so they do not prove who sent the email either.
Forward mail to the app
Section titled “Forward mail to the app”Keelson does not receive mail for your own domain. Keep receiving mail where you receive it now and forward it to the app’s address.
- In Gmail, open Settings → See all settings → Forwarding and POP/IMAP and add the app’s receiving address as a forwarding address.
- Gmail sends a confirmation email from
[email protected]to that address. Open Recent emails on the app’s Email settings page in the console and read the confirmation code. This works even when the app is not running. - Enter the code in Gmail and turn on forwarding. To forward only some emails, create a Gmail filter that forwards matching messages.
The confirmation email is also stored in the table like any other email. If you use on_receive, the task is started for it too.
Google Workspace (groups)
Section titled “Google Workspace (groups)”Add the app’s receiving address as a member of the Google Group that receives the mail (for example, support@). The group then delivers a copy of each email to the app.
- If the address cannot be added, your Google Workspace administrator needs to allow groups to have members outside the organization.
- When the original sender’s domain has a strict DMARC policy, Google Groups rewrites
Fromto the group’s address and keeps the original sender inX-Original-From.from_addressusesX-Original-From, so it holds the original sender;header_fromholds the rewrittenFrom.to_addressesholds the group address.
Microsoft 365
Section titled “Microsoft 365”Microsoft 365 blocks automatic forwarding to external addresses by default (the outbound spam filter policy). An administrator must allow it before forwarding works. Otherwise forwarded mail bounces with 550 5.7.520 Access denied, Your organization does not allow external forwarding.
- In the Microsoft Defender portal, open the outbound anti-spam policy and set automatic forwarding to on. Prefer a custom policy that applies only to the mailbox that forwards.
- In that mailbox, set up forwarding to the app’s address (Outlook forwarding settings or an inbox rule), or use a mail flow rule.
Keelson has not tested forwarding from Microsoft 365. These steps are based on Microsoft’s documentation.
Limits
Section titled “Limits”| Item | Limit |
|---|---|
| Size of one email | 25 MiB, including attachments. Larger emails are refused |
| Body | text_body and html_body are each cut at 1 MiB (body_truncated = 1) |
| Attachments | No limit per attachment within the 25 MiB |
| Emails received per month | Per workspace, all apps combined. See below |
Monthly receiving limit (per workspace, calendar month in UTC):
| Starter | Plus | Team | Business | |
|---|---|---|---|---|
| Emails received per month | 500 | 3,000 | 10,000 | 30,000 |
An email is counted once, after duplicates are removed. Workspace Owners and Admins are notified at 80%, 100%, and 150%.
- Above 100%: emails are still received and stored, but
on_receiveis not started for them. The console marks those emails. After upgrading your plan, you can starton_receivefor them from the console. - At 150%: the workspace’s addresses refuse mail, and senders get a bounce. Receiving resumes at 00:00 UTC on the 1st of the next month or after a plan change. A few emails that arrive while this takes effect may still be stored.
Emails that could not be saved
Section titled “Emails that could not be saved”If an email cannot be written to the table, for example because the table was dropped, its primary key was removed, a trigger rejected the row, or a different file already exists under inbound/, Keelson holds the email. The console’s email history shows the reason and how to fix it. After fixing the table, retry the email from the console.
- Held emails are kept for 7 days, up to 1,000 per app. While the app is at 1,000 held emails, new mail to its address is refused.
- After 7 days a held email is recorded as expired, and the email and its attachments are deleted.
The email history in the console also shows emails waiting to be saved, emails that could not be read as email at all, and emails discarded because receiving was turned off.
spam_score is a simple score. Keelson does not quarantine or delete mail based on it, and every email is stored. Forwarded mail often fails SPF, so ordinary mail can get a score too. Decide in your app what to do with high scores.
If mail does not arrive
Section titled “If mail does not arrive”- Check the console. Open the email history on the app’s Email settings page. If the email is there, Keelson received it; check its state (held, and why).
- Check the address. Mail to a rotated address, or to an app with receiving turned off, is refused.
- Check the limits. At 150% of the monthly limit, or with 1,000 held emails, new mail is refused. Emails over 25 MiB are refused.
- Check the forwarding. Gmail forwarding starts only after you enter the confirmation code. Microsoft 365 blocks external forwarding unless an administrator allows it.
- Authentication checks before Keelson. Mail that fails both SPF and DKIM, or that fails the sender domain’s DMARC policy, is rejected by the receiving service before it reaches Keelson. Such mail leaves no record in Keelson, and the sender usually gets a bounce. Forwarding that changes the message can cause this. Forwarding through a Google Group delivers mail from such senders.
Test locally
Section titled “Test locally”keelson dev email stores a saved email file (.eml) in your local database and Files, the same way Keelson stores received mail.
export KEELSON_DB_URL=file:local.dbkeelson dev email ./samples/order.emlkeelson dev email <file.eml> [--to <address>]- The message is parsed with the same mapping as on Keelson and inserted as one row into
keelson_inbound_emailsin the database atKEELSON_DB_URL. If the table does not exist, it is created (locally only). - Attachments are written under
KEELSON_FILES_DIR(default./.keelson/files) atinbound/<email_id>/<attachment_id>, where the Files SDK reads them in local development. KEELSON_DB_URLmust point to your machine: afile:URL, or anhttp://,libsql://, orws://URL whose host is127.0.0.1orlocalhost. Otherwise, or if it is unset, the command exits with code 2 without writing anything.--tosets the receiving address (envelope_to). The default is[email protected].- The output shows the
email_id, the subject, the number of attachments, and where they were written. email_idis derived from the message contents and--to. Running the same file again printsAlready storedand exits with code 0.- A file that cannot be parsed as an email exits with code 1.
on_receiveis not started. Ifkeelson.yamldeclares it, the command prints how to run the task:
keelson dev task run process-mail --payload '{"email_id":"…"}'To get an .eml file, download the original message from your mail client (in Gmail, Show original → Download original).