This commit is contained in:
Neal Shah authored and GitHub committed 2026-10-14 20:29:55 -04:00
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', () => {
+6 -2
View File
@@ -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
+12 -8
View File
@@ -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,
+116 -30
View File
@@ -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');
+16 -3
View File
@@ -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();
});
});
+20 -6
View File
@@ -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 */
+48 -32
View File
@@ -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);