From b78daed5622289e99ef8df43e1ac5adb18852766 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 23 May 2026 21:30:32 +0800 Subject: [PATCH] feat(send): gate email-in to Plus, Pro, and Lifetime plans (#4280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email-in (`@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) --- .../services/send-address-plan-gate.test.ts | 184 ++++++++++++++++++ .../integrations/SendToReadestForm.tsx | 46 ++++- .../readest-app/src/pages/api/send/address.ts | 24 ++- .../readest-app/src/pages/api/send/senders.ts | 22 ++- apps/readest-app/src/utils/access.ts | 15 ++ .../workers/send-email/src/index.ts | 20 ++ 6 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/send-address-plan-gate.test.ts diff --git a/apps/readest-app/src/__tests__/services/send-address-plan-gate.test.ts b/apps/readest-app/src/__tests__/services/send-address-plan-gate.test.ts new file mode 100644 index 00000000..d946d7c4 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/send-address-plan-gate.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { isEmailInPlan } from '@/utils/access'; +import type { UserPlan } from '@/types/quota'; + +// Hoisted mocks — installed before importing the route handler. + +const validateUserMock = vi.fn(); +const getUserProfilePlanMock = vi.fn(); +vi.mock('@/utils/access', async () => { + // Reach the real module so `isEmailInPlan` keeps the production logic + // (we test it directly below) while patching the two functions the + // route actually calls. + const actual = await vi.importActual('@/utils/access'); + return { + ...actual, + validateUserAndToken: (...args: unknown[]) => validateUserMock(...args), + getUserProfilePlan: (...args: unknown[]) => getUserProfilePlanMock(...args), + }; +}); + +vi.mock('@/utils/cors', () => ({ + corsAllMethods: vi.fn(), + runMiddleware: vi.fn(async () => undefined), +})); + +// Supabase admin client — must not be touched on the gate-blocked path; +// if any test calls into it the gate has leaked. +const supabaseTouched = vi.fn(); + +// Permissive chain proxy: every property access returns a callable that +// returns the same proxy; awaiting or calling a terminal returns +// `{ data: null, error: null }`. Sufficient for verifying the gate +// passes without modelling the full PostgREST builder. +const chainProxy = (): unknown => { + const empty = { data: null, error: null }; + const handler: ProxyHandler<{ then?: unknown }> = { + get(_target, prop) { + if (prop === 'then') return undefined; + return (..._args: unknown[]) => { + // Terminal methods return the empty result directly. + if (prop === 'maybeSingle' || prop === 'single') return Promise.resolve(empty); + return chainProxy(); + }; + }, + }; + return new Proxy({}, handler); +}; + +vi.mock('@/utils/supabase', () => ({ + createSupabaseAdminClient: () => { + supabaseTouched(); + return { from: () => chainProxy() }; + }, +})); + +const { default: addressHandler } = await import('@/pages/api/send/address'); +const { default: sendersHandler } = await import('@/pages/api/send/senders'); + +interface MockRes { + status: ReturnType; + json: ReturnType; + _status: number; + _body: Record | undefined; +} + +function makeRes(): MockRes { + const res: MockRes = { status: vi.fn(), json: vi.fn(), _status: 0, _body: undefined }; + res.status.mockImplementation((code: number) => { + res._status = code; + return res as unknown as NextApiResponse; + }); + res.json.mockImplementation((body: Record) => { + res._body = body; + return res as unknown as NextApiResponse; + }); + return res; +} + +function makeReq(method: 'GET' | 'POST', body?: unknown): NextApiRequest { + return { + method, + headers: { authorization: 'Bearer testtoken' }, + body, + query: {}, + } as unknown as NextApiRequest; +} + +beforeEach(() => { + validateUserMock.mockReset(); + getUserProfilePlanMock.mockReset(); + supabaseTouched.mockReset(); + validateUserMock.mockResolvedValue({ + user: { id: 'user-1', email: 'u@example.com' }, + token: 'testtoken', + }); +}); + +describe('isEmailInPlan helper', () => { + test('allows plus, pro, and lifetime (purchase)', () => { + expect(isEmailInPlan('plus')).toBe(true); + expect(isEmailInPlan('pro')).toBe(true); + expect(isEmailInPlan('purchase')).toBe(true); + }); + + test('blocks the free tier', () => { + expect(isEmailInPlan('free')).toBe(false); + }); +}); + +describe('/api/send/address — plan gate', () => { + test('returns 403 with code=plan_required for free users on GET (lazy-create blocked)', async () => { + getUserProfilePlanMock.mockReturnValue('free' satisfies UserPlan); + const res = makeRes(); + await addressHandler(makeReq('GET'), res as unknown as NextApiResponse); + + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ + code: 'plan_required', + plan: 'free', + requiredPlans: ['plus', 'pro', 'purchase'], + }); + // Critically: no Supabase access on the gate-blocked path. A free + // user must never get a row allocated in `send_addresses`. + expect(supabaseTouched).not.toHaveBeenCalled(); + }); + + test('returns 403 for free users on POST (rotation blocked)', async () => { + getUserProfilePlanMock.mockReturnValue('free' satisfies UserPlan); + const res = makeRes(); + await addressHandler(makeReq('POST', { slug: 'myname' }), res as unknown as NextApiResponse); + + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ code: 'plan_required' }); + expect(supabaseTouched).not.toHaveBeenCalled(); + }); + + test.each([ + 'plus', + 'pro', + 'purchase', + ])('lets %s users through the gate', async (plan) => { + getUserProfilePlanMock.mockReturnValue(plan); + const res = makeRes(); + await addressHandler(makeReq('GET'), res as unknown as NextApiResponse); + // The gate is past — Supabase was touched. We don't care here what + // the eventual response is (the Supabase mock returns no row). + expect(supabaseTouched).toHaveBeenCalled(); + expect(res._status).not.toBe(403); + }); +}); + +describe('/api/send/senders — plan gate', () => { + test('returns 403 for free users on GET (list blocked)', async () => { + getUserProfilePlanMock.mockReturnValue('free' satisfies UserPlan); + const res = makeRes(); + await sendersHandler(makeReq('GET'), res as unknown as NextApiResponse); + + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ code: 'plan_required' }); + expect(supabaseTouched).not.toHaveBeenCalled(); + }); + + test('returns 403 for free users on POST (add sender blocked)', async () => { + getUserProfilePlanMock.mockReturnValue('free' satisfies UserPlan); + const res = makeRes(); + await sendersHandler( + makeReq('POST', { email: 'friend@example.com' }), + res as unknown as NextApiResponse, + ); + + expect(res._status).toBe(403); + expect(res._body).toMatchObject({ code: 'plan_required' }); + expect(supabaseTouched).not.toHaveBeenCalled(); + }); + + test.each(['plus', 'pro', 'purchase'])('lets %s users past the gate', async (plan) => { + getUserProfilePlanMock.mockReturnValue(plan); + const res = makeRes(); + await sendersHandler(makeReq('GET'), res as unknown as NextApiResponse); + expect(supabaseTouched).toHaveBeenCalled(); + expect(res._status).not.toBe(403); + }); +}); diff --git a/apps/readest-app/src/components/settings/integrations/SendToReadestForm.tsx b/apps/readest-app/src/components/settings/integrations/SendToReadestForm.tsx index 57070d6e..c3610451 100644 --- a/apps/readest-app/src/components/settings/integrations/SendToReadestForm.tsx +++ b/apps/readest-app/src/components/settings/integrations/SendToReadestForm.tsx @@ -7,8 +7,10 @@ import { useAuth } from '@/context/AuthContext'; import { fetchWithAuth } from '@/utils/fetch'; import { getAPIBaseUrl } from '@/services/environment'; import { isInboxDrainEnabled, setInboxDrainEnabled } from '@/services/send/devicePrefs'; -import { navigateToLogin } from '@/utils/nav'; +import { getAccessToken, getUserProfilePlan, isEmailInPlan } from '@/utils/access'; +import { navigateToLogin, navigateToProfile } from '@/utils/nav'; import { eventDispatcher } from '@/utils/event'; +import type { UserPlan } from '@/types/quota'; import type { DBSendAllowedSender, DBSendInboxItem } from '@/types/sendRecords'; import SubPageHeader from '../SubPageHeader'; import { BoxedList, SectionTitle, SettingLabel, SettingsSwitchRow } from '../primitives'; @@ -45,6 +47,11 @@ const SendToReadestForm: React.FC = ({ onBack }) => { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [drainEnabled, setDrainEnabled] = useState(() => isInboxDrainEnabled()); + // `null` while we're still reading the JWT; lets the loading skeleton + // stay up rather than briefly flashing the upgrade card for a paid user + // on a slow client. + const [userPlan, setUserPlan] = useState(null); + const canUseEmailIn = userPlan !== null && isEmailInPlan(userPlan); // Editing affordances stay collapsed once configured, keeping the panel // minimal; the refresh / plus icons reveal the input rows. const [editingAddress, setEditingAddress] = useState(false); @@ -61,6 +68,15 @@ const SendToReadestForm: React.FC = ({ onBack }) => { const load = useCallback(async () => { try { + // Resolve the user's plan first — free users get the upgrade card and + // we skip the address / senders calls entirely (they'd 403 anyway). + const token = await getAccessToken(); + const plan: UserPlan = token ? getUserProfilePlan(token) : 'free'; + setUserPlan(plan); + if (!isEmailInPlan(plan)) { + setLoading(false); + return; + } const [addrRes, sendersRes, inboxRes] = await Promise.all([ fetchWithAuth(`${apiBase}/send/address`, { method: 'GET' }), fetchWithAuth(`${apiBase}/send/senders`, { method: 'GET' }), @@ -207,6 +223,34 @@ const SendToReadestForm: React.FC = ({ onBack }) => { ))} + ) : !canUseEmailIn ? ( + // Free-tier gate. One card, one CTA, plus a callout for the free + // clip channels so the panel doesn't read as pure paywall. +
+
+ + + +

{_('Email books straight to your library')}

+

+ {_( + 'Forward attachments and articles to your private Readest address. Available on the Plus, Pro, and Lifetime plans.', + )} +

+ +

+ {_( + 'You can still clip articles for free with the in-app Send button, the mobile Share menu, or the browser extension.', + )} +

+
+
) : (
diff --git a/apps/readest-app/src/pages/api/send/address.ts b/apps/readest-app/src/pages/api/send/address.ts index 4107e9e6..3ede363e 100644 --- a/apps/readest-app/src/pages/api/send/address.ts +++ b/apps/readest-app/src/pages/api/send/address.ts @@ -1,7 +1,12 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { createSupabaseAdminClient } from '@/utils/supabase'; import { corsAllMethods, runMiddleware } from '@/utils/cors'; -import { validateUserAndToken } from '@/utils/access'; +import { + EMAIL_IN_PLANS, + getUserProfilePlan, + isEmailInPlan, + validateUserAndToken, +} from '@/utils/access'; import { generateSendAddress, buildSendAddress, @@ -27,11 +32,24 @@ const fullAddress = (localPart: string) => `${localPart}@${SEND_EMAIL_DOMAIN}`; export default async function handler(req: NextApiRequest, res: NextApiResponse) { await runMiddleware(req, res, corsAllMethods); - const { user } = await validateUserAndToken(req.headers['authorization']); - if (!user) { + const { user, token } = await validateUserAndToken(req.headers['authorization']); + if (!user || !token) { return res.status(403).json({ error: 'Not authenticated' }); } + // Email-in is a paid feature. The client renders a friendly upgrade + // card on receiving this response, so the structured body (code + + // requiredPlans) matters — UI keys off it. + const plan = getUserProfilePlan(token); + if (!isEmailInPlan(plan)) { + return res.status(403).json({ + error: 'Email-in is available on the Plus, Pro, and Lifetime plans', + code: 'plan_required', + plan, + requiredPlans: EMAIL_IN_PLANS, + }); + } + const supabase = createSupabaseAdminClient(); if (req.method === 'GET') { diff --git a/apps/readest-app/src/pages/api/send/senders.ts b/apps/readest-app/src/pages/api/send/senders.ts index cf06cd54..0f011677 100644 --- a/apps/readest-app/src/pages/api/send/senders.ts +++ b/apps/readest-app/src/pages/api/send/senders.ts @@ -1,7 +1,12 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { createSupabaseAdminClient } from '@/utils/supabase'; import { corsAllMethods, runMiddleware } from '@/utils/cors'; -import { validateUserAndToken } from '@/utils/access'; +import { + EMAIL_IN_PLANS, + getUserProfilePlan, + isEmailInPlan, + validateUserAndToken, +} from '@/utils/access'; import { normalizeSenderEmail } from '@/services/send/sendAddress'; import type { DBSendAllowedSender } from '@/types/sendRecords'; @@ -20,11 +25,22 @@ const MAX_EMAIL_LENGTH = 254; export default async function handler(req: NextApiRequest, res: NextApiResponse) { await runMiddleware(req, res, corsAllMethods); - const { user } = await validateUserAndToken(req.headers['authorization']); - if (!user) { + const { user, token } = await validateUserAndToken(req.headers['authorization']); + if (!user || !token) { return res.status(403).json({ error: 'Not authenticated' }); } + // Sender allowlist only matters for the email-in channel — gate it too. + const plan = getUserProfilePlan(token); + if (!isEmailInPlan(plan)) { + return res.status(403).json({ + error: 'Email-in is available on the Plus, Pro, and Lifetime plans', + code: 'plan_required', + plan, + requiredPlans: EMAIL_IN_PLANS, + }); + } + const supabase = createSupabaseAdminClient(); if (req.method === 'GET') { diff --git a/apps/readest-app/src/utils/access.ts b/apps/readest-app/src/utils/access.ts index 1bb19a69..3bc13471 100644 --- a/apps/readest-app/src/utils/access.ts +++ b/apps/readest-app/src/utils/access.ts @@ -30,6 +30,21 @@ export const getUserProfilePlan = (token: string): UserPlan => { return plan; }; +/** + * Plans that include the "Send to Readest via email" feature: Plus, + * Pro, and Lifetime (`purchase`). Free users see an upgrade card on + * the client and get a 403 from the server endpoints that allocate / + * rotate the address, plus a bounce from the inbound email Worker. + * + * Other Send channels (in-app `/send` page, mobile share-sheet, browser + * extension) stay open to free users — the gate is the personal email + * inbox only. + */ +export const EMAIL_IN_PLANS: readonly UserPlan[] = ['plus', 'pro', 'purchase']; + +export const isEmailInPlan = (plan: UserPlan): boolean => + (EMAIL_IN_PLANS as readonly UserPlan[]).includes(plan); + export const STORAGE_QUOTA_GRACE_BYTES = 10 * 1024 * 1024; // 10 MB grace export const getStoragePlanData = (token: string) => { diff --git a/apps/readest-app/workers/send-email/src/index.ts b/apps/readest-app/workers/send-email/src/index.ts index 8357b748..77e37d3d 100644 --- a/apps/readest-app/workers/send-email/src/index.ts +++ b/apps/readest-app/workers/send-email/src/index.ts @@ -67,6 +67,26 @@ export default { // 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) {