From 7fa2e6e21e93f96489a414f974e571e956848c83 Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Wed, 3 Jan 2024 06:17:50 +0000 Subject: [PATCH 01/10] feat: nodemailer debug logging --- .../src/util/NodemailerMsalProxy.js | 11 +++++++++++ .../src/util/NodemailerMsalProxy.test.js | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js b/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js index 8817ba04ef..3e7d64b1b4 100644 --- a/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js +++ b/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js @@ -1,6 +1,9 @@ import { ConfidentialClientApplication } from "@azure/msal-node"; +import Logger from "@bugslifesolutions/logger"; import nodemailer from "nodemailer"; +const logCtx = { name: "nodemailer", file: "NodemailerMsalProxy" }; + const userTokens = {}; /** * @name sendEmail @@ -18,6 +21,8 @@ export default async function sendEmail(context, { sendEmailFailed }) { const { to, shopId, ...otherEmailFields } = job; + Logger.debug({ ...logCtx, to, shopId, fn: "sendEmail" }, "Running sendEmail"); + const { nodemailerTransportOptions } = await context.queries.appSettings(context, shopId); @@ -70,6 +75,12 @@ export default async function sendEmail(context, { */ async function getNewToken(nodemailerTransportOptions) { const msalConfig = { auth: nodemailerTransportOptions.auth }; + const logMsalAuth = { ...msalConfig.auth }; + if (logMsalAuth.clientSecret) { + // Hide password from auth logging + logMsalAuth.clientSecret = "*".repeat(logMsalAuth.clientSecret.length); + } + Logger.debug({ ...logCtx, logMsalAuth, fn: "getNewToken" }, "Running getNewToken"); const cca = new ConfidentialClientApplication(msalConfig); return cca.acquireTokenByClientCredential({ scopes: ["https://outlook.office365.com/.default"] }); } diff --git a/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.test.js b/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.test.js index 610c294301..3ae2251d3e 100644 --- a/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.test.js +++ b/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.test.js @@ -1,7 +1,26 @@ +// Mock the logger module +jest.mock("@bugslifesolutions/logger", () => ({ + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() +})); +import Logger from "@bugslifesolutions/logger"; import sendEmail from "./NodemailerMsalProxy"; const wrapWithDone = (done, fn) => (...args) => { try { + // Check if the logger.debug method was called with the expected arguments + expect(Logger.debug).toHaveBeenCalledWith( + expect.objectContaining({ + name: "nodemailer", + file: "NodemailerMsalProxy", + to: expect.any(String), + shopId: expect.any(Number), + fn: "sendEmail" + }), + "Running sendEmail" + ); fn(...args); done(); } catch (error) { From 3e32ab2f763c907ded9ad27dc0c168c5b9a87e78 Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Wed, 3 Jan 2024 06:24:49 +0000 Subject: [PATCH 02/10] ci: changeset --- .changeset/sharp-knives-breathe.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sharp-knives-breathe.md diff --git a/.changeset/sharp-knives-breathe.md b/.changeset/sharp-knives-breathe.md new file mode 100644 index 0000000000..c4506d97d8 --- /dev/null +++ b/.changeset/sharp-knives-breathe.md @@ -0,0 +1,5 @@ +--- +"@bugslifesolutions/bl-api-plugin-nodemailer": minor +--- + +Nodemailer plugin debug logging From 155af68aaede29beb7abe3eae97ac75c7ad24acc Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Wed, 3 Jan 2024 16:10:52 +0000 Subject: [PATCH 03/10] feat: debug logging of appEvents use --- packages/api-core/src/util/appEvents.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/api-core/src/util/appEvents.js b/packages/api-core/src/util/appEvents.js index f6f57efb6c..7c64b77e2c 100644 --- a/packages/api-core/src/util/appEvents.js +++ b/packages/api-core/src/util/appEvents.js @@ -1,4 +1,11 @@ import Logger from "@bugslifesolutions/logger"; +import pkg from "../../../package.json"; + +const logCtx = { + name: pkg.name, + version: pkg.version, + file: "appEvents.js" +}; /** * This is a temporary events solution on our path to @@ -23,7 +30,7 @@ async function synchronousPromiseLoop(name, funcs, args) { try { await func(...args); } catch (error) { - Logger.error(`Error in "${name}" consumer`, error); + Logger.error({ ...logCtx }, `Error in "${name}" consumer`, error); } if (funcs.length) { @@ -38,14 +45,17 @@ class AppEvents { } resume() { + Logger.debug({ ...logCtx, fn: "resume" }, `stopped was "${this.stopped}"`); this.stopped = false; } stop() { + Logger.debug({ ...logCtx, fn: "stop" }, `stopped was "${this.stopped}"`); this.stopped = true; } async emit(name, ...args) { + Logger.debug({ ...logCtx, fn: "emit" }, `AppEvents emit "${name}", stopped is "${this.stopped}"`); if (this.stopped || !this.handlers[name]) return; // Can't use forEach or map because we want each func to wait @@ -59,6 +69,7 @@ class AppEvents { } this.handlers[name].push(func); + Logger.debug({ ...logCtx, fn: "on" }, `AppEvents on "${name}", handler count "${this.handlers[name].length}", stopped is "${this.stopped}"`); } } From 06218a2c6e0bef98fcc794f23ca840b118552d00 Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Wed, 3 Jan 2024 18:23:48 +0000 Subject: [PATCH 04/10] ci: upgrade to pnpm 8.14 --- .circleci/config.yml | 5 +---- .github/workflows/prerelease.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/tagging-and-release.yml | 2 +- README.md | 4 ++-- apps/reaction/Dockerfile | 2 +- 6 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 3aaa76db9b..39d05f393b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,7 +18,7 @@ defaults: &build install_pnpm: &install_pnpm - run: name: Install pnpm package manager - command: sudo npm i -g pnpm@8.11.0 + command: sudo npm i -g pnpm@8.14.0 pnpm_install: &pnpm_install - run: @@ -232,9 +232,6 @@ jobs: echo "ES_CATALOG_SYNC_ENTERPRISESEARCH_URL=http://localhost:9090" >> .env echo "ES_CATALOG_SYNC_ENTERPRISESEARCH_KEY=secretkey" >> .env echo "REDIS_SERVER=redis://redis.reaction.localhost:6379" >> .env - - run: - name: Create reaction.localhost network - command: docker network create "reaction.localhost" || true - run: name: Configure docker-compose.circleci.yml with the latest docker image reference command: | diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index fd221db7d3..e83d44dcaf 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -35,7 +35,7 @@ jobs: node-version: "18.18.2" - name: Install pnpm - run: npm i -g pnpm@8.11.0 + run: npm i -g pnpm@8.14.0 - name: Install dependencies run: pnpm install --ignore-scripts @@ -96,7 +96,7 @@ jobs: node-version: "18.18.2" - name: Install pnpm - run: npm i -g pnpm@8.11.0 + run: npm i -g pnpm@8.14.0 - name: Install dependencies run: pnpm install --ignore-scripts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 282f345d6d..7fd644cfbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: node-version: "18.18.2" - name: Install pnpm - run: npm i -g pnpm@8.11.0 + run: npm i -g pnpm@8.14.0 - name: Install Dependencies run: pnpm install --ignore-scripts diff --git a/.github/workflows/tagging-and-release.yml b/.github/workflows/tagging-and-release.yml index b699474e30..1806cdf44b 100644 --- a/.github/workflows/tagging-and-release.yml +++ b/.github/workflows/tagging-and-release.yml @@ -29,7 +29,7 @@ jobs: - name: Install dependencies run: | - npm i -g pnpm@8.11.0 + npm i -g pnpm@8.14.0 pnpm install -r --ignore-scripts - name: Get tag version id: get-tag-version diff --git a/README.md b/README.md index 3a7e4ccf77..b907aafa49 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ instructions ## Install PNPM ```bash -npm i -g pnpm@8.11.0 +npm i -g pnpm@8.14.0 ``` ## Clone and Start the source @@ -141,7 +141,7 @@ npm i -g pnpm@8.11.0 ```bash git clone https://github.com/reactioncommerce/reaction.git cd reaction -pnpm install +pnpm install -r cp apps/reaction/.env.example apps/reaction/.env ``` diff --git a/apps/reaction/Dockerfile b/apps/reaction/Dockerfile index 2fafa8def4..0048e42e18 100644 --- a/apps/reaction/Dockerfile +++ b/apps/reaction/Dockerfile @@ -12,7 +12,7 @@ COPY ./apps/reaction ./apps/reaction COPY ./packages ./packages COPY .npmrc .nvmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN npm i -g pnpm@8.11.0 && pnpm --filter=reaction deploy deps --ignore-scripts +RUN npm i -g pnpm@8.14.0 && pnpm --filter=reaction deploy deps --ignore-scripts # hadolint ignore=DL3003,SC2015 RUN cd deps/node_modules/sharp && npm run install From 52ee0772288f6b022d0ce6d79354894935c4d7dd Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Thu, 4 Jan 2024 14:23:52 +0000 Subject: [PATCH 05/10] fix: register nodemailer sendEmail event handler in preStartup - otherwise the bull queue processor will never detect completion of the appEvent.emit --- .circleci/config.yml | 2 +- apps/reaction/Dockerfile | 2 +- docker-compose.circleci.yml | 14 +++---- docker-compose.dev.yml | 41 ++++++++----------- docker-compose.localtest.yml | 29 +------------ docker-compose.yml | 9 ++-- package.json | 6 +-- packages/api-core/src/util/appEvents.js | 5 ++- .../src/util/returnEmailProcessor.js | 4 +- .../bl-api-plugin-nodemailer/src/index.js | 2 +- .../src/util/NodemailerMsalProxy.js | 41 ++++++++++--------- 11 files changed, 58 insertions(+), 97 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 39d05f393b..dd07738e84 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,7 +23,7 @@ install_pnpm: &install_pnpm pnpm_install: &pnpm_install - run: name: Install PNPM dependencies - command: pnpm install -r + command: pnpm install integration: &integration <<: *env diff --git a/apps/reaction/Dockerfile b/apps/reaction/Dockerfile index 0048e42e18..440ddff4cd 100644 --- a/apps/reaction/Dockerfile +++ b/apps/reaction/Dockerfile @@ -31,7 +31,7 @@ RUN chown node:node . ENV NODE_ENV=production -COPY --from=deps /app/deps /usr/local/src/app +COPY --from=deps /app/deps . USER node diff --git a/docker-compose.circleci.yml b/docker-compose.circleci.yml index e1b464e619..9d5bc1858a 100644 --- a/docker-compose.circleci.yml +++ b/docker-compose.circleci.yml @@ -9,40 +9,36 @@ version: "3.9" networks: reaction: name: reaction.localhost - external: true services: api: - image: DOCKER_IMAGE_URI_VERSIONED + image: ${API_IMAGE_URI:-DOCKER_IMAGE_URI_VERSIONED} depends_on: - mongo env_file: - ./.env networks: - - default - reaction ports: - "3000:3000" mongo: - image: mongo:5.0 + image: ${MONGO_IMAGE_URI:-mongo:5.0} command: mongod --oplogSize 128 --replSet rs0 --storageEngine=wiredTiger networks: - - default - reaction ports: - "27017:27017" volumes: - mongo-db4:/data/db healthcheck: # re-run rs.initiate() after startup if it failed. - test: test $$(echo "rs.status().ok || rs.initiate().ok" | mongo --quiet) -eq 1 + test: test $$(echo "rs.status().ok || rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo.reaction.localhost:27017'}]})" | mongo --quiet) -eq 1 interval: 10s - start_period: 30s + start_period: 50s redis: - image: redis:7 + image: redis:${REDIS_VERSION:-7} networks: - - default - reaction ports: - "6379:6379" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8e63df3d73..edfc309708 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,34 +1,25 @@ +# This Docker Compose file is used for development purposes. +# It defines the services and configurations required to run the Reaction development platform.# This docker-compose file is used to override the default docker-compose file +# in the reactioncommerce/reaction repository. It is used to add additional +# services to the default docker-compose file, such as maildev. +# +# example docker-compose command: +# docker-compose -f docker-compose.yml -f docker-compose.override.yml up -d +# or in this case: +# docker-compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.dev.yml up -d version: "3.4" -services: - mongo: - image: mongo:5.0 - command: mongod --oplogSize 128 --replSet rs0 --storageEngine=wiredTiger - networks: - default: - ports: - - "27017:27017" - volumes: - - mongo-db4:/data/db - healthcheck: # re-run rs.initiate() after startup if it failed. - test: test $$(echo "rs.status().ok || rs.initiate().ok" | mongo --quiet) -eq 1 - interval: 10s - start_period: 30s - - redis: - image: redis:7 - networks: - default: - ports: - - "6379:6379" +networks: + reaction: + name: reaction.localhost + attachable: true +services: maildev: image: maildev/maildev networks: - default: + - default + - reaction ports: - "1080:1080" - "1025:1025" - -volumes: - mongo-db4: diff --git a/docker-compose.localtest.yml b/docker-compose.localtest.yml index a0af2317fc..6c6da61fa2 100644 --- a/docker-compose.localtest.yml +++ b/docker-compose.localtest.yml @@ -9,31 +9,4 @@ version: "3.9" networks: reaction: name: reaction.localhost - external: true - -services: - mongo: - image: mongo:4.2 - command: mongod --oplogSize 128 --replSet rs0 --storageEngine=wiredTiger - networks: - - default - - reaction - ports: - - "27017:27017" - volumes: - - mongo-db4:/data/db - healthcheck: # re-run rs.initiate() after startup if it failed. - test: test $$(echo "rs.status().ok || rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo.reaction.localhost:27017'}]})" | mongo --quiet) -eq 1 - interval: 10s - start_period: 30s - - redis: - image: redis:7 - networks: - - default - - reaction - ports: - - "6379:6379" - -volumes: - mongo-db4: + attachable: true diff --git a/docker-compose.yml b/docker-compose.yml index 24ea073192..c56fa58f3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,11 +9,10 @@ version: "3.9" networks: reaction: name: reaction.localhost - external: true services: api: - image: reactioncommerce/reaction:5.1.0.0 + image: ${API_IMAGE_URI:-alpine:latest} depends_on: - mongo env_file: @@ -24,7 +23,7 @@ services: - "3000:3000" mongo: - image: mongo:4.2 + image: ${MONGO_IMAGE_URI:-mongo:5.0} command: mongod --oplogSize 128 --replSet rs0 --storageEngine=wiredTiger networks: - reaction @@ -33,12 +32,12 @@ services: volumes: - mongo-db4:/data/db healthcheck: # re-run rs.initiate() after startup if it failed. - test: test $$(echo "rs.status().ok || rs.initiate().ok" | mongo --quiet) -eq 1 + test: test $$(echo "rs.status().ok || rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo.reaction.localhost:27017'}]})" | mongo --quiet) -eq 1 interval: 10s start_period: 50s redis: - image: redis:7 + image: redis:${REDIS_VERSION:-7} networks: - reaction ports: diff --git a/package.json b/package.json index a685f90089..976dd3fe11 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "env:version": "node ./extract_dot_version.mjs", "env": "node CleanseProcessEnv.js", - "start:dev": "npm run start:dev -w apps/reaction", + "start:dev": "REACTION_SHOULD_ENCODE_IDS=false REACTION_LOG_LEVEL=DEBUG npm run start:dev -w apps/reaction", "start:meteor-blaze-app": "npm run start -w=apps/meteor-blaze-app", "build:packages": "pnpm -r run build", "test": "pnpm -r run test", @@ -24,8 +24,8 @@ "postinstall": "npm run build:packages && is-ci || is-docker || husky install .husky", "api:docker:env": "pnpm run env:version && . ./.version && . ./.env.docker && echo \"DOCKER_IMAGE_URI is $DOCKER_IMAGE_URI\" && echo \"DOCKER_IMAGE_URI_VERSIONED is $DOCKER_IMAGE_URI_VERSIONED\"", "api:docker:build": "pnpm run api:docker:env && . ./.version && . ./.env.docker && docker build --no-cache -t $DOCKER_IMAGE_URI_VERSIONED -t $DOCKER_IMAGE_URI:latest -f ./apps/reaction/Dockerfile .", - "api:docker:inspect": "pnpm run api:docker:env && . ./.version && . ./.env.docker && docker run --rm --network='reaction.localhost' --env-file './.env.docker' --mount type=bind,source=$(pwd)/apps/reaction/plugins.json,target=/usr/local/src/app/plugins.json $DOCKER_IMAGE_URI_VERSIONED node --inspect ./src/index.js", - "api:docker:inspect2": "pnpm run api:docker:env && . ./.version && . ./.env.docker && docker run --rm -it --network='reaction.localhost' --env-file './.env.docker' --mount type=bind,source=$(pwd)/apps/reaction/plugins.json,target=/usr/local/src/app/plugins.json $DOCKER_IMAGE_URI_VERSIONED node", + "api:docker:inspect": "pnpm run api:docker:env && . ./.version && . ./.env.docker && docker run --rm -p 9229:9229 --network='reaction.localhost' --env-file './.env.docker' --mount type=bind,source=$(pwd)/apps/reaction/plugins.json,target=/usr/local/src/app/plugins.json $DOCKER_IMAGE_URI_VERSIONED node --inspect=0.0.0.0:9229 ./src/index.js", + "api:docker:inspect-brk": "pnpm run api:docker:env && . ./.version && . ./.env.docker && docker run --rm -p 9229:9229 --network='reaction.localhost' --env-file './.env.docker' --mount type=bind,source=$(pwd)/apps/reaction/plugins.json,target=/usr/local/src/app/plugins.json $DOCKER_IMAGE_URI_VERSIONED node --inspect-brk=0.0.0.0:9229 ./src/index.js", "api:docker:push": "pnpm run api:docker:env && . ./.version && . ./.env.docker && docker push $DOCKER_IMAGE_URI_VERSIONED && docker push $DOCKER_IMAGE_URI:latest" }, "homepage": "https://github.com/bugslifesolutions/reaction", diff --git a/packages/api-core/src/util/appEvents.js b/packages/api-core/src/util/appEvents.js index 7c64b77e2c..14f44dc2b8 100644 --- a/packages/api-core/src/util/appEvents.js +++ b/packages/api-core/src/util/appEvents.js @@ -1,5 +1,5 @@ import Logger from "@bugslifesolutions/logger"; -import pkg from "../../../package.json"; +import pkg from "./../../package.json" assert { type: "json" }; const logCtx = { name: pkg.name, @@ -55,12 +55,13 @@ class AppEvents { } async emit(name, ...args) { - Logger.debug({ ...logCtx, fn: "emit" }, `AppEvents emit "${name}", stopped is "${this.stopped}"`); + Logger.debug({ ...logCtx, fn: "emit", args: JSON.stringify(args) }, `started AppEvents emit "${name}", stopped is "${this.stopped}"`); if (this.stopped || !this.handlers[name]) return; // Can't use forEach or map because we want each func to wait // until the previous func promise resolves await synchronousPromiseLoop(name, this.handlers[name].slice(0), args); + Logger.debug({ ...logCtx, fn: "emit" }, `finished AppEvents emit "${name}", handler count "${this.handlers[name].length}"`); } on(name, func) { diff --git a/packages/api-plugin-email/src/util/returnEmailProcessor.js b/packages/api-plugin-email/src/util/returnEmailProcessor.js index 2d8540b93b..52c6d9bb19 100644 --- a/packages/api-plugin-email/src/util/returnEmailProcessor.js +++ b/packages/api-plugin-email/src/util/returnEmailProcessor.js @@ -46,7 +46,7 @@ export default function returnEmailProcessor(context) { } }); - Logger.info({ logCtx, message }, "Send email completed"); + Logger.info({ ...logCtx, message }, "Send email completed"); resolve(message); } @@ -67,7 +67,7 @@ export default function returnEmailProcessor(context) { }); // TODO This logging leaks PI to logs which is a NO-NO - Logger.error({ logCtx, message }, "Send email job failed"); + Logger.error({ ...logCtx, message }, "Send email job failed"); reject(message); } diff --git a/packages/bl-api-plugin-nodemailer/src/index.js b/packages/bl-api-plugin-nodemailer/src/index.js index caf4f2a9ca..98db26d90b 100644 --- a/packages/bl-api-plugin-nodemailer/src/index.js +++ b/packages/bl-api-plugin-nodemailer/src/index.js @@ -15,7 +15,7 @@ export default async function register(app) { name: "bls-api-plugin-nodemailer", version: pkg.version, functionsByType: { - startup: [startup] + preStartup: [startup] }, graphQL: { resolvers, diff --git a/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js b/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js index 3e7d64b1b4..bcd07ce1a9 100644 --- a/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js +++ b/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js @@ -27,20 +27,6 @@ export default async function sendEmail(context, { await context.queries.appSettings(context, shopId); const transporter = nodemailer.createTransport(nodemailerTransportOptions); - if (nodemailerTransportOptions.pool !== true) { - try { - const newToken = await getNewToken(nodemailerTransportOptions); - otherEmailFields.auth = { - user: nodemailerTransportOptions.auth.user, - accessToken: newToken.accessToken, - refreshToken: newToken.refreshToken - }; - } catch (error) { - sendEmailFailed(job, `sending email for job failed: ${error.toString()}`); - transporter.close(); - return; - } - } transporter.set("oauth2_provision_cb", async (user, renew, callback) => { let userToken = userTokens[user]; if (renew || !userToken) { @@ -57,15 +43,30 @@ export default async function sendEmail(context, { } return callback(null, userToken.accessToken); }); - // TODO: - await transporter.sendMail({ to, shopId, ...otherEmailFields }, (error) => { - if (error) { + + if (nodemailerTransportOptions.pool !== true) { + try { + const newToken = await getNewToken(nodemailerTransportOptions); + otherEmailFields.auth = { + user: nodemailerTransportOptions.auth.user, + accessToken: newToken.accessToken, + refreshToken: newToken.refreshToken + }; + } catch (error) { sendEmailFailed(job, `sending email for job failed: ${error.toString()}`); - } else { - sendEmailCompleted(job, `sending email job to ${to} succeeded.`); + transporter.close(); + return; } + } + + try { + await transporter.sendMail({ to, shopId, ...otherEmailFields }); + sendEmailCompleted(job, `sending email job to ${to} succeeded.`); + } catch (error) { + throw new Error(`sending email for job failed: ${error.toString()}`); + } finally { transporter.close(); - }); + } } /** From ac26febd01ae5c48b09664d91704f72ab32a2c64 Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Thu, 4 Jan 2024 21:31:58 +0000 Subject: [PATCH 06/10] fix: sendEmail error resolves by rejecting bull worker process promise --- .../src/api/createQueue.js | 8 +++---- .../src/util/returnEmailProcessor.js | 24 +++++++++++++++---- .../src/util/NodemailerMsalProxy.js | 15 ++++++------ 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/packages/api-plugin-bull-queue/src/api/createQueue.js b/packages/api-plugin-bull-queue/src/api/createQueue.js index 265d4192cb..07dc070bcc 100644 --- a/packages/api-plugin-bull-queue/src/api/createQueue.js +++ b/packages/api-plugin-bull-queue/src/api/createQueue.js @@ -61,16 +61,16 @@ export default function createQueue(context, queueName, options, processorFn) { newQueue.on("error", (err) => { const error = `${err}`; // need to turn this info a string - Logger.error({ error, queueName, ...logCtx }, "Error processing background job"); + Logger.error({ queueName, error, ...logCtx }, "Error processing background job"); }); newQueue.on("stalled", (job) => { - Logger.error({ queueName, options, job, ...logCtx }, "Job stalled"); + Logger.error({ queueName, job, ...logCtx }, "Job stalled"); }); newQueue.on("failed", (job, err) => { - const error = `${err}`; // need to turn this info a string - Logger.error({ error, ...logCtx }, "Job process failed"); + const error = `${err}`; // need to turn this into a string + Logger.error({ queueName, job, error, ...logCtx }, "Job process failed"); }); return newQueue; } diff --git a/packages/api-plugin-email/src/util/returnEmailProcessor.js b/packages/api-plugin-email/src/util/returnEmailProcessor.js index 52c6d9bb19..27e8eb9e61 100644 --- a/packages/api-plugin-email/src/util/returnEmailProcessor.js +++ b/packages/api-plugin-email/src/util/returnEmailProcessor.js @@ -73,8 +73,8 @@ export default function returnEmailProcessor(context) { } /** - * @summary send the email - * @return {Promise} undefined + * @summary Process the email job by updating the email details in the database and emitting an event to send the email. + * @returns {Promise} A promise that resolves when the email processing is complete. */ async function process() { const { from, to, subject, html, ...optionalEmailFields } = job; @@ -104,9 +104,25 @@ export default function returnEmailProcessor(context) { upsert: true }); - await appEvents.emit("sendEmail", { job, sendEmailCompleted, sendEmailFailed }); + try { + await appEvents.emit("sendEmail", { job, sendEmailCompleted, sendEmailFailed }); + } catch (error) { + sendEmailFailed(job, `sending email for job failed: ${error.toString()}`); + } + } + + /** + * Bull will retry the job if the promise is rejected. + * Bull will not retry the job if the promise is resolved. + * Bull will handle an error thrown in the process function by retrying the job + * however it will not have this try/catch context to log the error. + */ + try { + await process(); + } catch (error) { + Logger.error({ ...logCtx, error }, "Unexpected error in processEmailJobs"); + reject(error); } - await process(); }); } return processEmailJobs; diff --git a/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js b/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js index bcd07ce1a9..0c79c37461 100644 --- a/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js +++ b/packages/bl-api-plugin-nodemailer/src/util/NodemailerMsalProxy.js @@ -63,7 +63,7 @@ export default async function sendEmail(context, { await transporter.sendMail({ to, shopId, ...otherEmailFields }); sendEmailCompleted(job, `sending email job to ${to} succeeded.`); } catch (error) { - throw new Error(`sending email for job failed: ${error.toString()}`); + sendEmailFailed(job, `sending email for job failed: ${error.toString()}`); } finally { transporter.close(); } @@ -75,13 +75,12 @@ export default async function sendEmail(context, { * @return {Promise} TokenInfo */ async function getNewToken(nodemailerTransportOptions) { - const msalConfig = { auth: nodemailerTransportOptions.auth }; - const logMsalAuth = { ...msalConfig.auth }; - if (logMsalAuth.clientSecret) { - // Hide password from auth logging - logMsalAuth.clientSecret = "*".repeat(logMsalAuth.clientSecret.length); - } - Logger.debug({ ...logCtx, logMsalAuth, fn: "getNewToken" }, "Running getNewToken"); + // Create a copy of the auth property to avoid ConfidentialClientApplication mutating the original + const msalConfig = { auth: { ...nodemailerTransportOptions.auth } }; + const maskedMsalAuth = { ...msalConfig.auth }; + // Hide secret from auth logging + maskedMsalAuth.clientSecret = "/* secret */"; + Logger.debug({ ...logCtx, auth: maskedMsalAuth, fn: "getNewToken" }, "Running getNewToken"); const cca = new ConfidentialClientApplication(msalConfig); return cca.acquireTokenByClientCredential({ scopes: ["https://outlook.office365.com/.default"] }); } From ccf47819ed6b9f2708f23a1b2865a18fd5a7af41 Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Thu, 4 Jan 2024 21:44:37 +0000 Subject: [PATCH 07/10] ci: changeset details --- .changeset/curvy-bats-rush.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/curvy-bats-rush.md diff --git a/.changeset/curvy-bats-rush.md b/.changeset/curvy-bats-rush.md new file mode 100644 index 0000000000..c50c3f8eb7 --- /dev/null +++ b/.changeset/curvy-bats-rush.md @@ -0,0 +1,21 @@ +--- +"@bugslifesolutions/bl-api-plugin-nodemailer": minor +"@bugslifesolutions/api-plugin-bull-queue": minor +"@bugslifesolutions/api-plugin-email": minor +"@bugslifesolutions/api-core": minor +--- + +# Enhancements: + +AppEvent debug logging + +# Fixed: + +Nodemailer plugin: + +- MSAL Auth secret corruption +- ensure auth failures are handled with error logging and transporter close + +Email plugin: + +- ensure process is rejected on unexpected errors From fa861e2cf462d511c42b63aa69e2f520a2948d30 Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Mon, 8 Jan 2024 01:47:58 +0000 Subject: [PATCH 08/10] feat: authentication plugin uses email templates plugin - leveraging the new sendEmailWithTemplate accounts plugin mutation --- .../src/mutations/index.js | 2 + .../src/mutations/sendEmailWithTemplate.js | 216 ++++++++++++++++++ .../mutations/sendEmailWithTemplate.test.js | 117 ++++++++++ .../sendResetAccountPasswordEmail.js | 2 +- .../src/resolvers/Mutation/index.js | 2 + .../Mutation/sendEmailWithTemplate.js | 26 +++ .../src/schemas/account.graphql | 100 +++++--- .../babel.config.cjs | 1 + .../api-plugin-authentication/jest.config.cjs | 1 + .../api-plugin-authentication/package.json | 26 +-- .../src/util/accountServer.js | 88 ++++--- .../src/util/accountServer.test.js | 82 +++++++ .../src/util/emailTemplates.js | 19 ++ .../src/util/emailTemplates.test.js | 14 ++ .../src/templates/accounts/passwordChanged.js | 158 +++++++++++++ .../src/templates/index.js | 22 +- support/emails.mongodb.js | 48 ++++ 17 files changed, 825 insertions(+), 99 deletions(-) create mode 100644 packages/api-plugin-accounts/src/mutations/sendEmailWithTemplate.js create mode 100644 packages/api-plugin-accounts/src/mutations/sendEmailWithTemplate.test.js create mode 100644 packages/api-plugin-accounts/src/resolvers/Mutation/sendEmailWithTemplate.js create mode 100644 packages/api-plugin-authentication/babel.config.cjs create mode 100644 packages/api-plugin-authentication/jest.config.cjs create mode 100644 packages/api-plugin-authentication/src/util/accountServer.test.js create mode 100644 packages/api-plugin-authentication/src/util/emailTemplates.js create mode 100644 packages/api-plugin-authentication/src/util/emailTemplates.test.js create mode 100644 packages/api-plugin-email-templates/src/templates/accounts/passwordChanged.js create mode 100644 support/emails.mongodb.js diff --git a/packages/api-plugin-accounts/src/mutations/index.js b/packages/api-plugin-accounts/src/mutations/index.js index eabc50bd42..fa861168fb 100644 --- a/packages/api-plugin-accounts/src/mutations/index.js +++ b/packages/api-plugin-accounts/src/mutations/index.js @@ -13,6 +13,7 @@ import removeAccountFromGroup from "./removeAccountFromGroup.js"; import removeAccountGroup from "./removeAccountGroup.js"; import revokeAdminUIAccess from "./revokeAdminUIAccess.js"; import sendResetAccountPasswordEmail from "./sendResetAccountPasswordEmail.js"; +import sendEmailWithTemplate from "./sendEmailWithTemplate.js"; import setAccountDefaultEmail from "./setAccountDefaultEmail.js"; import updateAccount from "./updateAccount.js"; import updateAccountAddressBookEntry from "./updateAccountAddressBookEntry.js"; @@ -36,6 +37,7 @@ export default { removeAccountGroup, revokeAdminUIAccess, sendResetAccountPasswordEmail, + sendEmailWithTemplate, setAccountDefaultEmail, updateAccount, updateAccountAddressBookEntry, diff --git a/packages/api-plugin-accounts/src/mutations/sendEmailWithTemplate.js b/packages/api-plugin-accounts/src/mutations/sendEmailWithTemplate.js new file mode 100644 index 0000000000..a53f6c67d9 --- /dev/null +++ b/packages/api-plugin-accounts/src/mutations/sendEmailWithTemplate.js @@ -0,0 +1,216 @@ +import _ from "lodash"; +import SimpleSchema from "simpl-schema"; +import ReactionError from "@bugslifesolutions/reaction-error"; + +/** + * Defines the schema for the data object. + * + * @typedef {Object} DataSchema + * @property {string} passwordResetUrl - The URL for password reset. This will take precedence over input.url if provided. + * @property {string} url - The URL. This will take precedence over input.url if provided. + */ +const dataSchema = new SimpleSchema({ + passwordResetUrl: { + type: String, + optional: true + }, + url: { + type: String, + optional: true + } + // Add other fields that you expect in the `data` object here +}); + +/** + * Defines the schema for the input object. + * + * @typedef {Object} InputSchema + * @property {string} to - The email address to send to. + * @property {DataSchema} data - Additional data to send with the email. + * @property {string} [templateName="accounts/resetPassword"] - The name of the email template to use. Defaults to "accounts/resetPassword". + */ +const inputSchema = new SimpleSchema({ + to: String, + templateData: { + type: dataSchema, + optional: true, + blackbox: true // You can still keep this if you want to allow additional unspecified fields + }, + templateName: { + type: String, + optional: true + } +}); + +/** + * Fetches the primary shop and extracts relevant shop data for the email. + * + * @async + * @param {Object} context - The context object. This object should contain collections, including Shops. + * @throws {ReactionError} Throws an error if the shop is not found. + * @returns {Promise} A promise that resolves to an object containing the shop and template data for the email. + * @returns {Promise} return.shop - The shop object. + * @returns {Promise} return.templateData - The template data for the email. + * @returns {Promise} return.templateData.contactEmail - The contact email of the shop. + * @returns {Promise} return.templateData.homepage - The home URL of the storefront. + * @returns {Promise} return.templateData.copyrightDate - The current year. + * @returns {Promise} return.templateData.legalName - The company name of the shop. + * @returns {Promise} return.templateData.physicalAddress - The physical address of the shop. + * @returns {Promise} return.templateData.physicalAddress.address - The address. + * @returns {Promise} return.templateData.physicalAddress.city - The city. + * @returns {Promise} return.templateData.physicalAddress.region - The region. + * @returns {Promise} return.templateData.physicalAddress.postal - The postal code. + * @returns {Promise} return.templateData.shopName - The name of the shop. + */ +export async function getPrimaryShopData(context) { + const { + collections: { Shops } + } = context; + + const shop = await Shops.findOne({ shopType: "primary" }); + if (!shop) throw new ReactionError("not-found", "Shop not found"); + + const contactEmail = shop.emails && shop.emails[0] && shop.emails[0].address; + const address = _.get(shop, "addressBook[0]", {}); + const physicalAddress = { + address: `${address.address1} ${address.address2}`, + city: address.city, + region: address.region, + postal: address.postal + }; + + return { + shop, + templateData: { + contactEmail, + homepage: _.get(shop, "storefrontUrls.storefrontHomeUrl", null), + copyrightDate: new Date().getFullYear(), + legalName: address.company, + physicalAddress, + shopName: shop.name + } + }; +} + +/** + * Fetches the account by email. + * + * @async + * @param {Object} context - The context object. This object should contain collections, including Accounts. + * @param {string} email - The email address to send to. + * @throws {ReactionError} Throws an error if the account is not found. + * @returns {Promise} A promise that resolves to the account. + * @returns {Promise} return._id - The ID of the account. + * @returns {Promise} return.emails - The email addresses of the account. + * @returns {Promise} return.profile - The profile of the account. + * @returns {Promise} return.profile.language - The language of the account. + * @returns {Promise} return.profile.firstName - The first name of the account. + * @returns {Promise} return.profile.lastName - The last name of the account. + * @returns {Promise} return.profile.fullName - The full name of the account. + * @returns {Promise} return.profile.displayName - The display name of the account. + * @returns {Promise} return.profile.avatarUrl - The avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlOriginal - The original avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlThumb - The thumbnail avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlMedium - The medium avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlLarge - The large avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlSmall - The small avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlTiny - The tiny avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlHuge - The huge avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlFacebook - The Facebook avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlTwitter - The Twitter avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlGoogle - The Google avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlGithub - The GitHub avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlInstagram - The Instagram avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlPinterest - The Pinterest avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlTumblr - The Tumblr avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlSkype - The Skype avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlLinkedIn - The LinkedIn avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlYouTube - The YouTube avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlVimeo - The Vimeo avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlSoundCloud - The SoundCloud avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlFlickr - The Flickr avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlDribbble - The Dribbble avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlBehance - The Behance avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlReddit - The Reddit avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlSpotify - The Spotify avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlVine - The Vine avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlVk - The VK avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlYandex - The Yandex avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlTwitch - The Twitch avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlMedium - The Medium avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlSlack - The Slack avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlLine - The Line avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlWhatsApp - The WhatsApp avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlTelegram - The Telegram avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlWeChat - The WeChat avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlQQ - The QQ avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlKakaoTalk - The KakaoTalk avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlViber - The Viber avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlSnapchat - The Snapchat avatar URL of the account. + * @returns {Promise} return.profile.avatarUrlWeibo - The Weibo avatar URL of the account. + */ +export async function getAccount(context, email) { + const account = await context.collections.Accounts.findOne({ "emails.address": email }); + if (!account) throw new ReactionError("not-found", "Account not found"); + return account; +} + +/** + * It validates the input, retrieves the account associated with the provided email address, + * fetches the primary shop data, merges the template data with the shop data, and sends an email + * using the specified template. If no template name is provided, it defaults to "accounts/resetPassword". + * The function returns the email address to which the email was sent. + * + * @async + * @param {Object} context - The context object. This object should contain collections, including Accounts. + * @param {InputSchema} input - The input object. + * @throws {ReactionError} Throws an error if the account is not found. + * @returns {Promise} A promise that resolves when the email has been sent. + */ +export async function sendEmailWithTemplate(context, input) { + const { to, templateData = {} } = input; + let { templateName } = input; + + inputSchema.validate(input); + + const caseInsensitiveEmail = to.toLowerCase(); + const account = await getAccount(context, caseInsensitiveEmail); + + const primaryShopData = await getPrimaryShopData(context); + + const templateDataWithShopData = { + ...templateData, + ...primaryShopData.templateData + }; + + // Use the default value if templateName is an empty string, null, or undefined + templateName = templateName || "accounts/resetPassword"; + + await sendEmail(context, account, to, templateDataWithShopData, templateName, primaryShopData.shop); + return to; +} +/** + * Sends an email. + * + * @async + * @param {Object} context - The context object. + * @param {Object} account - The account object. + * @param {string} to - The email address to send to. + * @param {Object} templateData - The data to include in the email. + * @param {string} templateName - The name of the email template to use. + * @param {Object} fromShop - The shop object from which the email is sent. + * @returns {Promise} A promise that resolves when the email has been sent. + */ +async function sendEmail(context, account, to, templateData, templateName, fromShop) { + const language = account.profile && account.profile.language; + + return context.mutations.sendEmail(context, { + data: templateData, + fromShop, + language, + templateName, + to + }); +} + +export default sendEmailWithTemplate; diff --git a/packages/api-plugin-accounts/src/mutations/sendEmailWithTemplate.test.js b/packages/api-plugin-accounts/src/mutations/sendEmailWithTemplate.test.js new file mode 100644 index 0000000000..3ba3fb7a2e --- /dev/null +++ b/packages/api-plugin-accounts/src/mutations/sendEmailWithTemplate.test.js @@ -0,0 +1,117 @@ +import { sendEmailWithTemplate } from "./sendEmailWithTemplate"; + +const fromShop = { + _id: "shop1", + name: "Shop 1", + profile: { language: "en" }, + emails: { addresses: ["test@exampleshow.com"] } + // ... other properties of the shop +}; + +// Mock context +const context = { + collections: { + Accounts: { + findOne: jest.fn() + }, + Shops: { + findOne: jest.fn() + } + }, + mutations: { + sendEmail: jest.fn() + } +}; + +// Mock input +const input = { + to: "test@example.com", + templateData: {}, + templateName: "accounts/testTemplateName" +}; + +describe("sendEmailWithTemplate", () => { + beforeEach(() => { + // Default mock implementation + context.collections.Accounts.findOne.mockImplementation(() => Promise.resolve({ + _id: "account1", + profile: { language: "en" } + // ... other properties of the account + })); + context.collections.Shops.findOne.mockImplementation(() => Promise.resolve(fromShop)); + }); + + afterEach(() => { + // Clear all mocks after each test + jest.clearAllMocks(); + }); + + it("should send an email with a template", async () => { + await sendEmailWithTemplate(context, input); + + expect(context.mutations.sendEmail).toHaveBeenCalledWith(context, { + data: { + contactEmail: undefined, + copyrightDate: 2024, + homepage: null, + legalName: undefined, + physicalAddress: { + address: "undefined undefined", + city: undefined, + postal: undefined, + region: undefined + }, + shopName: fromShop.name + }, + fromShop, + language: "en", + templateName: "accounts/testTemplateName", + to: "test@example.com" + }); + }); + + it("should throw an error if the email is invalid", async () => { + const invalidInput = { ...input, email: undefined }; + + await expect(sendEmailWithTemplate(context, invalidInput)).rejects.toThrow(); + }); + + it("should throw an error if a required parameter is missing", async () => { + const invalidInput = { ...input }; + delete invalidInput.to; + + await expect(sendEmailWithTemplate(context, invalidInput)).rejects.toThrow(); + }); + + it("should default template name to accounts/resetPassword", async () => { + const missingTemplateNameInput = { ...input, templateName: "" }; + + await sendEmailWithTemplate(context, missingTemplateNameInput); + + expect(context.mutations.sendEmail).toHaveBeenCalledWith(context, { + data: { + contactEmail: undefined, + copyrightDate: 2024, + homepage: null, + legalName: undefined, + physicalAddress: { + address: "undefined undefined", + city: undefined, + postal: undefined, + region: undefined + }, + shopName: fromShop.name + }, + fromShop, + language: "en", + templateName: "accounts/resetPassword", + to: "test@example.com" + }); + }); + + it("should handle errors from getAccount", async () => { + context.collections.Accounts.findOne.mockImplementation(() => { throw new Error("Account not found"); }); + + await expect(sendEmailWithTemplate(context, input)).rejects.toThrow("Account not found"); + }); +}); diff --git a/packages/api-plugin-accounts/src/mutations/sendResetAccountPasswordEmail.js b/packages/api-plugin-accounts/src/mutations/sendResetAccountPasswordEmail.js index 04495e3a0c..3f7c8bbe42 100644 --- a/packages/api-plugin-accounts/src/mutations/sendResetAccountPasswordEmail.js +++ b/packages/api-plugin-accounts/src/mutations/sendResetAccountPasswordEmail.js @@ -1,6 +1,6 @@ +import ReactionError from "@bugslifesolutions/reaction-error"; import _ from "lodash"; import SimpleSchema from "simpl-schema"; -import ReactionError from "@bugslifesolutions/reaction-error"; const inputSchema = new SimpleSchema({ email: String, diff --git a/packages/api-plugin-accounts/src/resolvers/Mutation/index.js b/packages/api-plugin-accounts/src/resolvers/Mutation/index.js index a2fabeeb06..b7c70b3879 100644 --- a/packages/api-plugin-accounts/src/resolvers/Mutation/index.js +++ b/packages/api-plugin-accounts/src/resolvers/Mutation/index.js @@ -11,6 +11,7 @@ import removeAccountGroup from "./removeAccountGroup.js"; import removeAccountFromGroup from "./removeAccountFromGroup.js"; import revokeAdminUIAccess from "./revokeAdminUIAccess.js"; import sendResetAccountPasswordEmail from "./sendResetAccountPasswordEmail.js"; +import sendEmailWithTemplate from "./sendEmailWithTemplate.js"; import setAccountDefaultEmail from "./setAccountDefaultEmail.js"; import updateAccount from "./updateAccount.js"; import updateAccountAddressBookEntry from "./updateAccountAddressBookEntry.js"; @@ -32,6 +33,7 @@ export default { removeAccountGroup, revokeAdminUIAccess, sendResetAccountPasswordEmail, + sendEmailWithTemplate, setAccountDefaultEmail, updateAccount, updateAccountAddressBookEntry, diff --git a/packages/api-plugin-accounts/src/resolvers/Mutation/sendEmailWithTemplate.js b/packages/api-plugin-accounts/src/resolvers/Mutation/sendEmailWithTemplate.js new file mode 100644 index 0000000000..91b3eb2bd2 --- /dev/null +++ b/packages/api-plugin-accounts/src/resolvers/Mutation/sendEmailWithTemplate.js @@ -0,0 +1,26 @@ +/** + * @name Mutation/sendEmailWithTemplate + * @summary resolver for the sendEmailWithTemplate GraphQL mutation + * @param {Object} _ - unused + * @param {Object} args.input - an object of all mutation arguments that were sent by the client + * @param {String} args.input.email - email of account to send the email to + * @param {String} args.input.templateName - name of the email template + * @param {Object} args.input.data - data to be used in the email template + * @param {String} [args.input.clientMutationId] - An optional string identifying the mutation call + * @param {Object} context - an object containing the per-request state + * @returns {Object} sendEmailWithTemplatePayload + */ +export default async function sendEmailWithTemplate(_, { input }, context) { + const { email, templateName, data, clientMutationId = null } = input; + + const emailAddress = await context.mutations.sendEmailWithTemplate(context, { + email, + templateName, + data + }); + + return { + email: emailAddress, + clientMutationId + }; +} diff --git a/packages/api-plugin-accounts/src/schemas/account.graphql b/packages/api-plugin-accounts/src/schemas/account.graphql index 785ddbb0c9..7aa8cb9a31 100644 --- a/packages/api-plugin-accounts/src/schemas/account.graphql +++ b/packages/api-plugin-accounts/src/schemas/account.graphql @@ -97,6 +97,21 @@ input SendResetAccountPasswordEmailInput { email: String! } +"Input for the `sendEmailWithTemplate` mutation" +input SendEmailWithTemplateInput { + "Email of account to send the email to" + to: String! + + "Name of the email template" + templateName: String + + "Data to be used in the email template" + templateData: JSON + + "An optional string identifying the mutation call" + clientMutationId: String +} + "Describes an account update" input UpdateAccountInput { "The account ID, which defaults to the viewer account" @@ -188,16 +203,16 @@ type Account implements Node { "A list of physical or mailing addresses associated with this account" addressBook( "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." - after: ConnectionCursor, + after: ConnectionCursor "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." - before: ConnectionCursor, + before: ConnectionCursor "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." - first: ConnectionLimitInt, + first: ConnectionLimitInt "Return at most this many results. This parameter may be used with the `before` parameter." - last: ConnectionLimitInt, + last: ConnectionLimitInt "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." offset: Int @@ -294,7 +309,6 @@ type BasicAccount implements Node { "List of shop Ids" adminUIShopIds: [String] - } type Profile { @@ -374,6 +388,15 @@ type SendResetAccountPasswordEmailPayload { email: String! } +"The response from the `sendEmailWithTemplate` mutation" +type SendEmailWithTemplatePayload { + "The same string you sent with the mutation params, for matching mutation calls with their responses" + clientMutationId: String + + "The email address of the account to send the email to" + email: String! +} + "The response from the `removeAccountAddressBookEntry` mutation" type RemoveAccountAddressBookEntryPayload { "The removed address" @@ -464,6 +487,12 @@ extend type Mutation { input: SendResetAccountPasswordEmailInput! ): SendResetAccountPasswordEmailPayload + "Send an email with a template" + sendEmailWithTemplate( + "Mutation input" + input: SendEmailWithTemplateInput! + ): SendEmailWithTemplatePayload + "Set default email address for an account" setAccountDefaultEmail( "Mutation input" @@ -491,36 +520,33 @@ extend type Mutation { extend type Query { "Returns the account with the provided ID" - account( - "The account ID" - id: ID! - ): Account + account("The account ID" id: ID!): Account "Query to get a filtered list of Accounts" filterAccounts( "Shop ID" - shopId: ID!, + shopId: ID! "Input Conditions for fliter (use either 'any' or 'all' not both)" - conditions: FilterConditionsInput, + conditions: FilterConditionsInput "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." - after: ConnectionCursor, + after: ConnectionCursor "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." - before: ConnectionCursor, + before: ConnectionCursor "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." - first: ConnectionLimitInt, + first: ConnectionLimitInt "Return at most this many results. This parameter may be used with the `before` parameter." - last: ConnectionLimitInt, + last: ConnectionLimitInt "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." offset: Int "Return results sorted in this order" - sortOrder: SortOrder = desc, + sortOrder: SortOrder = desc "By default, accounts are sorted by createdAt. Set this to sort by one of the other allowed fields" sortBy: AccountSortByField = createdAt @@ -529,28 +555,28 @@ extend type Query { "Query to get a filtered list of Customers" filterCustomers( "Shop ID" - shopId: ID!, + shopId: ID! "Input Conditions for fliter (use either 'any' or 'all' not both)" - conditions: FilterConditionsInput, + conditions: FilterConditionsInput "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." - after: ConnectionCursor, + after: ConnectionCursor "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." - before: ConnectionCursor, + before: ConnectionCursor "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." - first: ConnectionLimitInt, + first: ConnectionLimitInt "Return at most this many results. This parameter may be used with the `before` parameter." - last: ConnectionLimitInt, + last: ConnectionLimitInt "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." offset: Int "Return results sorted in this order" - sortOrder: SortOrder = desc, + sortOrder: SortOrder = desc "By default, customers are sorted by createdAt. Set this to sort by one of the other allowed fields" sortBy: AccountSortByField = createdAt @@ -559,28 +585,28 @@ extend type Query { "Returns accounts optionally filtered by account groups" accounts( "Return only accounts in any of these groups" - groupIds: [ID], + groupIds: [ID] "Return accounts that aren't in any groups" - notInAnyGroups: Boolean, + notInAnyGroups: Boolean "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." - after: ConnectionCursor, + after: ConnectionCursor "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." - before: ConnectionCursor, + before: ConnectionCursor "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." - first: ConnectionLimitInt, + first: ConnectionLimitInt "Return at most this many results. This parameter may be used with the `before` parameter." - last: ConnectionLimitInt, + last: ConnectionLimitInt "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." - offset: Int, + offset: Int "Return results sorted in this order" - sortOrder: SortOrder = asc, + sortOrder: SortOrder = asc "By default, groups are sorted by when they were created, oldest first. Set this to sort by one of the other allowed fields" sortBy: AccountSortByField = createdAt @@ -589,22 +615,22 @@ extend type Query { "Returns customer accounts" customers( "Return only results that come after this cursor. Use this with `first` to specify the number of results to return." - after: ConnectionCursor, + after: ConnectionCursor "Return only results that come before this cursor. Use this with `last` to specify the number of results to return." - before: ConnectionCursor, + before: ConnectionCursor "Return at most this many results. This parameter may be used with either `after` or `offset` parameters." - first: ConnectionLimitInt, + first: ConnectionLimitInt "Return at most this many results. This parameter may be used with the `before` parameter." - last: ConnectionLimitInt, + last: ConnectionLimitInt "Return only results that come after the Nth result. This parameter may be used with the `first` parameter." - offset: Int, + offset: Int "Return results sorted in this order" - sortOrder: SortOrder = asc, + sortOrder: SortOrder = asc "By default, groups are sorted by when they were created, oldest first. Set this to sort by one of the other allowed fields" sortBy: AccountSortByField = createdAt diff --git a/packages/api-plugin-authentication/babel.config.cjs b/packages/api-plugin-authentication/babel.config.cjs new file mode 100644 index 0000000000..ae79e6d67e --- /dev/null +++ b/packages/api-plugin-authentication/babel.config.cjs @@ -0,0 +1 @@ +module.exports = require("@bugslifesolutions/api-utils/lib/configs/babel.config.cjs"); diff --git a/packages/api-plugin-authentication/jest.config.cjs b/packages/api-plugin-authentication/jest.config.cjs new file mode 100644 index 0000000000..abbbf8bbd7 --- /dev/null +++ b/packages/api-plugin-authentication/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@bugslifesolutions/api-utils/lib/configs/jest.config.cjs"); diff --git a/packages/api-plugin-authentication/package.json b/packages/api-plugin-authentication/package.json index bf5cb43cea..8d5e5b2f1b 100644 --- a/packages/api-plugin-authentication/package.json +++ b/packages/api-plugin-authentication/package.json @@ -43,31 +43,7 @@ "node-fetch": "^2.7.0", "simpl-schema": "~3.2.0" }, - "resolutions": { - "lodash": "4.17.21" - }, - "eslintConfig": { - "extends": "@reactioncommerce", - "parserOptions": { - "ecmaVersion": 2020, - "sourceType": "module", - "ecmaFeatures": { - "impliedStrict": true - } - }, - "env": { - "es6": true, - "jasmine": true - }, - "rules": { - "node/no-missing-import": "off", - "node/no-missing-require": "off", - "node/no-unsupported-features/es-syntax": "off", - "node/no-unpublished-import": "off", - "node/no-unpublished-require": "off" - } - }, "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/api-plugin-authentication/src/util/accountServer.js b/packages/api-plugin-authentication/src/util/accountServer.js index c4b5957fc4..996a3f81a0 100644 --- a/packages/api-plugin-authentication/src/util/accountServer.js +++ b/packages/api-plugin-authentication/src/util/accountServer.js @@ -1,61 +1,87 @@ -// eslint-disable-next-line node/no-extraneous-import -import mongoConnectWithRetry from "@bugslifesolutions/api-core/src/util/mongoConnectWithRetry.js"; +import { AccountsModule } from "@accounts/graphql-api"; import { Mongo } from "@accounts/mongo"; -import { AccountsServer } from "@accounts/server"; import { AccountsPassword } from "@accounts/password"; +import { AccountsServer } from "@accounts/server"; +import mongoConnectWithRetry from "@bugslifesolutions/api-core/src/util/mongoConnectWithRetry.js"; import mongoose from "mongoose"; -import { AccountsModule } from "@accounts/graphql-api"; import config from "../config.js"; +import { emailTemplates } from "./emailTemplates.js"; let accountsServer; let accountsGraphQL; -export default async (app) => { - if (accountsServer && accountsGraphQL) { - return { accountsServer, accountsGraphQL }; - } - const { MONGO_URL, STORE_URL, TOKEN_SECRET } = config; - const { context } = app; - - const client = await mongoConnectWithRetry(MONGO_URL); +const createMongoConnection = async (mongoUrl) => { + const client = await mongoConnectWithRetry(mongoUrl); const db = client.db(); - const accountsMongo = new Mongo(db, { + return new Mongo(db, { convertUserIdToMongoObjectId: false, convertSessionIdToMongoObjectId: false, idProvider: () => mongoose.Types.ObjectId().toString() }); +}; +export const prepareEmailFactory = (storeUrl) => (to, token, user, pathFragment, emailTemplate) => { + const tokenizedUrl = `${storeUrl}/${pathFragment}/${token}`; + let templateData = { + to, + user, + username: user.username, + token, + pathFragment, + tokenizedUrl + }; + let text; + if (emailTemplate) { + if (typeof emailTemplate.prepareEmail === "function") { + templateData = emailTemplate.prepareEmail(templateData); + } + if (typeof emailTemplate.text === "function") { + text = emailTemplate.text(templateData); + } + } + return { + templateName: emailTemplate ? emailTemplate.templateName : undefined, + templateData, + text + }; +}; + + +const createAccountsServer = (context, accountsMongo, storeUrl, tokenSecret) => { const password = new AccountsPassword(); + const prepareEmail = prepareEmailFactory(storeUrl); - accountsServer = new AccountsServer( + return new AccountsServer( { - siteUrl: STORE_URL, - tokenSecret: TOKEN_SECRET, + siteUrl: storeUrl, + tokenSecret, db: accountsMongo, enableAutologin: true, ambiguousErrorMessages: false, - sendMail: async ({ to, text }) => { - const query = text.split("/"); - const token = query[query.length - 1]; - const url = `${STORE_URL}/?resetToken=${token}`; - await context.mutations.sendResetAccountPasswordEmail(context, { - email: to, - url - }); + prepareEmail, + sendMail: async (preparedEmail) => { + await context.mutations.sendEmailWithTemplate(context, preparedEmail); }, - emailTemplates: { - resetPassword: { - from: null, - // hack to pass the URL to sendMail function - text: (user, url) => url - } - } + emailTemplates }, { password } ); +}; + +export default async (app) => { + if (accountsServer && accountsGraphQL) { + return { accountsServer, accountsGraphQL }; + } + + const { MONGO_URL, STORE_URL, TOKEN_SECRET } = config; + const { context } = app; + + const accountsMongo = await createMongoConnection(MONGO_URL); + accountsServer = createAccountsServer(context, accountsMongo, STORE_URL, TOKEN_SECRET); accountsGraphQL = AccountsModule.forRoot({ accountsServer }); + return { accountsServer, accountsGraphQL }; }; diff --git a/packages/api-plugin-authentication/src/util/accountServer.test.js b/packages/api-plugin-authentication/src/util/accountServer.test.js new file mode 100644 index 0000000000..13fbe7b7b9 --- /dev/null +++ b/packages/api-plugin-authentication/src/util/accountServer.test.js @@ -0,0 +1,82 @@ +import { prepareEmailFactory } from "./accountServer.js"; + +describe("prepareEmailFactory", () => { + const storeUrl = "http://store.com"; + const to = "test@example.com"; + const token = "token"; + const user = { username: "testuser" }; + const pathFragment = "reset"; + const emailTemplate = { + templateName: "templateName", + prepareEmail: jest.fn((data) => ({ ...data, prepared: true })), + text: jest.fn((data) => `Hello, ${data.username}`) + }; + + it("returns correct data when emailTemplate is provided", () => { + const prepareEmail = prepareEmailFactory(storeUrl); + const result = prepareEmail(to, token, user, pathFragment, emailTemplate); + + expect(result).toEqual({ + templateName: "templateName", + templateData: { + to, + user, + username: "testuser", + token, + pathFragment, + tokenizedUrl: "http://store.com/reset/token", + prepared: true + }, + text: "Hello, testuser" + }); + }); + + it("returns correct data when emailTemplate is not provided", () => { + const prepareEmail = prepareEmailFactory(storeUrl); + const result = prepareEmail(to, token, user, pathFragment, undefined); + + expect(result).toEqual({ + templateName: undefined, + templateData: { + to, + user, + username: "testuser", + token, + pathFragment, + tokenizedUrl: "http://store.com/reset/token" + }, + text: undefined + }); + }); + + it("returns correct data when emailTemplate does not have prepareEmail and text functions", () => { + const prepareEmail = prepareEmailFactory(storeUrl); + const result = prepareEmail(to, token, user, pathFragment, {}); + + expect(result).toEqual({ + templateName: undefined, + templateData: { + to, + user, + username: "testuser", + token, + pathFragment, + tokenizedUrl: "http://store.com/reset/token" + }, + text: undefined + }); + }); + + it("should set tokenizedUrl correctly when emailTemplate is resetPassword", () => { + const emailTemplate = { + templateName: "resetPassword", + prepareEmail: jest.fn((data) => ({ ...data, prepared: true })), + text: jest.fn((data) => `Hello, ${data.username}`) + }; + + const prepareEmail = prepareEmailFactory(storeUrl); + const result = prepareEmail(to, token, user, pathFragment, emailTemplate); + + expect(result.templateData.tokenizedUrl).toBe(`${storeUrl}/${pathFragment}/${token}`); + }); +}); diff --git a/packages/api-plugin-authentication/src/util/emailTemplates.js b/packages/api-plugin-authentication/src/util/emailTemplates.js new file mode 100644 index 0000000000..0ca853f311 --- /dev/null +++ b/packages/api-plugin-authentication/src/util/emailTemplates.js @@ -0,0 +1,19 @@ +export const emailTemplates = { + verifyEmail: { + templateName: "accounts/verifyEmail", + text: ({ user, storeUrl, pathFragment, token }) => + `Hello ${user.username}, please verify your email by clicking this link: ${storeUrl}/${pathFragment}/${token}` + }, + resetPassword: { + templateName: "accounts/resetPassword", + prepareMail: (templateData) => { + templateData.tokenizedUrl = `${templateData.storeUrl}/?resetToken=${templateData.token}`; + }, + text: ({ user, tokenizedUrl }) => + `Hello ${user.username}, please reset your password by clicking this link: ${tokenizedUrl}` + }, + passwordChanged: { + templateName: "accounts/passwordChanged", + text: ({ user }) => `Hello ${user.username}, your password has been changed.` + } +}; diff --git a/packages/api-plugin-authentication/src/util/emailTemplates.test.js b/packages/api-plugin-authentication/src/util/emailTemplates.test.js new file mode 100644 index 0000000000..9b4dd7b6e5 --- /dev/null +++ b/packages/api-plugin-authentication/src/util/emailTemplates.test.js @@ -0,0 +1,14 @@ +import { emailTemplates } from "./emailTemplates"; + +describe("emailTemplates", () => { + it("should correctly prepare the resetPassword email", () => { + const templateData = { + storeUrl: "http://test.com", + token: "testToken" + }; + + emailTemplates.resetPassword.prepareMail(templateData); + + expect(templateData.tokenizedUrl).toBe(`${templateData.storeUrl}/?resetToken=${templateData.token}`); + }); +}); diff --git a/packages/api-plugin-email-templates/src/templates/accounts/passwordChanged.js b/packages/api-plugin-email-templates/src/templates/accounts/passwordChanged.js new file mode 100644 index 0000000000..792a3e83b6 --- /dev/null +++ b/packages/api-plugin-email-templates/src/templates/accounts/passwordChanged.js @@ -0,0 +1,158 @@ +/* eslint-disable max-len */ +export default ` + + + + +Basic Email + + + + + + + + + + +
+ + + + +
+ + + + + + +
 
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#if socialLinks.display}} + + + + + + + + + + {{else}} + + + + {{/if}} + + + + + + + + + + + + + + + + + + + + + + +
 
logo
 
Your Password was successfully changed
 
 
 
You received this email because you have an account with {{shopName}}. Questions or suggestions? Email us at {{contactEmail}}
 
+ + + {{#if socialLinks.twitter.display}} + + {{/if}} + {{#if socialLinks.facebook.display}} + + + {{/if}} + {{#if socialLinks.googlePlus.display}} + + + {{/if}} + +
+ twt_icon +   + fb_icon +   + g_plus_icon +
+
 
 
 
 
© {{copyrightDate}} {{#if legalName}}{{legalName}}{{else}}{{shopName}}{{/if}}. All rights reserved
 
{{physicalAddress.address}}, {{physicalAddress.city}}, {{physicalAddress.region}} {{physicalAddress.postal}}
 
+
+ + +`; diff --git a/packages/api-plugin-email-templates/src/templates/index.js b/packages/api-plugin-email-templates/src/templates/index.js index f43ae68e85..045d91e4de 100644 --- a/packages/api-plugin-email-templates/src/templates/index.js +++ b/packages/api-plugin-email-templates/src/templates/index.js @@ -1,13 +1,14 @@ -import coreDefaultTemplate from "./coreDefault.js"; import inviteNewShopMemberTemplate from "./accounts/inviteNewShopMember.js"; +import passwordChangedTemplate from "./accounts/passwordChanged.js"; import resetPasswordTemplate from "./accounts/resetPassword.js"; import welcomeEmailTemplate from "./accounts/sendWelcomeEmail.js"; -import verifyUpdatedEmailTemplate from "./accounts/verifyUpdatedEmail.js"; import verifyEmailTemplate from "./accounts/verifyEmail.js"; +import verifyUpdatedEmailTemplate from "./accounts/verifyUpdatedEmail.js"; +import coreDefaultTemplate from "./coreDefault.js"; +import orderItemRefundTemplate from "./orders/itemRefund.js"; import coreOrderNewTemplate from "./orders/new.js"; -import orderShippedTemplate from "./orders/shipped.js"; import orderRefundedTemplate from "./orders/refunded.js"; -import orderItemRefundTemplate from "./orders/itemRefund.js"; +import orderShippedTemplate from "./orders/shipped.js"; export default [ /* @@ -49,6 +50,17 @@ export default [ template: resetPasswordTemplate, subject: "{{shop.name}}: Here's your password reset link" }, + /* + * Accounts - Password Changed + * When: Password is successfully changed + */ + { + language: "en", + title: "Accounts - Password Changed", + name: "accounts/passwordChanged", + template: passwordChangedTemplate, + subject: "{{shop.name}}: Your password was changed" + }, /* * Accounts - Welcome Email @@ -68,7 +80,7 @@ export default [ */ { language: "en", - title: "Accounts - Verify Account (via LaunchDock)", + title: "Accounts - Verify Account", name: "accounts/verifyEmail", template: verifyEmailTemplate, subject: "{{shopName}}: Please verify your email address" diff --git a/support/emails.mongodb.js b/support/emails.mongodb.js new file mode 100644 index 0000000000..848b164bf7 --- /dev/null +++ b/support/emails.mongodb.js @@ -0,0 +1,48 @@ +/* global use, db */ +// MongoDB Playground +// To disable this template go to Settings | MongoDB | Use Default Template For Playground. +// Make sure you are connected to enable completions and to be able to run a playground. +// Use Ctrl+Space inside a snippet or a string literal to trigger completions. +// The result of the last command run in a playground is shown on the results panel. +// By default the first 20 documents will be returned with a cursor. +// Use 'console.log()' to print to the debug output. +// For more documentation on playgrounds please refer to +// https://www.mongodb.com/docs/mongodb-vscode/playgrounds/ + +// Select the database to use. +// Select the database to use +use("reaction"); + +// Find sessions for specific user IDs +db.sessions.find({ userId: { $in: ["65982b52f7baafaf2cda6700", "63e949fb2e4ba74df1974fe8"] } }); + +// Find emails where the html field contains "success" or "password" and the subject field does not contain "reset" +db.Emails.find({ + html: { $regex: /success|password/, $options: "i" }, + subject: { $not: { $regex: /reset/, $options: "i" } } +}); + +// Count emails where the html field contains "success" or "password" +const countHtmlSuccessOrPassword = db.Emails.countDocuments({ + html: { $regex: /success|password/, $options: "i" } +}); +print(`Count of emails where html contains "success" or "password": ${countHtmlSuccessOrPassword}`); + +// Count emails where the subject field contains "reset" +const countSubjectReset = db.Emails.countDocuments({ + subject: { $regex: /reset/, $options: "i" } +}); +print(`Count of emails where subject contains "reset": ${countSubjectReset}`); + +// Count emails where the subject field does not contain "reset" +const countSubjectNotReset = db.Emails.countDocuments({ + subject: { $not: { $regex: /reset/, $options: "i" } } +}); +print(`Count of emails where subject does not contain "reset": ${countSubjectNotReset}`); + +// Count emails where the html field contains "success" or "password" and the subject field does not contain "reset" +const countHtmlSuccessOrPasswordAndSubjectNotReset = db.Emails.countDocuments({ + html: { $regex: /success|password/, $options: "i" }, + subject: { $not: { $regex: /reset/, $options: "i" } } +}); +print(`Count of emails where html contains "success" or "password" and subject does not contain "reset": ${countHtmlSuccessOrPasswordAndSubjectNotReset}`); From c3e2657c5e2ed7c7ec76138296a1e15e9402f4f1 Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Mon, 8 Jan 2024 04:53:07 +0000 Subject: [PATCH 09/10] fix: unknown type JSON in makeExecutableSchema --- .../api-plugin-accounts/src/schemas/account.graphql | 2 +- .../src/util/accountServer.test.js | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/api-plugin-accounts/src/schemas/account.graphql b/packages/api-plugin-accounts/src/schemas/account.graphql index 7aa8cb9a31..b26a46c2ac 100644 --- a/packages/api-plugin-accounts/src/schemas/account.graphql +++ b/packages/api-plugin-accounts/src/schemas/account.graphql @@ -106,7 +106,7 @@ input SendEmailWithTemplateInput { templateName: String "Data to be used in the email template" - templateData: JSON + templateData: JSONObject "An optional string identifying the mutation call" clientMutationId: String diff --git a/packages/api-plugin-authentication/src/util/accountServer.test.js b/packages/api-plugin-authentication/src/util/accountServer.test.js index 13fbe7b7b9..e17be1c9a6 100644 --- a/packages/api-plugin-authentication/src/util/accountServer.test.js +++ b/packages/api-plugin-authentication/src/util/accountServer.test.js @@ -66,17 +66,4 @@ describe("prepareEmailFactory", () => { text: undefined }); }); - - it("should set tokenizedUrl correctly when emailTemplate is resetPassword", () => { - const emailTemplate = { - templateName: "resetPassword", - prepareEmail: jest.fn((data) => ({ ...data, prepared: true })), - text: jest.fn((data) => `Hello, ${data.username}`) - }; - - const prepareEmail = prepareEmailFactory(storeUrl); - const result = prepareEmail(to, token, user, pathFragment, emailTemplate); - - expect(result.templateData.tokenizedUrl).toBe(`${storeUrl}/${pathFragment}/${token}`); - }); }); From 1225ce62e126351dfe56ef07b857b43a745435ee Mon Sep 17 00:00:00 2001 From: Todd Hiles Date: Mon, 8 Jan 2024 05:12:51 +0000 Subject: [PATCH 10/10] ci: changeset description --- .changeset/sixty-teachers-sip.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/sixty-teachers-sip.md diff --git a/.changeset/sixty-teachers-sip.md b/.changeset/sixty-teachers-sip.md new file mode 100644 index 0000000000..8717568cf6 --- /dev/null +++ b/.changeset/sixty-teachers-sip.md @@ -0,0 +1,11 @@ +--- +"@bugslifesolutions/api-plugin-authentication": major +"@bugslifesolutions/api-plugin-email-templates": minor +"@bugslifesolutions/api-plugin-accounts": minor +--- + +Authentication plugin now uses email templates to render the email body. + +Potential Breaking Change: + +The built-in accountsjs email templates are not used in favor of using the email templates. This change was made to enable customization of the email body consistent with other email sending use cases. \ No newline at end of file