mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 15:20:26 +00:00
email read api
This commit is contained in:
1 parent
1b6334c02c
commit
5235526c9a
17 files changed
+1475
-12
No files matched your search
+46
-11
@@ -1,17 +1,50 @@
|
||||
---
|
||||
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 — receipts, alerts, confirmations — through a worker, from a Puter-controlled address.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
The Puter.js Email API has two halves. Your app can **read the user's mailbox**: every Puter account has an address, `{username}@puter.email`, and the mail it receives is stored in the user's own cloud drive as standard `message/rfc822` objects. And your app can **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.
|
||||
|
||||
## Reading the user's mailbox
|
||||
|
||||
Mail sent to `{username}@puter.email` lands under the user's `~/.mail` folder, one object per message, and the user's own outgoing mail is kept there too. `list()` pages through a folder newest first without downloading a single message, and `get()` fetches and parses one message, attachments included.
|
||||
|
||||
The mailbox belongs to the user, so it is protected like the rest of their files. Your app reads it only after the user grants `fs:/{username}/.mail:read`:
|
||||
|
||||
```js
|
||||
const user = await puter.auth.getUser();
|
||||
const granted = await puter.perms.request('permission', {
|
||||
permission: `fs:/${user.username}/.mail:read`,
|
||||
});
|
||||
```
|
||||
|
||||
Storage is the source of truth: a message is a file, and its `path` comes back on every listing item, so `puter.fs.read()` and `puter.fs.delete()` work on it directly.
|
||||
|
||||
```html;email-list
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const page = await puter.email.list({ limit: 10 });
|
||||
for (const message of page.items) {
|
||||
puter.print(`${message.date} ${message.subject}<br>`);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Sending transactional email
|
||||
|
||||
<div class="info">Transactional email is available to accounts on a paid plan for now. A call from a free account fails with <code>402</code> <code>subscription_required</code>.</div>
|
||||
|
||||
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.
|
||||
Transactional email 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.
|
||||
|
||||
Mail goes out from a Puter-controlled address labelled with your app's title, for example `"My App" <my-app-no-reply@apps.puter.email>`, 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 `<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.
|
||||
|
||||
## 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.
|
||||
|
||||
```js
|
||||
@@ -27,14 +60,16 @@ router.post('/notify', async ({ request, user }) => {
|
||||
});
|
||||
```
|
||||
|
||||
## Functions
|
||||
|
||||
- **[`puter.email.sendTransactional()`](/Email/sendTransactional/)** - Send a transactional email from your app
|
||||
|
||||
## Recipient protections
|
||||
### 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
|
||||
### Mail the user sends as themselves
|
||||
|
||||
`puter.email.send()` is a separate surface: it sends a message from the user's own `<username>@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.
|
||||
`puter.email.send()` is a separate surface: it sends a message from the user's own `<username>@puter.email` address, composed in the client and filed in the user's `~/.mail` folder, where `list({ folder: 'sent' })` finds it. Use `sendTransactional()` when your app is the sender; use `send()` when the user is.
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## 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 the parsed message:
|
||||
|
||||
```js
|
||||
{
|
||||
id: '019...',
|
||||
folder: 'inbox',
|
||||
path: '/alice/.mail/objects/2026-03-15/019...--WW91ciBvcmRlcg',
|
||||
uid: 'a1b2c3...',
|
||||
size: 48213,
|
||||
date: '2026-03-15T12:34:56.000Z', // the Date header, or the filing time if unparseable
|
||||
subject: 'Your order has shipped',
|
||||
messageId: '<abc@example.com>',
|
||||
inReplyTo: null,
|
||||
references: null,
|
||||
from: { name: 'Example Shop', address: 'orders@example.com' },
|
||||
to: [{ name: 'Alice', address: 'alice@puter.email' }],
|
||||
cc: [],
|
||||
bcc: [], // populated only on sent copies
|
||||
replyTo: [],
|
||||
headers: [{ key: 'subject', originalKey: 'Subject', value: 'Your order has shipped' }, /* ... */],
|
||||
text: 'Hi Alice, ...',
|
||||
html: '<p>Hi Alice, ...</p>', // null when the message has no HTML part
|
||||
attachments: [
|
||||
{
|
||||
filename: 'invoice.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
disposition: 'attachment',
|
||||
size: 30211,
|
||||
content: ArrayBuffer // the decoded bytes
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
With `raw: true`, the `Promise` resolves to a `Blob` of the message exactly as it was 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
|
||||
|
||||
<strong class="example-title">Read the newest message in the inbox</strong>
|
||||
|
||||
```html;email-get
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const { items } = await puter.email.list({ limit: 1 });
|
||||
if (items.length === 0) return puter.print('Inbox is empty');
|
||||
|
||||
const message = await puter.email.get(items[0].id);
|
||||
puter.print(`From: ${message.from?.name} <${message.from?.address}><br>`);
|
||||
puter.print(`Subject: ${message.subject}<br>`);
|
||||
puter.print(`<pre>${message.text}</pre>`);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<strong class="example-title">Save every attachment of a message to the user's Documents</strong>
|
||||
|
||||
```html
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const { items } = await puter.email.list({ limit: 1 });
|
||||
if (items.length === 0) return puter.print('Inbox is empty');
|
||||
|
||||
const message = await puter.email.get(items[0].id);
|
||||
for (const attachment of message.attachments) {
|
||||
const name = attachment.filename ?? 'attachment';
|
||||
await puter.fs.write(`~/Documents/${name}`, new Blob([attachment.content], { type: attachment.mimeType }));
|
||||
puter.print(`Saved ${name} (${attachment.size} bytes)<br>`);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<strong class="example-title">Download the raw message</strong>
|
||||
|
||||
```html
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const { items } = await puter.email.list({ folder: 'sent', limit: 1 });
|
||||
if (items.length === 0) return puter.print('Nothing sent yet');
|
||||
|
||||
const blob = await puter.email.get({ id: items[0].id, folder: 'sent', raw: true });
|
||||
const text = await blob.text();
|
||||
puter.print(`<pre>${text.slice(0, 2000)}</pre>`);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## 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 a page:
|
||||
|
||||
```js
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: '019...', // pass to puter.email.get()
|
||||
subject: 'Your order has shipped', // may be truncated to 80 characters
|
||||
date: '2026-03-15T12:34:56.789Z', // when the message was filed
|
||||
size: 48213, // raw message size in bytes
|
||||
folder: 'inbox',
|
||||
path: '/alice/.mail/objects/2026-03-15/019...--WW91ciBvcmRlcg',
|
||||
uid: 'a1b2c3...'
|
||||
},
|
||||
// ...
|
||||
],
|
||||
cursor: 'eyJ2IjoxLCJmIjoiaW5ib3giLCJkIjoiMjAyNi0wMy0xNSJ9' // only while more pages exist
|
||||
}
|
||||
```
|
||||
|
||||
A page may hold fewer than `limit` items while more pages exist. 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 such pages.
|
||||
|
||||
## 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
|
||||
|
||||
<strong class="example-title">List the ten newest messages</strong>
|
||||
|
||||
```html;email-list
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const page = await puter.email.list({ limit: 10 });
|
||||
for (const message of page.items) {
|
||||
puter.print(`${message.date} ${message.subject}<br>`);
|
||||
}
|
||||
if (page.cursor) puter.print('More messages available.');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<strong class="example-title">Walk the whole sent folder with a cursor</strong>
|
||||
|
||||
```html
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
let cursor = null;
|
||||
let count = 0;
|
||||
do {
|
||||
const page = await puter.email.list({ folder: 'sent', limit: 100, cursor });
|
||||
count += page.items.length;
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
puter.print(`${count} sent messages`);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<strong class="example-title">Stream pages</strong>
|
||||
|
||||
```html
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
for await (const page of puter.email.list({ stream: true, limit: 25 })) {
|
||||
for (const message of page.items) {
|
||||
puter.print(`${message.subject}<br>`);
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#5f5f5f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-mail-icon lucide-mail">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -434,6 +434,23 @@ const examples = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
children: [
|
||||
{
|
||||
title: 'List messages',
|
||||
description: 'List the newest messages in the user\'s Puter mailbox with Puter.js. Run and modify this example in the playground.',
|
||||
slug: 'email-list',
|
||||
source: '/playground/examples/email-list.html',
|
||||
},
|
||||
{
|
||||
title: 'Read a message',
|
||||
description: 'Fetch and parse a message from the user\'s Puter mailbox with Puter.js, sender, body and attachments included. Run this example in the playground.',
|
||||
slug: 'email-get',
|
||||
source: '/playground/examples/email-get.html',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Key-Value Store',
|
||||
children: [
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
// Find the newest message, then download and parse it.
|
||||
const { items } = await puter.email.list({ limit: 1 });
|
||||
if (items.length === 0) {
|
||||
puter.print('Inbox is empty');
|
||||
return;
|
||||
}
|
||||
const message = await puter.email.get(items[0].id);
|
||||
puter.print(`From: ${message.from?.name} <${message.from?.address}><br>`);
|
||||
puter.print(`Subject: ${message.subject}<br>`);
|
||||
puter.print(`Attachments: ${message.attachments.length}<br>`);
|
||||
puter.print(`<pre>${message.text}</pre>`);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
// The ten newest messages in the inbox. Listing reads the mailbox
|
||||
// folder only, so no message is downloaded.
|
||||
const page = await puter.email.list({ limit: 10 });
|
||||
if (page.items.length === 0) {
|
||||
puter.print('Inbox is empty');
|
||||
return;
|
||||
}
|
||||
for (const message of page.items) {
|
||||
puter.print(`${message.date} ${message.subject}<br>`);
|
||||
}
|
||||
if (page.cursor) puter.print('More messages available.');
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -594,6 +594,39 @@ let sidebar = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
title_tag: 'Email',
|
||||
icon: '/assets/img/email.svg',
|
||||
source: '/Email.md',
|
||||
path: '/Email',
|
||||
children: [
|
||||
{
|
||||
title: '<code>sendTransactional()</code>',
|
||||
page_title: '<code>puter.email.sendTransactional()</code>',
|
||||
title_tag: 'puter.email.sendTransactional()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/Email/sendTransactional.md',
|
||||
path: '/Email/sendTransactional',
|
||||
},
|
||||
{
|
||||
title: '<code>list()</code>',
|
||||
page_title: '<code>puter.email.list()</code>',
|
||||
title_tag: 'puter.email.list()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/Email/list.md',
|
||||
path: '/Email/list',
|
||||
},
|
||||
{
|
||||
title: '<code>get()</code>',
|
||||
page_title: '<code>puter.email.get()</code>',
|
||||
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',
|
||||
|
||||
Vendored
+11
@@ -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 {
|
||||
|
||||
@@ -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<string, (...args: unknown[]) => 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
|
||||
|
||||
@@ -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<EmailSummary | null>}
|
||||
*/
|
||||
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<EmailMessage>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {EmailGetOptions & { raw: true }} options
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {EmailGetOptions & { raw?: false }} options
|
||||
* @returns {Promise<EmailMessage>}
|
||||
*/
|
||||
/**
|
||||
* 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<EmailMessage | Blob>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
@@ -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<EmailFolder, string>} */
|
||||
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';
|
||||
};
|
||||
@@ -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<EmailSummary>} 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<string[]>}
|
||||
*/
|
||||
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<EmailListPage>}
|
||||
*/
|
||||
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<Record<string, any>>, 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<EmailListPage>}
|
||||
*/
|
||||
/**
|
||||
* @overload
|
||||
* @param {EmailListOptions} [options]
|
||||
* @returns {Promise<EmailListPage>}
|
||||
*/
|
||||
/**
|
||||
* 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<EmailListPage> | AsyncIterableIterator<EmailListPage>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
@@ -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<string, string[]>} 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 <ada@example.com>',
|
||||
'To: Bob <bob@puter.email>',
|
||||
'Subject: Full subject that is longer than the name carries',
|
||||
'Date: Fri, 15 Mar 2024 01:00:00 +0000',
|
||||
'Message-ID: <m1@example.com>',
|
||||
'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('<m1@example.com>');
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -18,4 +18,95 @@
|
||||
* `<img src="cid:...">`. 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 {};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { suite } from '../harness/types.ts';
|
||||
import type { TestContext } from '../harness/types.ts';
|
||||
|
||||
type Folder = 'inbox' | 'sent';
|
||||
const DIRS: Record<Folder, string> = { 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: <suite@example.com>',
|
||||
'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 <ada@example.com>',
|
||||
'To: Bob <bob@puter.email>',
|
||||
'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: <get@example.com>',
|
||||
'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, '<get@example.com>');
|
||||
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');
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user