From a843e2c505870914235ff52f35808184d830e97a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 12 Jan 2026 13:39:54 +0000 Subject: [PATCH 1/3] fix(firestore-send-email): validation issue --- .../__tests__/prepare-payload.test.ts | 40 +++ .../functions/__tests__/validation.test.ts | 18 ++ .../functions/package-lock.json | 270 ++++++++++++------ firestore-send-email/functions/package.json | 4 +- firestore-send-email/functions/src/index.ts | 10 - .../functions/src/prepare-payload.ts | 19 +- .../functions/src/validation.ts | 1 + 7 files changed, 251 insertions(+), 111 deletions(-) diff --git a/firestore-send-email/functions/__tests__/prepare-payload.test.ts b/firestore-send-email/functions/__tests__/prepare-payload.test.ts index c9a7be394..36136bf8a 100644 --- a/firestore-send-email/functions/__tests__/prepare-payload.test.ts +++ b/firestore-send-email/functions/__tests__/prepare-payload.test.ts @@ -48,6 +48,12 @@ class MockTemplates { text: undefined, subject: "Template Subject", }; + case "template-with-object-attachments": + return { + html: "

Template HTML

", + subject: "Template Subject", + attachments: { filename: "bad.pdf", content: "test" }, + }; default: return {}; } @@ -144,6 +150,20 @@ describe("preparePayload Template Merging", () => { expect(result.message.attachments).toEqual([{ filename: "template.pdf" }]); }); + it("should throw formatted error when template returns object attachments (not array)", async () => { + const payload = { + to: "test@example.com", + template: { + name: "template-with-object-attachments", + data: {}, + }, + }; + + await expect(preparePayload(payload)).rejects.toThrow( + "Invalid message configuration: Field 'message.attachments' must be an array" + ); + }); + it("should gracefully handle template with no content", async () => { const payload = { to: "test@example.com", @@ -440,3 +460,23 @@ describe("preparePayload Template Merging", () => { }); }); }); + +describe("preparePayload", () => { + it("should throw error attachments object not in an array", async () => { + const payload = { + to: "test@example.com", + message: { + subject: "Test Subject", + text: "Test text", + attachments: { + filename: "test.txt", + content: "test", + }, + }, + }; + + await expect(preparePayload(payload)).rejects.toThrow( + "Invalid message configuration: Field 'message.attachments' must be an array" + ); + }); +}); diff --git a/firestore-send-email/functions/__tests__/validation.test.ts b/firestore-send-email/functions/__tests__/validation.test.ts index 1d74fca27..759113905 100644 --- a/firestore-send-email/functions/__tests__/validation.test.ts +++ b/firestore-send-email/functions/__tests__/validation.test.ts @@ -283,6 +283,24 @@ describe("validatePayload", () => { ); }); + it("should throw ValidationError for single attachments not in an array", () => { + const invalidPayload = { + to: "test@example.com", + message: { + subject: "Test Subject", + text: "Test message", + attachments: { + filename: "test.txt", + content: "test", + }, + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + expect(() => validatePayload(invalidPayload)).toThrow( + "Invalid message configuration: Field 'message.attachments' must be an array" + ); + }); + it("should throw ValidationError for invalid field types", () => { const invalidPayload = { to: 123, diff --git a/firestore-send-email/functions/package-lock.json b/firestore-send-email/functions/package-lock.json index 94e91b416..5fd6e15cc 100644 --- a/firestore-send-email/functions/package-lock.json +++ b/firestore-send-email/functions/package-lock.json @@ -14,7 +14,7 @@ "firebase-admin": "^13.2.0", "firebase-functions": "^6.3.2", "handlebars": "^4.5.3", - "nodemailer": "^6.9.13", + "nodemailer": "^7.0.12", "rimraf": "^2.6.3", "smtp-server": "^3.13.4", "typescript": "^5.7.3", @@ -1514,22 +1514,6 @@ "form-data": "^2.5.0" } }, - "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz", - "integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==", - "optional": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.35", - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">= 0.12" - } - }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -1698,15 +1682,32 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", - "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1858,32 +1859,63 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1969,6 +2001,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -1989,6 +2022,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -2601,38 +2635,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -2845,17 +2880,21 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "mime-types": "^2.1.12" + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" }, "engines": { - "node": ">= 6" + "node": ">= 0.12" } }, "node_modules/forwarded": { @@ -3341,6 +3380,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -4161,10 +4201,11 @@ "dev": true }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -4243,11 +4284,12 @@ } }, "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -4315,11 +4357,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -4703,9 +4746,10 @@ } }, "node_modules/node-forge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", - "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", "engines": { "node": ">= 6.13.0" } @@ -4723,9 +4767,10 @@ "dev": true }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", + "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", + "license": "MIT-0", "engines": { "node": ">=6.0.0" } @@ -4772,6 +4817,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -5082,11 +5128,12 @@ ] }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -5110,15 +5157,45 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5267,7 +5344,8 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -5381,6 +5459,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -5399,6 +5478,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -5414,6 +5494,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5431,6 +5512,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5467,23 +5549,25 @@ } }, "node_modules/smtp-server": { - "version": "3.13.6", - "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.13.6.tgz", - "integrity": "sha512-dqbSPKn3PCq3Gp5hxBM99u7PET7cQSAWrauhtArJbc+zrf5xNEOjm9+Ob3lySySrRoIEvNE0dz+w2H/xWFJNRw==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.18.0.tgz", + "integrity": "sha512-xINTnh0H8JDAKOAGSnFX8mgXB/L4Oz8dG4P0EgKAzJEszngxEEx4vOys+yNpsUc6yIyTKS8m2BcIffq4Htma/w==", + "license": "MIT-0", "dependencies": { "base32.js": "0.1.0", "ipv6-normalize": "1.0.1", - "nodemailer": "6.9.15", + "nodemailer": "7.0.11", "punycode.js": "2.3.1" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.18.0" } }, "node_modules/smtp-server/node_modules/nodemailer": { - "version": "6.9.15", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", - "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "license": "MIT-0", "engines": { "node": ">=6.0.0" } diff --git a/firestore-send-email/functions/package.json b/firestore-send-email/functions/package.json index f2eae28d6..6fb680744 100644 --- a/firestore-send-email/functions/package.json +++ b/firestore-send-email/functions/package.json @@ -11,7 +11,9 @@ "local:emulator": "cd ../../_emulator && firebase emulators:start -P demo-test", "test": "cd ../../_emulator && firebase emulators:exec --only extensions jest -P demo-test", "testIfEmulatorRunning": "wait-on tcp:4001 && jest", + "testIfEmulatorRunning:file": "wait-on tcp:4001 && jest", "test:local": "concurrently --kill-others \"npm run local:emulator\" \"npm run testIfEmulatorRunning\"", + "test:local:file": "concurrently --kill-others \"npm run local:emulator\" \"npm run testIfEmulatorRunning:file --\"", "test:watch": "concurrently \"npm run local:emulator\" \"jest --watch\"", "test:coverage": "concurrently --kill-others \"npm run local:emulator\" \"wait-on tcp:4001 && jest --coverage\"", "test:e2e:sendgrid": "cd ../../_emulator && firebase emulators:exec --project=demo-test \" cd ../firestore-send-email/functions && E2E_SENDGRID=true jest __tests__/e2e/sendgrid.test.ts\"", @@ -28,7 +30,7 @@ "firebase-admin": "^13.2.0", "firebase-functions": "^6.3.2", "handlebars": "^4.5.3", - "nodemailer": "^6.9.13", + "nodemailer": "^7.0.12", "rimraf": "^2.6.3", "smtp-server": "^3.13.4", "typescript": "^5.7.3", diff --git a/firestore-send-email/functions/src/index.ts b/firestore-send-email/functions/src/index.ts index 1611a051e..0d04be470 100644 --- a/firestore-send-email/functions/src/index.ts +++ b/firestore-send-email/functions/src/index.ts @@ -93,16 +93,6 @@ async function transportLayer() { return setSmtpCredentials(config); } -function validateFieldArray(field: string, array?: string[]) { - if (!Array.isArray(array)) { - throw new Error(`Invalid field "${field}". Expected an array of strings.`); - } - - if (array.find((item) => typeof item !== "string")) { - throw new Error(`Invalid field "${field}". Expected an array of strings.`); - } -} - function getExpireAt(startTime: Timestamp) { const now = startTime.toDate(); const value = config.TTLExpireValue; diff --git a/firestore-send-email/functions/src/prepare-payload.ts b/firestore-send-email/functions/src/prepare-payload.ts index 5574c732d..2998cc483 100644 --- a/firestore-send-email/functions/src/prepare-payload.ts +++ b/firestore-send-email/functions/src/prepare-payload.ts @@ -1,12 +1,11 @@ import { DocumentData } from "firebase-admin/firestore"; import { validatePayload, - attachmentSchema, attachmentsSchema, + ValidationError, } from "./validation"; import * as logs from "./logs"; import config from "./config"; -import { z } from "zod"; let db: any; let templates: any; @@ -41,11 +40,17 @@ export async function preparePayload( const templateRender = await templates.render(template.name, template.data); const mergeMessage = payload.message || {}; - let attachments = attachmentsSchema.parse( - templateRender.attachments - ? templateRender.attachments - : mergeMessage.attachments - ); + const attachmentsValue = templateRender.attachments + ? templateRender.attachments + : mergeMessage.attachments; + + const attachmentsResult = attachmentsSchema.safeParse(attachmentsValue); + if (!attachmentsResult.success) { + throw new ValidationError( + "Invalid message configuration: Field 'message.attachments' must be an array" + ); + } + let attachments = attachmentsResult.data; const handleTemplateValue = (value: any) => { if (value === null) { diff --git a/firestore-send-email/functions/src/validation.ts b/firestore-send-email/functions/src/validation.ts index 0d13af707..2ba4faf1c 100644 --- a/firestore-send-email/functions/src/validation.ts +++ b/firestore-send-email/functions/src/validation.ts @@ -294,6 +294,7 @@ export function validatePayload(payload: any) { } const result = payloadSchema.safeParse(payload); + if (!result.success) { throw new ValidationError(formatZodError(result.error)); } From 26eb712bb8c1f04e58cf5d790951fec79b43246f Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 12 Jan 2026 16:04:47 +0000 Subject: [PATCH 2/3] refactor(firestore-send-email): improve validation logic and test coverage --- .../__tests__/delivery.integration.test.ts | 906 ++++++++++++++++++ .../functions/__tests__/index.test.ts | 146 +++ .../__tests__/prepare-payload.test.ts | 193 +++- .../functions/__tests__/validation.test.ts | 186 +++- .../functions/src/delivery.ts | 107 +++ firestore-send-email/functions/src/index.ts | 149 +-- .../functions/src/prepare-payload.ts | 147 +-- .../functions/src/validation.ts | 80 +- 8 files changed, 1684 insertions(+), 230 deletions(-) create mode 100644 firestore-send-email/functions/__tests__/delivery.integration.test.ts create mode 100644 firestore-send-email/functions/__tests__/index.test.ts create mode 100644 firestore-send-email/functions/src/delivery.ts diff --git a/firestore-send-email/functions/__tests__/delivery.integration.test.ts b/firestore-send-email/functions/__tests__/delivery.integration.test.ts new file mode 100644 index 000000000..1891618c6 --- /dev/null +++ b/firestore-send-email/functions/__tests__/delivery.integration.test.ts @@ -0,0 +1,906 @@ +import { deliverEmail, DeliveryDependencies } from "../src/delivery"; +import { setDependencies } from "../src/prepare-payload"; + +/** + * Helper to create a mock DocumentReference with the given payload + */ +function createMockRef(payload: any) { + return { + get: jest.fn().mockResolvedValue({ + exists: true, + data: () => payload, + }), + path: "mail/test-doc-id", + } as any; +} + +/** + * Helper to create a mock DocumentReference that doesn't exist + */ +function createNonExistentRef() { + return { + get: jest.fn().mockResolvedValue({ + exists: false, + }), + path: "mail/non-existent", + } as any; +} + +describe("deliverEmail integration", () => { + const mockTransport = { + sendMail: jest.fn(), + }; + + const deps: DeliveryDependencies = { + transport: mockTransport as any, + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock dependencies for preparePayload (db and templates) + setDependencies({ getAll: jest.fn() }, null); + + // Default successful transport response + mockTransport.sendMail.mockResolvedValue({ + messageId: "test-msg-123", + accepted: ["user@example.com"], + rejected: [], + pending: [], + response: "250 OK", + }); + }); + + describe("basic delivery scenarios", () => { + test("delivers email with text content", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test Subject", text: "Plain text content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(result.info?.messageId).toBe("test-msg-123"); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ["user@example.com"], + subject: "Test Subject", + text: "Plain text content", + }) + ); + }); + + test("delivers email with HTML content", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "HTML Email", html: "

Hello

World

" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + html: "

Hello

World

", + }) + ); + }); + + test("delivers email with both text and HTML content", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { + subject: "Multi-format Email", + text: "Plain text version", + html: "

HTML version

", + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + text: "Plain text version", + html: "

HTML version

", + }) + ); + }); + + test("returns delivery info with all fields", async () => { + mockTransport.sendMail.mockResolvedValue({ + messageId: "unique-id-456", + queueId: "sendgrid-queue-789", + accepted: ["a@test.com", "b@test.com"], + rejected: ["invalid@test.com"], + pending: ["slow@test.com"], + response: "250 Message accepted", + }); + + const mockRef = createMockRef({ + to: ["a@test.com", "b@test.com", "invalid@test.com", "slow@test.com"], + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(result.info).toEqual({ + messageId: "unique-id-456", + sendgridQueueId: "sendgrid-queue-789", + accepted: ["a@test.com", "b@test.com"], + rejected: ["invalid@test.com"], + pending: ["slow@test.com"], + response: "250 Message accepted", + }); + }); + }); + + describe("recipient handling", () => { + test("converts single string recipient to array", async () => { + const mockRef = createMockRef({ + to: "single@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ["single@example.com"], + }) + ); + }); + + test("handles array of recipients", async () => { + const mockRef = createMockRef({ + to: ["user1@example.com", "user2@example.com", "user3@example.com"], + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ["user1@example.com", "user2@example.com", "user3@example.com"], + }) + ); + }); + + test("handles all recipient types (to, cc, bcc)", async () => { + const mockRef = createMockRef({ + to: ["to1@example.com", "to2@example.com"], + cc: "cc@example.com", + bcc: ["bcc1@example.com", "bcc2@example.com"], + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ["to1@example.com", "to2@example.com"], + cc: ["cc@example.com"], + bcc: ["bcc1@example.com", "bcc2@example.com"], + }) + ); + }); + + test("handles cc only (no to)", async () => { + const mockRef = createMockRef({ + cc: "cc-only@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: [], + cc: ["cc-only@example.com"], + }) + ); + }); + + test("handles bcc only (no to or cc)", async () => { + const mockRef = createMockRef({ + bcc: ["bcc-only@example.com"], + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: [], + cc: [], + bcc: ["bcc-only@example.com"], + }) + ); + }); + + test("fails when no recipients at all", async () => { + const mockRef = createMockRef({ + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + }); + + describe("delivery state handling", () => { + test("processes when state is PROCESSING", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(result.skipped).toBeUndefined(); + expect(mockTransport.sendMail).toHaveBeenCalled(); + }); + + test("skips when state is PENDING", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PENDING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.skipped).toBe(true); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + + test("skips when state is SUCCESS", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "SUCCESS" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.skipped).toBe(true); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + + test("skips when state is ERROR", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "ERROR" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.skipped).toBe(true); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + + test("skips when state is RETRY", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "RETRY" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.skipped).toBe(true); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + + test("skips when delivery object is missing", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + // No delivery object + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.skipped).toBe(true); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + + test("skips when document does not exist", async () => { + const mockRef = createNonExistentRef(); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.skipped).toBe(true); + expect(result.error).toContain("does not exist"); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + }); + + describe("SendGrid configuration", () => { + test("handles SendGrid templateId", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + sendGrid: { + templateId: "d-abc123def456", + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: "d-abc123def456", + }) + ); + }); + + test("handles SendGrid with dynamicTemplateData", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + sendGrid: { + templateId: "d-template-id", + dynamicTemplateData: { + firstName: "John", + lastName: "Doe", + orderNumber: 12345, + items: ["Item A", "Item B"], + }, + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: "d-template-id", + dynamicTemplateData: { + firstName: "John", + lastName: "Doe", + orderNumber: 12345, + items: ["Item A", "Item B"], + }, + }) + ); + }); + + test("handles SendGrid with mailSettings", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + sendGrid: { + templateId: "d-template-id", + mailSettings: { + sandboxMode: { enable: true }, + bypassListManagement: { enable: false }, + }, + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + mailSettings: { + sandboxMode: { enable: true }, + bypassListManagement: { enable: false }, + }, + }) + ); + }); + + test("handles SendGrid with all options combined", async () => { + const mockRef = createMockRef({ + to: ["user1@example.com", "user2@example.com"], + cc: "cc@example.com", + from: "sender@company.com", + replyTo: "support@company.com", + sendGrid: { + templateId: "d-full-template", + dynamicTemplateData: { name: "Customer" }, + mailSettings: { sandboxMode: { enable: false } }, + }, + categories: ["transactional", "welcome"], + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: ["user1@example.com", "user2@example.com"], + cc: ["cc@example.com"], + from: "sender@company.com", + replyTo: "support@company.com", + templateId: "d-full-template", + dynamicTemplateData: { name: "Customer" }, + mailSettings: { sandboxMode: { enable: false } }, + categories: ["transactional", "welcome"], + }) + ); + }); + + test("handles SendGrid template with message content override", async () => { + // When both sendGrid.templateId and message content are provided, + // the message fields should still be passed to allow fallback/override + const mockRef = createMockRef({ + to: "user@example.com", + sendGrid: { + templateId: "d-my-template", + dynamicTemplateData: { name: "John" }, + }, + message: { + subject: "Fallback Subject", + text: "Fallback text if template fails", + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: "d-my-template", + dynamicTemplateData: { name: "John" }, + subject: "Fallback Subject", + text: "Fallback text if template fails", + }) + ); + }); + + test("handles SendGrid with empty message object", async () => { + // SendGrid with empty message should still work (template provides content) + const mockRef = createMockRef({ + to: "user@example.com", + sendGrid: { + templateId: "d-template-provides-all", + }, + message: {}, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + templateId: "d-template-provides-all", + }) + ); + }); + + test("handles SendGrid mailSettings only (no templateId)", async () => { + // mailSettings can be used without templateId for sandbox mode etc + const mockRef = createMockRef({ + to: "user@example.com", + sendGrid: { + mailSettings: { + sandboxMode: { enable: true }, + }, + }, + message: { + subject: "Test in Sandbox", + text: "This email is in sandbox mode", + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "Test in Sandbox", + text: "This email is in sandbox mode", + mailSettings: { + sandboxMode: { enable: true }, + }, + }) + ); + }); + }); + + describe("attachments", () => { + test("handles message with attachments array", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { + subject: "Email with attachments", + text: "See attached files", + attachments: [ + { filename: "report.pdf", content: "base64content" }, + { filename: "image.png", path: "/path/to/image.png" }, + ], + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: [ + { filename: "report.pdf", content: "base64content" }, + { filename: "image.png", path: "/path/to/image.png" }, + ], + }) + ); + }); + + test("handles message with empty attachments array", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { + subject: "No attachments", + text: "Plain email", + attachments: [], + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + }); + + test("handles attachment with all properties", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { + subject: "Full attachment", + text: "Content", + attachments: [ + { + filename: "document.pdf", + content: "base64data", + contentType: "application/pdf", + contentDisposition: "attachment", + cid: "unique-cid", + encoding: "base64", + }, + ], + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: expect.arrayContaining([ + expect.objectContaining({ + filename: "document.pdf", + contentType: "application/pdf", + }), + ]), + }) + ); + }); + }); + + describe("custom email options", () => { + test("uses custom from address", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + from: "custom-sender@company.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + from: "custom-sender@company.com", + }) + ); + }); + + test("uses custom replyTo address", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + replyTo: "support@company.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + replyTo: "support@company.com", + }) + ); + }); + + test("includes custom headers", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + headers: { + "X-Custom-Header": "custom-value", + "X-Priority": "1", + }, + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + "X-Custom-Header": "custom-value", + "X-Priority": "1", + }, + }) + ); + }); + + test("includes categories", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + categories: ["marketing", "newsletter", "weekly"], + message: { subject: "Newsletter", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + categories: ["marketing", "newsletter", "weekly"], + }) + ); + }); + }); + + describe("error handling", () => { + test("returns error when transport fails with SMTP error", async () => { + mockTransport.sendMail.mockRejectedValue( + new Error("SMTP connection refused") + ); + + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.skipped).toBeUndefined(); + expect(result.error).toContain("SMTP connection refused"); + }); + + test("returns error when transport times out", async () => { + mockTransport.sendMail.mockRejectedValue( + new Error("Connection timed out") + ); + + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain("timed out"); + }); + + test("returns error when authentication fails", async () => { + mockTransport.sendMail.mockRejectedValue( + new Error("Invalid login: 535 Authentication failed") + ); + + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain("Authentication failed"); + }); + + test("returns error for validation failure - missing message and template", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + // No message, template, or sendGrid + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + + test("returns error for invalid attachments (object instead of array)", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { + subject: "Test", + text: "Content", + attachments: { filename: "bad.pdf" }, // Object, not array + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(false); + expect(result.error).toContain("array"); + expect(mockTransport.sendMail).not.toHaveBeenCalled(); + }); + }); + + describe("edge cases", () => { + test("rejects empty message object without template or sendGrid", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: {}, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + // Empty message without template/sendGrid should fail - needs subject + content + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid message configuration"); + }); + + test("handles null values in transport response", async () => { + mockTransport.sendMail.mockResolvedValue({ + messageId: null, + accepted: null, + rejected: null, + pending: null, + response: null, + }); + + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(result.info).toEqual({ + messageId: null, + sendgridQueueId: null, + accepted: [], + rejected: [], + pending: [], + response: null, + }); + }); + + test("handles very long recipient list", async () => { + const manyRecipients = Array.from( + { length: 100 }, + (_, i) => `user${i}@example.com` + ); + + const mockRef = createMockRef({ + to: manyRecipients, + message: { subject: "Mass email", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: manyRecipients, + }) + ); + }); + + test("handles unicode in email content", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { + subject: "日本語の件名 🎉", + text: "Émojis: 👍 🚀 ✨", + html: "

中文内容 with émojis 🌟

", + }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "日本語の件名 🎉", + text: "Émojis: 👍 🚀 ✨", + html: "

中文内容 with émojis 🌟

", + }) + ); + }); + + test("handles special characters in email addresses", async () => { + const mockRef = createMockRef({ + to: '"John Doe" ', + from: "Company Name ", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const result = await deliverEmail(mockRef, deps); + + expect(result.success).toBe(true); + }); + }); + + describe("concurrent delivery attempts", () => { + test("multiple calls with same ref return consistent results", async () => { + const mockRef = createMockRef({ + to: "user@example.com", + message: { subject: "Test", text: "Content" }, + delivery: { state: "PROCESSING" }, + }); + + const [result1, result2, result3] = await Promise.all([ + deliverEmail(mockRef, deps), + deliverEmail(mockRef, deps), + deliverEmail(mockRef, deps), + ]); + + // All should succeed (though in production, the state machine would prevent duplicates) + expect(result1.success).toBe(true); + expect(result2.success).toBe(true); + expect(result3.success).toBe(true); + expect(mockTransport.sendMail).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/firestore-send-email/functions/__tests__/index.test.ts b/firestore-send-email/functions/__tests__/index.test.ts new file mode 100644 index 000000000..6fae25411 --- /dev/null +++ b/firestore-send-email/functions/__tests__/index.test.ts @@ -0,0 +1,146 @@ +// Mock Timestamp class that mimics firebase-admin Timestamp +class MockTimestamp { + private _date: Date; + + constructor(seconds: number, nanoseconds: number) { + this._date = new Date(seconds * 1000 + nanoseconds / 1000000); + } + + static fromDate(date: Date): MockTimestamp { + return new MockTimestamp(Math.floor(date.getTime() / 1000), 0); + } + + toDate(): Date { + return this._date; + } +} + +// Mock firebase-admin/firestore module +jest.mock("firebase-admin/firestore", () => ({ + Timestamp: MockTimestamp, + getFirestore: jest.fn(), + FieldValue: { + increment: jest.fn(), + serverTimestamp: jest.fn(), + }, +})); + +// Mock other dependencies that index.ts imports +jest.mock("firebase-admin/app", () => ({ + initializeApp: jest.fn(), +})); + +jest.mock("firebase-functions/v2/firestore", () => ({ + onDocumentWritten: jest.fn(), +})); + +jest.mock("../src/logs", () => ({ + init: jest.fn(), + start: jest.fn(), + complete: jest.fn(), + error: jest.fn(), + attemptingDelivery: jest.fn(), + delivered: jest.fn(), + deliveryError: jest.fn(), + info: jest.fn(), + invalidMessage: jest.fn(), + missingUids: jest.fn(), +})); + +jest.mock("../src/events", () => ({ + setupEventChannel: jest.fn(), + recordStartEvent: jest.fn(), + recordSuccessEvent: jest.fn(), + recordErrorEvent: jest.fn(), + recordProcessingEvent: jest.fn(), + recordPendingEvent: jest.fn(), + recordRetryEvent: jest.fn(), + recordCompleteEvent: jest.fn(), +})); + +jest.mock("../src/helpers", () => ({ + isSendGrid: jest.fn(), + setSmtpCredentials: jest.fn(), +})); + +jest.mock("../src/delivery", () => ({ + deliverEmail: jest.fn(), +})); + +jest.mock("../src/prepare-payload", () => ({ + setDependencies: jest.fn(), + preparePayload: jest.fn(), +})); + +import { calculateExpireAt } from "../src/index"; +import { Timestamp } from "firebase-admin/firestore"; + +describe("calculateExpireAt", () => { + const baseDate = new Date("2024-01-15T12:00:00Z"); + + test("calculates hour expiration correctly", () => { + const baseTimestamp = Timestamp.fromDate(baseDate); + const result = calculateExpireAt(baseTimestamp, "hour", 2); + const expected = new Date("2024-01-15T14:00:00Z"); + expect(result.toDate()).toEqual(expected); + }); + + test("calculates day expiration correctly", () => { + const baseTimestamp = Timestamp.fromDate(baseDate); + const result = calculateExpireAt(baseTimestamp, "day", 3); + const expected = new Date("2024-01-18T12:00:00Z"); + expect(result.toDate()).toEqual(expected); + }); + + test("calculates week expiration correctly", () => { + const baseTimestamp = Timestamp.fromDate(baseDate); + const result = calculateExpireAt(baseTimestamp, "week", 2); + const expected = new Date("2024-01-29T12:00:00Z"); + expect(result.toDate()).toEqual(expected); + }); + + test("calculates month expiration correctly", () => { + const baseTimestamp = Timestamp.fromDate(baseDate); + const result = calculateExpireAt(baseTimestamp, "month", 1); + const expected = new Date("2024-02-15T12:00:00Z"); + expect(result.toDate()).toEqual(expected); + }); + + test("calculates year expiration correctly", () => { + const baseTimestamp = Timestamp.fromDate(baseDate); + const result = calculateExpireAt(baseTimestamp, "year", 1); + const expected = new Date("2025-01-15T12:00:00Z"); + expect(result.toDate()).toEqual(expected); + }); + + test("throws error for unknown TTLExpireType", () => { + const baseTimestamp = Timestamp.fromDate(baseDate); + expect(() => calculateExpireAt(baseTimestamp, "invalid", 1)).toThrow( + "Unknown TTLExpireType: invalid" + ); + }); + + test("handles zero value", () => { + const baseTimestamp = Timestamp.fromDate(baseDate); + const result = calculateExpireAt(baseTimestamp, "day", 0); + expect(result.toDate()).toEqual(baseDate); + }); + + test("handles large values", () => { + const baseTimestamp = Timestamp.fromDate(baseDate); + const result = calculateExpireAt(baseTimestamp, "day", 365); + // 2024 is a leap year (366 days), so 365 days from Jan 15 is Jan 14 next year + const expected = new Date("2025-01-14T12:00:00Z"); + expect(result.toDate()).toEqual(expected); + }); + + test("handles month boundary correctly", () => { + // Jan 31 + 1 month - JavaScript Date overflows Feb + const jan31 = Timestamp.fromDate(new Date("2024-01-31T12:00:00Z")); + const result = calculateExpireAt(jan31, "month", 1); + // For 2024 (leap year), Jan 31 + 1 month = March 2 (Feb has 29 days, 31-29=2) + const resultDate = result.toDate(); + expect(resultDate.getMonth()).toBe(2); // March (0-indexed) + expect(resultDate.getDate()).toBe(2); + }); +}); diff --git a/firestore-send-email/functions/__tests__/prepare-payload.test.ts b/firestore-send-email/functions/__tests__/prepare-payload.test.ts index 36136bf8a..e2fd8e3a0 100644 --- a/firestore-send-email/functions/__tests__/prepare-payload.test.ts +++ b/firestore-send-email/functions/__tests__/prepare-payload.test.ts @@ -1,3 +1,11 @@ +// Mock the config module before importing prepare-payload +jest.mock("../src/config", () => ({ + default: { + usersCollection: "users", + }, + __esModule: true, +})); + import { preparePayload, setDependencies } from "../src/prepare-payload"; class MockTemplates { @@ -160,7 +168,7 @@ describe("preparePayload Template Merging", () => { }; await expect(preparePayload(payload)).rejects.toThrow( - "Invalid message configuration: Field 'message.attachments' must be an array" + "Field 'message.attachments' must be an array" ); }); @@ -271,15 +279,16 @@ describe("preparePayload Template Merging", () => { expect(result.message.subject).toBe("Direct Subject"); }); - it("should handle empty message object", async () => { + it("should reject empty message object without template or sendGrid", async () => { const payload = { to: "test@example.com", message: {}, }; - const result = await preparePayload(payload); - - expect(result.message).toEqual({}); + // Empty message fails on subject requirement first + await expect(preparePayload(payload)).rejects.toThrow( + "Invalid message configuration" + ); }); it("should handle template with null values", async () => { @@ -480,3 +489,177 @@ describe("preparePayload", () => { ); }); }); + +describe("preparePayload UID resolution", () => { + const createMockDocSnapshot = ( + id: string, + email: string | null, + exists: boolean = true + ) => ({ + id, + exists, + get: (field: string) => (field === "email" ? email : undefined), + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should resolve UIDs to emails successfully", async () => { + const mockGetAll = jest + .fn() + .mockResolvedValue([ + createMockDocSnapshot("uid1", "user1@example.com"), + createMockDocSnapshot("uid2", "user2@example.com"), + ]); + + const mockDb = { + getAll: mockGetAll, + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockImplementation((uid) => ({ id: uid })), + }), + }; + + setDependencies(mockDb, null); + + const payload = { + toUids: ["uid1", "uid2"], + message: { + subject: "Test Subject", + text: "Test text", + }, + }; + + const result = await preparePayload(payload); + + expect(result.to).toContain("user1@example.com"); + expect(result.to).toContain("user2@example.com"); + }); + + it("should handle mixed valid and invalid UIDs", async () => { + const mockGetAll = jest.fn().mockResolvedValue([ + createMockDocSnapshot("uid1", "user1@example.com"), + createMockDocSnapshot("uid2", null), // User exists but no email + createMockDocSnapshot("uid3", "user3@example.com"), + createMockDocSnapshot("uid4", null, false), // User doesn't exist + ]); + + const mockDb = { + getAll: mockGetAll, + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockImplementation((uid) => ({ id: uid })), + }), + }; + + setDependencies(mockDb, null); + + const payload = { + toUids: ["uid1", "uid2", "uid3", "uid4"], + message: { + subject: "Test Subject", + text: "Test text", + }, + }; + + const result = await preparePayload(payload); + + // Only valid emails should be included + expect(result.to).toEqual(["user1@example.com", "user3@example.com"]); + }); + + it("should handle all UIDs failing to resolve", async () => { + const mockGetAll = jest + .fn() + .mockResolvedValue([ + createMockDocSnapshot("uid1", null, false), + createMockDocSnapshot("uid2", null, false), + ]); + + const mockDb = { + getAll: mockGetAll, + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockImplementation((uid) => ({ id: uid })), + }), + }; + + setDependencies(mockDb, null); + + const payload = { + toUids: ["uid1", "uid2"], + message: { + subject: "Test Subject", + text: "Test text", + }, + }; + + const result = await preparePayload(payload); + + // No emails resolved, so to should be empty + expect(result.to).toEqual([]); + }); + + it("should resolve UIDs across to, cc, and bcc", async () => { + const mockGetAll = jest + .fn() + .mockResolvedValue([ + createMockDocSnapshot("uid1", "to@example.com"), + createMockDocSnapshot("uid2", "cc@example.com"), + createMockDocSnapshot("uid3", "bcc@example.com"), + ]); + + const mockDb = { + getAll: mockGetAll, + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockImplementation((uid) => ({ id: uid })), + }), + }; + + setDependencies(mockDb, null); + + const payload = { + toUids: ["uid1"], + ccUids: ["uid2"], + bccUids: ["uid3"], + message: { + subject: "Test Subject", + text: "Test text", + }, + }; + + const result = await preparePayload(payload); + + expect(result.to).toEqual(["to@example.com"]); + expect(result.cc).toEqual(["cc@example.com"]); + expect(result.bcc).toEqual(["bcc@example.com"]); + }); + + it("should combine direct emails with resolved UIDs", async () => { + const mockGetAll = jest + .fn() + .mockResolvedValue([ + createMockDocSnapshot("uid1", "resolved@example.com"), + ]); + + const mockDb = { + getAll: mockGetAll, + collection: jest.fn().mockReturnValue({ + doc: jest.fn().mockImplementation((uid) => ({ id: uid })), + }), + }; + + setDependencies(mockDb, null); + + const payload = { + to: "direct@example.com", + toUids: ["uid1"], + message: { + subject: "Test Subject", + text: "Test text", + }, + }; + + const result = await preparePayload(payload); + + expect(result.to).toEqual(["direct@example.com", "resolved@example.com"]); + }); +}); diff --git a/firestore-send-email/functions/__tests__/validation.test.ts b/firestore-send-email/functions/__tests__/validation.test.ts index 759113905..c71e38ec0 100644 --- a/firestore-send-email/functions/__tests__/validation.test.ts +++ b/firestore-send-email/functions/__tests__/validation.test.ts @@ -1,4 +1,8 @@ -import { validatePayload, ValidationError } from "../src/validation"; +import { + validatePayload, + validatePreparedPayload, + ValidationError, +} from "../src/validation"; describe("validatePayload", () => { describe("valid payloads", () => { @@ -639,3 +643,183 @@ describe("validatePayload", () => { }); }); }); + +describe("validatePreparedPayload", () => { + describe("valid prepared payloads", () => { + test("should accept valid prepared payload with arrays", () => { + const payload = { + to: ["test@example.com"], + cc: [], + bcc: [], + message: { subject: "Test", text: "Hello" }, + }; + expect(() => validatePreparedPayload(payload)).not.toThrow(); + }); + + test("should accept prepared payload with empty recipient arrays", () => { + const payload = { + to: [], + cc: [], + bcc: [], + message: { subject: "Test", text: "Hello" }, + }; + expect(() => validatePreparedPayload(payload)).not.toThrow(); + }); + + test("should accept prepared payload with attachments array", () => { + const payload = { + to: ["test@example.com"], + cc: [], + bcc: [], + message: { + subject: "Test", + text: "Hello", + attachments: [{ filename: "test.txt", content: "test" }], + }, + }; + expect(() => validatePreparedPayload(payload)).not.toThrow(); + }); + + test("should accept prepared payload with sendGrid config", () => { + const payload = { + to: ["test@example.com"], + cc: [], + bcc: [], + sendGrid: { + templateId: "d-template-id", + dynamicTemplateData: { name: "Test" }, + }, + }; + expect(() => validatePreparedPayload(payload)).not.toThrow(); + }); + }); + + describe("invalid prepared payloads", () => { + test("should reject non-array to field", () => { + const payload = { + to: "test@example.com", + cc: [], + bcc: [], + }; + expect(() => validatePreparedPayload(payload)).toThrow(ValidationError); + expect(() => validatePreparedPayload(payload)).toThrow( + "Field 'to' must be an array" + ); + }); + + test("should reject non-array cc field", () => { + const payload = { + to: [], + cc: "test@example.com", + bcc: [], + }; + expect(() => validatePreparedPayload(payload)).toThrow(ValidationError); + expect(() => validatePreparedPayload(payload)).toThrow( + "Field 'cc' must be an array" + ); + }); + + test("should reject non-array attachments", () => { + const payload = { + to: ["test@example.com"], + cc: [], + bcc: [], + message: { + subject: "Test", + text: "Hello", + attachments: { filename: "test.txt" }, + }, + }; + expect(() => validatePreparedPayload(payload)).toThrow(ValidationError); + expect(() => validatePreparedPayload(payload)).toThrow( + "Field 'message.attachments' must be an array" + ); + }); + }); +}); + +describe("validatePayload recipient edge cases", () => { + test("should accept valid email address", () => { + const payload = { + to: "valid@example.com", + message: { + subject: "Test", + text: "Hello", + }, + }; + expect(() => validatePayload(payload)).not.toThrow(); + }); + + test("should accept email array with valid addresses", () => { + const payload = { + to: ["valid1@example.com", "valid2@example.com"], + message: { + subject: "Test", + text: "Hello", + }, + }; + expect(() => validatePayload(payload)).not.toThrow(); + }); + + test("should reject payload with no recipients at all", () => { + const payload = { + message: { + subject: "Test", + text: "Hello", + }, + }; + expect(() => validatePayload(payload)).toThrow(ValidationError); + expect(() => validatePayload(payload)).toThrow( + "Email must have at least one recipient" + ); + }); + + test("should reject payload with empty recipient arrays", () => { + const payload = { + to: [], + cc: [], + bcc: [], + message: { + subject: "Test", + text: "Hello", + }, + }; + expect(() => validatePayload(payload)).toThrow(ValidationError); + expect(() => validatePayload(payload)).toThrow( + "Email must have at least one recipient" + ); + }); + + test("should accept payload with only cc recipient", () => { + const payload = { + cc: "cc@example.com", + message: { + subject: "Test", + text: "Hello", + }, + }; + expect(() => validatePayload(payload)).not.toThrow(); + }); + + test("should accept payload with only bcc recipient", () => { + const payload = { + bcc: "bcc@example.com", + message: { + subject: "Test", + text: "Hello", + }, + }; + expect(() => validatePayload(payload)).not.toThrow(); + }); + + test("should accept payload with toUids instead of direct email", () => { + const payload = { + toUids: ["uid1", "uid2"], + message: { + subject: "Test", + text: "Hello", + }, + }; + expect(() => validatePayload(payload)).not.toThrow(); + }); +}); diff --git a/firestore-send-email/functions/src/delivery.ts b/firestore-send-email/functions/src/delivery.ts new file mode 100644 index 000000000..c3da519ba --- /dev/null +++ b/firestore-send-email/functions/src/delivery.ts @@ -0,0 +1,107 @@ +/** + * Delivery module - extracted for testability. + * Contains the core email delivery logic that can be tested + * with injected dependencies. + */ + +import { DocumentReference } from "firebase-admin/firestore"; +import * as nodemailer from "nodemailer"; +import * as logs from "./logs"; +import config from "./config"; +import { preparePayload } from "./prepare-payload"; +import { ExtendedSendMailOptions } from "./types"; + +export interface DeliveryDependencies { + transport: nodemailer.Transporter; +} + +export interface SendMailInfoLike { + messageId: string | null; + sendgridQueueId?: string | null; + accepted: string[]; + rejected: string[]; + pending: string[]; + response: string | null; +} + +export interface DeliveryResult { + success: boolean; + info?: SendMailInfoLike; + error?: string; + skipped?: boolean; +} + +/** + * Prepares and delivers an email from a Firestore document. + * Returns the result without updating Firestore (caller handles that). + * + * @param ref - Firestore document reference + * @param deps - Dependencies (transport) for sending email + * @returns DeliveryResult indicating success/failure and info + */ +export async function deliverEmail( + ref: DocumentReference, + deps: DeliveryDependencies +): Promise { + // Fetch the Firestore document + const snapshot = await ref.get(); + if (!snapshot.exists) { + return { success: false, skipped: true, error: "Document does not exist" }; + } + + let payload = snapshot.data(); + + // Only attempt delivery if the payload is still in the "PROCESSING" state + if (!payload.delivery || payload.delivery.state !== "PROCESSING") { + return { success: false, skipped: true, error: "Not in PROCESSING state" }; + } + + logs.attemptingDelivery(ref); + + try { + // Prepare the payload for delivery (e.g., formatting recipients, templates) + payload = await preparePayload(payload); + + // Validate that there is at least one recipient (to, cc, or bcc) + if (!payload.to.length && !payload.cc.length && !payload.bcc.length) { + throw new Error( + "Failed to deliver email. Expected at least 1 recipient." + ); + } + + const mailOptions: ExtendedSendMailOptions = { + from: payload.from || config.defaultFrom, + replyTo: payload.replyTo || config.defaultReplyTo, + to: payload.to, + cc: payload.cc, + bcc: payload.bcc, + subject: payload.message?.subject, + text: payload.message?.text, + html: payload.message?.html, + headers: payload?.headers, + attachments: payload.message?.attachments, + categories: payload.categories, + templateId: payload.sendGrid?.templateId, + dynamicTemplateData: payload.sendGrid?.dynamicTemplateData, + mailSettings: payload.sendGrid?.mailSettings, + }; + + logs.info("Sending via transport.sendMail()", { mailOptions }); + const result = (await deps.transport.sendMail(mailOptions)) as any; + + const info: SendMailInfoLike = { + messageId: result.messageId || null, + sendgridQueueId: result.queueId || null, + accepted: result.accepted || [], + rejected: result.rejected || [], + pending: result.pending || [], + response: result.response || null, + }; + + logs.delivered(ref, info); + return { success: true, info }; + } catch (e) { + logs.deliveryError(ref, e); + return { success: false, error: e.toString() }; + } +} diff --git a/firestore-send-email/functions/src/index.ts b/firestore-send-email/functions/src/index.ts index 0d04be470..b58d53818 100644 --- a/firestore-send-email/functions/src/index.ts +++ b/firestore-send-email/functions/src/index.ts @@ -30,11 +30,12 @@ import * as nodemailer from "nodemailer"; import * as logs from "./logs"; import config from "./config"; import Templates from "./templates"; -import { Delivery, QueuePayload, ExtendedSendMailOptions } from "./types"; +import { Delivery, QueuePayload } from "./types"; import { isSendGrid, setSmtpCredentials } from "./helpers"; import * as events from "./events"; import { SendGridTransport } from "./nodemailer-sendgrid"; -import { preparePayload, setDependencies } from "./prepare-payload"; +import { setDependencies } from "./prepare-payload"; +import { deliverEmail } from "./delivery"; logs.init(); @@ -43,15 +44,6 @@ let transport: nodemailer.Transporter; let templates: Templates; let initialized = false; -interface SendMailInfoLike { - messageId: string | null; - sendgridQueueId?: string | null; - accepted: string[]; - rejected: string[]; - pending: string[]; - response: string | null; -} - /** * Initializes Admin SDK & SMTP connection if not already initialized. */ @@ -93,114 +85,75 @@ async function transportLayer() { return setSmtpCredentials(config); } -function getExpireAt(startTime: Timestamp) { - const now = startTime.toDate(); - const value = config.TTLExpireValue; - switch (config.TTLExpireType) { +/** + * Calculates expiration timestamp based on TTL configuration. + * Exported for testing. + */ +export function calculateExpireAt( + startTime: Timestamp, + ttlType: string, + ttlValue: number +): Timestamp { + const date = startTime.toDate(); + switch (ttlType) { case "hour": - now.setHours(now.getHours() + value); + date.setHours(date.getHours() + ttlValue); break; case "day": - now.setDate(now.getDate() + value); + date.setDate(date.getDate() + ttlValue); break; case "week": - now.setDate(now.getDate() + value * 7); + date.setDate(date.getDate() + ttlValue * 7); break; case "month": - now.setMonth(now.getMonth() + value); + date.setMonth(date.getMonth() + ttlValue); break; case "year": - now.setFullYear(now.getFullYear() + value); + date.setFullYear(date.getFullYear() + ttlValue); break; default: - // Optionally handle unknown types - throw new Error(`Unknown TTLExpireType: ${config.TTLExpireType}`); + throw new Error(`Unknown TTLExpireType: ${ttlType}`); } - return Timestamp.fromDate(now); + return Timestamp.fromDate(date); } -async function deliver(ref: DocumentReference): Promise { - // Fetch the Firestore document - const snapshot = await ref.get(); - if (!snapshot.exists) { - return; - } +function getExpireAt(startTime: Timestamp): Timestamp { + return calculateExpireAt( + startTime, + config.TTLExpireType, + config.TTLExpireValue + ); +} - let payload = snapshot.data(); +async function deliver(ref: DocumentReference): Promise { + const result = await deliverEmail(ref, { transport }); - // Only attempt delivery if the payload is still in the "PROCESSING" state - if (!payload.delivery || payload.delivery.state !== "PROCESSING") { + // Skip Firestore update if document was skipped (doesn't exist or not in PROCESSING state) + if (result.skipped) { return; } - logs.attemptingDelivery(ref); - // Prepare the Firestore document for delivery updates - const update = { + const update: Record = { "delivery.attempts": FieldValue.increment(1), "delivery.endTime": FieldValue.serverTimestamp(), "delivery.error": null, "delivery.leaseExpireTime": null, + "delivery.state": result.success ? "SUCCESS" : "ERROR", }; - try { - // Prepare the payload for delivery (e.g., formatting recipients, templates) - payload = await preparePayload(payload); - - // Validate that there is at least one recipient (to, cc, or bcc) - if (!payload.to.length && !payload.cc.length && !payload.bcc.length) { - throw new Error( - "Failed to deliver email. Expected at least 1 recipient." - ); - } - - const mailOptions: ExtendedSendMailOptions = { - from: payload.from || config.defaultFrom, - replyTo: payload.replyTo || config.defaultReplyTo, - to: payload.to, - cc: payload.cc, - bcc: payload.bcc, - subject: payload.message?.subject, - text: payload.message?.text, - html: payload.message?.html, - headers: payload?.headers, - attachments: payload.message?.attachments, - categories: payload.categories, - templateId: payload.sendGrid?.templateId, - dynamicTemplateData: payload.sendGrid?.dynamicTemplateData, - mailSettings: payload.sendGrid?.mailSettings, - }; - - logs.info("Sending via transport.sendMail()", { mailOptions }); - const result = (await transport.sendMail(mailOptions)) as any; - - const info: SendMailInfoLike = { - messageId: result.messageId || null, - sendgridQueueId: result.queueId || null, - accepted: result.accepted || [], - rejected: result.rejected || [], - pending: result.pending || [], - response: result.response || null, - }; - - // Update Firestore document to indicate success - update["delivery.state"] = "SUCCESS"; - update["delivery.info"] = info; - - logs.delivered(ref, info); - } catch (e) { - // Update Firestore document to indicate failure - update["delivery.state"] = "ERROR"; - update["delivery.error"] = e.toString(); - logs.deliveryError(ref, e); + if (result.success && result.info) { + update["delivery.info"] = result.info; + } else if (!result.success && result.error) { + update["delivery.error"] = result.error; } // Update the Firestore document transactionally to allow retries (#48) + // We could check state here is still PROCESSING, but we don't + // since the email sending will have been attempted regardless of what the + // delivery state was at that point, so we just update the state to reflect + // the result of the last attempt so as to not potentially cause duplicate sends. return db.runTransaction((transaction) => { - // We could check state here is still PROCESSING, but we don't - // since the email sending will have been attempted regardless of what the - // delivery state was at that point, so we just update the state to reflect - // the result of the last attempt so as to not potentially cause duplicate sends. transaction.update(ref, update); return Promise.resolve(); }); @@ -217,22 +170,8 @@ async function processWrite( // Note: we still check these again inside the transaction in case the state has // changed while the transaction was inflight. if (change.after.exists) { - const payloadAfter = change.after.data() as QueuePayload; - // The email has already been delivered, so we don't need to do anything. - if ( - payloadAfter && - payloadAfter.delivery && - payloadAfter.delivery.state === "SUCCESS" - ) { - return; - } - - // The email has previously failed to be delivered, so we can't do anything. - if ( - payloadAfter && - payloadAfter.delivery && - payloadAfter.delivery.state === "ERROR" - ) { + const state = (change.after.data() as QueuePayload)?.delivery?.state; + if (state === "SUCCESS" || state === "ERROR") { return; } } diff --git a/firestore-send-email/functions/src/prepare-payload.ts b/firestore-send-email/functions/src/prepare-payload.ts index 2998cc483..554104b15 100644 --- a/firestore-send-email/functions/src/prepare-payload.ts +++ b/firestore-send-email/functions/src/prepare-payload.ts @@ -1,9 +1,5 @@ import { DocumentData } from "firebase-admin/firestore"; -import { - validatePayload, - attachmentsSchema, - ValidationError, -} from "./validation"; +import { validatePayload, validatePreparedPayload } from "./validation"; import * as logs from "./logs"; import config from "./config"; @@ -15,15 +11,22 @@ export function setDependencies(database: any, templatesInstance: any) { templates = templatesInstance; } -function validateFieldArray(field: string, array?: string[]) { - if (!Array.isArray(array)) { - throw new Error(`Invalid field "${field}". Expected an array of strings.`); - } - - if (array.find((item) => typeof item !== "string")) { - throw new Error(`Invalid field "${field}". Expected an array of strings.`); - } -} +/** + * Normalizes recipient field to an array of strings. + */ +const normalizeRecipients = (value: string | string[] | undefined): string[] => + typeof value === "string" ? [value] : value || []; + +/** + * Resolves UIDs to email addresses using a pre-fetched email map. + */ +const resolveUidsToEmails = ( + uids: string[] | undefined, + emailMap: Record +): string[] => + (uids || []) + .map((uid) => emailMap[uid]) + .filter((email): email is string => !!email); export async function preparePayload( payload: DocumentData @@ -40,78 +43,36 @@ export async function preparePayload( const templateRender = await templates.render(template.name, template.data); const mergeMessage = payload.message || {}; - const attachmentsValue = templateRender.attachments + const attachments = templateRender.attachments ? templateRender.attachments : mergeMessage.attachments; - const attachmentsResult = attachmentsSchema.safeParse(attachmentsValue); - if (!attachmentsResult.success) { - throw new ValidationError( - "Invalid message configuration: Field 'message.attachments' must be an array" - ); - } - let attachments = attachmentsResult.data; + // Convert null to undefined so it doesn't overwrite existing values + const handleTemplateValue = (value: any) => + value === null ? undefined : value; - const handleTemplateValue = (value: any) => { - if (value === null) { - return undefined; - } - if (value === "") { - return ""; - } - if (value === undefined) { - return undefined; - } - return value || undefined; - }; - - const templateContent = { - subject: handleTemplateValue(templateRender.subject), - html: handleTemplateValue(templateRender.html), - text: handleTemplateValue(templateRender.text), - amp: handleTemplateValue(templateRender.amp), - attachments: attachments || [], - }; - - Object.keys(templateContent).forEach((key) => { - if (templateContent[key] === undefined) { - delete templateContent[key]; - } - }); + const templateContent = Object.fromEntries( + Object.entries({ + subject: handleTemplateValue(templateRender.subject), + html: handleTemplateValue(templateRender.html), + text: handleTemplateValue(templateRender.text), + amp: handleTemplateValue(templateRender.amp), + attachments: attachments || [], + }).filter(([_, v]) => v !== undefined) + ); payload.message = Object.assign(mergeMessage, templateContent); } - let to: string[] = []; - let cc: string[] = []; - let bcc: string[] = []; - - if (typeof payload.to === "string") { - to = [payload.to]; - } else if (payload.to) { - validateFieldArray("to", payload.to); - to = to.concat(payload.to); - } - - if (typeof payload.cc === "string") { - cc = [payload.cc]; - } else if (payload.cc) { - validateFieldArray("cc", payload.cc); - cc = cc.concat(payload.cc); - } - - if (typeof payload.bcc === "string") { - bcc = [payload.bcc]; - } else if (payload.bcc) { - validateFieldArray("bcc", payload.bcc); - bcc = bcc.concat(payload.bcc); - } + let to = normalizeRecipients(payload.to); + let cc = normalizeRecipients(payload.cc); + let bcc = normalizeRecipients(payload.bcc); if (!payload.toUids && !payload.ccUids && !payload.bccUids) { payload.to = to; payload.cc = cc; payload.bcc = bcc; - return payload; + return validatePreparedPayload(payload); } if (!config.usersCollection) { @@ -121,17 +82,14 @@ export async function preparePayload( let uids: string[] = []; if (payload.toUids) { - validateFieldArray("toUids", payload.toUids); uids = uids.concat(payload.toUids); } if (payload.ccUids) { - validateFieldArray("ccUids", payload.ccUids); uids = uids.concat(payload.ccUids); } if (payload.bccUids) { - validateFieldArray("bccUids", payload.bccUids); uids = uids.concat(payload.bccUids); } @@ -165,38 +123,9 @@ export async function preparePayload( logs.missingUids(missingUids); - if (payload.toUids) { - payload.toUids.forEach((uid) => { - const email = toFetch[uid]; - if (email) { - to.push(email); - } - }); - } - - payload.to = to; - - if (payload.ccUids) { - payload.ccUids.forEach((uid) => { - const email = toFetch[uid]; - if (email) { - cc.push(email); - } - }); - } - - payload.cc = cc; - - if (payload.bccUids) { - payload.bccUids.forEach((uid) => { - const email = toFetch[uid]; - if (email) { - bcc.push(email); - } - }); - } - - payload.bcc = bcc; + payload.to = to.concat(resolveUidsToEmails(payload.toUids, toFetch)); + payload.cc = cc.concat(resolveUidsToEmails(payload.ccUids, toFetch)); + payload.bcc = bcc.concat(resolveUidsToEmails(payload.bccUids, toFetch)); - return payload; + return validatePreparedPayload(payload); } diff --git a/firestore-send-email/functions/src/validation.ts b/firestore-send-email/functions/src/validation.ts index 2ba4faf1c..cb4d09782 100644 --- a/firestore-send-email/functions/src/validation.ts +++ b/firestore-send-email/functions/src/validation.ts @@ -281,16 +281,27 @@ export function validatePayload(payload: any) { ); } - if (payload.message && Object.keys(payload.message).length > 0) { - const messageSchema = payload.template - ? templateMessageSchema - : standardMessageSchema; - validateField( - payload.message, - messageSchema, - "message", - "message configuration" - ); + if (payload.message !== undefined) { + // If there's a template or sendGrid, message content is optional (they provide it) + if (payload.template || payload.sendGrid) { + // Only validate message structure if it has content + if (Object.keys(payload.message).length > 0) { + validateField( + payload.message, + templateMessageSchema, + "message", + "message configuration" + ); + } + } else { + // No template/sendGrid - message must have subject + content + validateField( + payload.message, + standardMessageSchema, + "message", + "message configuration" + ); + } } const result = payloadSchema.safeParse(payload); @@ -309,3 +320,52 @@ export function validatePayload(payload: any) { ); } } + +/** + * Schema for the prepared message after transformations. + */ +const preparedMessageSchema = z + .object({ + subject: z.string().optional(), + text: z.string().nullable().optional(), + html: z.string().nullable().optional(), + amp: z.string().optional(), + attachments: attachmentsSchema, + }) + .passthrough(); + +/** + * Schema for the prepared payload after all transformations. + * Recipients are normalized to arrays, attachments validated. + */ +export const preparedPayloadSchema = z + .object({ + to: z.array(z.string()).default([]), + cc: z.array(z.string()).default([]), + bcc: z.array(z.string()).default([]), + from: z.string().optional(), + replyTo: z.string().optional(), + headers: z.record(z.any()).optional(), + message: preparedMessageSchema.optional(), + template: templateSchema.optional(), + sendGrid: sendGridSchema.optional(), + categories: z.array(z.string()).optional(), + }) + .passthrough(); + +export type PreparedPayload = z.infer; + +/** + * Validates the prepared payload after all transformations. + * Called at the end of preparePayload() before returning. + * @param payload - The prepared payload to validate + * @returns The validated payload + * @throws {ValidationError} When validation fails + */ +export function validatePreparedPayload(payload: unknown): PreparedPayload { + const result = preparedPayloadSchema.safeParse(payload); + if (!result.success) { + throw new ValidationError(formatZodError(result.error, "prepared payload")); + } + return result.data; +} From 3a261e55ec0db47c58f8247e1501f7ac2346943a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 12 Jan 2026 17:14:11 +0000 Subject: [PATCH 3/3] test(firestore-send-email): add more tests --- .../{helpers.test.ts => transport.test.ts} | 2 +- .../__tests__/{index.test.ts => ttl.test.ts} | 54 +-------------- firestore-send-email/functions/src/index.ts | 43 +----------- .../src/{helpers.ts => transport.ts} | 0 firestore-send-email/functions/src/ttl.ts | 66 +++++++++++++++++++ 5 files changed, 70 insertions(+), 95 deletions(-) rename firestore-send-email/functions/__tests__/{helpers.test.ts => transport.test.ts} (99%) rename firestore-send-email/functions/__tests__/{index.test.ts => ttl.test.ts} (73%) rename firestore-send-email/functions/src/{helpers.ts => transport.ts} (100%) create mode 100644 firestore-send-email/functions/src/ttl.ts diff --git a/firestore-send-email/functions/__tests__/helpers.test.ts b/firestore-send-email/functions/__tests__/transport.test.ts similarity index 99% rename from firestore-send-email/functions/__tests__/helpers.test.ts rename to firestore-send-email/functions/__tests__/transport.test.ts index 817b5d897..725f14fd1 100644 --- a/firestore-send-email/functions/__tests__/helpers.test.ts +++ b/firestore-send-email/functions/__tests__/transport.test.ts @@ -1,7 +1,7 @@ import Mail = require("nodemailer/lib/mailer"); const { logger } = require("firebase-functions"); -import { setSmtpCredentials, isSendGrid } from "../src/helpers"; +import { setSmtpCredentials, isSendGrid } from "../src/transport"; import { AuthenticatonType, Config } from "../src/types"; const consoleLogSpy = jest.spyOn(logger, "warn").mockImplementation(); diff --git a/firestore-send-email/functions/__tests__/index.test.ts b/firestore-send-email/functions/__tests__/ttl.test.ts similarity index 73% rename from firestore-send-email/functions/__tests__/index.test.ts rename to firestore-send-email/functions/__tests__/ttl.test.ts index 6fae25411..5ab116832 100644 --- a/firestore-send-email/functions/__tests__/index.test.ts +++ b/firestore-send-email/functions/__tests__/ttl.test.ts @@ -18,61 +18,9 @@ class MockTimestamp { // Mock firebase-admin/firestore module jest.mock("firebase-admin/firestore", () => ({ Timestamp: MockTimestamp, - getFirestore: jest.fn(), - FieldValue: { - increment: jest.fn(), - serverTimestamp: jest.fn(), - }, })); -// Mock other dependencies that index.ts imports -jest.mock("firebase-admin/app", () => ({ - initializeApp: jest.fn(), -})); - -jest.mock("firebase-functions/v2/firestore", () => ({ - onDocumentWritten: jest.fn(), -})); - -jest.mock("../src/logs", () => ({ - init: jest.fn(), - start: jest.fn(), - complete: jest.fn(), - error: jest.fn(), - attemptingDelivery: jest.fn(), - delivered: jest.fn(), - deliveryError: jest.fn(), - info: jest.fn(), - invalidMessage: jest.fn(), - missingUids: jest.fn(), -})); - -jest.mock("../src/events", () => ({ - setupEventChannel: jest.fn(), - recordStartEvent: jest.fn(), - recordSuccessEvent: jest.fn(), - recordErrorEvent: jest.fn(), - recordProcessingEvent: jest.fn(), - recordPendingEvent: jest.fn(), - recordRetryEvent: jest.fn(), - recordCompleteEvent: jest.fn(), -})); - -jest.mock("../src/helpers", () => ({ - isSendGrid: jest.fn(), - setSmtpCredentials: jest.fn(), -})); - -jest.mock("../src/delivery", () => ({ - deliverEmail: jest.fn(), -})); - -jest.mock("../src/prepare-payload", () => ({ - setDependencies: jest.fn(), - preparePayload: jest.fn(), -})); - -import { calculateExpireAt } from "../src/index"; +import { calculateExpireAt } from "../src/ttl"; import { Timestamp } from "firebase-admin/firestore"; describe("calculateExpireAt", () => { diff --git a/firestore-send-email/functions/src/index.ts b/firestore-send-email/functions/src/index.ts index b58d53818..09147bc2a 100644 --- a/firestore-send-email/functions/src/index.ts +++ b/firestore-send-email/functions/src/index.ts @@ -31,11 +31,12 @@ import * as logs from "./logs"; import config from "./config"; import Templates from "./templates"; import { Delivery, QueuePayload } from "./types"; -import { isSendGrid, setSmtpCredentials } from "./helpers"; +import { isSendGrid, setSmtpCredentials } from "./transport"; import * as events from "./events"; import { SendGridTransport } from "./nodemailer-sendgrid"; import { setDependencies } from "./prepare-payload"; import { deliverEmail } from "./delivery"; +import { getExpireAt } from "./ttl"; logs.init(); @@ -85,46 +86,6 @@ async function transportLayer() { return setSmtpCredentials(config); } -/** - * Calculates expiration timestamp based on TTL configuration. - * Exported for testing. - */ -export function calculateExpireAt( - startTime: Timestamp, - ttlType: string, - ttlValue: number -): Timestamp { - const date = startTime.toDate(); - switch (ttlType) { - case "hour": - date.setHours(date.getHours() + ttlValue); - break; - case "day": - date.setDate(date.getDate() + ttlValue); - break; - case "week": - date.setDate(date.getDate() + ttlValue * 7); - break; - case "month": - date.setMonth(date.getMonth() + ttlValue); - break; - case "year": - date.setFullYear(date.getFullYear() + ttlValue); - break; - default: - throw new Error(`Unknown TTLExpireType: ${ttlType}`); - } - return Timestamp.fromDate(date); -} - -function getExpireAt(startTime: Timestamp): Timestamp { - return calculateExpireAt( - startTime, - config.TTLExpireType, - config.TTLExpireValue - ); -} - async function deliver(ref: DocumentReference): Promise { const result = await deliverEmail(ref, { transport }); diff --git a/firestore-send-email/functions/src/helpers.ts b/firestore-send-email/functions/src/transport.ts similarity index 100% rename from firestore-send-email/functions/src/helpers.ts rename to firestore-send-email/functions/src/transport.ts diff --git a/firestore-send-email/functions/src/ttl.ts b/firestore-send-email/functions/src/ttl.ts new file mode 100644 index 000000000..e635a07c5 --- /dev/null +++ b/firestore-send-email/functions/src/ttl.ts @@ -0,0 +1,66 @@ +/** + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Timestamp } from "firebase-admin/firestore"; +import config from "./config"; + +/** + * Calculates expiration timestamp based on TTL configuration. + * @param startTime - The starting timestamp + * @param ttlType - The type of TTL (hour, day, week, month, year) + * @param ttlValue - The number of units to add + * @returns The calculated expiration timestamp + */ +export function calculateExpireAt( + startTime: Timestamp, + ttlType: string, + ttlValue: number +): Timestamp { + const date = startTime.toDate(); + switch (ttlType) { + case "hour": + date.setHours(date.getHours() + ttlValue); + break; + case "day": + date.setDate(date.getDate() + ttlValue); + break; + case "week": + date.setDate(date.getDate() + ttlValue * 7); + break; + case "month": + date.setMonth(date.getMonth() + ttlValue); + break; + case "year": + date.setFullYear(date.getFullYear() + ttlValue); + break; + default: + throw new Error(`Unknown TTLExpireType: ${ttlType}`); + } + return Timestamp.fromDate(date); +} + +/** + * Gets the expiration timestamp based on the configured TTL settings. + * @param startTime - The starting timestamp + * @returns The calculated expiration timestamp + */ +export function getExpireAt(startTime: Timestamp): Timestamp { + return calculateExpireAt( + startTime, + config.TTLExpireType, + config.TTLExpireValue + ); +}