mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-21 11:01:16 +00:00
PUT-1903 (#4226)
This commit is contained in:
1 parent
c6c0dd5e29
commit
e5ca303c50
12 files changed
+614
-83
No files matched your search
@@ -115,6 +115,31 @@ async function checkMemory(key, limit, windowMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend up to `units` from one bucket at once and report how many fit: the
|
||||
* many-unit form of `checkMemory`, for a caller that would otherwise loop.
|
||||
*/
|
||||
async function claimMemory(key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
let entry = memoryWindows.get(key);
|
||||
if (!entry) {
|
||||
if (memoryWindows.size >= MEMORY_MAX_KEYS) {
|
||||
const oldest = memoryWindows.keys().next().value;
|
||||
memoryWindows.delete(oldest);
|
||||
}
|
||||
entry = { ts: [], windowMs };
|
||||
memoryWindows.set(key, entry);
|
||||
} else {
|
||||
entry.windowMs = windowMs;
|
||||
}
|
||||
const timestamps = entry.ts;
|
||||
const cutoff = now - windowMs;
|
||||
while (timestamps.length > 0 && timestamps[0] < cutoff) timestamps.shift();
|
||||
const admitted = Math.max(0, Math.min(units, limit - timestamps.length));
|
||||
for (let i = 0; i < admitted; i++) timestamps.push(now);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bucket's state without spending from it. For gates whose budget is
|
||||
* consumed by something other than the request being admitted — a failed
|
||||
@@ -182,6 +207,32 @@ async function checkRedis(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `checkRedis` for many units in one round trip: add them all, count, and take
|
||||
* back however many landed over the limit. Same add-then-count shape, so
|
||||
* concurrent claims can under-admit but never over-admit.
|
||||
*/
|
||||
async function claimRedis(redis, key, limit, windowMs, units) {
|
||||
const redisKey = `rate:${key}`;
|
||||
const now = Date.now();
|
||||
const members = Array.from(
|
||||
{ length: units },
|
||||
() => `${now}:${crypto.randomUUID()}`,
|
||||
);
|
||||
const results = await redis
|
||||
.multi()
|
||||
.zremrangebyscore(redisKey, 0, now - windowMs)
|
||||
.zadd(redisKey, ...members.flatMap((member) => [now, member]))
|
||||
.zcard(redisKey)
|
||||
.pexpire(redisKey, windowMs)
|
||||
.exec();
|
||||
|
||||
const count = Number(multiResult(results, 2));
|
||||
const excess = Math.min(units, Math.max(0, count - limit));
|
||||
if (excess > 0) await redis.zrem(redisKey, ...members.slice(-excess));
|
||||
return units - excess;
|
||||
}
|
||||
|
||||
async function peekRedis(redis, key, limit, windowMs) {
|
||||
const redisKey = `rate:${key}`;
|
||||
const results = await redis
|
||||
@@ -218,6 +269,31 @@ async function checkKv(kv, key, limit, windowMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `checkKv` for many units: one read, then a row per admitted unit. Newest
|
||||
// first for the same reason `checkKv` reads that way.
|
||||
async function claimKv(kv, key, limit, windowMs, units) {
|
||||
const prefix = `rate:${key}:`;
|
||||
const { res } = await kv.list({
|
||||
as: 'keys',
|
||||
pattern: prefix,
|
||||
limit: limit + 1,
|
||||
reverse: true,
|
||||
});
|
||||
const keys = Array.isArray(res) ? res : (res?.items ?? []);
|
||||
const admitted = Math.max(0, Math.min(units, limit - keys.length));
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
Array.from({ length: admitted }, () =>
|
||||
kv.set({
|
||||
key: `${prefix}${now}:${crypto.randomUUID()}`,
|
||||
value: 1,
|
||||
expireAt: Math.ceil((now + windowMs) / 1000),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
// `list` filters by TTL, so a non-expired row is in-window — same read
|
||||
// `checkKv` does, minus the write.
|
||||
async function peekKv(kv, key, limit) {
|
||||
@@ -395,6 +471,10 @@ function instrumentBackendPair(name, pair) {
|
||||
withSpan('rate_limit.peek', attrs, () =>
|
||||
pair.peek(key, limit, windowMs),
|
||||
),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
withSpan('rate_limit.claim', attrs, () =>
|
||||
pair.claim(key, limit, windowMs, units),
|
||||
),
|
||||
acquire: (key, limit) =>
|
||||
withSpan('rate_limit.acquire', attrs, () =>
|
||||
pair.acquire(key, limit),
|
||||
@@ -405,6 +485,7 @@ function instrumentBackendPair(name, pair) {
|
||||
const memoryBackendPair = instrumentBackendPair('memory', {
|
||||
rate: checkMemory,
|
||||
peek: peekMemory,
|
||||
claim: claimMemory,
|
||||
acquire: acquireMemoryConcurrent,
|
||||
});
|
||||
|
||||
@@ -453,6 +534,8 @@ export function configureRateLimit({
|
||||
checkRedis(redis, key, limit, windowMs),
|
||||
peek: (key, limit, windowMs) =>
|
||||
peekRedis(redis, key, limit, windowMs),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimRedis(redis, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -460,6 +543,8 @@ export function configureRateLimit({
|
||||
backends.kv = instrumentBackendPair('kv', {
|
||||
rate: (key, limit, windowMs) => checkKv(kv, key, limit, windowMs),
|
||||
peek: (key, limit) => peekKv(kv, key, limit),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimKv(kv, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -770,6 +855,25 @@ export async function checkRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `checkRateLimit` for `units` hits at once, in one backend round trip.
|
||||
* Resolves to how many were admitted (0..units) and spent; the rest were not
|
||||
* charged. Fails open, admitting every unit, like the rest of this module.
|
||||
*/
|
||||
export async function claimRateLimit(key, limit, windowMs, units, backend) {
|
||||
if (!(units > 0)) return 0;
|
||||
const bk = resolveBackend(backend);
|
||||
try {
|
||||
return await bk.claim(key, limit, windowMs, units);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[rate-limit] imperative claim failed, failing open:',
|
||||
err,
|
||||
);
|
||||
return units;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge a route-shaped rate-limit spec from inside a handler.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
checkRateLimit,
|
||||
claimRateLimit,
|
||||
concurrencyGate,
|
||||
consumeRouteRateLimit,
|
||||
configureRateLimit,
|
||||
@@ -1479,6 +1480,20 @@ describe('rate + concurrent limiting — kv backend', () => {
|
||||
await Promise.all(held.map((slot) => slot.release()));
|
||||
});
|
||||
|
||||
it('claims many units in one call, admitting only what fits', async () => {
|
||||
const key = uniqueScope();
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(2);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('claims against a full bucket as full while expired rows are unswept', async () => {
|
||||
const key = uniqueScope();
|
||||
await seedUnswept(`rate:${key}:`, 3);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('holds a concurrent slot until it is released', async () => {
|
||||
const req = { ip: '9.9.9.9', headers: {}, actor: undefined };
|
||||
const opts = { limit: 1, backend: 'kv', scope: uniqueScope() };
|
||||
@@ -1994,3 +2009,59 @@ describe('acquireConcurrent (imperative)', () => {
|
||||
configureRateLimit();
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimRateLimit', () => {
|
||||
const uniqueKey = () => `claim-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
describe.each([
|
||||
['memory', () => configureRateLimit()],
|
||||
[
|
||||
'redis',
|
||||
() => configureRateLimit({ default: 'redis', redis: new RedisMock() }),
|
||||
],
|
||||
])('%s backend', (_name, configure) => {
|
||||
beforeEach(configure);
|
||||
|
||||
it('admits every unit while under the limit', async () => {
|
||||
expect(await claimRateLimit(uniqueKey(), 5, 60_000, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('admits exactly up to the limit and spends only that', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(2);
|
||||
// Nothing spent past the limit, so the bucket is exactly full.
|
||||
expect(await checkRateLimit(key, 5, 60_000)).toBe(false);
|
||||
expect(await checkRateLimit(key, 6, 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('admits nothing once the bucket is full', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 2, 60_000, 2)).toBe(2);
|
||||
expect(await claimRateLimit(key, 2, 60_000, 4)).toBe(0);
|
||||
});
|
||||
|
||||
it('shares its bucket with checkRateLimit', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await checkRateLimit(key, 3, 60_000)).toBe(true);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 5)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('claims nothing for zero units', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 1, 60_000, 0)).toBe(0);
|
||||
expect(await checkRateLimit(key, 1, 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open, admitting every unit, when the backend throws', async () => {
|
||||
const redis = new RedisMock();
|
||||
redis.multi = () => {
|
||||
throw new Error('redis down');
|
||||
};
|
||||
configureRateLimit({ default: 'redis', redis });
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await claimRateLimit(uniqueKey(), 1, 60_000, 4)).toBe(4);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,13 @@ describe('addresses', () => {
|
||||
'dan@puterstaging.email',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a username with capitals yields lowercase addresses', () => {
|
||||
expect(puterEmailAddressesOf('Dan')).toEqual([
|
||||
'dan@puter.email',
|
||||
'dan@puterstaging.email',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('account eligibility', () => {
|
||||
|
||||
@@ -59,9 +59,13 @@ export const isPuterEmailAddress = (address: string): boolean =>
|
||||
export const puterEmailUsername = (address: string): string =>
|
||||
address.split('@')[0];
|
||||
|
||||
/** Every address that resolves to `username`. */
|
||||
/**
|
||||
* Every address that resolves to `username`, lowercased: callers compare these
|
||||
* against addresses parsed out of headers, which are lowercased too, and a
|
||||
* username stored with capitals would otherwise match nothing.
|
||||
*/
|
||||
export const puterEmailAddressesOf = (username: string): string[] =>
|
||||
PUTER_EMAIL_DOMAINS.map((domain) => `${username}@${domain}`);
|
||||
PUTER_EMAIL_DOMAINS.map((domain) => `${username.toLowerCase()}@${domain}`);
|
||||
|
||||
/**
|
||||
* A temp account has neither a password nor an email - the same test
|
||||
|
||||
@@ -42,8 +42,8 @@ A plain-text body. Shorthand for `text`; use the options form for anything else.
|
||||
- `attachments` (Array) - Up to **10** attachments, **25 MB** in total. Each is an object:
|
||||
- `filename` (String) - Required with `content`; defaults to the file's name for a `path`/`uid` attachment.
|
||||
- `content` (String) - The file body as base64. Mutually exclusive with `path`/`uid`.
|
||||
- `path` (String) - A Puter path (`~/` allowed). The file is read on the server with the caller's file permissions, falling back to the worker owner's, and streamed rather than uploaded — prefer this over `content` for anything larger than a few hundred kilobytes.
|
||||
- `uid` (String) - A Puter file uid, as an alternative to `path`.
|
||||
- `path` (String) - A Puter path (`~/` allowed, meaning the caller's home). The file is read on the server with the caller's file permissions only, and streamed rather than uploaded — prefer this over `content` for anything larger than a few hundred kilobytes.
|
||||
- `uid` (String) - A Puter file uid, as an alternative to `path`. Read with the caller's file permissions, falling back to the worker owner's — use a uid to attach a file from the worker owner's storage.
|
||||
- `contentType` (String) - MIME type. Detected from the file for `path`/`uid` attachments.
|
||||
- `cid` (String) - Content-ID that makes the attachment an inline part of the `html` body: with `cid: 'logo'`, `<img src="cid:logo">` shows it. Printable ASCII with no whitespace or angle brackets. Inline parts are billed like any other attachment.
|
||||
|
||||
@@ -52,7 +52,7 @@ A plain-text body. Shorthand for `text`; use the options form for anything else.
|
||||
A `Promise` that resolves to an object:
|
||||
|
||||
- `messageId` (String | null) - The `Message-ID` of the first delivery: the transport's for a relayed copy, the stored message's for a Puter mailbox.
|
||||
- `cost` (Number) - What the send was charged, in microcents.
|
||||
- `cost` (Number) - What the send was charged, in microcents: one delivery's price for each recipient in neither `failed` nor `suppressed`.
|
||||
- `suppressed` (Array) - Recipients (lowercased) dropped because they unsubscribed from your app's mail. The message went to the others.
|
||||
- `failed` (Array) - Recipients (lowercased) whose delivery failed. Everyone else got their copy — retry with just these addresses.
|
||||
|
||||
@@ -65,7 +65,7 @@ In case of an error, the `Promise` rejects with `{ message, code }`. Codes you m
|
||||
- `subscription_required` (402) - The calling account is not on a paid plan.
|
||||
- `insufficient_funds` (402) - The calling account has no usage credit left for this send.
|
||||
- `bad_request` (400) - Invalid arguments: a bad address, a missing body, too many recipients, an invalid attachment.
|
||||
- `not_found` (404) - Every recipient was a Puter address with no mailbox to receive in.
|
||||
- `not_found` (404) - An attachment's `path`/`uid` could not be read; or every recipient was a Puter address with no mailbox to receive in.
|
||||
- `too_many_requests` (429) - Over the rate limit; see [Rate Limits and Quotas](/rate-limits-and-quotas/#email).
|
||||
|
||||
## Recipients on Puter
|
||||
@@ -86,7 +86,7 @@ Every message carries an unsubscribe link and a report-abuse link. Opting out is
|
||||
|
||||
## Limits and cost
|
||||
|
||||
Sends are rate-limited per calling account; the numbers are on [Rate Limits and Quotas](/rate-limits-and-quotas/#email). A send costs a flat charge per message plus a per-byte charge for attachments, and is charged to the calling account. A send that reaches nobody is not charged.
|
||||
Sends are rate-limited per calling account; the numbers are on [Rate Limits and Quotas](/rate-limits-and-quotas/#email). Each recipient's delivery costs a flat charge plus a per-byte charge for the attachments it carries, so a send to three recipients costs three deliveries. Only deliveries that succeed are charged — retrying the `failed` addresses never pays twice — and a send that reaches nobody is not charged. The calling account pays.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -125,6 +125,10 @@ router.post('/api/receipt', async ({ request, user }) => {
|
||||
<strong class="example-title">Attach a file and show an inline image</strong>
|
||||
|
||||
```js
|
||||
// The uid of a file in the worker owner's storage, e.g. from
|
||||
// `(await me.puter.fs.stat('~/Public/logo.png')).uid`.
|
||||
const LOGO_UID = '...';
|
||||
|
||||
router.post('/api/invoice', async ({ request, user }) => {
|
||||
const { to } = await request.json();
|
||||
return await user.puter.email.sendTransactional({
|
||||
@@ -132,9 +136,9 @@ router.post('/api/invoice', async ({ request, user }) => {
|
||||
subject: 'Your invoice',
|
||||
html: '<img src="cid:logo" alt="Acme"><p>Your invoice is attached.</p>',
|
||||
attachments: [
|
||||
// Streamed from the worker owner's storage; shown inline via the cid.
|
||||
{ path: '~/Public/logo.png', cid: 'logo' },
|
||||
// Streamed from the user's own storage as a regular attachment.
|
||||
// A uid falls back to the worker owner's storage; shown inline via the cid.
|
||||
{ uid: LOGO_UID, cid: 'logo' },
|
||||
// A path is always the caller's: streamed from the user's own storage.
|
||||
{ path: '~/Documents/invoice.pdf' },
|
||||
],
|
||||
emailAccessToken: me.puter.authToken,
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
// Multipart email files are handled as String'd FormData to save on Multipart parsing logic. The primary goal of this library
|
||||
// is not necessarily high performance but high maintanability.
|
||||
|
||||
import { PuterJSError } from '../../lib/PuterJSError.js';
|
||||
|
||||
/** Every refusal here is the caller's input; the SDK error contract wants a code. */
|
||||
const invalid = (message) => new PuterJSError(message, 'invalid_request');
|
||||
|
||||
/**
|
||||
* One attachment: either inline base64 `content`, or a Puter FS reference
|
||||
* (`path`/`uid`) read server-side with the caller's — falling back to the
|
||||
* authorizing worker's — file permissions. FS references are streamed from
|
||||
* (`path`/`uid`) read server-side with the caller's file permissions; a `uid`
|
||||
* falls back to the authorizing worker's. FS references are streamed from
|
||||
* storage and never travel through the request, so prefer them for anything
|
||||
* larger than a few hundred kilobytes.
|
||||
*
|
||||
* @typedef {Object} EmailAttachment
|
||||
* @property {string} [filename] Required with `content`; defaults to the file's name for FS refs.
|
||||
* @property {string} [content] Base64 file body. Mutually exclusive with `path`/`uid`.
|
||||
* @property {string} [path] Puter FS path (supports `~/`). Mutually exclusive with `content`.
|
||||
* @property {string} [uid] Puter FS entry uid. Mutually exclusive with `content`.
|
||||
* @property {string} [path] Puter FS path (supports `~/`, the caller's home). Mutually exclusive with `content`.
|
||||
* @property {string} [uid] Puter FS entry uid; the only way to attach a file of the worker owner's. Mutually exclusive with `content`.
|
||||
* @property {string|undefined} [cid] Email content ID for inline images
|
||||
* @property {string} [contentType] MIME type of the attachment.
|
||||
*/
|
||||
@@ -40,25 +45,103 @@ export function getRFC822DateUTC(date = new Date()) {
|
||||
|
||||
export function emlHeader(key, value) {
|
||||
if (!isASCII(key)) {
|
||||
throw new Error("eml header key " + key + " is not valid ASCII");
|
||||
throw invalid("eml header key " + key + " is not valid ASCII");
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!isASCII(val)) {
|
||||
throw new Error("eml header value " + val + " is not valid ASCII");
|
||||
throw invalid("eml header value " + val + " is not valid ASCII");
|
||||
}
|
||||
})
|
||||
|
||||
return `${key}: ${value.join(', ')}\r\n`;
|
||||
} else {
|
||||
if (!isASCII(value)) {
|
||||
throw new Error("eml header value " + value + " is not valid ASCII");
|
||||
throw invalid("eml header value " + value + " is not valid ASCII");
|
||||
}
|
||||
return `${key}: ${value}\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// UTF-8 bytes per encoded word: 45 bytes is 60 base64 characters, which with
|
||||
// the `=?UTF-8?B?` and `?=` wrapping stays inside RFC 2047's 75.
|
||||
const ENCODED_WORD_BYTES = 45;
|
||||
|
||||
const bytesToBase64 = (bytes) => {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
/**
|
||||
* Header text as RFC 2047 encoded words: printable ASCII comes back as is,
|
||||
* anything else as `=?UTF-8?B?...?=` words split on character boundaries and
|
||||
* folded onto continuation lines. Control characters are refused either way:
|
||||
* they have no business in a header, encoded or not.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeWord(text) {
|
||||
if (typeof text !== 'string' || /[\x00-\x1f\x7f]/.test(text)) {
|
||||
throw invalid("eml header value " + text + " is not valid");
|
||||
}
|
||||
if (isASCII(text)) return text;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const words = [];
|
||||
let pending = [];
|
||||
for (const char of text) {
|
||||
const bytes = encoder.encode(char);
|
||||
if (pending.length + bytes.length > ENCODED_WORD_BYTES) {
|
||||
words.push(pending);
|
||||
pending = [];
|
||||
}
|
||||
pending.push(...bytes);
|
||||
}
|
||||
if (pending.length > 0) words.push(pending);
|
||||
return words.map(word => `=?UTF-8?B?${bytesToBase64(word)}?=`).join('\r\n ');
|
||||
}
|
||||
|
||||
/**
|
||||
* One address-list entry, its display name encoded when it needs to be. The
|
||||
* address itself has to stay ASCII: mail without SMTPUTF8 has no encoding
|
||||
* for it.
|
||||
*
|
||||
* @param {string} entry `addr@host` or `Name <addr@host>`
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeAddress(entry) {
|
||||
if (isASCII(entry)) return entry;
|
||||
const match = /^\s*(.*?)\s*<([^<>]*)>\s*$/.exec(String(entry));
|
||||
if (!match || !isASCII(match[2])) {
|
||||
throw invalid("eml address " + entry + " is not valid ASCII");
|
||||
}
|
||||
const name = match[1].replace(/^"(.*)"$/, '$1').replace(/\\(.)/g, '$1');
|
||||
return `${encodeWord(name)} <${match[2]}>`;
|
||||
}
|
||||
|
||||
/** A free-text header (Subject), RFC 2047 encoded when not plain ASCII. */
|
||||
export function textHeader(key, value) {
|
||||
if (!isASCII(key)) {
|
||||
throw invalid("eml header key " + key + " is not valid ASCII");
|
||||
}
|
||||
return `${key}: ${encodeWord(value)}\r\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An address-list header (To, Cc, Bcc, Reply-To, From) with non-ASCII display
|
||||
* names encoded. A string is one entry, or an ASCII list passed through as is.
|
||||
*/
|
||||
export function addressHeader(key, value) {
|
||||
if (!isASCII(key)) {
|
||||
throw invalid("eml header key " + key + " is not valid ASCII");
|
||||
}
|
||||
const entries = Array.isArray(value) ? value : [value];
|
||||
return `${key}: ${entries.map(encodeAddress).join(', ')}\r\n`;
|
||||
}
|
||||
|
||||
export function determineTopLevelMimeType({ attachments = [], text, html }) {
|
||||
const inlineAttachments = attachments.filter(a => a.cid);
|
||||
const regularAttachments = attachments.filter(a => !a.cid);
|
||||
@@ -78,7 +161,7 @@ export function determineTopLevelMimeType({ attachments = [], text, html }) {
|
||||
if (html) {
|
||||
return { isMultiPart: false, mimeType: 'text/html; charset=UTF-8' };
|
||||
}
|
||||
throw new Error('Email must have text, html, or an attachment');
|
||||
throw invalid('Email must have text, html, or an attachment');
|
||||
}
|
||||
|
||||
|
||||
@@ -100,21 +183,22 @@ export const isASCII = (str) => /^[\x20-\x7E]*$/.test(str);
|
||||
|
||||
/**
|
||||
* Composes a multipart email attachment
|
||||
*
|
||||
* @param {EmailAttachment} attachment
|
||||
*
|
||||
* @param {EmailAttachment} attachment
|
||||
* @param {import('../../index.js').Puter} [puter] Reads `path` attachments; the module's own instance.
|
||||
*/
|
||||
export async function composeAttachment({ cid, content, contentType, filename, path, uid }) {
|
||||
export async function composeAttachment({ cid, content, contentType, filename, path, uid }, puter = globalThis.puter) {
|
||||
if (path && content) {
|
||||
throw new Error("Path and Content are mutually exclusive");
|
||||
throw invalid("Path and Content are mutually exclusive");
|
||||
}
|
||||
if (!path && !content && !uid) {
|
||||
throw new Error("Attachment requires content, path, or uid");
|
||||
throw invalid("Attachment requires content, path, or uid");
|
||||
}
|
||||
if (uid) {
|
||||
throw new Error("uid attachments are not supported yet");
|
||||
throw invalid("uid attachments are not supported yet");
|
||||
}
|
||||
if (contentType && !filename) {
|
||||
throw new Error("filename parameter required for contentType");
|
||||
throw invalid("filename parameter required for contentType");
|
||||
}
|
||||
|
||||
let b64emailContent = content;
|
||||
@@ -129,12 +213,12 @@ export async function composeAttachment({ cid, content, contentType, filename, p
|
||||
}
|
||||
|
||||
if (!filename) {
|
||||
throw new Error("Attachment's filename cannot be determined automatically. Please provide filename!");
|
||||
throw invalid("Attachment's filename cannot be determined automatically. Please provide filename!");
|
||||
}
|
||||
filename = filename.replaceAll('\\', '\\\\');
|
||||
filename = filename.replaceAll('"', '\\"');
|
||||
if (!isASCII(filename)) {
|
||||
throw new Error("filename must be ascii");
|
||||
throw invalid("filename must be ascii");
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
@@ -142,7 +226,7 @@ export async function composeAttachment({ cid, content, contentType, filename, p
|
||||
}
|
||||
|
||||
if (!mimeType) {
|
||||
throw new Error("Attachment's mimetype cannot be determined automatically. Please provide contentType!");
|
||||
throw invalid("Attachment's mimetype cannot be determined automatically. Please provide contentType!");
|
||||
}
|
||||
|
||||
mimeType += ';name="' + filename + '"';
|
||||
@@ -168,34 +252,36 @@ export function combineParts(parts, boundary) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {EmailComposeOptions} param0
|
||||
*
|
||||
* @param {EmailComposeOptions} param0
|
||||
* @param {import('../../index.js').Puter} [puter] The module's own instance: who the mail is from, and
|
||||
* whose storage `path` attachments are read from.
|
||||
*/
|
||||
export async function compose({ from, to, cc, bcc, subject, replyTo, attachments = [], text, html }) {
|
||||
export async function compose({ from, to, cc, bcc, subject, replyTo, attachments = [], text, html }, puter = globalThis.puter) {
|
||||
// --- Header constructing logic ---
|
||||
let headerLines = "MIME-Version: 1.0\r\n";
|
||||
|
||||
headerLines += emlHeader('Date', getRFC822DateUTC());
|
||||
|
||||
if (subject) {
|
||||
headerLines += emlHeader('Subject', subject);
|
||||
headerLines += textHeader('Subject', subject);
|
||||
}
|
||||
if (to) {
|
||||
headerLines += emlHeader('To', to);
|
||||
headerLines += addressHeader('To', to);
|
||||
} else {
|
||||
throw new Error("Cannot compose email without recipient!");
|
||||
throw invalid("Cannot compose email without recipient!");
|
||||
}
|
||||
if (cc) {
|
||||
headerLines += emlHeader('cc', cc);
|
||||
headerLines += addressHeader('cc', cc);
|
||||
}
|
||||
if (bcc) {
|
||||
headerLines += emlHeader('bcc', bcc);
|
||||
headerLines += addressHeader('bcc', bcc);
|
||||
}
|
||||
if (replyTo) {
|
||||
headerLines += emlHeader('Reply-To', replyTo);
|
||||
headerLines += addressHeader('Reply-To', replyTo);
|
||||
}
|
||||
if (from) {
|
||||
headerLines += emlHeader('From', from);
|
||||
headerLines += addressHeader('From', from);
|
||||
} else {
|
||||
// Add From Block (hardcoded to puter.email for now)
|
||||
const userinfo = await puter.getUser();
|
||||
@@ -229,8 +315,8 @@ export async function compose({ from, to, cc, bcc, subject, replyTo, attachments
|
||||
const inlineAttachments = attachments.filter(a => a.cid);
|
||||
const regularAttachments = attachments.filter(a => !a.cid);
|
||||
|
||||
const inlineParts = await Promise.all(inlineAttachments.map(composeAttachment));
|
||||
const regularParts = await Promise.all(regularAttachments.map(composeAttachment));
|
||||
const inlineParts = await Promise.all(inlineAttachments.map(a => composeAttachment(a, puter)));
|
||||
const regularParts = await Promise.all(regularAttachments.map(a => composeAttachment(a, puter)));
|
||||
|
||||
// Build the text/html content as one part. Only wrap it in its own nested
|
||||
// multipart/alternative (fresh boundary) when both text and html are present.
|
||||
|
||||
@@ -502,8 +502,63 @@ describe('compose', () => {
|
||||
await expect(compose({ ...baseOptions, attachments: [] })).rejects.toThrow(/text, html, or an attachment/);
|
||||
});
|
||||
|
||||
it('rejects a non-ASCII subject', async () => {
|
||||
await expect(compose({ ...baseOptions, subject: 'héllo', text: 'hi' })).rejects.toThrow(/not valid ASCII/);
|
||||
it('encodes a non-ASCII subject as RFC 2047 words instead of refusing it', async () => {
|
||||
const eml = await compose({ ...baseOptions, subject: 'héllo', text: 'hi' });
|
||||
expect(eml).toContain('Subject: =?UTF-8?B?aMOpbGxv?=\r\n');
|
||||
expect(isASCII(eml.split('\r\n\r\n')[0].replace(/\r\n/g, ''))).toBe(true);
|
||||
});
|
||||
|
||||
it('folds a long non-ASCII subject into words of at most 75 characters', async () => {
|
||||
const subject = 'Réunion — 東京 🎉 '.repeat(12);
|
||||
const eml = await compose({ ...baseOptions, subject, text: 'hi' });
|
||||
const head = eml.split('\r\n\r\n')[0];
|
||||
const subjectLines = head.slice(head.indexOf('Subject: ')).split('\r\n');
|
||||
const words = [subjectLines[0].slice('Subject: '.length)];
|
||||
for ( const line of subjectLines.slice(1) ) {
|
||||
if ( ! line.startsWith(' ') ) break;
|
||||
words.push(line.slice(1));
|
||||
}
|
||||
expect(words.length).toBeGreaterThan(1);
|
||||
for ( const word of words ) {
|
||||
expect(word).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
|
||||
expect(word.length).toBeLessThanOrEqual(75);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses control characters in a subject, encoded or not', async () => {
|
||||
await expect(compose({ ...baseOptions, subject: 'hi\r\nBcc: x@y.z', text: 'hi' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
await expect(compose({ ...baseOptions, subject: 'héllo\r\nBcc: x@y.z', text: 'hi' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
});
|
||||
|
||||
it('refuses a non-ASCII address while allowing a non-ASCII display name', async () => {
|
||||
await expect(compose({ ...baseOptions, to: 'ü@example.com', text: 'hi' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
await expect(compose({ ...baseOptions, to: 'Jürgen <ü@example.com>', text: 'hi' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
await expect(compose({ ...baseOptions, to: 'Jürgen <j@example.com>', text: 'hi' })).resolves.toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('rejects with a coded PuterJSError, per the SDK error contract', async () => {
|
||||
await expect(compose({ subject: 'hi', text: 'hi' }))
|
||||
.rejects.toMatchObject({ name: 'PuterJSError', code: 'invalid_request' });
|
||||
await expect(composeAttachment({ uid: 'uid-1' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
expect(() => emlHeader('Subject', 'héllo')).toThrow(expect.objectContaining({ code: 'invalid_request' }));
|
||||
});
|
||||
|
||||
it('uses the instance it is given rather than the global one', async () => {
|
||||
const own = {
|
||||
fs: { read: vi.fn(async () => new Blob([PNG_BYTES], { type: 'image/png' })) },
|
||||
getUser: vi.fn(async () => ({ username: 'carol' })),
|
||||
};
|
||||
const eml = await compose({ to: 'bob@example.com', subject: 'hi', html: '<p/>', attachments: [{ path: '~/x.png', cid: 'c' }] }, own);
|
||||
expect(eml).toContain('From: carol@puter.email\r\n');
|
||||
expect(own.fs.read).toHaveBeenCalledWith('~/x.png');
|
||||
expect(own.getUser).toHaveBeenCalled();
|
||||
expect(fsRead).not.toHaveBeenCalled();
|
||||
expect(getUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,6 +568,24 @@ describe('compose round-trips through a MIME parser', () => {
|
||||
// The parser terminates every text body with a newline of its own.
|
||||
const bodyOf = (str) => str?.replace(/\n$/, '');
|
||||
|
||||
it('recovers a UTF-8 subject and display names', async () => {
|
||||
const subject = 'Grüße ✓ — 東京 🎉 '.repeat(6).trim();
|
||||
const parsed = await parse({
|
||||
subject,
|
||||
to: ['"José Pérez" <jose@example.com>', 'plain@example.com'],
|
||||
cc: 'Zoë <zoe@example.com>',
|
||||
from: 'Ålice <alice@puter.email>',
|
||||
text: 'hi',
|
||||
});
|
||||
expect(parsed.subject).toBe(subject);
|
||||
expect(parsed.to).toEqual([
|
||||
{ address: 'jose@example.com', name: 'José Pérez' },
|
||||
{ address: 'plain@example.com', name: '' },
|
||||
]);
|
||||
expect(parsed.cc).toEqual([{ address: 'zoe@example.com', name: 'Zoë' }]);
|
||||
expect(parsed.from).toEqual({ address: 'alice@puter.email', name: 'Ålice' });
|
||||
});
|
||||
|
||||
it('recovers a plain text body', async () => {
|
||||
const parsed = await parse({ text: 'hello there' });
|
||||
expect(bodyOf(parsed.text)).toBe('hello there');
|
||||
|
||||
@@ -32,7 +32,8 @@ import { list } from './list.js';
|
||||
*
|
||||
* @typedef {Object} EmailSendResult
|
||||
* @property {string | null} messageId First transport message id reported for this send, when available.
|
||||
* @property {number} cost Total charge for this send, in microcents.
|
||||
* @property {number} cost Total charge for this send, in microcents: one delivery's price for each
|
||||
* recipient that got a copy.
|
||||
* @property {string[]} suppressed Recipients omitted because they opted out of this app's mail.
|
||||
* @property {string[]} failed Recipients whose delivery attempt failed. Everyone else got their copy —
|
||||
* retry with just these addresses. A send where every delivery fails rejects instead.
|
||||
@@ -82,7 +83,8 @@ const preprocessSendArgs = (args) => {
|
||||
* // Inline or Puter-FS attachments:
|
||||
* attachments: [
|
||||
* { filename, content, contentType }, // content = base64
|
||||
* { path: '~/Documents/report.pdf' }, // streamed server-side
|
||||
* { path: '~/Documents/report.pdf' }, // the caller's file, streamed server-side
|
||||
* { uid: LOGO_UID }, // a uid may also name a file of the worker owner's
|
||||
* ],
|
||||
* });
|
||||
* });
|
||||
@@ -141,6 +143,9 @@ export class EmailModule extends PuterModule {
|
||||
iface: 'puter-transactional-email',
|
||||
method: 'sendTransactional',
|
||||
argNames: ['to', 'subject', 'body'],
|
||||
// This module's instance, not `globalThis.puter`: in a worker the two
|
||||
// differ (`user.puter` vs `me.puter`), and the caller is the one billed.
|
||||
puter: this.puter,
|
||||
preprocess: preprocessSendArgs,
|
||||
upgradePrompt: {
|
||||
method: 'puter.email.sendTransactional',
|
||||
@@ -157,7 +162,15 @@ export class EmailModule extends PuterModule {
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
send = async (options) => {
|
||||
const req = await fetchUrl(`${this.APIOrigin}/email/send`, { method: "POST", includePuterAuth: true, body: new Blob([await compose(options)], { type: 'message/rfc822' }) });
|
||||
// `includePuterAuth` reads the global instance's token, so any other
|
||||
// instance (a worker's `user.puter`) sends its own.
|
||||
const isGlobal = this.puter === globalThis.puter;
|
||||
const req = await fetchUrl(`${this.APIOrigin}/email/send`, {
|
||||
method: "POST",
|
||||
includePuterAuth: isGlobal,
|
||||
...(isGlobal ? {} : { authToken: this.authToken }),
|
||||
body: new Blob([await compose(options, this.puter)], { type: 'message/rfc822' }),
|
||||
});
|
||||
const result = await req.json();
|
||||
if ( ! req.ok ) {
|
||||
promptIfUpgradeRequired(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* The email methods have to act for the instance they hang off, not for
|
||||
* `globalThis.puter`: in a worker, `user.puter` and `me.puter` are different
|
||||
* identities, and the one making the call is the one billed and rate-limited.
|
||||
*/
|
||||
|
||||
const captured = vi.hoisted(() => ({ specs: [], fetches: [] }));
|
||||
|
||||
vi.mock('../../lib/utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
makeDriverMethod: (spec) => {
|
||||
captured.specs.push(spec);
|
||||
return async () => ({});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/networkUtils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
fetchUrl: async (url, opts) => {
|
||||
captured.fetches.push({ url, opts });
|
||||
return { ok: true, status: 200, json: async () => ({ delivered: [] }) };
|
||||
},
|
||||
}));
|
||||
|
||||
const { EmailModule } = await import('./Email.js');
|
||||
|
||||
const origPuter = globalThis.puter;
|
||||
|
||||
const makePuter = (username, authToken) => ({
|
||||
APIOrigin: 'https://api.example.test',
|
||||
authToken,
|
||||
getUser: vi.fn(async () => ({ username })),
|
||||
fs: { read: vi.fn() },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
captured.specs.length = 0;
|
||||
captured.fetches.length = 0;
|
||||
globalThis.puter = makePuter('global', 'global-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.puter = origPuter;
|
||||
});
|
||||
|
||||
describe('EmailModule binds to its own instance', () => {
|
||||
it('hands sendTransactional its own puter, not the global one', () => {
|
||||
const own = makePuter('caller', 'caller-token');
|
||||
new EmailModule(own);
|
||||
const spec = captured.specs.find(s => s.method === 'sendTransactional');
|
||||
expect(spec.puter).toBe(own);
|
||||
});
|
||||
|
||||
it('sends from, and authenticates as, a non-global instance', async () => {
|
||||
const own = makePuter('caller', 'caller-token');
|
||||
const email = new EmailModule(own);
|
||||
await email.send({ to: 'bob@example.com', subject: 'hi', text: 'hi' });
|
||||
|
||||
expect(own.getUser).toHaveBeenCalled();
|
||||
expect(globalThis.puter.getUser).not.toHaveBeenCalled();
|
||||
const [{ url, opts }] = captured.fetches;
|
||||
expect(url).toBe('https://api.example.test/email/send');
|
||||
expect(opts.includePuterAuth).toBe(false);
|
||||
expect(opts.authToken).toBe('caller-token');
|
||||
expect(await opts.body.text()).toContain('From: caller@puter.email\r\n');
|
||||
});
|
||||
|
||||
it('keeps the live-token path for the global instance', async () => {
|
||||
const email = new EmailModule(globalThis.puter);
|
||||
await email.send({ to: 'bob@example.com', subject: 'hi', text: 'hi' });
|
||||
const [{ opts }] = captured.fetches;
|
||||
expect(opts.includePuterAuth).toBe(true);
|
||||
expect(opts.authToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -122,15 +122,24 @@ export const toSummary = (entry, folder) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a listing resumes: the day folder being walked and, while it still has
|
||||
* pages, the readdir cursor within it.
|
||||
* Where a listing resumes: the day folder being walked; while it still has
|
||||
* pages, the readdir cursor within it; and the readdir cursor of the page of
|
||||
* day folders that day was read from, so resuming does not re-list every day
|
||||
* newer than it. A cursor without that last part (issued before it existed)
|
||||
* walks the days from the top, which is only slower.
|
||||
*
|
||||
* @typedef {{ folder: EmailFolder, day: string, fsCursor?: string }} ListPosition
|
||||
* @typedef {{ folder: EmailFolder, day: string, fsCursor?: string, daysCursor?: string }} ListPosition
|
||||
*/
|
||||
|
||||
/** @param {ListPosition} position @returns {string} */
|
||||
export const encodeCursor = ({ folder, day, fsCursor }) => {
|
||||
const payload = { v: 1, f: folder, d: day, ...(fsCursor ? { c: fsCursor } : {}) };
|
||||
export const encodeCursor = ({ folder, day, fsCursor, daysCursor }) => {
|
||||
const payload = {
|
||||
v: 1,
|
||||
f: folder,
|
||||
d: day,
|
||||
...(fsCursor ? { c: fsCursor } : {}),
|
||||
...(daysCursor ? { dc: daysCursor } : {}),
|
||||
};
|
||||
return btoa(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
@@ -153,7 +162,12 @@ export const decodeCursor = (cursor, folder) => {
|
||||
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 } : {}) };
|
||||
return {
|
||||
folder,
|
||||
day: payload.d,
|
||||
...(typeof payload.c === 'string' ? { fsCursor: payload.c } : {}),
|
||||
...(typeof payload.dc === 'string' ? { daysCursor: payload.dc } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/** A readdir on a folder that was never created. @param {unknown} e */
|
||||
|
||||
@@ -12,36 +12,49 @@ import {
|
||||
/** @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;
|
||||
// Day folders read per directory page. Small on purpose: a listing resumes
|
||||
// from the page its cursor's day came from, so this bounds what every later
|
||||
// page re-reads before reaching its own day. About a month of a mailbox that
|
||||
// gets mail daily.
|
||||
const DAY_PAGE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Every day folder in `folder`, newest first. A mailbox that was never
|
||||
* written to has no folder at all, which lists as empty.
|
||||
* The day folders in `folder`, newest first, read a directory page at a time
|
||||
* starting at `daysCursor`. Each day comes with the cursor of the page it was
|
||||
* read from, which is where a later listing can pick the walk back up. A
|
||||
* mailbox that was never written to has no folder at all, which walks as
|
||||
* empty.
|
||||
*
|
||||
* @this {import('./Email.js').EmailModule}
|
||||
* @param {import('../../index.js').Puter} puter
|
||||
* @param {EmailFolder} folder
|
||||
* @returns {Promise<string[]>}
|
||||
* @param {string | undefined} daysCursor
|
||||
* @returns {AsyncGenerator<{ day: string, daysCursor?: 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);
|
||||
async function* walkDays (puter, folder, daysCursor) {
|
||||
let cursor = daysCursor ?? null;
|
||||
while ( true ) {
|
||||
/** @type {{ items: Array<Record<string, any>>, cursor?: string }} */
|
||||
let page;
|
||||
try {
|
||||
page = await puter.fs.readdir({
|
||||
path: folderRoot(folder),
|
||||
limit: DAY_PAGE_LIMIT,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'desc',
|
||||
cursor,
|
||||
});
|
||||
} catch (e) {
|
||||
if ( ! isMissingDirectory(e) ) throw e;
|
||||
return;
|
||||
}
|
||||
for ( const entry of page.items ) {
|
||||
if ( entry.is_dir && isDayName(entry.name) ) {
|
||||
yield { day: entry.name, ...(cursor ? { daysCursor: cursor } : {}) };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ( ! isMissingDirectory(e) ) throw e;
|
||||
if ( ! page.cursor ) return;
|
||||
cursor = page.cursor;
|
||||
}
|
||||
return days.sort().reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,18 +69,20 @@ async function listDays (folder) {
|
||||
* @returns {Promise<EmailListPage>}
|
||||
*/
|
||||
async function fetchPage (folder, limit, position) {
|
||||
const days = await listDays.call(this, folder);
|
||||
const days = walkDays(this.puter, folder, position?.daysCursor);
|
||||
/** @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;
|
||||
let next = await days.next();
|
||||
while ( position && ! next.done && next.value.day > position.day ) {
|
||||
next = await days.next();
|
||||
}
|
||||
let fsCursor = position && ! next.done && next.value.day === position.day ? position.fsCursor : undefined;
|
||||
|
||||
while ( dayIndex < days.length && items.length < limit ) {
|
||||
const day = days[dayIndex];
|
||||
while ( ! next.done && items.length < limit ) {
|
||||
const { day, daysCursor } = next.value;
|
||||
// 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.
|
||||
@@ -94,13 +109,14 @@ async function fetchPage (folder, limit, position) {
|
||||
if ( ! fsCursor ) break;
|
||||
}
|
||||
if ( fsCursor ) {
|
||||
return { items, cursor: encodeCursor({ folder, day, fsCursor }) };
|
||||
return { items, cursor: encodeCursor({ folder, day, fsCursor, daysCursor }) };
|
||||
}
|
||||
dayIndex += 1;
|
||||
next = await days.next();
|
||||
}
|
||||
|
||||
if ( dayIndex < days.length ) {
|
||||
return { items, cursor: encodeCursor({ folder, day: days[dayIndex] }) };
|
||||
if ( ! next.done ) {
|
||||
const { day, daysCursor } = next.value;
|
||||
return { items, cursor: encodeCursor({ folder, day, daysCursor }) };
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
@@ -79,6 +79,13 @@ describe('cursors', () => {
|
||||
expect(decodeCursor(encodeCursor({ folder: 'sent', day: '2024-03-15' }), 'sent')).toEqual({ folder: 'sent', day: '2024-03-15' });
|
||||
});
|
||||
|
||||
it('carries the day-listing cursor, and reads one issued without it', () => {
|
||||
const position = { folder: 'inbox', day: '2024-03-15', fsCursor: 'abc', daysCursor: 'dc1' };
|
||||
expect(decodeCursor(encodeCursor(position), 'inbox')).toEqual(position);
|
||||
const legacy = btoa(JSON.stringify({ v: 1, f: 'inbox', d: '2024-03-15', c: 'abc' }));
|
||||
expect(decodeCursor(legacy, 'inbox')).toEqual({ folder: 'inbox', day: '2024-03-15', fsCursor: 'abc' });
|
||||
});
|
||||
|
||||
it('treats null and undefined as the first page', () => {
|
||||
expect(decodeCursor(null, 'inbox')).toBeUndefined();
|
||||
expect(decodeCursor(undefined, 'inbox')).toBeUndefined();
|
||||
@@ -208,6 +215,61 @@ describe('list()', () => {
|
||||
expect(calls.every(c => c.path.startsWith('~/.mail/sent'))).toBe(true);
|
||||
});
|
||||
|
||||
it('resumes the day walk where the cursor left it instead of re-listing from the top', async () => {
|
||||
// 100 days, one message each: far more day folders than one page of them.
|
||||
const tree = { '~/.mail/objects': [] };
|
||||
const dayIds = [];
|
||||
for ( let i = 0; i < 100; i++ ) {
|
||||
const ms = Date.UTC(2024, 0, 1) + i * 24 * 60 * 60 * 1000;
|
||||
const day = new Date(ms).toISOString().slice(0, 10);
|
||||
const id = uuidAt(ms + 1000);
|
||||
tree['~/.mail/objects'].push(day);
|
||||
tree[`~/.mail/objects/${ day }`] = [`${ id }--${ toB64Url(day) }`];
|
||||
dayIds.unshift(id);
|
||||
}
|
||||
const { fs, calls } = fakeFs(tree);
|
||||
const email = moduleOver(fs);
|
||||
|
||||
const seen = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const before = calls.length;
|
||||
const page = await email.list.call(email, { limit: 7, cursor });
|
||||
seen.push(...page.items.map(m => m.id));
|
||||
const dayListings = calls.slice(before).filter(c => c.path === '~/.mail/objects');
|
||||
// Never more than the page holding the cursor's day plus the one
|
||||
// after it, however deep into the mailbox the cursor points.
|
||||
expect(dayListings.length).toBeLessThanOrEqual(2);
|
||||
if ( cursor ) {
|
||||
const resumedFrom = decodeCursor(cursor, 'inbox').daysCursor ?? null;
|
||||
expect(dayListings[0].cursor).toBe(resumedFrom);
|
||||
}
|
||||
cursor = page.cursor;
|
||||
} while ( cursor );
|
||||
expect(seen).toEqual(dayIds);
|
||||
// Only pages whose day sits in the first page of day folders start
|
||||
// there; the deeper ones resumed mid-folder.
|
||||
const fromTop = calls.filter(c => c.path === '~/.mail/objects' && ! c.cursor);
|
||||
expect(fromTop.length).toBeLessThanOrEqual(Math.ceil(30 / 7) + 1);
|
||||
expect(calls.some(c => c.path === '~/.mail/objects' && c.cursor)).toBe(true);
|
||||
});
|
||||
|
||||
it('resumes from a cursor issued before day-listing cursors existed', async () => {
|
||||
const { fs } = fakeFs(inboxTree);
|
||||
const email = moduleOver(fs);
|
||||
const legacy = btoa(JSON.stringify({ v: 1, f: 'inbox', d: '2024-03-15' }));
|
||||
const page = await email.list.call(email, { limit: 10, cursor: legacy });
|
||||
expect(page.items.map(m => m.id)).toEqual([ids.d15a, ids.d14b, ids.d14a]);
|
||||
});
|
||||
|
||||
it('continues from the next older day when the cursor day was deleted', async () => {
|
||||
const { fs } = fakeFs({ ...inboxTree, '~/.mail/objects': ['2024-03-14', '2024-03-16'] });
|
||||
const email = moduleOver(fs);
|
||||
const gone = encodeCursor({ folder: 'inbox', day: '2024-03-15', fsCursor: '5' });
|
||||
const page = await email.list.call(email, { limit: 10, cursor: gone });
|
||||
expect(page.items.map(m => m.id)).toEqual([ids.d14b, ids.d14a]);
|
||||
});
|
||||
|
||||
it('rejects offset, includeTotal, and a foreign cursor before any request', async () => {
|
||||
const { fs, calls } = fakeFs(inboxTree);
|
||||
const email = moduleOver(fs);
|
||||
|
||||
Reference in new issue
Block a user