From 3b6f9c125aa32dbdedddfe33d1b88028b323c6ab Mon Sep 17 00:00:00 2001 From: Paul Goldschmidt Date: Tue, 24 Mar 2026 11:44:45 +0100 Subject: [PATCH 1/7] add nodemailer --- functions/package-lock.json | 25 +++++++++++++++++++++++-- functions/package.json | 4 +++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/functions/package-lock.json b/functions/package-lock.json index 4efe8a7..bd66766 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -1,12 +1,12 @@ { "name": "functions", - "version": "4.0.0-beta.6", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "functions", - "version": "4.0.0-beta.6", + "version": "4.0.0", "dependencies": { "@resvg/resvg-js": "^2.6.2", "d3": "^7.9.0", @@ -17,6 +17,7 @@ "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.2", "luxon": "^3.7.2", + "nodemailer": "^8.0.3", "openai": "^6.17.0", "zod": "^3.25.76" }, @@ -29,6 +30,7 @@ "@types/luxon": "^3.4.2", "@types/mocha": "^10.0.7", "@types/node": "^22", + "@types/nodemailer": "^7.0.11", "@types/sinon": "^21.0.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", @@ -3422,6 +3424,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pako": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", @@ -10434,6 +10446,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.3.tgz", + "integrity": "sha512-JQNBqvK+bj3NMhUFR3wmCl3SYcOeMotDiwDBvIoCuQdF0PvlIY0BH+FJ2CG7u4cXKPChplE78oowlH/Otsc4ZQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/functions/package.json b/functions/package.json index 9f831eb..7cbc6bc 100644 --- a/functions/package.json +++ b/functions/package.json @@ -34,6 +34,7 @@ "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.2", "luxon": "^3.7.2", + "nodemailer": "^8.0.3", "openai": "^6.17.0", "zod": "^3.25.76" }, @@ -46,6 +47,7 @@ "@types/luxon": "^3.4.2", "@types/mocha": "^10.0.7", "@types/node": "^22", + "@types/nodemailer": "^7.0.11", "@types/sinon": "^21.0.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", @@ -70,4 +72,4 @@ "serialize-javascript": "^7.0.4", "http-proxy-agent": "^7.0.2" } -} \ No newline at end of file +} From fbe68691bfdd908b3892492fed6e9fbe85b1ba91 Mon Sep 17 00:00:00 2001 From: Paul Goldschmidt Date: Tue, 24 Mar 2026 11:44:55 +0100 Subject: [PATCH 2/7] secretsssss --- functions/src/env.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/functions/src/env.ts b/functions/src/env.ts index 1947835..06a04b9 100644 --- a/functions/src/env.ts +++ b/functions/src/env.ts @@ -7,6 +7,12 @@ import { defineSecret } from "firebase-functions/params"; enum SecretKey { OPENAI_API_KEY = "OPENAI_API_KEY", + SMTP_HOST = "SMTP_HOST", + SMTP_PORT = "SMTP_PORT", + SMTP_USER = "SMTP_USER", + SMTP_PASSWORD = "SMTP_PASSWORD", + FEEDBACK_COORDINATOR_EMAIL = "FEEDBACK_COORDINATOR_EMAIL", + FEEDBACK_SENDER_EMAIL = "FEEDBACK_SENDER_EMAIL", } const openaiApiKey = defineSecret(SecretKey.OPENAI_API_KEY); @@ -16,3 +22,30 @@ export const getOpenaiApiKey = (): string => openaiApiKey.value(); export const getOpenaiSecretKeys = (): string[] => [SecretKey.OPENAI_API_KEY]; export const openaiApiKeyParam: ReturnType = openaiApiKey; + +const smtpHost = defineSecret(SecretKey.SMTP_HOST); +const smtpPort = defineSecret(SecretKey.SMTP_PORT); +const smtpUser = defineSecret(SecretKey.SMTP_USER); +const smtpPassword = defineSecret(SecretKey.SMTP_PASSWORD); +const feedbackCoordinatorEmail = defineSecret( + SecretKey.FEEDBACK_COORDINATOR_EMAIL, +); +const feedbackSenderEmail = defineSecret(SecretKey.FEEDBACK_SENDER_EMAIL); + +export const getSmtpHost = (): string => smtpHost.value(); +export const getSmtpPort = (): string => smtpPort.value(); +export const getSmtpUser = (): string => smtpUser.value(); +export const getSmtpPassword = (): string => smtpPassword.value(); +export const getFeedbackCoordinatorEmail = (): string => + feedbackCoordinatorEmail.value(); +export const getFeedbackSenderEmail = (): string => feedbackSenderEmail.value(); + +export const feedbackEmailSecretParams: Array> = + [ + smtpHost, + smtpPort, + smtpUser, + smtpPassword, + feedbackCoordinatorEmail, + feedbackSenderEmail, + ]; From 3c41543e86f958af8f23c3f2e0dc1a8bb85f3538 Mon Sep 17 00:00:00 2001 From: Paul Goldschmidt Date: Tue, 24 Mar 2026 11:45:06 +0100 Subject: [PATCH 3/7] main function --- functions/src/functions/onFeedbackCreated.ts | 90 ++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 functions/src/functions/onFeedbackCreated.ts diff --git a/functions/src/functions/onFeedbackCreated.ts b/functions/src/functions/onFeedbackCreated.ts new file mode 100644 index 0000000..3b47ab6 --- /dev/null +++ b/functions/src/functions/onFeedbackCreated.ts @@ -0,0 +1,90 @@ +// This source file is part of the MyHeart Counts project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT + +import { logger } from "firebase-functions"; +import { onDocumentCreated } from "firebase-functions/v2/firestore"; +import { createTransport } from "nodemailer"; +import { + feedbackEmailSecretParams, + getFeedbackCoordinatorEmail, + getFeedbackSenderEmail, + getSmtpHost, + getSmtpPassword, + getSmtpPort, + getSmtpUser, +} from "../env.js"; +import { privilegedServiceAccount } from "./helpers.js"; + +const formatFeedbackEmail = ( + feedbackId: string, + data: Record, +): { subject: string; text: string } => { + const subject = `[MyHeartCounts] New Feedback Received (ID: ${feedbackId})`; + + const { accountId, ...rest } = data; + const lines: string[] = [ + `New feedback has been submitted.`, + ``, + `Feedback ID: ${feedbackId}`, + `From user: ${typeof accountId === "string" ? accountId : "unknown"}`, + ``, + `--- Feedback Content ---`, + ]; + + for (const [key, value] of Object.entries(rest)) { + const formatted = + typeof value === "string" ? value : JSON.stringify(value, null, 2); + lines.push(`${key}: ${formatted}`); + } + + return { subject, text: lines.join("\n") }; +}; + +export const onFeedbackCreated = onDocumentCreated( + { + document: "feedback/{feedbackId}", + serviceAccount: privilegedServiceAccount, + secrets: feedbackEmailSecretParams, + }, + async (event) => { + const snapshot = event.data; + if (!snapshot) { + logger.warn("onFeedbackCreated: No data in event"); + return; + } + + const feedbackId = event.params.feedbackId; + const data = snapshot.data(); + const { subject, text } = formatFeedbackEmail(feedbackId, data); + + try { + const port = Number(getSmtpPort()); + const transporter = createTransport({ + host: getSmtpHost(), + port, + secure: port === 465, + auth: { + user: getSmtpUser(), + pass: getSmtpPassword(), + }, + }); + + await transporter.sendMail({ + from: getFeedbackSenderEmail(), + to: getFeedbackCoordinatorEmail(), + subject, + text, + }); + + logger.info( + `Feedback notification sent for ${feedbackId} to ${getFeedbackCoordinatorEmail()}`, + ); + } catch (error) { + logger.error( + `Failed to send feedback notification for ${feedbackId}: ${String(error)}`, + ); + } + }, +); From 5fa640d8a7594f8dac3603399b62b60edc819f85 Mon Sep 17 00:00:00 2001 From: Paul Goldschmidt Date: Tue, 24 Mar 2026 11:45:12 +0100 Subject: [PATCH 4/7] add to index --- functions/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/functions/src/index.ts b/functions/src/index.ts index b0bc07e..5739a72 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -44,6 +44,7 @@ export * from "./functions/updateStaticData.js"; export * from "./functions/updateUserInformation.js"; export * from "./functions/planNudges.js"; export * from "./functions/sendNudges.js"; +export * from "./functions/onFeedbackCreated.js"; export * from "./functions/onUserQuestionnaireResponseWritten.js"; export * from "./functions/deleteHealthSamples.js"; export * from "./functions/onArchivedLiveHealthSampleUploaded.js"; From ac5e6092311436eb2fb5bb4f4de6c24ae782e68f Mon Sep 17 00:00:00 2001 From: Paul Goldschmidt Date: Thu, 16 Jul 2026 10:06:19 +0200 Subject: [PATCH 5/7] update getting smtp credentials --- functions/src/env.ts | 38 +++++++++----------------------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/functions/src/env.ts b/functions/src/env.ts index 06a04b9..41d968e 100644 --- a/functions/src/env.ts +++ b/functions/src/env.ts @@ -7,12 +7,8 @@ import { defineSecret } from "firebase-functions/params"; enum SecretKey { OPENAI_API_KEY = "OPENAI_API_KEY", - SMTP_HOST = "SMTP_HOST", - SMTP_PORT = "SMTP_PORT", - SMTP_USER = "SMTP_USER", + SMTP_USERNAME = "SMTP_USERNAME", SMTP_PASSWORD = "SMTP_PASSWORD", - FEEDBACK_COORDINATOR_EMAIL = "FEEDBACK_COORDINATOR_EMAIL", - FEEDBACK_SENDER_EMAIL = "FEEDBACK_SENDER_EMAIL", } const openaiApiKey = defineSecret(SecretKey.OPENAI_API_KEY); @@ -23,29 +19,13 @@ export const getOpenaiSecretKeys = (): string[] => [SecretKey.OPENAI_API_KEY]; export const openaiApiKeyParam: ReturnType = openaiApiKey; -const smtpHost = defineSecret(SecretKey.SMTP_HOST); -const smtpPort = defineSecret(SecretKey.SMTP_PORT); -const smtpUser = defineSecret(SecretKey.SMTP_USER); +const smtpUsername = defineSecret(SecretKey.SMTP_USERNAME); const smtpPassword = defineSecret(SecretKey.SMTP_PASSWORD); -const feedbackCoordinatorEmail = defineSecret( - SecretKey.FEEDBACK_COORDINATOR_EMAIL, -); -const feedbackSenderEmail = defineSecret(SecretKey.FEEDBACK_SENDER_EMAIL); - -export const getSmtpHost = (): string => smtpHost.value(); -export const getSmtpPort = (): string => smtpPort.value(); -export const getSmtpUser = (): string => smtpUser.value(); + +export const getSmtpUsername = (): string => smtpUsername.value(); + export const getSmtpPassword = (): string => smtpPassword.value(); -export const getFeedbackCoordinatorEmail = (): string => - feedbackCoordinatorEmail.value(); -export const getFeedbackSenderEmail = (): string => feedbackSenderEmail.value(); - -export const feedbackEmailSecretParams: Array> = - [ - smtpHost, - smtpPort, - smtpUser, - smtpPassword, - feedbackCoordinatorEmail, - feedbackSenderEmail, - ]; + +export const smtpUsernameParam: ReturnType = smtpUsername; + +export const smtpPasswordParam: ReturnType = smtpPassword; From d948cf1e897d87f1bb929c017ed1ec4f5a8b9bdf Mon Sep 17 00:00:00 2001 From: Paul Goldschmidt Date: Thu, 16 Jul 2026 10:22:25 +0200 Subject: [PATCH 6/7] add more secrets --- functions/src/env.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/functions/src/env.ts b/functions/src/env.ts index 41d968e..4f71eb1 100644 --- a/functions/src/env.ts +++ b/functions/src/env.ts @@ -7,8 +7,12 @@ import { defineSecret } from "firebase-functions/params"; enum SecretKey { OPENAI_API_KEY = "OPENAI_API_KEY", + SMTP_HOST = "SMTP_HOST", + SMTP_PORT = "SMTP_PORT", SMTP_USERNAME = "SMTP_USERNAME", SMTP_PASSWORD = "SMTP_PASSWORD", + FEEDBACK_SENDER_EMAIL = "FEEDBACK_SENDER_EMAIL", + FEEDBACK_COORDINATOR_EMAIL = "FEEDBACK_COORDINATOR_EMAIL", } const openaiApiKey = defineSecret(SecretKey.OPENAI_API_KEY); @@ -19,13 +23,38 @@ export const getOpenaiSecretKeys = (): string[] => [SecretKey.OPENAI_API_KEY]; export const openaiApiKeyParam: ReturnType = openaiApiKey; +const smtpHost = defineSecret(SecretKey.SMTP_HOST); +const smtpPort = defineSecret(SecretKey.SMTP_PORT); const smtpUsername = defineSecret(SecretKey.SMTP_USERNAME); const smtpPassword = defineSecret(SecretKey.SMTP_PASSWORD); +const feedbackSenderEmail = defineSecret(SecretKey.FEEDBACK_SENDER_EMAIL); +const feedbackCoordinatorEmail = defineSecret( + SecretKey.FEEDBACK_COORDINATOR_EMAIL, +); + +export const getSmtpHost = (): string => smtpHost.value(); + +export const getSmtpPort = (): string => smtpPort.value(); export const getSmtpUsername = (): string => smtpUsername.value(); export const getSmtpPassword = (): string => smtpPassword.value(); +export const getFeedbackSenderEmail = (): string => feedbackSenderEmail.value(); + +export const getFeedbackCoordinatorEmail = (): string => + feedbackCoordinatorEmail.value(); + export const smtpUsernameParam: ReturnType = smtpUsername; export const smtpPasswordParam: ReturnType = smtpPassword; + +export const feedbackEmailSecretParams: Array> = + [ + smtpHost, + smtpPort, + smtpUsername, + smtpPassword, + feedbackSenderEmail, + feedbackCoordinatorEmail, + ]; From 30e4bd64d44058aae1c8eea00e209917b410b90b Mon Sep 17 00:00:00 2001 From: Paul Goldschmidt Date: Thu, 16 Jul 2026 10:22:52 +0200 Subject: [PATCH 7/7] force Auth, require TLS --- functions/src/functions/onFeedbackCreated.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/functions/src/functions/onFeedbackCreated.ts b/functions/src/functions/onFeedbackCreated.ts index 3b47ab6..ecb1d96 100644 --- a/functions/src/functions/onFeedbackCreated.ts +++ b/functions/src/functions/onFeedbackCreated.ts @@ -6,6 +6,7 @@ import { logger } from "firebase-functions"; import { onDocumentCreated } from "firebase-functions/v2/firestore"; import { createTransport } from "nodemailer"; +import type SMTPTransport from "nodemailer/lib/smtp-transport"; import { feedbackEmailSecretParams, getFeedbackCoordinatorEmail, @@ -13,9 +14,9 @@ import { getSmtpHost, getSmtpPassword, getSmtpPort, - getSmtpUser, + getSmtpUsername, } from "../env.js"; -import { privilegedServiceAccount } from "./helpers.js"; +import { defaultServiceAccount } from "./helpers.js"; const formatFeedbackEmail = ( feedbackId: string, @@ -45,7 +46,7 @@ const formatFeedbackEmail = ( export const onFeedbackCreated = onDocumentCreated( { document: "feedback/{feedbackId}", - serviceAccount: privilegedServiceAccount, + serviceAccount: defaultServiceAccount, secrets: feedbackEmailSecretParams, }, async (event) => { @@ -60,19 +61,26 @@ export const onFeedbackCreated = onDocumentCreated( const { subject, text } = formatFeedbackEmail(feedbackId, data); try { + // requireTLS/forceAuth make the transport fail loudly if the server does + // not offer STARTTLS or AUTH, instead of silently sending + // unauthenticated. forceAuth is supported by nodemailer at runtime but + // missing from @types/nodemailer, so the options object needs a cast. const port = Number(getSmtpPort()); const transporter = createTransport({ host: getSmtpHost(), port, secure: port === 465, + requireTLS: true, + forceAuth: true, + logger: true, auth: { - user: getSmtpUser(), + user: getSmtpUsername(), pass: getSmtpPassword(), }, - }); + } as SMTPTransport.Options); await transporter.sendMail({ - from: getFeedbackSenderEmail(), + from: `"MyHeart Counts" <${getFeedbackSenderEmail()}>`, to: getFeedbackCoordinatorEmail(), subject, text,