diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 5ec3f196f..7b0717bca 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -162,7 +162,8 @@ "invalid_cron_expression": "Invalid cron expression", "currency_conversion_failed": "Failed to convert currencies", "recurrence_delete_failed": "Failed to delete recurrence", - "recurrence_update_failed": "Failed to update recurrence" + "recurrence_update_failed": "Failed to update recurrence", + "invite_email_failed": "Failed to send invite email. Check the SMTP configuration." }, "bank_transactions": { "choose_bank_provider": "Choose bank provider", diff --git a/src/components/AddExpense/SelectUserOrGroup.tsx b/src/components/AddExpense/SelectUserOrGroup.tsx index 85ef2265b..765dd9cd8 100644 --- a/src/components/AddExpense/SelectUserOrGroup.tsx +++ b/src/components/AddExpense/SelectUserOrGroup.tsx @@ -5,11 +5,13 @@ import { SendIcon } from 'lucide-react'; import { useTranslation } from 'next-i18next'; import Image from 'next/image'; import React, { useCallback } from 'react'; +import { toast } from 'sonner'; import { z } from 'zod'; import { useAddExpenseStore } from '~/store/addStore'; import { api } from '~/utils/api'; import { deserializeDefaultSplit } from '~/lib/defaultSplit'; +import { getInviteErrorToastKey, isInviteEmailSendFailed } from '~/lib/error/invite'; import { EntityAvatar } from '../ui/avatar'; import { Button } from '../ui/button'; @@ -48,13 +50,46 @@ export const SelectUserOrGroup: React.FC<{ const onAddEmailClick = useCallback( (invite = false) => { if (isEmail.success) { + const email = nameOrEmail; + const removePendingParticipant = () => { + const pendingParticipant = useAddExpenseStore + .getState() + .participants.find((participant) => -1 === participant.id); + if (email === pendingParticipant?.email) { + removeParticipant(-1); + } + }; + const addParticipant = (user: User) => { + removePendingParticipant(); + addOrUpdateParticipant(user); + if (email === useAddExpenseStore.getState().nameOrEmail) { + setNameOrEmail(''); + } + }; + addFriendMutation.mutate( - { email: nameOrEmail, sendInviteEmail: invite }, + { email, sendInviteEmail: invite }, { - onSuccess: (user) => { - removeParticipant(-1); - addOrUpdateParticipant(user); - setNameOrEmail(''); + onSuccess: addParticipant, + onError: (err) => { + const appErrorCode = err.data?.appErrorCode; + toast.error(t(getInviteErrorToastKey(appErrorCode))); + + // The friend row already exists whenever this router throws, so retry the participant-add. + if (isInviteEmailSendFailed(appErrorCode)) { + addFriendMutation.mutate( + { email, sendInviteEmail: false }, + { + onSuccess: addParticipant, + onError: () => { + removePendingParticipant(); + toast.error(t('errors.add_member_failed')); + }, + }, + ); + } else { + removePendingParticipant(); + } }, }, ); @@ -81,6 +116,7 @@ export const SelectUserOrGroup: React.FC<{ addOrUpdateParticipant, setNameOrEmail, removeParticipant, + t, ], ); @@ -105,6 +141,7 @@ export const SelectUserOrGroup: React.FC<{ [setGroup, setParticipants, setNameOrEmail], ); + const handleAddEmailClickTrue = useCallback(() => onAddEmailClick(true), [onAddEmailClick]); const handleAddEmailClickFalse = useCallback(() => onAddEmailClick(false), [onAddEmailClick]); if (group) { @@ -149,7 +186,7 @@ export const SelectUserOrGroup: React.FC<{ className="mt-4 text-cyan-500 hover:text-cyan-500" variant="outline" disabled={!isEmail.success} - onClick={handleAddEmailClickFalse} + onClick={handleAddEmailClickTrue} > {t('expense_details.add_expense_details.select_user_or_group.send_invite')} diff --git a/src/components/group/AddMembers.tsx b/src/components/group/AddMembers.tsx index 0a101537d..6a7ffbbb6 100644 --- a/src/components/group/AddMembers.tsx +++ b/src/components/group/AddMembers.tsx @@ -9,6 +9,7 @@ import { z } from 'zod'; import { Button } from '~/components/ui/button'; import { AppDrawer } from '~/components/ui/drawer'; +import { getInviteErrorToastKey, isInviteEmailSendFailed } from '~/lib/error/invite'; import { api } from '~/utils/api'; import { EntityAvatar } from '../ui/avatar'; @@ -87,11 +88,27 @@ const AddMembers: React.FC<{ function onAddEmailClick(invite = false) { if (isEmail.success) { + const email = inputValue.toLowerCase(); + const addUserToGroup = (user: { id: number }) => onSave({ ...userIds, [user.id]: true }); + addFriendMutation.mutate( - { email: inputValue.toLowerCase(), sendInviteEmail: invite }, + { email, sendInviteEmail: invite }, { - onSuccess: (user) => { - onSave({ ...userIds, [user.id]: true }); + onSuccess: addUserToGroup, + onError: (err) => { + const appErrorCode = err.data?.appErrorCode; + toast.error(t(getInviteErrorToastKey(appErrorCode))); + + // The friend row already exists whenever this router throws, so retry the group-add. + if (isInviteEmailSendFailed(appErrorCode)) { + addFriendMutation.mutate( + { email, sendInviteEmail: false }, + { + onSuccess: addUserToGroup, + onError: () => toast.error(t('errors.add_member_failed')), + }, + ); + } }, }, ); diff --git a/src/lib/error/invite.ts b/src/lib/error/invite.ts new file mode 100644 index 000000000..48cea963b --- /dev/null +++ b/src/lib/error/invite.ts @@ -0,0 +1,15 @@ +export const InviteErrorCode = { + INVITES_DISABLED: 'INVITES_DISABLED', + INVITE_RATE_LIMITED: 'INVITE_RATE_LIMITED', + INVITE_EMAIL_SEND_FAILED: 'INVITE_EMAIL_SEND_FAILED', +} as const; + +export const isInviteEmailSendFailed = (appErrorCode: unknown): boolean => + InviteErrorCode.INVITE_EMAIL_SEND_FAILED === appErrorCode; + +export const getInviteErrorToastKey = ( + appErrorCode: string | null | undefined, +): 'errors.invite_email_failed' | 'errors.add_member_failed' => + InviteErrorCode.INVITE_EMAIL_SEND_FAILED === appErrorCode + ? 'errors.invite_email_failed' + : 'errors.add_member_failed'; diff --git a/src/lib/inviteCooldown.ts b/src/lib/inviteCooldown.ts new file mode 100644 index 000000000..9bb38609a --- /dev/null +++ b/src/lib/inviteCooldown.ts @@ -0,0 +1,27 @@ +const INVITE_COOLDOWN_MS = 60_000; +const lastInviteAtByUserPair = new Map(); +let nextCleanupAt = 0; + +export const claimInviteCooldown = ( + inviteeId: number, + inviterId: number, + now = Date.now(), +): boolean => { + const userPair = `${inviteeId}:${inviterId}`; + const lastInviteAt = lastInviteAtByUserPair.get(userPair); + if (undefined !== lastInviteAt && now - lastInviteAt < INVITE_COOLDOWN_MS) { + return false; + } + + if (now >= nextCleanupAt) { + lastInviteAtByUserPair.forEach((inviteAt, pair) => { + if (now - inviteAt >= INVITE_COOLDOWN_MS) { + lastInviteAtByUserPair.delete(pair); + } + }); + nextCleanupAt = now + INVITE_COOLDOWN_MS; + } + + lastInviteAtByUserPair.set(userPair, now); + return true; +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9ad0df426..b1e7955e8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,11 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); diff --git a/src/server/api/appError.ts b/src/server/api/appError.ts new file mode 100644 index 000000000..5eb1cdf67 --- /dev/null +++ b/src/server/api/appError.ts @@ -0,0 +1,12 @@ +export class AppError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'AppError'; + this.code = code; + } +} + +export const getAppErrorCode = (cause: unknown): string | null => + cause instanceof AppError ? cause.code : null; diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 1b7e9a87f..8e6b8caf8 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -8,7 +8,10 @@ import { serializeDefaultSplit, toSortedFriendPair, } from '~/lib/defaultSplit'; +import { claimInviteCooldown } from '~/lib/inviteCooldown'; +import { InviteErrorCode } from '~/lib/error/invite'; import { simplifyDebts } from '~/lib/simplify'; +import { AppError } from '~/server/api/appError'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; @@ -25,6 +28,14 @@ import { importUserBalanceFromSplitWise, } from '../services/splitService'; +const throwInviteError = ( + code: 'PRECONDITION_FAILED' | 'TOO_MANY_REQUESTS' | 'INTERNAL_SERVER_ERROR', + inviteErrorCode: (typeof InviteErrorCode)[keyof typeof InviteErrorCode], + message: string, +): never => { + throw new TRPCError({ code, message, cause: new AppError(inviteErrorCode, message) }); +}; + export const userRouter = createTRPCRouter({ me: protectedProcedure.query(({ ctx }) => ctx.session.user), @@ -58,27 +69,47 @@ export const userRouter = createTRPCRouter({ inviteFriend: protectedProcedure .input(z.object({ email: z.string(), sendInviteEmail: z.boolean().optional() })) .mutation(async ({ input, ctx: { session } }) => { - const friend = await db.user.findUnique({ - where: { - email: input.email, - }, - }); - - if (friend) { - return friend; - } - - const user = await db.user.create({ - data: { + // Upsert avoids a find-then-create race where two concurrent invites for the same brand-new email both miss the lookup and hit the unique constraint. + const user = await db.user.upsert({ + where: { email: input.email }, + update: {}, + create: { email: input.email, name: input.email.split('@')[0], }, }); - if (input.sendInviteEmail) { - sendInviteEmail(input.email, session.user.name ?? session.user.email ?? '').catch((err) => { - console.error('Error sending invite email', err); - }); + // Only a just-created or not-yet-verified user should get an invite email. + if (input.sendInviteEmail && !user.emailVerified) { + if (!env.ENABLE_SENDING_INVITES) { + throwInviteError( + 'PRECONDITION_FAILED', + InviteErrorCode.INVITES_DISABLED, + 'Invite emails are disabled on this server.', + ); + } + + if (!claimInviteCooldown(user.id, session.user.id)) { + throwInviteError( + 'TOO_MANY_REQUESTS', + InviteErrorCode.INVITE_RATE_LIMITED, + 'Please wait before re-sending an invite to this address.', + ); + } + + let sent = false; + try { + sent = await sendInviteEmail(input.email, session.user.name ?? session.user.email ?? ''); + } catch (err) { + console.error('Error sending invite email to user', user.id, err); + } + if (!sent) { + throwInviteError( + 'INTERNAL_SERVER_ERROR', + InviteErrorCode.INVITE_EMAIL_SEND_FAILED, + 'Failed to send invite email. Check your SMTP configuration.', + ); + } } return user; diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index fa6fdbc1a..9ff2189ae 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -13,6 +13,7 @@ import { type Session } from 'next-auth'; import superjson from 'superjson'; import { ZodError, z } from 'zod'; +import { getAppErrorCode } from '~/server/api/appError'; import { getServerAuthSession } from '~/server/auth'; import { db } from '~/server/db'; @@ -76,6 +77,7 @@ const t = initTRPC.context().create({ data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + appErrorCode: getAppErrorCode(error.cause), }, }; }, @@ -119,7 +121,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { return next({ ctx: { - // infers the `session` as non-nullable + // Infers the `session` as non-nullable session: { ...ctx.session, user: ctx.session.user }, }, }); diff --git a/src/server/mailer.ts b/src/server/mailer.ts index 15c601cfc..c6a73d2cb 100644 --- a/src/server/mailer.ts +++ b/src/server/mailer.ts @@ -2,6 +2,7 @@ import { type User } from 'next-auth'; import nodemailer, { type Transporter } from 'nodemailer'; import { env } from '~/env'; +import { escapeHtml } from '~/lib/utils'; import { sendToDiscord } from './service-notification'; @@ -29,6 +30,9 @@ const getTransporter = () => { const transport = { ...mailServerConfig, secure: 465 === mailServerConfig.port, + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 30_000, }; transporter = nodemailer.createTransport(transport); @@ -61,14 +65,14 @@ export async function sendInviteEmail(email: string, name: string) { if ('development' === env.NODE_ENV) { console.log('Sending invite email', email, name); - return; + return true; } const subject = 'Invitation to SplitPro'; const text = `Hey,\n\nYou have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:\n${env.NEXTAUTH_URL}\n\nThanks,\nSplitPro Team`; - const html = `

Hey,

You have been invited to SplitPro by ${name}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:

Sign in to ${host}


Thanks,
SplitPro Team

`; + const html = `

Hey,

You have been invited to SplitPro by ${escapeHtml(name)}. It's a completely open source free alternative to splitwise. You can sign in to SplitPro by clicking the below URL:

Sign in to ${host}


Thanks,
SplitPro Team

`; - await sendMail(email, subject, text, html); + return await sendMail(email, subject, text, html); } export async function sendFeedbackEmail(feedback: string, user: User) { @@ -110,13 +114,17 @@ async function sendMail( } } catch (error) { console.log('Error sending email', error); - await sendToDiscord( - `Error sending email: ${ - error instanceof Error - ? `error.message: ${error.message}\nerror.stack: ${error.stack}` - : 'Unknown error' - }`, - ); + await Promise.resolve( + sendToDiscord( + `Error sending email: ${ + error instanceof Error + ? `error.message: ${error.message}\nerror.stack: ${error.stack}` + : 'Unknown error' + }`, + ), + ).catch((notificationError: unknown) => { + console.error('Failed to report email error to Discord', notificationError); + }); } return false; diff --git a/src/server/service-notification.ts b/src/server/service-notification.ts index 6d78d3907..eff6b005c 100644 --- a/src/server/service-notification.ts +++ b/src/server/service-notification.ts @@ -16,6 +16,7 @@ export async function sendToDiscord(message: string) { 'Content-Type': 'application/json', }, body: JSON.stringify({ content: message }), + signal: AbortSignal.timeout(5_000), }); if (response.ok) { diff --git a/tests/appError.test.ts b/tests/appError.test.ts new file mode 100644 index 000000000..7a96fd5a5 --- /dev/null +++ b/tests/appError.test.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +import { AppError, getAppErrorCode } from '~/server/api/appError'; + +describe('getAppErrorCode', () => { + describe('when the cause is an AppError', () => { + it('returns its code', () => { + expect(getAppErrorCode(new AppError('SOME_CODE', 'failed'))).toBe('SOME_CODE'); + }); + }); + + describe('when the cause is not an AppError', () => { + it('returns null for undefined', () => { + expect(getAppErrorCode(undefined)).toBeNull(); + }); + + it('returns null for a plain Error', () => { + expect(getAppErrorCode(new Error('boom'))).toBeNull(); + }); + + it('returns null for a ZodError', () => { + const result = z.string().safeParse(123); + expect(result.success).toBe(false); + expect(getAppErrorCode(!result.success ? result.error : undefined)).toBeNull(); + }); + }); +}); diff --git a/tests/inviteCooldown.test.ts b/tests/inviteCooldown.test.ts new file mode 100644 index 000000000..3c324833b --- /dev/null +++ b/tests/inviteCooldown.test.ts @@ -0,0 +1,31 @@ +import { claimInviteCooldown } from '~/lib/inviteCooldown'; + +describe('claimInviteCooldown', () => { + describe('when the same inviter invites the same user within 60 seconds', () => { + it('rejects the second request', () => { + expect(claimInviteCooldown(2, 1, 10_000)).toBe(true); + expect(claimInviteCooldown(2, 1, 69_999)).toBe(false); + }); + }); + + describe('when the cooldown has expired', () => { + it('allows another invite after 60 seconds', () => { + expect(claimInviteCooldown(3, 2, 10_000)).toBe(true); + expect(claimInviteCooldown(3, 2, 70_000)).toBe(true); + }); + }); + + describe('when different inviters invite the same user', () => { + it('tracks each inviter and invitee pair independently', () => { + expect(claimInviteCooldown(4, 3, 10_000)).toBe(true); + expect(claimInviteCooldown(4, 5, 10_001)).toBe(true); + }); + }); + + describe('when the same inviter invites different users', () => { + it('tracks each inviter and invitee pair independently', () => { + expect(claimInviteCooldown(7, 6, 10_000)).toBe(true); + expect(claimInviteCooldown(8, 6, 10_001)).toBe(true); + }); + }); +}); diff --git a/tests/inviteErrors.test.ts b/tests/inviteErrors.test.ts new file mode 100644 index 000000000..d11659682 --- /dev/null +++ b/tests/inviteErrors.test.ts @@ -0,0 +1,56 @@ +import { + InviteErrorCode, + getInviteErrorToastKey, + isInviteEmailSendFailed, +} from '~/lib/error/invite'; + +describe('isInviteEmailSendFailed', () => { + describe('when given the delivery failure code', () => { + it('returns true', () => { + expect(isInviteEmailSendFailed(InviteErrorCode.INVITE_EMAIL_SEND_FAILED)).toBe(true); + }); + }); + + describe('when given another code', () => { + it.each([ + InviteErrorCode.INVITES_DISABLED, + InviteErrorCode.INVITE_RATE_LIMITED, + undefined, + null, + 123, + {}, + 'SOME_UNRELATED_CODE', + '', + ])('returns false for %p', (value) => { + expect(isInviteEmailSendFailed(value)).toBe(false); + }); + }); +}); + +describe('getInviteErrorToastKey', () => { + describe('when the code is INVITE_EMAIL_SEND_FAILED', () => { + it('returns the invite-email-failed key', () => { + expect(getInviteErrorToastKey(InviteErrorCode.INVITE_EMAIL_SEND_FAILED)).toBe( + 'errors.invite_email_failed', + ); + }); + }); + + describe('when the code is any other InviteErrorCode value', () => { + it.each([InviteErrorCode.INVITES_DISABLED, InviteErrorCode.INVITE_RATE_LIMITED])( + 'returns the generic add-member-failed key for %s', + (code) => { + expect(getInviteErrorToastKey(code)).toBe('errors.add_member_failed'); + }, + ); + }); + + describe('when the code is unknown, null, or undefined', () => { + it.each(['SOME_UNRELATED_CODE', null, undefined])( + 'returns the generic add-member-failed key for %p', + (value) => { + expect(getInviteErrorToastKey(value)).toBe('errors.add_member_failed'); + }, + ); + }); +}); diff --git a/tests/mailer.test.ts b/tests/mailer.test.ts new file mode 100644 index 000000000..17913b0ff --- /dev/null +++ b/tests/mailer.test.ts @@ -0,0 +1,134 @@ +import nodemailer from 'nodemailer'; + +import { env } from '~/env'; +import { sendToDiscord } from '~/server/service-notification'; +import { sendInviteEmail } from '~/server/mailer'; + +jest.mock('~/env', () => ({ + env: { + NODE_ENV: 'production', + EMAIL_SERVER_HOST: 'smtp.example.com', + EMAIL_SERVER_PORT: '587', + EMAIL_SERVER_USER: 'user', + EMAIL_SERVER_PASSWORD: 'pass', + EMAIL_TLS_REJECT_UNAUTHORIZED: true, + FROM_EMAIL: 'noreply@example.com', + NEXTAUTH_URL: 'https://splitpro.example.com', + ENABLE_SENDING_INVITES: true, + }, +})); + +jest.mock('nodemailer', () => { + const sendMail = jest.fn(); + return { + __esModule: true, + default: { createTransport: jest.fn(() => ({ sendMail })) }, + }; +}); + +jest.mock('~/server/service-notification', () => ({ + sendToDiscord: jest.fn(), +})); + +const mockCreateTransport = jest.mocked(nodemailer.createTransport); +const mockSendMail = jest.mocked( + mockCreateTransport({} as Parameters[0]).sendMail, +); +const mockSendToDiscord = jest.mocked(sendToDiscord); + +const mockSentMessageInfo = { + messageId: 'test-message-id', + envelope: { from: 'noreply@example.com', to: ['friend@example.com'] }, + accepted: ['friend@example.com'], + rejected: [], + pending: [], + response: '250 OK', +}; + +describe('sendInviteEmail', () => { + beforeEach(() => { + jest.clearAllMocks(); + (env as { NODE_ENV: string }).NODE_ENV = 'production'; + (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = true; + }); + + it('creates the SMTP transport with bounded timeouts', async () => { + mockSendMail.mockResolvedValue(mockSentMessageInfo); + + await sendInviteEmail('friend@example.com', 'Alice'); + + expect(mockCreateTransport).toHaveBeenCalledWith( + expect.objectContaining({ + connectionTimeout: 10_000, + greetingTimeout: 10_000, + socketTimeout: 30_000, + }), + ); + }); + + describe('when the send succeeds', () => { + it('resolves true when the email actually sends', async () => { + mockSendMail.mockResolvedValue(mockSentMessageInfo); + + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true); + expect(mockSendMail).toHaveBeenCalledTimes(1); + }); + + it('escapes HTML special characters in the inviter name', async () => { + mockSendMail.mockResolvedValue(mockSentMessageInfo); + + await sendInviteEmail('friend@example.com', `A&B "D" 'E'`); + + const sentHtml = mockSendMail.mock.calls[0]?.[0]?.html; + expect(sentHtml).toEqual( + expect.stringContaining('A&B <C> "D" 'E''), + ); + expect(sentHtml).not.toEqual(expect.stringContaining(`A&B "D" 'E'`)); + }); + + it('does not alter or double-escape a plain alphanumeric name', async () => { + mockSendMail.mockResolvedValue(mockSentMessageInfo); + + await sendInviteEmail('friend@example.com', 'Alice Smith'); + + const sentHtml = mockSendMail.mock.calls[0]?.[0]?.html; + expect(sentHtml).toEqual(expect.stringContaining('Alice Smith')); + }); + }); + + describe('when the send fails', () => { + it('resolves false when the SMTP transport fails', async () => { + mockSendMail.mockRejectedValue(new Error('connect ECONNREFUSED')); + + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(false); + expect(mockSendToDiscord).toHaveBeenCalledTimes(1); + }); + + it('resolves false when reporting the SMTP failure to Discord also fails', async () => { + mockSendMail.mockRejectedValue(new Error('connect ECONNREFUSED')); + mockSendToDiscord.mockRejectedValue(new Error('Discord unavailable')); + + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(false); + }); + }); + + describe('in development mode', () => { + it('skips sending and resolves true', async () => { + (env as { NODE_ENV: string }).NODE_ENV = 'development'; + + await expect(sendInviteEmail('friend@example.com', 'Alice')).resolves.toBe(true); + expect(mockCreateTransport).not.toHaveBeenCalled(); + expect(mockSendMail).not.toHaveBeenCalled(); + }); + }); + + describe('when invites are disabled', () => { + it('still throws', async () => { + (env as { ENABLE_SENDING_INVITES: boolean }).ENABLE_SENDING_INVITES = false; + + await expect(sendInviteEmail('friend@example.com', 'Alice')).rejects.toThrow( + 'Sending invites is not enabled', + ); + }); + }); +});