diff --git a/src/docs/src/Email.md b/src/docs/src/Email.md
index 8514de2ed..3e07bfcc4 100644
--- a/src/docs/src/Email.md
+++ b/src/docs/src/Email.md
@@ -1,18 +1,75 @@
---
title: Email
-description: Send transactional email from your app — receipts, alerts, confirmations — through a worker, from a Puter-controlled address.
+description: Read the user's Puter mailbox and send transactional email from your app with Puter.js.
platforms: [websites, apps, nodejs, workers]
---
+The Email API lets your app read the user's Puter mailbox and send transactional email.
+
+Every Puter account has an email address, `{username}@puter.email`. Mail sent to it is stored in the user's own cloud drive, so your app can list the inbox and read messages, attachments included, once the user grants access to their mailbox. Your app can also send transactional email — a signup confirmation, a receipt, an alert — from a Puter-controlled address, with no mail server, sending domain, or DKIM to set up. A recipient with a Puter account can be reached at `{username}@puter.email`: that copy is filed straight into their Puter mailbox rather than relayed, on any plan, as long as they have set their mailbox up.
+
+With the [User-Pays Model](/user-pays-model/), the account making the call covers its own usage: mail lives in the user's storage, and sends land on the caller's allowance rather than yours.
+
Transactional email is available to accounts on a paid plan for now. A call from a free account fails with 402subscription_required.
-The Puter.js Email API lets your app send **transactional email**: mail your app sends to a person because of something they did — a signup confirmation, a receipt, a password reset, an alert. It is not a mailing-list tool: a message reaches at most ten recipients, and every recipient can opt out of your app's mail with one click.
+## Features
-Mail goes out from a Puter-controlled address labelled with your app's title, for example `"My App" `, so you never set up a mail server, a sending domain, or DKIM. Replies go to your account email unless you say otherwise. A recipient with a Puter account can be reached at `@puter.email`: that copy is filed straight into their Puter mailbox rather than relayed, on any plan, as long as they have set their mailbox up.
+
+
List Messages
+
Read a Message
+
Send Transactional Email
+
-## How sending works
+
-Every send is authorized by a **worker**. Either the worker sends directly, or your app sends with the user's session and passes the worker's token as `emailAccessToken`. In both cases the account that makes the call is the one billed and rate-limited — the [User-Pays Model](/user-pays-model/) — so your users' sends land on their own allowances rather than yours.
+#### List the ten newest messages in the inbox
+
+```html
+
+
+
+
+
+
+```
+
+
+
+#### Send a transactional email from a worker
```js
// In a worker: the worker authorizes the send, the calling user pays for it.
@@ -27,14 +84,10 @@ router.post('/notify', async ({ request, user }) => {
});
```
+
+
## Functions
+- **[`puter.email.list()`](/Email/list/)** - List the messages in the user's mailbox, newest first
+- **[`puter.email.get()`](/Email/get/)** - Fetch and parse one message, attachments included
- **[`puter.email.sendTransactional()`](/Email/sendTransactional/)** - Send a transactional email from your app
-
-## Recipient protections
-
-Every message carries an unsubscribe link and a report-abuse link. Opting out is per app: a recipient who unsubscribes from your app stops getting your app's mail and still hears from other apps. Opted-out recipients are dropped from your later sends and come back in the result's `suppressed` array, so your app can stop asking. A send whose every `to` recipient has opted out is rejected.
-
-## Mail the user sends as themselves
-
-`puter.email.send()` is a separate surface: it sends a message from the user's own `@puter.email` address, composed in the client and filed in the user's `~/.mail` folder. Use `sendTransactional()` when your app is the sender; use `send()` when the user is.
diff --git a/src/docs/src/Email/get.md b/src/docs/src/Email/get.md
new file mode 100644
index 000000000..44e67907c
--- /dev/null
+++ b/src/docs/src/Email/get.md
@@ -0,0 +1,110 @@
+---
+title: puter.email.get()
+description: Fetch and parse one message from the user's mailbox by its id.
+platforms: [websites, apps, nodejs, workers]
+---
+
+Reads one message by the `id` a listing returned. The whole raw message is downloaded and parsed, so the result carries the full subject, sender and recipients, the text and HTML bodies, every header, and each attachment with its bytes. Pass `raw: true` to get the unparsed `message/rfc822` bytes as a `Blob` instead.
+
+Reading the mailbox requires the `fs:/{username}/.mail:read` permission, the same grant [`puter.email.list()`](/Email/list/) needs.
+
+## Syntax
+
+```js
+puter.email.get(id)
+puter.email.get(options)
+```
+
+## Parameters
+
+#### `id` (String) (required)
+
+The message id from `puter.email.list()`. Looks up the inbox.
+
+#### `options` (Object)
+
+- `id` (String) (required): The message id.
+- `folder` (String): `'inbox'` or `'sent'`. Default `'inbox'`. A message id is unique within a folder, so a sent message must be read with `folder: 'sent'`.
+- `raw` (Boolean): When `true`, resolve with the raw message `Blob` and skip parsing. Default `false`.
+
+## Return value
+
+A `Promise` that resolves to an [`EmailMessage`](/Objects/emailmessage/) object: the full subject, sender and recipients, the text and HTML bodies, every header, and the attachments as [`EmailMessageAttachment`](/Objects/emailmessageattachment/) objects with their decoded bytes.
+
+With `raw: true`, the `Promise` resolves to a `Blob` of the `message/rfc822` bytes exactly as they were received.
+
+Messages can be up to 25 MiB, and `get()` holds the whole message in memory while parsing. For a message list, use `puter.email.list()` and read messages one at a time as the user opens them.
+
+## Errors
+
+Rejects with an object carrying a `code`:
+
+- `invalid_request`: `id` is missing or not a message id.
+- `not_found`: no message with that id in the folder.
+- Filesystem errors pass through unchanged, including the permission error an app gets without `fs:/{username}/.mail:read`.
+
+## Examples
+
+Read the newest message in the inbox
+
+```html
+
+
+
+
+
+
+```
+
+Save every attachment of a message to the user's Documents
+
+```html
+
+
+
+
+
+
+```
+
+Download the raw message
+
+```html
+
+
+
+
+
+
+```
diff --git a/src/docs/src/Email/list.md b/src/docs/src/Email/list.md
new file mode 100644
index 000000000..02e330ddb
--- /dev/null
+++ b/src/docs/src/Email/list.md
@@ -0,0 +1,102 @@
+---
+title: puter.email.list()
+description: List the messages in the user's mailbox, newest first, one page at a time.
+platforms: [websites, apps, nodejs, workers]
+---
+
+Lists the messages in one folder of the user's mailbox, newest first. Every call returns one page of `items` plus, while more pages exist, a `cursor` to pass back for the next one. Listing reads the mailbox's folder structure only, so it never downloads a message: each item carries the subject, date, and size, and `puter.email.get()` fetches the rest.
+
+The mailbox belongs to the user, so it is protected like the rest of their files. An app reads it only after the user grants `fs:/{username}/.mail:read`, for example with `puter.perms.request('permission', { permission: `fs:/${username}/.mail:read` })`.
+
+## Syntax
+
+```js
+puter.email.list()
+puter.email.list(options)
+```
+
+## Parameters
+
+#### `options` (Object) (optional)
+
+- `folder` (String): `'inbox'` for mail received at the user's address, `'sent'` for their outgoing copies. Default `'inbox'`.
+- `limit` (Number): Maximum messages per page. Default `50`, capped at `1000`.
+- `cursor` (String | null): The `cursor` from the previous page. `null` or absent fetches the first page.
+- `stream` (Boolean): When `true`, returns an async iterator of pages for `for await ... of` instead of a single page.
+
+`offset` and `includeTotal` are not supported for mailboxes; passing either rejects with `invalid_request`.
+
+## Return value
+
+A `Promise` that resolves to an [`EmailListPage`](/Objects/emaillistpage/) object: its `items` are [`EmailSummary`](/Objects/emailsummary/) objects, newest first, and its `cursor` is present only while more pages exist. A page may hold fewer than `limit` items while more pages exist, so iterate until `cursor` is absent rather than checking the page size. A mailbox that has never received mail lists as `{ items: [] }`.
+
+With `stream: true`, the method returns an `AsyncIterableIterator` of [`EmailListPage`](/Objects/emaillistpage/) objects instead.
+
+## Errors
+
+Rejects with an object carrying a `code`:
+
+- `invalid_request`: an unknown `folder`, a non-positive `limit`, a malformed cursor or one from a different folder, or `offset`/`includeTotal`.
+- Filesystem errors pass through unchanged. In particular, an app that has not been granted `fs:/{username}/.mail:read` gets the permission error the filesystem returns.
+
+## Examples
+
+List the ten newest messages
+
+```html
+
+
+
+
+
+
+```
+
+Walk the whole sent folder with a cursor
+
+```html
+
+
+
+
+
+
+```
+
+Stream pages
+
+```html
+
+
+
+
+
+
+```
diff --git a/src/docs/src/Objects.md b/src/docs/src/Objects.md
index 227068f95..2c4e30cd0 100644
--- a/src/docs/src/Objects.md
+++ b/src/docs/src/Objects.md
@@ -12,6 +12,10 @@ Various object types and classes that represent different entities in the Puter
- **[ChatResponse](/Objects/chatresponse/)** - Represents an AI chat response
- **[ChatResponseChunk](/Objects/chatresponsechunk/)** - Represents a chunk of streaming chat response data
- **[DetailedAppUsage](/Objects/detailedappusage/)** - Represents detailed resource usage statistics for a specific application
+- **[EmailListPage](/Objects/emaillistpage/)** - Represents one page of mailbox listing results
+- **[EmailMessage](/Objects/emailmessage/)** - Represents a fully parsed message from the user's mailbox
+- **[EmailMessageAttachment](/Objects/emailmessageattachment/)** - Represents one attachment of a parsed message, bytes included
+- **[EmailSummary](/Objects/emailsummary/)** - Represents one message in a mailbox listing
- **[FSItem](/Objects/fsitem/)** - Represents a file or directory
- **[KVPair](/Objects/kvpair/)** - Represents a key-value pair
- **[MonthlyUsage](/Objects/monthlyusage/)** - Represents user's monthly resource usage information
diff --git a/src/docs/src/Objects/emaillistpage.md b/src/docs/src/Objects/emaillistpage.md
new file mode 100644
index 000000000..e216ccdb4
--- /dev/null
+++ b/src/docs/src/Objects/emaillistpage.md
@@ -0,0 +1,18 @@
+---
+title: EmailListPage
+description: One page of mailbox listing results from puter.email.list().
+---
+
+An `EmailListPage` object is one page of results from [`puter.email.list()`](/Email/list/).
+
+## Attributes
+
+#### `items` (Array)
+
+An array of [`EmailSummary`](/Objects/emailsummary/) objects, newest first.
+
+#### `cursor` (String) (optional)
+
+A pagination cursor for the next page. Present only while more pages exist. Pass it as `cursor` to the next `puter.email.list()` call to continue.
+
+A page may hold fewer than `limit` items while `cursor` is still present — always iterate until `cursor` is absent rather than checking the page size.
diff --git a/src/docs/src/Objects/emailmessage.md b/src/docs/src/Objects/emailmessage.md
new file mode 100644
index 000000000..5735fe486
--- /dev/null
+++ b/src/docs/src/Objects/emailmessage.md
@@ -0,0 +1,84 @@
+---
+title: EmailMessage
+description: A fully parsed message from the user's mailbox, returned by puter.email.get().
+---
+
+An `EmailMessage` object is a message from the user's mailbox, downloaded and parsed by [`puter.email.get()`](/Email/get/). It has every attribute of an [`EmailSummary`](/Objects/emailsummary/) plus the parsed contents below.
+
+## Attributes
+
+#### `id` (String)
+
+The message id.
+
+#### `folder` (String)
+
+The folder the message was read from: `'inbox'` or `'sent'`.
+
+#### `path` (String)
+
+The path of the raw `message/rfc822` object in the user's cloud drive.
+
+#### `uid` (String)
+
+The file system uid of the raw object.
+
+#### `size` (Number)
+
+The size of the raw message in bytes.
+
+#### `date` (String)
+
+An ISO 8601 timestamp taken from the message's `Date` header, or the filing time when the header is missing or unparseable.
+
+#### `subject` (String)
+
+The full subject line.
+
+#### `messageId` (String | null)
+
+The `Message-ID` header, angle brackets included, or `null`.
+
+#### `inReplyTo` (String | null)
+
+The `In-Reply-To` header, or `null`.
+
+#### `references` (String | null)
+
+The `References` header, or `null`.
+
+#### `from` (Object | null)
+
+The sender, as `{ name, address }`, or `null` when the message has no `From` header.
+
+#### `to` (Array)
+
+The `To` recipients, each `{ name, address }`. An address group appears as `{ name, group: [...] }`.
+
+#### `cc` (Array)
+
+The `Cc` recipients, in the same shape as `to`.
+
+#### `bcc` (Array)
+
+The `Bcc` recipients, in the same shape as `to`. Only a message in the `sent` folder carries them.
+
+#### `replyTo` (Array)
+
+The `Reply-To` addresses, in the same shape as `to`.
+
+#### `headers` (Array)
+
+Every header of the message, each `{ key, originalKey, value }` where `key` is the lowercase name and `originalKey` preserves the original case.
+
+#### `text` (String | null)
+
+The plain-text body, or `null` when the message has none.
+
+#### `html` (String | null)
+
+The HTML body, or `null` when the message has none.
+
+#### `attachments` (Array)
+
+The message's attachments, each an [`EmailMessageAttachment`](/Objects/emailmessageattachment/) with its decoded bytes.
diff --git a/src/docs/src/Objects/emailmessageattachment.md b/src/docs/src/Objects/emailmessageattachment.md
new file mode 100644
index 000000000..179c92814
--- /dev/null
+++ b/src/docs/src/Objects/emailmessageattachment.md
@@ -0,0 +1,40 @@
+---
+title: EmailMessageAttachment
+description: One attachment of a message returned by puter.email.get(), bytes included.
+---
+
+An `EmailMessageAttachment` object is one attachment of an [`EmailMessage`](/Objects/emailmessage/). The whole message is downloaded when it is read, so every attachment comes with its decoded bytes.
+
+## Attributes
+
+#### `filename` (String | null)
+
+The attachment's file name, or `null` when the message did not give one.
+
+#### `mimeType` (String)
+
+The attachment's MIME type, for example `application/pdf`.
+
+#### `disposition` (String | null)
+
+`'attachment'`, `'inline'`, or `null` when the message did not say.
+
+#### `contentId` (String) (optional)
+
+The `Content-ID`, without angle brackets. Inline images are referenced from the HTML body as `cid:` URLs with this value.
+
+#### `related` (Boolean) (optional)
+
+`true` for parts referenced from the HTML body, such as inline images.
+
+#### `size` (Number)
+
+The byte length of `content`.
+
+#### `content` (ArrayBuffer)
+
+The decoded attachment bytes. Wrap them in a `Blob` to save or display them:
+
+```js
+new Blob([attachment.content], { type: attachment.mimeType })
+```
diff --git a/src/docs/src/Objects/emailsummary.md b/src/docs/src/Objects/emailsummary.md
new file mode 100644
index 000000000..8537a61a4
--- /dev/null
+++ b/src/docs/src/Objects/emailsummary.md
@@ -0,0 +1,36 @@
+---
+title: EmailSummary
+description: One message as it appears in a mailbox listing from puter.email.list().
+---
+
+An `EmailSummary` object represents one message in a page returned by [`puter.email.list()`](/Email/list/). It is built from the mailbox listing alone, without opening the message, so it carries what the listing knows: the id, a subject, the filing time, and the size. Pass its `id` to [`puter.email.get()`](/Email/get/) for the full [`EmailMessage`](/Objects/emailmessage/).
+
+## Attributes
+
+#### `id` (String)
+
+The message id. Unique within a folder; pass it to `puter.email.get()`.
+
+#### `subject` (String)
+
+The subject line, possibly truncated to 80 characters. The full subject is on the [`EmailMessage`](/Objects/emailmessage/).
+
+#### `date` (String)
+
+An ISO 8601 timestamp of when the message was filed in the mailbox.
+
+#### `size` (Number)
+
+The size of the raw message in bytes.
+
+#### `folder` (String)
+
+The folder the message was listed from: `'inbox'` or `'sent'`.
+
+#### `path` (String)
+
+The path of the raw `message/rfc822` object in the user's cloud drive. It works with the file system API directly, for example `puter.fs.read(path)` or `puter.fs.delete(path)`.
+
+#### `uid` (String)
+
+The file system uid of the raw object.
diff --git a/src/docs/src/assets/img/email.svg b/src/docs/src/assets/img/email.svg
new file mode 100644
index 000000000..15286b287
--- /dev/null
+++ b/src/docs/src/assets/img/email.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js
index f195a0b8f..d2708eb3b 100755
--- a/src/docs/src/sidebar.js
+++ b/src/docs/src/sidebar.js
@@ -594,6 +594,39 @@ let sidebar = [
},
],
},
+ {
+ title: 'Email',
+ title_tag: 'Email',
+ icon: '/assets/img/email.svg',
+ source: '/Email.md',
+ path: '/Email',
+ children: [
+ {
+ title: 'sendTransactional()',
+ page_title: 'puter.email.sendTransactional()',
+ title_tag: 'puter.email.sendTransactional()',
+ icon: '/assets/img/function.svg',
+ source: '/Email/sendTransactional.md',
+ path: '/Email/sendTransactional',
+ },
+ {
+ title: 'list()',
+ page_title: 'puter.email.list()',
+ title_tag: 'puter.email.list()',
+ icon: '/assets/img/function.svg',
+ source: '/Email/list.md',
+ path: '/Email/list',
+ },
+ {
+ title: 'get()',
+ page_title: 'puter.email.get()',
+ title_tag: 'puter.email.get()',
+ icon: '/assets/img/function.svg',
+ source: '/Email/get.md',
+ path: '/Email/get',
+ },
+ ],
+ },
{
title: 'Key-Value Store',
title_tag: 'Key-Value Store',
@@ -1232,6 +1265,34 @@ let sidebar = [
source: '/Objects/detailedappusage.md',
path: '/Objects/detailedappusage',
},
+ {
+ title: 'EmailListPage',
+ title_tag: 'EmailListPage',
+ icon: '/assets/img/object.svg',
+ source: '/Objects/emaillistpage.md',
+ path: '/Objects/emaillistpage',
+ },
+ {
+ title: 'EmailMessage',
+ title_tag: 'EmailMessage',
+ icon: '/assets/img/object.svg',
+ source: '/Objects/emailmessage.md',
+ path: '/Objects/emailmessage',
+ },
+ {
+ title: 'EmailMessageAttachment',
+ title_tag: 'EmailMessageAttachment',
+ icon: '/assets/img/object.svg',
+ source: '/Objects/emailmessageattachment.md',
+ path: '/Objects/emailmessageattachment',
+ },
+ {
+ title: 'EmailSummary',
+ title_tag: 'EmailSummary',
+ icon: '/assets/img/object.svg',
+ source: '/Objects/emailsummary.md',
+ path: '/Objects/emailsummary',
+ },
{
title: 'FSItem',
title_tag: 'FSItem',
diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts
index 5cfa7ade6..60c7665da 100644
--- a/src/puter-js/index.d.ts
+++ b/src/puter-js/index.d.ts
@@ -93,6 +93,17 @@ export type {
EmailSendOptions,
EmailSendResult,
} from './types/modules/Email/Email.js';
+export type {
+ EmailAddress,
+ EmailFolder,
+ EmailGetOptions,
+ EmailHeader,
+ EmailListOptions,
+ EmailListStreamOptions,
+ EmailMessage,
+ EmailMessageAttachment,
+ EmailSummary,
+} from './types/modules/Email/types.js';
// -- puter.events --
export type {
diff --git a/src/puter-js/src/modules/Email/Email.js b/src/puter-js/src/modules/Email/Email.js
index bb83a3a28..b22f31611 100644
--- a/src/puter-js/src/modules/Email/Email.js
+++ b/src/puter-js/src/modules/Email/Email.js
@@ -3,6 +3,10 @@ import { PuterModule } from '../../lib/PuterModule.js';
import { promptIfUpgradeRequired } from '../../lib/upgradePrompt.js';
import * as utils from '../../lib/utils.js';
import { compose } from './ComposerLib.js';
+import { get } from './get.js';
+import { list } from './list.js';
+
+/** @typedef {import('../../index.js').Puter} Puter */
/** @typedef {import('./types.js').EmailAttachment} EmailAttachment */
@@ -100,8 +104,32 @@ const preprocessSendArgs = (args) => {
*
* `send()` is the previous name of this method and still works; it will be
* removed once existing apps have moved to `sendTransactional()`.
+ *
+ * Reading is the other half: every account has a mailbox at
+ * `{username}@puter.email`, stored as `message/rfc822` objects under the
+ * user's `~/.mail`. `list()` pages through a folder newest first without
+ * downloading anything, and `get()` fetches and parses one message. An app
+ * needs the `fs:/{username}/.mail:read` permission to read its user's mail.
*/
export class EmailModule extends PuterModule {
+ // The fields hold the unbound functions so they keep the full overloaded
+ // types (`bind` erases overloads); the constructor rebinds them at runtime
+ // so destructured calls (`const { list } = puter.email`) keep working.
+ list = list;
+ get = get;
+
+ /** @param {Puter} puter */
+ constructor (puter) {
+ super(puter);
+
+ const methods = /** @type {Record unknown>} */ (
+ /** @type {unknown} */ (this)
+ );
+ for ( const name of ['list', 'get'] ) {
+ methods[name] = methods[name].bind(this);
+ }
+ }
+
/**
* Sends one transactional email. The positional form is shorthand for a
* plain-text body; everything else (html, cc/bcc, attachments,
@@ -148,7 +176,7 @@ export class EmailModule extends PuterModule {
* The public face of the module: derived from the class, with the internal
* `puter` handle and the legacy `authToken` accessor omitted.
*
- * @typedef {import('../lib/types.js').OmitMembers<
+ * @typedef {import('../../lib/types.js').OmitMembers<
* typeof EmailModule,
* 'puter' | 'authToken'
* >} EmailConstructor
diff --git a/src/puter-js/src/modules/Email/get.js b/src/puter-js/src/modules/Email/get.js
new file mode 100644
index 000000000..aa5008724
--- /dev/null
+++ b/src/puter-js/src/modules/Email/get.js
@@ -0,0 +1,141 @@
+import PostalMime from 'postal-mime';
+import { iteratePages } from '../../lib/pagination.js';
+import { PuterJSError } from '../../lib/PuterJSError.js';
+import {
+ candidateDays, dayPath, isMissingDirectory, isUuidV7, normalizeFolder, toSummary,
+} from './lib/mailbox.js';
+
+/** @typedef {import('./types.js').EmailFolder} EmailFolder */
+/** @typedef {import('./types.js').EmailGetOptions} EmailGetOptions */
+/** @typedef {import('./types.js').EmailMessage} EmailMessage */
+/** @typedef {import('./types.js').EmailMessageAttachment} EmailMessageAttachment */
+/** @typedef {import('./types.js').EmailSummary} EmailSummary */
+
+const DIRECTORY_PAGE_LIMIT = 1000;
+
+/**
+ * The listing entry for `id`, or null. The id names the day it was filed
+ * under, so this is one listing of one day folder in the common case.
+ *
+ * @this {import('./Email.js').EmailModule}
+ * @param {EmailFolder} folder
+ * @param {string} id
+ * @returns {Promise}
+ */
+async function locate (folder, id) {
+ const prefix = `${ id }--`;
+ for ( const day of candidateDays(id) ) {
+ const fetchPage = pageParams => this.puter.fs.readdir({
+ path: dayPath(folder, day),
+ limit: DIRECTORY_PAGE_LIMIT,
+ ...pageParams,
+ });
+ try {
+ for await ( const page of iteratePages(fetchPage) ) {
+ const entry = page.items.find(item => item.name.toLowerCase().startsWith(prefix));
+ if ( entry ) return toSummary(entry, folder);
+ }
+ } catch (e) {
+ if ( ! isMissingDirectory(e) ) throw e;
+ }
+ }
+ return null;
+}
+
+/** @param {import('postal-mime').Attachment} part @returns {EmailMessageAttachment} */
+const toAttachment = (part) => {
+ let content;
+ if ( part.content instanceof ArrayBuffer ) {
+ content = part.content;
+ } else if ( typeof part.content === 'string' ) {
+ content = new TextEncoder().encode(part.content).buffer;
+ } else {
+ content = part.content.buffer.slice(part.content.byteOffset, part.content.byteOffset + part.content.byteLength);
+ }
+ return {
+ filename: part.filename,
+ mimeType: part.mimeType,
+ disposition: part.disposition,
+ ...(part.contentId !== undefined ? { contentId: part.contentId } : {}),
+ ...(part.related !== undefined ? { related: part.related } : {}),
+ size: content.byteLength,
+ content: /** @type {ArrayBuffer} */ (content),
+ };
+};
+
+/**
+ * @param {EmailSummary} summary
+ * @param {import('postal-mime').Email} parsed
+ * @returns {EmailMessage}
+ */
+const toMessage = (summary, parsed) => {
+ const headerDate = parsed.date ? new Date(parsed.date) : null;
+ return {
+ ...summary,
+ subject: parsed.subject ?? summary.subject,
+ date: headerDate && ! Number.isNaN(headerDate.getTime()) ? headerDate.toISOString() : summary.date,
+ messageId: parsed.messageId ?? null,
+ inReplyTo: parsed.inReplyTo ?? null,
+ references: parsed.references ?? null,
+ from: parsed.from ?? null,
+ to: parsed.to ?? [],
+ cc: parsed.cc ?? [],
+ bcc: parsed.bcc ?? [],
+ replyTo: parsed.replyTo ?? [],
+ headers: parsed.headers,
+ text: parsed.text ?? null,
+ html: parsed.html ?? null,
+ attachments: parsed.attachments.map(toAttachment),
+ };
+};
+
+/**
+ * @overload
+ * @param {string} id
+ * @returns {Promise}
+ */
+/**
+ * @overload
+ * @param {EmailGetOptions & { raw: true }} options
+ * @returns {Promise}
+ */
+/**
+ * @overload
+ * @param {EmailGetOptions & { raw?: false }} options
+ * @returns {Promise}
+ */
+/**
+ * Reads one message by id. Downloads the whole raw message (up to 25 MiB)
+ * and parses it, resolving with an {@link EmailMessage} whose attachments
+ * carry their bytes. With `raw: true` it resolves with the unparsed
+ * `message/rfc822` `Blob` instead.
+ *
+ * Rejects with `not_found` when no message with that id is in the folder
+ * (`inbox` unless `folder` says otherwise).
+ *
+ * @this {import('./Email.js').EmailModule}
+ * @param {string | EmailGetOptions} idOrOptions
+ * @returns {Promise}
+ */
+export async function get (idOrOptions) {
+ const options = typeof idOrOptions === 'string' ? { id: idOrOptions } : idOrOptions;
+ if ( options === null || typeof options !== 'object' ) {
+ throw new PuterJSError('`id` is required', 'invalid_request');
+ }
+ if ( ! isUuidV7(options.id) ) {
+ throw new PuterJSError('`id` must be a message id from a listing', 'invalid_request');
+ }
+ const id = options.id.toLowerCase();
+ const folder = normalizeFolder(options.folder);
+
+ const summary = await locate.call(this, folder, id);
+ if ( ! summary ) {
+ throw new PuterJSError('No such message', 'not_found');
+ }
+
+ const blob = await this.puter.fs.read(summary.path);
+ if ( options.raw === true ) return blob;
+
+ const parsed = await PostalMime.parse(blob);
+ return toMessage(summary, parsed);
+}
diff --git a/src/puter-js/src/modules/Email/lib/mailbox.js b/src/puter-js/src/modules/Email/lib/mailbox.js
new file mode 100644
index 000000000..3f3763b1f
--- /dev/null
+++ b/src/puter-js/src/modules/Email/lib/mailbox.js
@@ -0,0 +1,163 @@
+import { PuterJSError } from '../../../lib/PuterJSError.js';
+
+/** @typedef {import('../types.js').EmailFolder} EmailFolder */
+/** @typedef {import('../types.js').EmailSummary} EmailSummary */
+
+// A mailbox is a folder of `message/rfc822` objects under `~/.mail`, one
+// subfolder per UTC day, each object named `{uuidv7}--{base64url(subject)}`.
+// Everything here derives from that layout and touches no network.
+
+/** @type {Record} */
+export const FOLDER_DIRS = { inbox: 'objects', sent: 'sent' };
+
+export const DEFAULT_FOLDER = /** @type {EmailFolder} */ ('inbox');
+export const DEFAULT_LIMIT = 50;
+export const MAX_LIMIT = 1000;
+
+const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const ENTRY_NAME = /^([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12})--(.*)$/i;
+const DAY_NAME = /^\d{4}-\d{2}-\d{2}$/;
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/** @param {unknown} folder @returns {EmailFolder} */
+export const normalizeFolder = (folder) => {
+ if ( folder === undefined ) return DEFAULT_FOLDER;
+ if ( typeof folder === 'string' && Object.prototype.hasOwnProperty.call(FOLDER_DIRS, folder) ) {
+ return /** @type {EmailFolder} */ (folder);
+ }
+ throw new PuterJSError(`Unknown folder: ${ String(folder) }`, 'invalid_request');
+};
+
+/** @param {unknown} limit @returns {number} */
+export const normalizeLimit = (limit) => {
+ if ( limit === undefined ) return DEFAULT_LIMIT;
+ if ( typeof limit !== 'number' || ! Number.isFinite(limit) || limit < 1 ) {
+ throw new PuterJSError('`limit` must be a positive number', 'invalid_request');
+ }
+ return Math.min(Math.floor(limit), MAX_LIMIT);
+};
+
+/** @param {EmailFolder} folder */
+export const folderRoot = (folder) => `~/.mail/${ FOLDER_DIRS[folder] }`;
+
+/** @param {EmailFolder} folder @param {string} day */
+export const dayPath = (folder, day) => `${ folderRoot(folder) }/${ day }`;
+
+/** @param {unknown} id @returns {id is string} */
+export const isUuidV7 = (id) => typeof id === 'string' && UUID_V7.test(id);
+
+/** @param {string} name */
+export const isDayName = (name) => DAY_NAME.test(name);
+
+/**
+ * The instant a v7 uuid was minted: its first 48 bits are Unix milliseconds.
+ *
+ * @param {string} id
+ * @returns {Date}
+ */
+export const uuidV7Date = (id) => new Date(parseInt(id.slice(0, 8) + id.slice(9, 13), 16));
+
+/** @param {Date} date @returns {string} `YYYY-MM-DD` in UTC. */
+export const dayOf = (date) => date.toISOString().slice(0, 10);
+
+/**
+ * The day folders a message with this id can live in, most likely first. The
+ * folder name is stamped a moment before the uuid, so a message can straddle
+ * midnight, and the uuid clock can run slightly ahead under load.
+ *
+ * @param {string} id
+ * @returns {string[]}
+ */
+export const candidateDays = (id) => {
+ const minted = uuidV7Date(id);
+ return [0, 1, -1].map(offset => dayOf(new Date(minted.getTime() + offset * DAY_MS)));
+};
+
+/**
+ * @param {string} encoded base64url, as the writers produce it
+ * @returns {string} Empty when the segment cannot be decoded.
+ */
+export const decodeSubject = (encoded) => {
+ if ( ! encoded ) return '';
+ try {
+ const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
+ const binary = atob(padded);
+ const bytes = new Uint8Array(binary.length);
+ for ( let i = 0; i < binary.length; i++ ) bytes[i] = binary.charCodeAt(i);
+ return new TextDecoder('utf-8').decode(bytes);
+ } catch (e) {
+ return '';
+ }
+};
+
+/**
+ * @param {string} name
+ * @returns {{ id: string, subject: string } | null} Null for anything that is not a mail object.
+ */
+export const parseEntryName = (name) => {
+ const match = ENTRY_NAME.exec(name);
+ if ( ! match ) return null;
+ return { id: match[1].toLowerCase(), subject: decodeSubject(match[2]) };
+};
+
+/**
+ * @param {{ name: string, path: string, uid: string, size?: number | null, is_dir?: boolean | number }} entry a readdir entry
+ * @param {EmailFolder} folder
+ * @returns {EmailSummary | null}
+ */
+export const toSummary = (entry, folder) => {
+ if ( entry.is_dir ) return null;
+ const parsed = parseEntryName(entry.name);
+ if ( ! parsed ) return null;
+ return {
+ id: parsed.id,
+ subject: parsed.subject,
+ date: uuidV7Date(parsed.id).toISOString(),
+ size: Number(entry.size ?? 0),
+ folder,
+ path: entry.path,
+ uid: entry.uid,
+ };
+};
+
+/**
+ * Where a listing resumes: the day folder being walked and, while it still has
+ * pages, the readdir cursor within it.
+ *
+ * @typedef {{ folder: EmailFolder, day: string, fsCursor?: string }} ListPosition
+ */
+
+/** @param {ListPosition} position @returns {string} */
+export const encodeCursor = ({ folder, day, fsCursor }) => {
+ const payload = { v: 1, f: folder, d: day, ...(fsCursor ? { c: fsCursor } : {}) };
+ return btoa(JSON.stringify(payload));
+};
+
+/**
+ * @param {unknown} cursor
+ * @param {EmailFolder} folder The folder being listed; a cursor from another folder is refused.
+ * @returns {ListPosition | undefined}
+ */
+export const decodeCursor = (cursor, folder) => {
+ if ( cursor === undefined || cursor === null ) return undefined;
+ let payload;
+ try {
+ payload = JSON.parse(atob(String(cursor)));
+ } catch (e) {
+ throw new PuterJSError('Invalid cursor', 'invalid_request');
+ }
+ if ( ! payload || payload.v !== 1 || typeof payload.d !== 'string' || ! isDayName(payload.d) ) {
+ throw new PuterJSError('Invalid cursor', 'invalid_request');
+ }
+ if ( payload.f !== folder ) {
+ throw new PuterJSError('Cursor does not match the requested folder', 'invalid_request');
+ }
+ return { folder, day: payload.d, ...(typeof payload.c === 'string' ? { fsCursor: payload.c } : {}) };
+};
+
+/** A readdir on a folder that was never created. @param {unknown} e */
+export const isMissingDirectory = (e) => {
+ const code = /** @type {{ code?: string } | null} */ (e)?.code;
+ return code === 'subject_does_not_exist' || code === 'not_found';
+};
diff --git a/src/puter-js/src/modules/Email/list.js b/src/puter-js/src/modules/Email/list.js
new file mode 100644
index 000000000..aa83c2c79
--- /dev/null
+++ b/src/puter-js/src/modules/Email/list.js
@@ -0,0 +1,154 @@
+import { iteratePages } from '../../lib/pagination.js';
+import { PuterJSError } from '../../lib/PuterJSError.js';
+import {
+ dayPath, decodeCursor, encodeCursor, folderRoot, isDayName, isMissingDirectory,
+ normalizeFolder, normalizeLimit, toSummary,
+} from './lib/mailbox.js';
+
+/** @typedef {import('./types.js').EmailFolder} EmailFolder */
+/** @typedef {import('./types.js').EmailListOptions} EmailListOptions */
+/** @typedef {import('./types.js').EmailListStreamOptions} EmailListStreamOptions */
+/** @typedef {import('./types.js').EmailSummary} EmailSummary */
+/** @typedef {import('../../lib/types.js').ListPage} EmailListPage */
+/** @typedef {import('./lib/mailbox.js').ListPosition} ListPosition */
+
+// Page size for the SDK's own walks over the mailbox's day folders.
+const DIRECTORY_PAGE_LIMIT = 1000;
+
+/**
+ * Every day folder in `folder`, newest first. A mailbox that was never
+ * written to has no folder at all, which lists as empty.
+ *
+ * @this {import('./Email.js').EmailModule}
+ * @param {EmailFolder} folder
+ * @returns {Promise}
+ */
+async function listDays (folder) {
+ const fetchPage = pageParams => this.puter.fs.readdir({
+ path: folderRoot(folder),
+ limit: DIRECTORY_PAGE_LIMIT,
+ sortBy: 'name',
+ sortOrder: 'desc',
+ ...pageParams,
+ });
+ const days = [];
+ try {
+ for await ( const page of iteratePages(fetchPage) ) {
+ for ( const entry of page.items ) {
+ if ( entry.is_dir && isDayName(entry.name) ) days.push(entry.name);
+ }
+ }
+ } catch (e) {
+ if ( ! isMissingDirectory(e) ) throw e;
+ }
+ return days.sort().reverse();
+}
+
+/**
+ * One page of the listing: walks day folders newest first, reading each
+ * newest-message-first, until `limit` messages are collected or the folder
+ * runs out.
+ *
+ * @this {import('./Email.js').EmailModule}
+ * @param {EmailFolder} folder
+ * @param {number} limit
+ * @param {ListPosition | undefined} position
+ * @returns {Promise}
+ */
+async function fetchPage (folder, limit, position) {
+ const days = await listDays.call(this, folder);
+ /** @type {EmailSummary[]} */
+ const items = [];
+
+ // Resume at the cursor's day; a day folder deleted since the cursor was
+ // issued means continuing from the next older one.
+ let dayIndex = position ? days.findIndex(day => day <= position.day) : 0;
+ if ( dayIndex === -1 ) return { items };
+ let fsCursor = position && days[dayIndex] === position.day ? position.fsCursor : undefined;
+
+ while ( dayIndex < days.length && items.length < limit ) {
+ const day = days[dayIndex];
+ // Keep reading this day until the page is full or the day is spent:
+ // entries that are not mail objects are skipped, and skipping must
+ // not shorten the page while the same day still has messages.
+ while ( items.length < limit ) {
+ /** @type {{ items: Array>, cursor?: string }} */
+ let page;
+ try {
+ page = await this.puter.fs.readdir({
+ path: dayPath(folder, day),
+ sortBy: 'name',
+ sortOrder: 'desc',
+ limit: limit - items.length,
+ cursor: fsCursor ?? null,
+ });
+ } catch (e) {
+ if ( ! isMissingDirectory(e) ) throw e;
+ page = { items: [] };
+ }
+ for ( const entry of page.items ) {
+ const summary = toSummary(entry, folder);
+ if ( summary ) items.push(summary);
+ }
+ fsCursor = page.cursor;
+ if ( ! fsCursor ) break;
+ }
+ if ( fsCursor ) {
+ return { items, cursor: encodeCursor({ folder, day, fsCursor }) };
+ }
+ dayIndex += 1;
+ }
+
+ if ( dayIndex < days.length ) {
+ return { items, cursor: encodeCursor({ folder, day: days[dayIndex] }) };
+ }
+ return { items };
+}
+
+/**
+ * @overload
+ * @param {EmailListStreamOptions} options
+ * @returns {AsyncIterableIterator}
+ */
+/**
+ * @overload
+ * @param {EmailListOptions} [options]
+ * @returns {Promise}
+ */
+/**
+ * Lists the messages in a mailbox folder, newest first, one page at a time.
+ * Resolves with `{ items, cursor? }`: `cursor` is present while more pages
+ * exist, and a page may hold fewer than `limit` items before the end. With
+ * `stream: true` it instead returns an async iterator of pages for
+ * `for await ... of`.
+ *
+ * Each item is an {@link EmailSummary} built from the mailbox listing alone,
+ * so listing never downloads a message. Reading the inbox of an app's user
+ * requires the `fs:/{username}/.mail:read` permission.
+ *
+ * @this {import('./Email.js').EmailModule}
+ * @param {EmailListOptions & { stream?: boolean, offset?: unknown, includeTotal?: unknown }} [options]
+ * @returns {Promise | AsyncIterableIterator}
+ */
+export function list (options = {}) {
+ if ( options === null || typeof options !== 'object' || Array.isArray(options) ) {
+ throw new PuterJSError('`options` must be an object', 'invalid_request');
+ }
+ if ( options.offset !== undefined ) {
+ throw new PuterJSError('`offset` is not supported; pass `cursor` to resume from a position.', 'invalid_request');
+ }
+ if ( options.includeTotal !== undefined ) {
+ throw new PuterJSError('`includeTotal` is not supported for mailboxes.', 'invalid_request');
+ }
+ const folder = normalizeFolder(options.folder);
+ const limit = normalizeLimit(options.limit);
+ const start = decodeCursor(options.cursor, folder);
+
+ if ( options.stream === true ) {
+ return iteratePages(
+ pageParams => fetchPage.call(this, folder, limit, decodeCursor(pageParams.cursor, folder)),
+ { cursor: options.cursor ?? null },
+ );
+ }
+ return fetchPage.call(this, folder, limit, start);
+}
diff --git a/src/puter-js/src/modules/Email/mailbox.test.js b/src/puter-js/src/modules/Email/mailbox.test.js
new file mode 100644
index 000000000..531a37015
--- /dev/null
+++ b/src/puter-js/src/modules/Email/mailbox.test.js
@@ -0,0 +1,283 @@
+import { describe, expect, it } from 'vitest';
+import {
+ candidateDays, decodeCursor, decodeSubject, encodeCursor, isUuidV7, normalizeFolder, normalizeLimit,
+ parseEntryName, toSummary, uuidV7Date,
+} from './lib/mailbox.js';
+import { get } from './get.js';
+import { list } from './list.js';
+
+// 2024-03-15T12:34:56.789Z, as the writers would have minted it.
+const MS = Date.UTC(2024, 2, 15, 12, 34, 56, 789);
+const uuidAt = (ms, tail = '000-000000000000') => {
+ const hex = ms.toString(16).padStart(12, '0');
+ return `${ hex.slice(0, 8) }-${ hex.slice(8, 12) }-7000-8${ tail }`;
+};
+const toB64Url = (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str)))
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+
+describe('uuidv7 helpers', () => {
+ it('recovers the mint time from the first 48 bits', () => {
+ expect(uuidV7Date(uuidAt(MS)).getTime()).toBe(MS);
+ });
+
+ it('accepts only version 7 uuids', () => {
+ expect(isUuidV7(uuidAt(MS))).toBe(true);
+ expect(isUuidV7(uuidAt(MS).toUpperCase())).toBe(true);
+ expect(isUuidV7('018e4b6c-1a2b-4000-8000-000000000000')).toBe(false);
+ expect(isUuidV7('not-a-uuid')).toBe(false);
+ expect(isUuidV7(42)).toBe(false);
+ });
+
+ it('lists the derived day first, then the next and previous days', () => {
+ expect(candidateDays(uuidAt(MS))).toEqual(['2024-03-15', '2024-03-16', '2024-03-14']);
+ });
+
+ it('crosses month and year boundaries when picking adjacent days', () => {
+ expect(candidateDays(uuidAt(Date.UTC(2023, 11, 31, 23, 59, 59, 999))))
+ .toEqual(['2023-12-31', '2024-01-01', '2023-12-30']);
+ expect(candidateDays(uuidAt(Date.UTC(2024, 2, 1, 0, 0, 0, 1))))
+ .toEqual(['2024-03-01', '2024-03-02', '2024-02-29']);
+ });
+});
+
+describe('entry names', () => {
+ it('decodes a base64url subject, unicode included', () => {
+ expect(decodeSubject(toB64Url('Réunion — 東京 🎉'))).toBe('Réunion — 東京 🎉');
+ });
+
+ it('decodes an empty or undecodable subject to the empty string', () => {
+ expect(decodeSubject('')).toBe('');
+ expect(decodeSubject('%%%not base64%%%')).toBe('');
+ });
+
+ it('parses a mail object name into id and subject', () => {
+ const id = uuidAt(MS);
+ expect(parseEntryName(`${ id.toUpperCase() }--${ toB64Url('Hello') }`)).toEqual({ id, subject: 'Hello' });
+ expect(parseEntryName(`${ id }--`)).toEqual({ id, subject: '' });
+ });
+
+ it('rejects names that are not mail objects', () => {
+ expect(parseEntryName('notes.txt')).toBeNull();
+ expect(parseEntryName('018e4b6c-1a2b-4000-8000-000000000000--abc')).toBeNull();
+ expect(parseEntryName(`${ uuidAt(MS) }-abc`)).toBeNull();
+ });
+
+ it('builds a summary from a listing entry and skips directories', () => {
+ const id = uuidAt(MS);
+ const entry = { name: `${ id }--${ toB64Url('Hi') }`, path: `/u/.mail/objects/2024-03-15/${ id }--SGk`, uid: 'uid-1', size: 123, is_dir: false };
+ expect(toSummary(entry, 'inbox')).toEqual({
+ id, subject: 'Hi', date: '2024-03-15T12:34:56.789Z', size: 123, folder: 'inbox', path: entry.path, uid: 'uid-1',
+ });
+ expect(toSummary({ ...entry, is_dir: true }, 'inbox')).toBeNull();
+ });
+});
+
+describe('cursors', () => {
+ it('round-trips a position', () => {
+ const position = { folder: 'inbox', day: '2024-03-15', fsCursor: 'abc' };
+ expect(decodeCursor(encodeCursor(position), 'inbox')).toEqual(position);
+ expect(decodeCursor(encodeCursor({ folder: 'sent', day: '2024-03-15' }), 'sent')).toEqual({ folder: 'sent', day: '2024-03-15' });
+ });
+
+ it('treats null and undefined as the first page', () => {
+ expect(decodeCursor(null, 'inbox')).toBeUndefined();
+ expect(decodeCursor(undefined, 'inbox')).toBeUndefined();
+ });
+
+ it('refuses garbage and cursors from another folder', () => {
+ expect(() => decodeCursor('not-a-cursor', 'inbox')).toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ expect(() => decodeCursor(encodeCursor({ folder: 'sent', day: '2024-03-15' }), 'inbox'))
+ .toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ });
+});
+
+describe('option validation', () => {
+ it('defaults and validates folder and limit', () => {
+ expect(normalizeFolder(undefined)).toBe('inbox');
+ expect(normalizeFolder('sent')).toBe('sent');
+ expect(() => normalizeFolder('outbox')).toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ expect(normalizeLimit(undefined)).toBe(50);
+ expect(normalizeLimit(7.9)).toBe(7);
+ expect(normalizeLimit(5000)).toBe(1000);
+ expect(() => normalizeLimit(0)).toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ expect(() => normalizeLimit('10')).toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ });
+});
+
+/**
+ * An in-memory `puter.fs` over a mailbox tree, honouring the readdir options
+ * the module relies on: name sort, `limit`, and cursor pages. Cursors are the
+ * index of the next entry, which is what the walk needs from them.
+ *
+ * @param {Record} tree dir path (`~/...`) -> child names; leaf files map to nothing
+ */
+const fakeFs = (tree, files = {}) => {
+ const calls = [];
+ const readdir = async (options) => {
+ calls.push(options);
+ const children = tree[options.path];
+ if ( ! children ) throw { code: 'subject_does_not_exist', message: 'missing' };
+ const sorted = [...children].sort();
+ if ( options.sortOrder === 'desc' ) sorted.reverse();
+ const start = options.cursor ? Number(options.cursor) : 0;
+ const limit = options.limit ?? sorted.length;
+ const slice = sorted.slice(start, start + limit);
+ const items = slice.map(name => ({
+ name,
+ path: `${ options.path }/${ name }`,
+ uid: `uid:${ name }`,
+ size: 10,
+ is_dir: tree[`${ options.path }/${ name }`] !== undefined,
+ }));
+ const end = start + slice.length;
+ return end < sorted.length ? { items, cursor: String(end) } : { items };
+ };
+ const read = async (path) => new Blob([files[path] ?? '']);
+ return { fs: { readdir, read }, calls };
+};
+
+const ids = {
+ d14a: uuidAt(Date.UTC(2024, 2, 14, 8), '000-00000000000a'),
+ d14b: uuidAt(Date.UTC(2024, 2, 14, 9), '000-00000000000b'),
+ d15a: uuidAt(Date.UTC(2024, 2, 15, 1), '000-00000000000c'),
+ d16a: uuidAt(Date.UTC(2024, 2, 16, 3), '000-00000000000d'),
+ d16b: uuidAt(Date.UTC(2024, 2, 16, 4), '000-00000000000e'),
+};
+const inboxTree = {
+ '~/.mail/objects': ['2024-03-14', '2024-03-15', '2024-03-16', 'stray.txt'],
+ '~/.mail/objects/2024-03-14': [`${ ids.d14a }--${ toB64Url('a') }`, `${ ids.d14b }--${ toB64Url('b') }`],
+ '~/.mail/objects/2024-03-15': [`${ ids.d15a }--${ toB64Url('c') }`],
+ '~/.mail/objects/2024-03-16': [`${ ids.d16a }--${ toB64Url('d') }`, `${ ids.d16b }--${ toB64Url('e') }`, 'README'],
+};
+const moduleOver = (fs) => ({ puter: { fs }, list, get });
+
+describe('list()', () => {
+ it('walks day folders newest first and pages across them', async () => {
+ const { fs } = fakeFs(inboxTree);
+ const email = moduleOver(fs);
+
+ const first = await email.list.call(email, { limit: 3 });
+ expect(first.items.map(m => m.id)).toEqual([ids.d16b, ids.d16a, ids.d15a]);
+ expect(first.items.map(m => m.subject)).toEqual(['e', 'd', 'c']);
+ expect(first.cursor).toBeTypeOf('string');
+
+ const second = await email.list.call(email, { limit: 3, cursor: first.cursor });
+ expect(second.items.map(m => m.id)).toEqual([ids.d14b, ids.d14a]);
+ expect(second.cursor).toBeUndefined();
+ });
+
+ it('resumes inside a day folder when a page ends mid-day', async () => {
+ const { fs } = fakeFs(inboxTree);
+ const email = moduleOver(fs);
+ const seen = [];
+ let cursor = null;
+ do {
+ const page = await email.list.call(email, { limit: 1, cursor });
+ seen.push(...page.items.map(m => m.id));
+ cursor = page.cursor;
+ } while ( cursor );
+ expect(seen).toEqual([ids.d16b, ids.d16a, ids.d15a, ids.d14b, ids.d14a]);
+ });
+
+ it('streams pages', async () => {
+ const { fs } = fakeFs(inboxTree);
+ const email = moduleOver(fs);
+ const pages = [];
+ for await ( const page of email.list.call(email, { stream: true, limit: 2 }) ) {
+ pages.push(page.items.map(m => m.id));
+ }
+ expect(pages).toEqual([[ids.d16b, ids.d16a], [ids.d15a, ids.d14b], [ids.d14a]]);
+ });
+
+ it('lists a mailbox that was never written as empty', async () => {
+ const { fs } = fakeFs({});
+ const email = moduleOver(fs);
+ expect(await email.list.call(email)).toEqual({ items: [] });
+ expect(await email.list.call(email, { folder: 'sent' })).toEqual({ items: [] });
+ });
+
+ it('reads the sent folder from its own directory', async () => {
+ const { fs, calls } = fakeFs({
+ '~/.mail/sent': ['2024-03-15'],
+ '~/.mail/sent/2024-03-15': [`${ ids.d15a }--${ toB64Url('sent one') }`],
+ });
+ const email = moduleOver(fs);
+ const page = await email.list.call(email, { folder: 'sent' });
+ expect(page.items).toHaveLength(1);
+ expect(page.items[0].folder).toBe('sent');
+ expect(calls.every(c => c.path.startsWith('~/.mail/sent'))).toBe(true);
+ });
+
+ it('rejects offset, includeTotal, and a foreign cursor before any request', async () => {
+ const { fs, calls } = fakeFs(inboxTree);
+ const email = moduleOver(fs);
+ expect(() => email.list.call(email, { offset: 3 })).toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ expect(() => email.list.call(email, { includeTotal: true })).toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ expect(() => email.list.call(email, { cursor: 'junk' })).toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ expect(() => email.list.call(email, { folder: 'outbox' })).toThrow(expect.objectContaining({ code: 'invalid_request' }));
+ expect(calls).toHaveLength(0);
+ });
+});
+
+describe('get()', () => {
+ const raw = [
+ 'From: Ada ',
+ 'To: Bob ',
+ 'Subject: Full subject that is longer than the name carries',
+ 'Date: Fri, 15 Mar 2024 01:00:00 +0000',
+ 'Message-ID: ',
+ 'Content-Type: text/plain; charset=utf-8',
+ '',
+ 'hello body',
+ ].join('\r\n');
+ const files = { [`~/.mail/objects/2024-03-15/${ ids.d15a }--${ toB64Url('c') }`]: raw };
+
+ it('locates by id with one listing of the derived day and parses the message', async () => {
+ const { fs, calls } = fakeFs(inboxTree, files);
+ const email = moduleOver(fs);
+ const msg = await email.get.call(email, ids.d15a);
+ expect(calls.map(c => c.path)).toEqual(['~/.mail/objects/2024-03-15']);
+ expect(msg.id).toBe(ids.d15a);
+ expect(msg.subject).toBe('Full subject that is longer than the name carries');
+ expect(msg.from).toEqual({ name: 'Ada', address: 'ada@example.com' });
+ expect(msg.to).toEqual([{ name: 'Bob', address: 'bob@puter.email' }]);
+ expect(msg.messageId).toBe('');
+ expect(msg.date).toBe('2024-03-15T01:00:00.000Z');
+ expect(msg.text.trim()).toBe('hello body');
+ expect(msg.attachments).toEqual([]);
+ expect(msg.path).toBe(`~/.mail/objects/2024-03-15/${ ids.d15a }--${ toB64Url('c') }`);
+ });
+
+ it('returns the raw blob when asked', async () => {
+ const { fs } = fakeFs(inboxTree, files);
+ const email = moduleOver(fs);
+ const blob = await email.get.call(email, { id: ids.d15a, raw: true });
+ expect(blob).toBeInstanceOf(Blob);
+ expect(await blob.text()).toBe(raw);
+ });
+
+ it('falls back to the adjacent days when the message straddled midnight', async () => {
+ // Minted just after midnight on the 16th, filed under the 15th.
+ const late = uuidAt(Date.UTC(2024, 2, 16, 0, 0, 0, 3), '000-0000000000ff');
+ const name = `${ late }--${ toB64Url('late') }`;
+ const { fs, calls } = fakeFs({
+ ...inboxTree,
+ '~/.mail/objects/2024-03-15': [...inboxTree['~/.mail/objects/2024-03-15'], name],
+ }, { [`~/.mail/objects/2024-03-15/${ name }`]: raw });
+ const email = moduleOver(fs);
+ const msg = await email.get.call(email, late);
+ expect(msg.id).toBe(late);
+ expect(calls.map(c => c.path)).toEqual(['~/.mail/objects/2024-03-16', '~/.mail/objects/2024-03-17', '~/.mail/objects/2024-03-15']);
+ });
+
+ it('rejects an unknown id with not_found and a malformed id with invalid_request', async () => {
+ const { fs } = fakeFs(inboxTree, files);
+ const email = moduleOver(fs);
+ await expect(email.get.call(email, uuidAt(Date.UTC(2024, 2, 15, 5), '000-0000000000aa')))
+ .rejects.toMatchObject({ code: 'not_found' });
+ await expect(email.get.call(email, { id: ids.d15a, folder: 'sent' }))
+ .rejects.toMatchObject({ code: 'not_found' });
+ await expect(email.get.call(email, 'nope')).rejects.toMatchObject({ code: 'invalid_request' });
+ await expect(email.get.call(email, {})).rejects.toMatchObject({ code: 'invalid_request' });
+ });
+});
diff --git a/src/puter-js/src/modules/Email/types.js b/src/puter-js/src/modules/Email/types.js
index de1af6c27..4dd28efdb 100644
--- a/src/puter-js/src/modules/Email/types.js
+++ b/src/puter-js/src/modules/Email/types.js
@@ -18,4 +18,95 @@
* ``. Printable ASCII without whitespace or angle brackets.
*/
+/**
+ * Which mailbox folder to read. `inbox` holds mail received at the user's
+ * address, `sent` the user's own outgoing copies.
+ *
+ * @typedef {'inbox' | 'sent'} EmailFolder
+ */
+
+/**
+ * One message as it appears in a listing. Built from the mailbox listing
+ * alone, without opening the message, so `subject` is the first 80 characters
+ * only and there is no sender: call `puter.email.get()` for the rest.
+ *
+ * @typedef {Object} EmailSummary
+ * @property {string} id Message id (a uuidv7); pass it to `get()`.
+ * @property {string} subject Subject, possibly truncated to 80 characters.
+ * @property {string} date ISO 8601 timestamp of when the message was filed.
+ * @property {number} size Size of the raw message in bytes.
+ * @property {EmailFolder} folder The folder the message was listed from.
+ * @property {string} path Filesystem path of the raw `message/rfc822` object.
+ * @property {string} uid Filesystem uid of the raw object.
+ */
+
+/**
+ * A parsed mailbox: `{ name, address }`, or a named group of them.
+ *
+ * @typedef {import('postal-mime').Address} EmailAddress
+ */
+
+/**
+ * One header of a parsed message.
+ *
+ * @typedef {import('postal-mime').Header} EmailHeader
+ */
+
+/**
+ * One attachment of a message returned by `get()`, bytes included.
+ *
+ * @typedef {Object} EmailMessageAttachment
+ * @property {string | null} filename
+ * @property {string} mimeType
+ * @property {'attachment' | 'inline' | null} disposition
+ * @property {string} [contentId] `Content-ID`, without the angle brackets, for inline references.
+ * @property {boolean} [related] `true` for parts referenced from the HTML body (inline images).
+ * @property {number} size Byte length of `content`.
+ * @property {ArrayBuffer} content The decoded attachment bytes.
+ */
+
+/**
+ * A fully parsed message, as returned by `get()`.
+ *
+ * @typedef {EmailSummary & {
+ * messageId: string | null,
+ * inReplyTo: string | null,
+ * references: string | null,
+ * from: EmailAddress | null,
+ * to: EmailAddress[],
+ * cc: EmailAddress[],
+ * bcc: EmailAddress[],
+ * replyTo: EmailAddress[],
+ * headers: EmailHeader[],
+ * text: string | null,
+ * html: string | null,
+ * attachments: EmailMessageAttachment[],
+ * }} EmailMessage
+ */
+
+/**
+ * Options for `list()`. Results are newest first. Every call returns one page;
+ * pass the page's `cursor` back to fetch the next one.
+ *
+ * @typedef {Object} EmailListOptions
+ * @property {EmailFolder} [folder] Folder to list. Default `'inbox'`.
+ * @property {number} [limit] Maximum messages per page. Default 50, capped at 1000.
+ * @property {string | null} [cursor] Opaque continuation cursor from a previous page; `null` or absent for the first page.
+ */
+
+/**
+ * The `stream: true` form of `list()`: an async iterator of pages.
+ *
+ * @typedef {EmailListOptions & { stream: true }} EmailListStreamOptions
+ */
+
+/**
+ * The options form of `get()`.
+ *
+ * @typedef {Object} EmailGetOptions
+ * @property {string} id Message id, from a listing.
+ * @property {EmailFolder} [folder] Folder the message is in. Default `'inbox'`.
+ * @property {boolean} [raw] When `true`, resolve with the raw `message/rfc822` bytes as a `Blob` instead of parsing.
+ */
+
export {};
diff --git a/src/puter-js/tests/api/suites/email.suite.ts b/src/puter-js/tests/api/suites/email.suite.ts
new file mode 100644
index 000000000..b198757ce
--- /dev/null
+++ b/src/puter-js/tests/api/suites/email.suite.ts
@@ -0,0 +1,198 @@
+import { suite } from '../harness/types.ts';
+import type { TestContext } from '../harness/types.ts';
+
+type Folder = 'inbox' | 'sent';
+const DIRS: Record = { inbox: 'objects', sent: 'sent' };
+
+const home = (t: TestContext) => `/${t.env.users.user.username}`;
+
+/**
+ * A v7 uuid minted at `ms`, the way the mail writers name objects: the first
+ * 48 bits are the timestamp, and `tail` keeps ids in one test distinct.
+ */
+const uuidAt = (ms: number, tail: string) => {
+ const hex = ms.toString(16).padStart(12, '0');
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-${tail.padStart(12, '0')}`;
+};
+
+const toB64Url = (str: string) =>
+ btoa(String.fromCharCode(...new TextEncoder().encode(str)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+
+const rfc822 = (fields: { from: string; to: string; subject: string; text: string; date?: string }) =>
+ [
+ `From: ${fields.from}`,
+ `To: ${fields.to}`,
+ `Subject: ${fields.subject}`,
+ ...(fields.date ? [`Date: ${fields.date}`] : []),
+ 'Message-ID: ',
+ 'MIME-Version: 1.0',
+ 'Content-Type: text/plain; charset=utf-8',
+ '',
+ fields.text,
+ ].join('\r\n');
+
+/**
+ * Files one message the way ingress does: under the folder's day directory,
+ * named `{uuidv7}--{base64url(subject)}`. `day` defaults to the uuid's own
+ * day. The inbox is shared by every test in the suite, so each test mints
+ * ids in its own year to stay out of the others' way.
+ */
+const seed = async (
+ t: TestContext,
+ folder: Folder,
+ ms: number,
+ tail: string,
+ subject: string,
+ raw: string,
+ day = new Date(ms).toISOString().slice(0, 10),
+) => {
+ const id = uuidAt(ms, tail);
+ const path = `${home(t)}/.mail/${DIRS[folder]}/${day}/${id}--${toB64Url(subject.slice(0, 80))}`;
+ await t.puter.fs.write(path, raw, { createMissingParents: true });
+ return { id, path };
+};
+
+const idsOf = (items: Array<{ id: string }>) => items.map((m) => m.id);
+
+/** Every message in `folder`, newest first, across every page. */
+const listAll = async (t: TestContext, folder: Folder, limit: number) => {
+ const all: Array<{ id: string; subject: string; date: string; size: number; folder: string; path: string }> = [];
+ for await (const page of t.puter.email.list({ folder, limit, stream: true })) {
+ all.push(...page.items);
+ }
+ return all;
+};
+
+export default suite('email', {
+ 'list returns messages newest first with the subject read off the name': async (t) => {
+ const base = Date.UTC(2020, 0, 10, 12);
+ const day1 = Date.UTC(2020, 0, 11, 9);
+ const a = await seed(t, 'inbox', base, 'a1', 'First 🎉', rfc822({ from: 'x@example.com', to: 'y@puter.email', subject: 'First 🎉', text: 'a' }));
+ const b = await seed(t, 'inbox', base + 60_000, 'a2', 'Second', rfc822({ from: 'x@example.com', to: 'y@puter.email', subject: 'Second', text: 'b' }));
+ const c = await seed(t, 'inbox', day1, 'a3', 'Third', rfc822({ from: 'x@example.com', to: 'y@puter.email', subject: 'Third', text: 'c' }));
+
+ const all = await listAll(t, 'inbox', 1000);
+ const ours = all.filter((m) => [a.id, b.id, c.id].includes(m.id));
+ t.assert.deepEqual(idsOf(ours), [c.id, b.id, a.id]);
+ t.assert.deepEqual(ours.map((m) => m.subject), ['Third', 'Second', 'First 🎉']);
+ t.assert.equal(ours[0].folder, 'inbox');
+ t.assert.equal(ours[0].path, c.path);
+ t.assert.equal(ours[0].date, new Date(day1).toISOString());
+ t.assert.ok(ours[0].size > 0, 'size comes from the listing');
+ },
+
+ 'list pages with a cursor across day folders in the sent folder': async (t) => {
+ const days = [Date.UTC(2018, 5, 1, 8), Date.UTC(2018, 5, 2, 8), Date.UTC(2018, 5, 3, 8)];
+ const seeded: string[] = [];
+ for (const [i, day] of days.entries()) {
+ for (const j of [0, 1]) {
+ const { id } = await seed(t, 'sent', day + j * 1000, `b${i}${j}`, `Sent ${i}${j}`, rfc822({ from: 'me@puter.email', to: 'x@example.com', subject: `Sent ${i}${j}`, text: 's' }));
+ seeded.push(id);
+ }
+ }
+ seeded.sort().reverse();
+
+ const paged: string[] = [];
+ let cursor: string | null | undefined = null;
+ let pages = 0;
+ do {
+ const page = await t.puter.email.list({ folder: 'sent', limit: 4, cursor });
+ t.assert.ok(page.items.length <= 4, 'a page never exceeds its limit');
+ paged.push(...idsOf(page.items));
+ cursor = page.cursor;
+ pages += 1;
+ } while (cursor);
+ t.assert.ok(pages >= 2, 'six messages at four a page takes more than one page');
+
+ const ours = paged.filter((id) => seeded.includes(id));
+ t.assert.deepEqual(ours, seeded);
+ t.assert.deepEqual(paged, idsOf(await listAll(t, 'sent', 2)));
+ },
+
+ 'list rejects offset and a foreign cursor': async (t) => {
+ const err = (await t.assert.rejects(async () => t.puter.email.list({ offset: 1 } as never))) as { code?: string };
+ t.assert.equal(err.code, 'invalid_request');
+ const bad = (await t.assert.rejects(async () => t.puter.email.list({ cursor: 'junk' }))) as { code?: string };
+ t.assert.equal(bad.code, 'invalid_request');
+ },
+
+ 'get parses the message, attachments included': async (t) => {
+ const boundary = 'suite-boundary';
+ const raw = [
+ 'From: Ada Lovelace ',
+ 'To: Bob ',
+ 'Cc: carol@example.com',
+ 'Subject: A subject that runs well past the eighty characters the object name is allowed to keep',
+ 'Date: Sun, 05 Mar 2017 10:00:00 +0000',
+ 'Message-ID: ',
+ 'MIME-Version: 1.0',
+ `Content-Type: multipart/mixed; boundary="${boundary}"`,
+ '',
+ `--${boundary}`,
+ 'Content-Type: text/plain; charset=utf-8',
+ '',
+ 'hello body',
+ `--${boundary}`,
+ 'Content-Type: text/plain; name="note.txt"',
+ 'Content-Disposition: attachment; filename="note.txt"',
+ 'Content-Transfer-Encoding: base64',
+ '',
+ btoa('attached!'),
+ `--${boundary}--`,
+ '',
+ ].join('\r\n');
+ const { id, path } = await seed(t, 'inbox', Date.UTC(2017, 2, 5, 10), 'c1', 'A subject that runs well past the eighty characters the object name is allowed to keep', raw);
+
+ const msg = await t.puter.email.get(id);
+ t.assert.equal(msg.id, id);
+ t.assert.equal(msg.path, path);
+ t.assert.equal(msg.subject, 'A subject that runs well past the eighty characters the object name is allowed to keep');
+ t.assert.deepEqual(msg.from, { name: 'Ada Lovelace', address: 'ada@example.com' });
+ t.assert.deepEqual(msg.to, [{ name: 'Bob', address: 'bob@puter.email' }]);
+ t.assert.deepEqual(msg.cc, [{ name: '', address: 'carol@example.com' }]);
+ t.assert.equal(msg.messageId, '');
+ t.assert.equal(msg.date, '2017-03-05T10:00:00.000Z');
+ t.assert.equal((msg.text ?? '').trim(), 'hello body');
+ t.assert.equal(msg.attachments.length, 1);
+ t.assert.equal(msg.attachments[0].filename, 'note.txt');
+ t.assert.equal(msg.attachments[0].size, 'attached!'.length);
+ t.assert.equal(new TextDecoder().decode(msg.attachments[0].content), 'attached!');
+ },
+
+ 'get with raw returns the bytes that were filed': async (t) => {
+ const raw = rfc822({ from: 'x@example.com', to: 'y@puter.email', subject: 'Raw', text: 'raw body' });
+ const { id } = await seed(t, 'inbox', Date.UTC(2016, 7, 1, 10), 'd1', 'Raw', raw);
+ const blob = await t.puter.email.get({ id, raw: true });
+ t.assert.equal(await blob.text(), raw);
+ },
+
+ 'get finds a message filed under the day before its id says': async (t) => {
+ // Filed a moment before midnight, minted a moment after.
+ const ms = Date.UTC(2015, 3, 2, 0, 0, 0, 5);
+ const raw = rfc822({ from: 'x@example.com', to: 'y@puter.email', subject: 'Straddle', text: 'late' });
+ const { id } = await seed(t, 'inbox', ms, 'e1', 'Straddle', raw, '2015-04-01');
+ const msg = await t.puter.email.get(id);
+ t.assert.equal(msg.subject, 'Straddle');
+ t.assert.ok(msg.path.includes('/2015-04-01/'), 'resolved to the folder it was actually filed in');
+ },
+
+ 'get reads the sent folder when asked': async (t) => {
+ const raw = rfc822({ from: 'me@puter.email', to: 'x@example.com', subject: 'Sent copy', text: 'sent' });
+ const { id } = await seed(t, 'sent', Date.UTC(2014, 0, 1, 10), 'f1', 'Sent copy', raw);
+ const msg = await t.puter.email.get({ id, folder: 'sent' });
+ t.assert.equal(msg.folder, 'sent');
+ t.assert.equal(msg.subject, 'Sent copy');
+ const missing = (await t.assert.rejects(async () => t.puter.email.get(id))) as { code?: string };
+ t.assert.equal(missing.code, 'not_found');
+ },
+
+ 'get of an unknown or malformed id rejects': async (t) => {
+ const unknown = (await t.assert.rejects(async () => t.puter.email.get(uuidAt(Date.UTC(2013, 0, 1), 'ff')))) as { code?: string };
+ t.assert.equal(unknown.code, 'not_found');
+ const malformed = (await t.assert.rejects(async () => t.puter.email.get('not-an-id'))) as { code?: string };
+ t.assert.equal(malformed.code, 'invalid_request');
+ },
+});
diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts
index 110a46dea..1fdc93a62 100644
--- a/src/puter-js/tests/api/suites/index.ts
+++ b/src/puter-js/tests/api/suites/index.ts
@@ -3,6 +3,7 @@ import ai from './ai.suite.ts';
import apps from './apps.suite.ts';
import auth from './auth.suite.ts';
import components from './components.suite.ts';
+import email from './email.suite.ts';
import events from './events.suite.ts';
import fs from './fs.suite.ts';
import hosting from './hosting.suite.ts';
@@ -25,6 +26,7 @@ export const suites: Suite[] = [
apps,
auth,
components,
+ email,
events,
fs,
hosting,