forked from akai/readest
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:
@@ -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'>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user