Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
13540ff
fix: propagate sendInviteEmail's send result instead of discarding it
SomSamantray Sep 5, 2026
1203c10
fix: surface invite-email send failures from inviteFriend mutation
SomSamantray Sep 5, 2026
5959eb8
fix: show toast and reconcile UI state on invite-email failure
SomSamantray Sep 5, 2026
ddf6890
fix(review): apply review findings
SomSamantray Sep 5, 2026
501dc85
chore: fix comment wording mangled by pre-commit autofix
SomSamantray Sep 5, 2026
f8bef61
fix: add machine-readable AppError cause for tRPC error discrimination
SomSamantray Sep 6, 2026
d60a7c7
fix: escape inviter name in invite-email HTML to prevent HTML injection
SomSamantray Sep 6, 2026
121c948
fix: rate-limit invite-email sends and stop logging raw recipient emails
SomSamantray Sep 6, 2026
70e58a6
fix: discriminate genuine send failures from other invite errors, cli…
SomSamantray Sep 6, 2026
31fa028
refactor: consolidate invite-error codes and toast mapping into one m…
SomSamantray Sep 6, 2026
b0cadd3
fix: harden inviteFriend against concurrent-create races and silent t…
SomSamantray Sep 6, 2026
135eb75
test: add coverage for inviteErrors.ts; align to project's test-struc…
SomSamantray Sep 6, 2026
13e6ee4
fix: apply the same error-recovery pattern to SelectUserOrGroup.tsx
SomSamantray Sep 6, 2026
1c9bd68
fix: wire the send_invite button to actually send an invite email
SomSamantray Sep 6, 2026
00a85fe
fix: replace persisted invite cooldown with in-memory map
SomSamantray Oct 3, 2026
e93c12f
fix(review): scope invite recovery to email failures
SomSamantray Oct 3, 2026
9c8f444
test: cover all invite email HTML escapes
SomSamantray Oct 3, 2026
45e8c97
fix: key invite cooldown by user pair
SomSamantray Oct 4, 2026
29242c6
fix: keep invite recovery independent
SomSamantray Oct 4, 2026
6f1abc9
fix: bound SMTP invite delivery timeouts
SomSamantray Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 43 additions & 6 deletions src/components/AddExpense/SelectUserOrGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
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';
Expand Down Expand Up @@ -48,13 +50,46 @@
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 },
Comment thread
SomSamantray marked this conversation as resolved.
{
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();
}
},
},
);
Expand All @@ -81,6 +116,7 @@
addOrUpdateParticipant,
setNameOrEmail,
removeParticipant,
t,
],
);

Expand All @@ -105,6 +141,7 @@
[setGroup, setParticipants, setNameOrEmail],
);

const handleAddEmailClickTrue = useCallback(() => onAddEmailClick(true), [onAddEmailClick]);
const handleAddEmailClickFalse = useCallback(() => onAddEmailClick(false), [onAddEmailClick]);

if (group) {
Expand Down Expand Up @@ -149,7 +186,7 @@
className="mt-4 text-cyan-500 hover:text-cyan-500"
variant="outline"
disabled={!isEmail.success}
onClick={handleAddEmailClickFalse}
onClick={handleAddEmailClickTrue}
>
<SendIcon className="mr-2 h-4 w-4" />
{t('expense_details.add_expense_details.select_user_or_group.send_invite')}
Expand All @@ -174,7 +211,7 @@
<button
key={f.id}
className="flex w-full items-center justify-between border-b border-gray-900 py-4"
onClick={() => handleFriendClick(f)}

Check warning on line 214 in src/components/AddExpense/SelectUserOrGroup.tsx

View workflow job for this annotation

GitHub Actions / check

eslint-plugin-react-perf(jsx-no-new-function-as-prop)

JSX attribute values should not contain functions created in the same scope.
>
<div className="flex min-w-0 items-center gap-4">
<EntityAvatar entity={f} size={35} />
Expand Down
23 changes: 20 additions & 3 deletions src/components/group/AddMembers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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')),
},
);
}
},
},
);
Expand Down
15 changes: 15 additions & 0 deletions src/lib/error/invite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export const InviteErrorCode = {
Comment thread
SomSamantray marked this conversation as resolved.
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';
27 changes: 27 additions & 0 deletions src/lib/inviteCooldown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const INVITE_COOLDOWN_MS = 60_000;
const lastInviteAtByUserPair = new Map<string, number>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's narrow this key type down to ${number}:${number}

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;
};
8 changes: 8 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
12 changes: 12 additions & 0 deletions src/server/api/appError.ts
Original file line number Diff line number Diff line change
@@ -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;
63 changes: 47 additions & 16 deletions src/server/api/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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),

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/server/api/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -76,6 +77,7 @@ const t = initTRPC.context<typeof createTRPCContext>().create({
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
appErrorCode: getAppErrorCode(error.cause),
},
};
},
Expand Down Expand Up @@ -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 },
},
});
Expand Down
28 changes: 18 additions & 10 deletions src/server/mailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = `<p>Hey,</p> <p>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:</p><p><a href="${env.NEXTAUTH_URL}">Sign in to ${host}</a></p><br><p>Thanks,<br/>SplitPro Team</p>`;
const html = `<p>Hey,</p> <p>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:</p><p><a href="${env.NEXTAUTH_URL}">Sign in to ${host}</a></p><br><p>Thanks,<br/>SplitPro Team</p>`;

await sendMail(email, subject, text, html);
return await sendMail(email, subject, text, html);
}

export async function sendFeedbackEmail(feedback: string, user: User) {
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/server/service-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading