From 391204c31eb5b4bf9b9931222de37337ea3b77ae Mon Sep 17 00:00:00 2001
From: Laurent Cozic <laurent@cozic.net>
Date: Mon, 9 Aug 2021 16:55:04 +0100
Subject: [PATCH] Server: Allow setting email key to prevent the same email to
 be sent multiple times

---
 packages/server/schema.sqlite                 | Bin 290816 -> 290816 bytes
 packages/server/src/db.ts                     |   2 +
 .../migrations/20210809163307_email_key.ts    |  16 ++++++
 packages/server/src/models/EmailModel.test.ts |  48 ++++++++++++++++++
 packages/server/src/models/EmailModel.ts      |  17 ++++++-
 5 files changed, 82 insertions(+), 1 deletion(-)
 create mode 100644 packages/server/src/migrations/20210809163307_email_key.ts
 create mode 100644 packages/server/src/models/EmailModel.test.ts

diff --git a/packages/server/schema.sqlite b/packages/server/schema.sqlite
index b8f4b4f35eb4413ec07c1ec7f752504858408fe3..38d060ffe4a92a78efbf23e6ef994370938c0cfd 100644
GIT binary patch
delta 1351
zcmZp8AlUFgaDud83j+g#I~213>9&bF#*8f+6PEBZi}5H=W*5+A;!)o0DDa+JLrsj8
z!I_iQ$iT?Zz{0@N(9GD_z&t)RH!(9OK0CEiFRPfbTDz~9ak96JCM%Ft^lx&dj2W2q
zbMh7$6EN%B<hL?fVAhApGO|Ws*1O5UvIbz*^T{2u+F;i8$vb2r>Ml)wE~@|*J3pC6
zP6f<5JK0oD4a_<<IYAC0dt!2{oHAH!|KzoDdSKSR$q(ez!K~erdE_BFCQmkpvL;MU
zl85N%p4=r5F{NqpdU=SB`pLJUV%3`zp7FDZi8C^ra!#JWC$h<)MZVs%JTSaGJJLBS
z!zshlDcdr?$jBr$qoOj|(KW9)q@*M<B{wtAAtygMF()IxxJ1P$QN_?eB`Mb_G%(-7
z)hs*Gu_)LtBHuB`v??{twItKRFr?TZFUnAv*_V@$*<1_^o0$Xd^7Eo7-268FGlo$9
zbbgvS)i}r`OD`nEB_qT=DAOd=D>XOO5#(!7GAPo7kYJ}a+~rq5aVnPZ+BBhNenOgg
zWUixSm}`h{ZnB%9Q%R+BX@E~uPH|3HP*`z>n?E5#2<e~h@Rm`62ibj_8XoW~5Yk4J
zqHO_;L>RR#fpJH^f{|;nS71i5cSdAbSx9z9foGMOe@bzZr<+klaZqA;+4LLd8D+VV
zf)VTjLb|p+V0<7?(CkGM3>Ga=*tCFKU^{mL)75x_F|+LeQ;YsUC!+1lADD<pMT<5l
z06n*8fxxr?W&tL)!tFu<%;y=!O-<qr<FoTpE8<I1a|_}Vb4pT+%uP+U{|IE(VqxK_
zWALA@7RKz&)gl_qE-op_*ulEJBZm1c<8;%ljH1(GnYkG?w|7P`e`N+1k&Hjun_`%^
KH^s2j2?GG~_s?Gd

delta 1297
zcmZp8AlUFgaDud869WT-I~213>DGxl#*9rH6PEBZi}H9+W*5-jET|yKGuc~4leOyH
zP5ZZ#D`m{UtXGq_$e4gxFDAd0(E_s`PL`210<-Q<4wf|lvu;f8kktmWj!oVn3sHAy
z@^e`Qu-N{|JaQ^v*51jca%y1K?#T&q5ZRrRTji9&V(TZbmD2;W)=hpOrw(SVnam>(
z(a}8F9Lj2(oFos?Q8T$q9%4$-<n{6p9R-tbL&b77DLmt66BTD<Hszc=flp+UL5qC7
zM{cBZQevoMZfIFpL1}8DuVt=#L10=@VNjTvNmZsxVoGjio<mN4a$-(KesPJ4QKE{W
zfl7vplUr_*UvOGZSXEBAc~oFkXiz|El4VL^US6V!SDJ;3GP5ryA+xy{7&bEp+~wy*
zQMmbS{AUcI{OSBOb83E4WmQsezGYN?eq@wup}A3Rsw2qPjG)Muf{<XRHr(Y`KyfOT
z@Y*z?W`06;h?`-WXTC>3WS*f}s*k&4xR;NQWl54*NmXi)Sz-ktLkQ`g?(mjTf(O}s
zn;IVQD-hB~l%j0`j6@i<ErD@IzCwA0i+5ppV7YsVlVPHTX^2@$UUr~cp+{JXX^ur$
zY1Z@`=NV<Wk%AHI0z$gBJz#twPtfc|6ATtDP}sD9TVOkP0@Kxaf-$r008@+pKqsQ@
z%paJDNJWb_C;&aTXo0}A0A>Lup1SQq0nFza#m$W44db)(QY+$1QgaL96LU&Zi_Fc8
zxBm!a)?xt`WlZVQ)xwy)xBCS#OE7Kkj9~uC%+*{P%q}h|$=L3?y*iBfEn|CW9P{?l
IIF@cf0AxMWSO5S3

diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts
index 82d06880b5..2839ae12a2 100644
--- a/packages/server/src/db.ts
+++ b/packages/server/src/db.ts
@@ -394,6 +394,7 @@ export interface Email extends WithDates {
 	sent_time?: number;
 	sent_success?: number;
 	error?: string;
+	key?: string;
 }
 
 export interface Token extends WithDates {
@@ -549,6 +550,7 @@ export const databaseSchema: DatabaseTables = {
 		error: { type: 'string' },
 		updated_time: { type: 'string' },
 		created_time: { type: 'string' },
+		key: { type: 'string' },
 	},
 	tokens: {
 		id: { type: 'number' },
diff --git a/packages/server/src/migrations/20210809163307_email_key.ts b/packages/server/src/migrations/20210809163307_email_key.ts
new file mode 100644
index 0000000000..ba868f4e95
--- /dev/null
+++ b/packages/server/src/migrations/20210809163307_email_key.ts
@@ -0,0 +1,16 @@
+import { Knex } from 'knex';
+import { DbConnection } from '../db';
+
+export async function up(db: DbConnection): Promise<any> {
+	await db.schema.alterTable('emails', function(table: Knex.CreateTableBuilder) {
+		table.text('key', 'mediumtext').defaultTo('').notNullable();
+	});
+
+	await db.schema.alterTable('emails', function(table: Knex.CreateTableBuilder) {
+		table.unique(['recipient_email', 'key']);
+	});
+}
+
+export async function down(_db: DbConnection): Promise<any> {
+
+}
diff --git a/packages/server/src/models/EmailModel.test.ts b/packages/server/src/models/EmailModel.test.ts
new file mode 100644
index 0000000000..581659f037
--- /dev/null
+++ b/packages/server/src/models/EmailModel.test.ts
@@ -0,0 +1,48 @@
+import { EmailSender } from '../db';
+import { beforeAllDb, afterAllTests, beforeEachDb, models, createUserAndSession } from '../utils/testing/testUtils';
+import paymentFailedTemplate from '../views/emails/paymentFailedTemplate';
+
+describe('EmailModel', function() {
+
+	beforeAll(async () => {
+		await beforeAllDb('EmailModel');
+	});
+
+	afterAll(async () => {
+		await afterAllTests();
+	});
+
+	beforeEach(async () => {
+		await beforeEachDb();
+	});
+
+	test('should not send the same keyed email twice', async function() {
+		const { user } = await createUserAndSession();
+
+		const sendEmail = async (key: string) => {
+			await models().email().push({
+				...paymentFailedTemplate(),
+				recipient_email: user.email,
+				recipient_id: user.id,
+				recipient_name: user.full_name || '',
+				sender_id: EmailSender.Support,
+				key: key,
+			});
+		};
+
+		const beforeCount = (await models().email().all()).length;
+
+		await sendEmail('payment_failed_1');
+
+		expect((await models().email().all()).length).toBe(beforeCount + 1);
+
+		await sendEmail('payment_failed_1');
+
+		expect((await models().email().all()).length).toBe(beforeCount + 1);
+
+		await sendEmail('payment_failed_2');
+
+		expect((await models().email().all()).length).toBe(beforeCount + 2);
+	});
+
+});
diff --git a/packages/server/src/models/EmailModel.ts b/packages/server/src/models/EmailModel.ts
index f5ddc598bc..bc9b014481 100644
--- a/packages/server/src/models/EmailModel.ts
+++ b/packages/server/src/models/EmailModel.ts
@@ -6,6 +6,7 @@ export interface EmailToSend {
 	recipient_email: string;
 	subject: string;
 	body: string;
+	key?: string;
 
 	recipient_name?: string;
 	recipient_id?: Uuid;
@@ -26,12 +27,26 @@ export default class EmailModel extends BaseModel<Email> {
 		return false;
 	}
 
-	public async push(email: EmailToSend) {
+	public async push(email: EmailToSend): Promise<Email | null> {
+		if (email.key) {
+			const existingEmail = await this.byRecipientAndKey(email.recipient_email, email.key);
+			if (existingEmail) return null; // noop - the email has already been sent
+		}
+
 		const output = await super.save({ ...email });
 		EmailModel.eventEmitter.emit('queued');
 		return output;
 	}
 
+	private async byRecipientAndKey(recipientEmail: string, key: string): Promise<Email> {
+		if (!key) throw new Error('Key cannot be empty');
+
+		return this.db(this.tableName)
+			.where('recipient_email', '=', recipientEmail)
+			.where('key', '=', key)
+			.first();
+	}
+
 	public async needToBeSent(): Promise<Email[]> {
 		return this.db(this.tableName).where('sent_time', '=', 0);
 	}