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); }