().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',
+ );
+ });
+ });
+});