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>
This commit is contained in:
Huang Xin
2026-05-23 21:30:32 +08:00
committed by GitHub
parent db1c474e77
commit b78daed562
6 changed files with 304 additions and 7 deletions
@@ -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<typeof import('@/utils/access')>('@/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<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
_status: number;
_body: Record<string, unknown> | 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<string, unknown>) => {
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<UserPlan>([
'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<UserPlan>(['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);
});
});
@@ -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<SendToReadestFormProps> = ({ 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<UserPlan | null>(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<SendToReadestFormProps> = ({ 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<SendToReadestFormProps> = ({ onBack }) => {
</div>
))}
</div>
) : !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.
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
<div className='flex flex-col items-center gap-3 px-6 py-8 text-center'>
<span className='bg-base-200 text-base-content/70 flex h-14 w-14 items-center justify-center rounded-full'>
<RiSendPlaneLine className='h-6 w-6' />
</span>
<h3 className='text-base font-semibold'>{_('Email books straight to your library')}</h3>
<p className='text-base-content/70 max-w-sm text-sm leading-relaxed'>
{_(
'Forward attachments and articles to your private Readest address. Available on the Plus, Pro, and Lifetime plans.',
)}
</p>
<button
type='button'
className='btn btn-contrast btn-sm mt-1'
onClick={() => navigateToProfile(router)}
>
{_('View plans')}
</button>
<p className='text-base-content/55 mt-2 max-w-sm text-xs leading-relaxed'>
{_(
'You can still clip articles for free with the in-app Send button, the mobile Share menu, or the browser extension.',
)}
</p>
</div>
</div>
) : (
<div className='space-y-6'>
<div className='w-full'>
+21 -3
View File
@@ -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') {
+19 -3
View File
@@ -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') {
+15
View File
@@ -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) => {
@@ -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) {