diff --git a/prisma/migrations/20260906080000_add_user_last_invited_at/migration.sql b/prisma/migrations/20260906080000_add_user_last_invited_at/migration.sql new file mode 100644 index 000000000..474a9cba2 --- /dev/null +++ b/prisma/migrations/20260906080000_add_user_last_invited_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastInvitedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 576ece269..dbe43f53c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { name String? email String? @unique emailVerified DateTime? + lastInvitedAt DateTime? image String? currency String @default("USD") defaultCurrency String? 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..9c0d9391b 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, isInviteErrorCode } from '~/lib/inviteErrors'; import { EntityAvatar } from '../ui/avatar'; import { Button } from '../ui/button'; @@ -48,13 +50,36 @@ export const SelectUserOrGroup: React.FC<{ const onAddEmailClick = useCallback( (invite = false) => { if (isEmail.success) { + const email = nameOrEmail; + const addParticipant = (user: User) => { + removeParticipant(-1); + addOrUpdateParticipant(user); + 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 (isInviteErrorCode(appErrorCode)) { + addFriendMutation.mutate( + { email, sendInviteEmail: false }, + { + onSuccess: addParticipant, + onError: () => { + removeParticipant(-1); + toast.error(t('errors.add_member_failed')); + }, + }, + ); + } else { + removeParticipant(-1); + } }, }, ); @@ -63,6 +88,7 @@ export const SelectUserOrGroup: React.FC<{ name: nameOrEmail, email: nameOrEmail, emailVerified: new Date(), + lastInvitedAt: null, image: null, currency: 'USD', defaultCurrency: null, @@ -81,6 +107,7 @@ export const SelectUserOrGroup: React.FC<{ addOrUpdateParticipant, setNameOrEmail, removeParticipant, + t, ], ); @@ -105,6 +132,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 +177,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/AddExpense/UserInput.tsx b/src/components/AddExpense/UserInput.tsx index e200c7c3b..8bc654e65 100644 --- a/src/components/AddExpense/UserInput.tsx +++ b/src/components/AddExpense/UserInput.tsx @@ -60,6 +60,7 @@ export const UserInput: React.FC<{ name: nameOrEmail, email: nameOrEmail, emailVerified: new Date(), + lastInvitedAt: null, image: null, currency: 'USD', defaultCurrency: null, diff --git a/src/components/group/AddMembers.tsx b/src/components/group/AddMembers.tsx index 0a101537d..b53bbf250 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, isInviteErrorCode } from '~/lib/inviteErrors'; 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 (isInviteErrorCode(appErrorCode)) { + addFriendMutation.mutate( + { email, sendInviteEmail: false }, + { + onSuccess: addUserToGroup, + onError: () => toast.error(t('errors.add_member_failed')), + }, + ); + } }, }, ); diff --git a/src/lib/inviteErrors.test.ts b/src/lib/inviteErrors.test.ts new file mode 100644 index 000000000..2fd3b602f --- /dev/null +++ b/src/lib/inviteErrors.test.ts @@ -0,0 +1,46 @@ +import { InviteErrorCode, getInviteErrorToastKey, isInviteErrorCode } from '~/lib/inviteErrors'; + +describe('isInviteErrorCode', () => { + describe('when given a known InviteErrorCode value', () => { + it.each(Object.values(InviteErrorCode))('returns true for %s', (code) => { + expect(isInviteErrorCode(code)).toBe(true); + }); + }); + + describe('when given anything else', () => { + it.each([undefined, null, 123, {}, 'SOME_UNRELATED_CODE', ''])( + 'returns false for %p', + (value) => { + expect(isInviteErrorCode(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/src/lib/inviteErrors.ts b/src/lib/inviteErrors.ts new file mode 100644 index 000000000..e9ddfb871 --- /dev/null +++ b/src/lib/inviteErrors.ts @@ -0,0 +1,17 @@ +export const InviteErrorCode = { + INVITES_DISABLED: 'INVITES_DISABLED', + INVITE_RATE_LIMITED: 'INVITE_RATE_LIMITED', + INVITE_EMAIL_SEND_FAILED: 'INVITE_EMAIL_SEND_FAILED', +} as const; + +const inviteErrorCodes: string[] = Object.values(InviteErrorCode); + +export const isInviteErrorCode = (appErrorCode: unknown): boolean => + 'string' === typeof appErrorCode && inviteErrorCodes.includes(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/pages/add.tsx b/src/pages/add.tsx index d26d049e7..587e8ead0 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -66,6 +66,7 @@ const AddPage: NextPageWithUser<{ ...user, defaultCurrency: user.defaultCurrency ?? null, emailVerified: null, + lastInvitedAt: null, name: user.name ?? null, email: user.email ?? null, image: user.image ?? null, diff --git a/src/pages/balances/[friendId].tsx b/src/pages/balances/[friendId].tsx index a5f87a0d0..9d495d77e 100644 --- a/src/pages/balances/[friendId].tsx +++ b/src/pages/balances/[friendId].tsx @@ -97,6 +97,7 @@ const FriendPage: NextPageWithUser = ({ user }) => { { ...user, emailVerified: null, + lastInvitedAt: null, name: user.name ?? null, email: user.email ?? null, image: user.image ?? null, diff --git a/src/server/api/appError.test.ts b/src/server/api/appError.test.ts new file mode 100644 index 000000000..7a96fd5a5 --- /dev/null +++ b/src/server/api/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/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..02f24f4bb 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -8,7 +8,9 @@ import { serializeDefaultSplit, toSortedFriendPair, } from '~/lib/defaultSplit'; +import { InviteErrorCode } from '~/lib/inviteErrors'; 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 +27,16 @@ import { importUserBalanceFromSplitWise, } from '../services/splitService'; +const INVITE_COOLDOWN_MS = 60_000; + +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 +70,58 @@ 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.', + ); + } + + // Claim the cooldown atomically, so concurrent requests can't both pass a read-then-write check. + const claim = await db.user.updateMany({ + where: { + id: user.id, + OR: [ + { lastInvitedAt: null }, + { lastInvitedAt: { lt: new Date(Date.now() - INVITE_COOLDOWN_MS) } }, + ], + }, + data: { lastInvitedAt: new Date() }, }); + if (0 === claim.count) { + 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..edb702384 100644 --- a/src/server/mailer.ts +++ b/src/server/mailer.ts @@ -8,6 +8,14 @@ import { sendToDiscord } from './service-notification'; // oxlint-disable-next-line init-declarations let transporter: Transporter; +const escapeHtml = (value: string): string => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + export const mailServerConfig = { host: env.EMAIL_SERVER_HOST, port: parseInt(env.EMAIL_SERVER_PORT ?? ''), @@ -61,14 +69,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) { diff --git a/src/tests/addStore.test.ts b/src/tests/addStore.test.ts index ffc82790c..9cbe4a534 100644 --- a/src/tests/addStore.test.ts +++ b/src/tests/addStore.test.ts @@ -23,6 +23,7 @@ const createMockUser = (id: number, name: string, email: string): User => ({ currency: 'USD', defaultCurrency: null, emailVerified: null, + lastInvitedAt: null, image: null, preferredLanguage: 'en', obapiProviderId: null, diff --git a/src/tests/mailer.test.ts b/src/tests/mailer.test.ts new file mode 100644 index 000000000..4425a2830 --- /dev/null +++ b/src/tests/mailer.test.ts @@ -0,0 +1,111 @@ +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; + }); + + 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', ''); + + const sentHtml = mockSendMail.mock.calls[0]?.[0]?.html; + expect(sentHtml).toEqual(expect.stringContaining('<script>')); + expect(sentHtml).not.toEqual(expect.stringContaining('')); + }); + + 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); + }); + }); + + 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', + ); + }); + }); +});