Files
readest/apps/readest-app/workers/send-email/src/index.ts
T
Huang Xin b78daed562 feat(send): gate email-in to Plus, Pro, and Lifetime plans (#4280)
Email-in (`<user>@readest.com`) is now a paid feature. The other
Send channels — in-app /send page, mobile share-sheet, browser
extension — stay open to free users.

Three enforcement layers:

- `pages/api/send/address.ts` and `pages/api/send/senders.ts` return
  403 with `{ code: 'plan_required', plan, requiredPlans }` for free
  users. No `send_addresses` row is allocated on the blocked path.
  `pages/api/send/inbox.ts` and `pages/api/send/inbox/file.ts` are
  deliberately left open — they're shared with the file-upload and
  extension channels.

- `workers/send-email` looks up `plans.plan` after resolving the
  recipient and bounces (not silently drops) inbound mail for free
  users with a one-sentence message pointing to upgrade plus the free
  clip channels. Bounce rather than drop so a downgraded user
  understands why their mail stops landing.

- `components/settings/integrations/SendToReadestForm.tsx` reads the
  user's plan from the JWT before any API call. Free users see one
  friendly card — headline, value prop, "View plans" CTA → /user, and
  a softer line about the free alternatives — instead of address /
  senders / activity sections of disabled controls. The
  IntegrationsPanel NavigationRow stays visible so users can discover
  the feature.

Single source of truth for the entitled tier set: `EMAIL_IN_PLANS` +
`isEmailInPlan(plan)` in `src/utils/access.ts`. Mirror copies live in
the Worker (no shared import surface) — keep them in sync.

Edge cases:
- Downgraded user: existing `send_addresses` row stays. All three
  layers block; re-upgrading silently restores the same address.
- Loading flicker: `userPlan` starts as `null` so the loading skeleton
  stays up rather than briefly flashing the upgrade card for a paid
  user on a slow client.

12 new unit tests cover the gate on `/api/send/address` and
`/api/send/senders` (GET + POST blocked for free users, no Supabase
access on the blocked path, allowed for plus / pro / purchase).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:30:32 +02:00

188 lines
6.6 KiB
TypeScript

import { createClient } from '@supabase/supabase-js';
import PostalMime from 'postal-mime';
interface Env {
SEND_EMAIL_DOMAIN: string;
MAX_MESSAGE_BYTES: string;
INBOX_PENDING_LIMIT: string;
SUPABASE_URL: string;
SUPABASE_SERVICE_ROLE_KEY: string;
INBOX_BUCKET: R2Bucket;
}
// Extensions Readest reads natively or converts client-side after import.
const ACCEPTED_EXTS = new Set([
'epub',
'mobi',
'azw',
'azw3',
'fb2',
'fbz',
'zip',
'cbz',
'pdf',
'txt',
'docx',
'rtf',
'html',
'htm',
]);
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
const extensionOf = (filename: string): string =>
filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : '';
/** First `#tag` token in an email subject (`my book #scifi` -> `scifi`). */
const parseSubjectTag = (subject: string | undefined): string | null => {
if (!subject) return null;
const match = subject.match(/#([\p{L}\p{N}_-]{1,40})/u);
return match ? match[1]! : null;
};
export default {
async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
// 1. Resolve the recipient local part to a user.
const localPart = message.to.split('@')[0]!.toLowerCase();
const { data: addressRow } = await supabase
.from('send_addresses')
.select('user_id, enabled')
.eq('address', localPart)
.maybeSingle();
if (!addressRow || !addressRow.enabled) {
// Unknown address: reject silently — no backscatter to a guessed address.
message.setReject('Unknown address');
return;
}
const userId = addressRow.user_id as string;
// 2. Anti-spoofing is enforced UPSTREAM by Cloudflare Email Routing, in
// the SMTP session before this Worker ever runs: it requires SPF or DKIM
// to pass, rejects per the sender's DMARC policy, and applies RBL + spam
// scoring. The Worker therefore cannot (and need not) re-verify — Cloudflare
// exposes no verdict header, and the `From` header is already trustworthy
// by the time we see it. The approved-sender allowlist below is the gate.
// 2a. Plan gate. Email-in is a paid feature (Plus / Pro / Lifetime).
// We bounce — not silently drop — so a paid user who downgraded knows
// their books aren't coming through and where to go. Mirror of the
// server-API + client-UI gate; same plan-tier set as `EMAIL_IN_PLANS`
// in `src/utils/access.ts`.
const { data: planRow } = await supabase
.from('plans')
.select('plan')
.eq('id', userId)
.maybeSingle();
const userPlan = ((planRow?.plan as string | undefined) || 'free').toLowerCase();
if (!['plus', 'pro', 'purchase'].includes(userPlan)) {
message.setReject(
'Send-to-Readest email-in requires the Plus, Pro, or Lifetime plan. ' +
'Open the Readest app to upgrade, or clip articles for free with the ' +
'in-app Send button, the mobile Share menu, or the browser extension.',
);
return;
}
// 3. Size guard (Cloudflare's own ceiling is ~25-30 MB).
const maxBytes = Number(env.MAX_MESSAGE_BYTES) || 26_214_400;
if (message.rawSize > maxBytes) {
message.setReject('Message too large — use the Send page for large files');
return;
}
// 4. Parse the MIME message.
const rawBuffer = await new Response(message.raw).arrayBuffer();
const parsed = await PostalMime.parse(rawBuffer);
const fromEmail = normalizeEmail(parsed.from?.address ?? message.from);
// 5. Approved-sender allowlist.
const { data: senderRow } = await supabase
.from('send_allowed_senders')
.select('status')
.eq('user_id', userId)
.eq('email', fromEmail)
.maybeSingle();
if (!senderRow || senderRow.status !== 'approved') {
if (!senderRow) {
// Record the sender as pending so the user can approve it in settings.
await supabase
.from('send_allowed_senders')
.insert({ user_id: userId, email: fromEmail, status: 'pending' });
}
message.setReject('Sender not approved — approve it in Readest settings');
return;
}
// 6. Inbox quota — blunt a leaked-address flood. Count both pending and
// claimed items: a crashed drainer can leave items stuck in `claimed`
// until the lease expires, and those still occupy the inbox.
const limit = Number(env.INBOX_PENDING_LIMIT) || 50;
const { count } = await supabase
.from('send_inbox')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.in('status', ['pending', 'claimed']);
if ((count ?? 0) >= limit) {
message.setReject('Inbox is full — open Readest to process pending items');
return;
}
const subjectTag = parseSubjectTag(parsed.subject);
// 7. Pick the first accepted attachment.
const attachment = (parsed.attachments ?? []).find((a) =>
ACCEPTED_EXTS.has(extensionOf(a.filename ?? '')),
);
if (attachment) {
const inboxId = crypto.randomUUID();
const filename = attachment.filename ?? 'document';
const payloadKey = `inbox/${userId}/${inboxId}/${filename}`;
const body =
typeof attachment.content === 'string'
? new TextEncoder().encode(attachment.content)
: new Uint8Array(attachment.content);
await env.INBOX_BUCKET.put(payloadKey, body);
const { error: insertError } = await supabase.from('send_inbox').insert({
id: inboxId,
user_id: userId,
kind: 'file',
source: 'email',
payload_key: payloadKey,
filename,
subject_tag: subjectTag,
byte_size: body.byteLength,
});
if (insertError) {
// The inbox row is the source of truth; without it the R2 object is
// an unreachable orphan. Delete it and reject so the sender retries.
await env.INBOX_BUCKET.delete(payloadKey).catch(() => {});
message.setReject('Could not queue the message — please retry');
}
return;
}
// 8. No attachment: treat a URL in the body as a read-later capture.
const urlMatch = (parsed.text ?? '').match(/https?:\/\/\S+/);
if (urlMatch) {
const { error: insertError } = await supabase.from('send_inbox').insert({
user_id: userId,
kind: 'url',
source: 'email',
url: urlMatch[0],
subject_tag: subjectTag,
});
if (insertError) {
message.setReject('Could not queue the message — please retry');
}
return;
}
message.setReject('No supported attachment or link found');
},
};