From 8d2247cf67585a3adb6ff89f8ff707617ab82aaf Mon Sep 17 00:00:00 2001 From: Carson Full Date: Thu, 10 Jul 2025 17:20:20 -0500 Subject: [PATCH 1/5] Migrate to @seedcompany/nestjs-email v5 --- package.json | 10 +- ...ts => dbl-upload-notification.handler.tsx} | 20 +- .../templates/dbl-upload-email.template.tsx | 174 ++++++++------- ...-report-workflow-notification.handler.tsx} | 15 +- ...project-workflow-notification.handler.tsx} | 8 +- .../authentication/authentication.service.ts | 8 +- src/core/config/config.service.ts | 33 ++- src/core/core.module.ts | 4 +- src/core/email/email.config.ts | 13 ++ src/core/email/index.ts | 7 + src/core/email/templates/base.tsx | 140 ++++++------ .../templates/forgot-password.template.tsx | 28 ++- .../email/templates/formatted-date-time.tsx | 20 +- src/core/email/templates/frontend-url.tsx | 16 +- ...rogress-report-status-changed.template.tsx | 23 +- .../project-step-changed.template.tsx | 29 ++- test/authentication.e2e-spec.ts | 4 +- yarn.lock | 203 +++++++++++------- 18 files changed, 383 insertions(+), 372 deletions(-) rename src/components/dbl-upload-notification/handlers/{dbl-upload-notification.handler.ts => dbl-upload-notification.handler.tsx} (95%) rename src/components/progress-report/workflow/handlers/{progress-report-workflow-notification.handler.ts => progress-report-workflow-notification.handler.tsx} (95%) rename src/components/project/workflow/handlers/{project-workflow-notification.handler.ts => project-workflow-notification.handler.tsx} (95%) create mode 100644 src/core/email/email.config.ts create mode 100644 src/core/email/index.ts diff --git a/package.json b/package.json index 9906e9b7d7..4c84dd2b0c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.440.0", "@aws-sdk/s3-request-presigner": "^3.440.0", + "@faire/mjml-react": "^3.5.0", "@faker-js/faker": "^9.7.0", "@fastify/compress": "^8.0.1", "@fastify/cookie": "^11.0.1", @@ -51,7 +52,7 @@ "@seedcompany/common": ">=0.19.1 <1", "@seedcompany/data-loader": "^2.0.1", "@seedcompany/nest": "^1.8.0", - "@seedcompany/nestjs-email": "^4.3.0", + "@seedcompany/nestjs-email": "^5.0.0-alpha.5", "@seedcompany/scripture": ">=0.8.0", "argon2": "^0.43.0", "aws-xray-sdk-core": "^3.5.3", @@ -91,13 +92,14 @@ "lru-cache": "^11.0.1", "luxon": "^3.5.0", "mime": "^4.0.7", + "mjml": "^4.15.3", "nanoid": "^5.1.5", "neo4j-driver": "^5.28.1", "p-retry": "^6.2.1", "plur": "^5.1.0", "prismjs-terminal": "^1.2.3", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", "read-package-up": "^11.0.0", "reflect-metadata": "^0.2.2", "rimraf": "^6.0.1", @@ -129,7 +131,7 @@ "@types/luxon": "^3.6.2", "@types/node": "^22.7.8", "@types/prismjs": "^1.26.2", - "@types/react": "^18.2.33", + "@types/react": "^19.1.8", "@types/stack-trace": "^0.0.33", "@types/triple-beam": "^1.3.4", "@types/validator": "^13.11.5", diff --git a/src/components/dbl-upload-notification/handlers/dbl-upload-notification.handler.ts b/src/components/dbl-upload-notification/handlers/dbl-upload-notification.handler.tsx similarity index 95% rename from src/components/dbl-upload-notification/handlers/dbl-upload-notification.handler.ts rename to src/components/dbl-upload-notification/handlers/dbl-upload-notification.handler.tsx index 21402c9917..e8e52e78b1 100644 --- a/src/components/dbl-upload-notification/handlers/dbl-upload-notification.handler.ts +++ b/src/components/dbl-upload-notification/handlers/dbl-upload-notification.handler.tsx @@ -5,7 +5,6 @@ import { type NonEmptyArray, setOf, } from '@seedcompany/common'; -import { EmailService } from '@seedcompany/nestjs-email'; import { Book, mergeVerseRanges, @@ -23,6 +22,7 @@ import { ResourceLoader, } from '~/core'; import { Identity } from '~/core/authentication'; +import { MailerService } from '~/core/email'; import { type ProgressReport, ProgressReportStatus as Status, @@ -49,7 +49,7 @@ export class DBLUploadNotificationHandler private readonly moduleRef: ModuleRef, private readonly resources: ResourceLoader, private readonly config: ConfigService, - private readonly mailer: EmailService, + private readonly mailer: MailerService, @Logger('progress-report:dbl-upload-notifier') private readonly logger: ILogger, ) {} @@ -123,13 +123,15 @@ export class DBLUploadNotificationHandler const to = props.recipient.email.value!; await this.mailer .withOptions({ send: !!this.config.email.notifyDblUpload }) - .render(DBLUpload, props) - .with({ - to, - ...(this.config.email.notifyDblUpload?.replyTo && { - 'reply-to': this.config.email.notifyDblUpload.replyTo, - }), - }) + .compose( + { + to, + ...(this.config.email.notifyDblUpload?.replyTo && { + 'reply-to': this.config.email.notifyDblUpload.replyTo, + }), + }, + , + ) .send(); } } diff --git a/src/components/dbl-upload-notification/templates/dbl-upload-email.template.tsx b/src/components/dbl-upload-notification/templates/dbl-upload-email.template.tsx index 0a987eb12c..dd0f34e90e 100644 --- a/src/components/dbl-upload-notification/templates/dbl-upload-email.template.tsx +++ b/src/components/dbl-upload-notification/templates/dbl-upload-email.template.tsx @@ -1,15 +1,7 @@ import { type NonEmptyArray } from '@seedcompany/common'; -import { - Column, - Text as Head, - Section, - Text, -} from '@seedcompany/nestjs-email/templates'; import type { Verse } from '@seedcompany/scripture'; import type { Range } from '~/common'; -import { EmailTemplate } from '~/core/email/templates/base'; -import { useFrontendUrl } from '~/core/email/templates/frontend-url'; -import { LanguageRef } from '~/core/email/templates/user-ref'; +import { EmailTemplate, LanguageRef, Mjml, useFrontendUrl } from '~/core/email'; import { type Engagement } from '../../../components/engagement/dto'; import { type Language } from '../../../components/language/dto'; import { type Project } from '../../../components/project/dto'; @@ -29,141 +21,145 @@ export function DBLUpload(props: Props) { const languageName = language.name.value; return ( -
- - + + + has recently indicated reaching some All Access goals via{' '} {project.name.value ?? 'Some Project'} . - - + + Books:{' '} {completedBooks.map((range) => range.start.book.name).join(', ')} - - -
-
- - + + + + + + Our records identify you as the Field Project Manager (FPM), and we’d like to confirm the next steps for uploading the text to the Digital Bible Library (DBL). - - + + To move forward, we need a few details from you. Please have your field partner complete this short form to provide the necessary information indicated below: - - -
-
- - ✅ First Step: Who will upload the Scripture to the DBL? - + + + + + + + ✅ First Step: Who will upload the Scripture to the DBL? + + 1. If someone is already responsible for uploading to the DBL, please let us know on the form so we can update our records and avoid unnecessary follow-ups. - - + + 2. If you need Seed Company to upload it to the DBL, we will need additional information. - - -
-
- - ✅ If Seed Company uploads to the DBL, please provide: - + + + + + + + ✅ If Seed Company uploads to the DBL, please provide: + + 🔹 Copyright Holder & Licensing – Who will hold the copyright for this text in DBL? - - + + The copyright holder can be the field partner or Seed Company if needed. - - + + We also need to confirm the licensing options you prefer for distribution. More details on these options are included in the attached information sheet. - - -
-
- - + + + + + + 🔹 Error-Free Text in Paratext – The text must pass Basic Checks in Paratext without errors. - - + + A quick way to verify is by printing the text to PDF format using PTXPrint ( learn here). Besides, our Investors love to see your progress and this is a great way to share it with them! 😊 - - + + If errors appear, they must be fixed before we can proceed. - - -
-
- - + + + + + + 🔹 Paratext Project Access – We need access to the project in Paratext. - - + + Please add SC DBL Admin to the project with the Consultant/Archivist role. - - + + This permission level is required for us to complete the upload. - - -
-
- - + + + + + + 🔹 Books Ready for Upload – Please confirm which books are ready for DBL. - - + + We can upload an entire testament or individual books that have completed consultant checking. - - -
-
- - + + + + + + 🔗{' '} Seed Company DBL Publication Request Form - - + + All of this information can be entered in the form linked above (with yellow highlight). - - -
-
- - + + + + + + 🔗{' '} Seed Company DBL Information Sheet - - + + Please review the linked information sheet for additional details about the DBL, the process, and licensing options. - - -
+ + +
); } diff --git a/src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.ts b/src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.tsx similarity index 95% rename from src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.ts rename to src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.tsx index 24a0a0ce3e..0b3242028d 100644 --- a/src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.ts +++ b/src/components/progress-report/workflow/handlers/progress-report-workflow-notification.handler.tsx @@ -1,5 +1,4 @@ import { entries, mapEntries } from '@seedcompany/common'; -import { EmailService } from '@seedcompany/nestjs-email'; import { type RequireExactlyOne } from 'type-fest'; import { type ID, Role, type UnsecuredDto } from '~/common'; import { @@ -10,6 +9,7 @@ import { Logger, } from '~/core'; import { Identity } from '~/core/authentication'; +import { MailerService } from '~/core/email'; import { type ProgressReportStatusChangedProps as EmailReportStatusNotification, ProgressReportStatusChanged, @@ -43,7 +43,7 @@ export class ProgressReportWorkflowNotificationHandler private readonly projectService: ProjectService, private readonly languageService: LanguageService, private readonly reportService: PeriodicReportService, - private readonly emailService: EmailService, + private readonly mailer: MailerService, private readonly workflowService: ProgressReportWorkflowService, @Logger('progress-report:status-change-notifier') private readonly logger: ILogger, @@ -95,11 +95,12 @@ export class ProgressReportWorkflowNotificationHandler for (const notification of notifications) { if (notification.recipient.email.value) { - await this.emailService.send( - notification.recipient.email.value, - ProgressReportStatusChanged, - notification, - ); + await this.mailer + .compose( + notification.recipient.email.value, + , + ) + .send(); } } } diff --git a/src/components/project/workflow/handlers/project-workflow-notification.handler.ts b/src/components/project/workflow/handlers/project-workflow-notification.handler.tsx similarity index 95% rename from src/components/project/workflow/handlers/project-workflow-notification.handler.ts rename to src/components/project/workflow/handlers/project-workflow-notification.handler.tsx index 3553cef8f2..8dda4fb188 100644 --- a/src/components/project/workflow/handlers/project-workflow-notification.handler.ts +++ b/src/components/project/workflow/handlers/project-workflow-notification.handler.tsx @@ -1,6 +1,5 @@ import { ModuleRef } from '@nestjs/core'; import { asyncPool } from '@seedcompany/common'; -import { EmailService } from '@seedcompany/nestjs-email'; import { type UnsecuredDto } from '~/common'; import { ConfigService, @@ -10,6 +9,7 @@ import { Logger, } from '~/core'; import { Identity } from '~/core/authentication'; +import { MailerService } from '~/core/email'; import { ProjectStepChanged, type ProjectStepChangedProps, @@ -30,7 +30,7 @@ export class ProjectWorkflowNotificationHandler private readonly config: ConfigService, private readonly users: UserService, private readonly projects: ProjectService, - private readonly emailService: EmailService, + private readonly mailer: MailerService, private readonly moduleRef: ModuleRef, @Logger('progress-report:status-change-notifier') private readonly logger: ILogger, @@ -95,7 +95,9 @@ export class ProjectWorkflowNotificationHandler previousStep, primaryPartnerName, ); - await this.emailService.send(notifier.email, ProjectStepChanged, props); + await this.mailer + .compose(notifier.email, ) + .send(); }); } diff --git a/src/core/authentication/authentication.service.ts b/src/core/authentication/authentication.service.ts index 9334381a40..6f5c6875c8 100644 --- a/src/core/authentication/authentication.service.ts +++ b/src/core/authentication/authentication.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { EmailService } from '@seedcompany/nestjs-email'; import { AuthenticationException, DuplicateException, @@ -9,6 +8,7 @@ import { ServerException, UnauthenticatedException, } from '~/common'; +import { MailerService } from '~/core/email'; import { ILogger, Logger } from '~/core/logger'; import { ForgotPassword } from '../email/templates'; import { disableAccessPolicies, Gel } from '../gel'; @@ -26,7 +26,7 @@ import { SessionManager } from './session/session.manager'; export class AuthenticationService { constructor( private readonly crypto: CryptoService, - private readonly email: EmailService, + private readonly mailer: MailerService, @Logger('authentication:service') private readonly logger: ILogger, private readonly repo: AuthenticationRepository, private readonly gel: Gel, @@ -135,9 +135,7 @@ export class AuthenticationService { const token = this.jwt.encode(); await this.repo.saveEmailToken(email, token); - await this.email.send(email, ForgotPassword, { - token, - }); + await this.mailer.compose(email, [ForgotPassword, { token }]).send(); } async resetPassword({ token, password }: ResetPasswordInput): Promise { diff --git a/src/core/config/config.service.ts b/src/core/config/config.service.ts index f05087f2a9..0913b5f570 100644 --- a/src/core/config/config.service.ts +++ b/src/core/config/config.service.ts @@ -1,8 +1,5 @@ import { csv } from '@seedcompany/common'; -import type { - EmailModuleOptions, - EmailOptionsFactory, -} from '@seedcompany/nestjs-email'; +import type { EmailModuleOptions as EmailOptions } from '@seedcompany/nestjs-email'; import type { Server as HttpServer } from 'http'; import { type LRUCache } from 'lru-cache'; import { DateTime, Duration, type DurationLike } from 'luxon'; @@ -14,8 +11,6 @@ import { type ID } from '~/common'; import { parseUri } from '../../components/file/bucket/parse-uri'; import { ProgressReportStatus } from '../../components/progress-report/dto/progress-report-status.enum'; import { TransitionName as ProgressReportTransitionName } from '../../components/progress-report/workflow/transitions'; -import { DefaultTimezoneWrapper } from '../email/templates/formatted-date-time'; -import { FrontendUrlWrapper } from '../email/templates/frontend-url'; import type { CookieOptions, CorsOptions, IRequest } from '../http'; import { LogLevel } from '../logger/logger.interface'; import { type EnvironmentService } from './environment.service'; @@ -30,7 +25,7 @@ type HttpTimeoutOptions = AppConfig['httpTimeouts']; const isDev = process.env.NODE_ENV === 'development'; export const makeConfig = (env: EnvironmentService) => - class ConfigService implements EmailOptionsFactory { + class ConfigService { port = env.number('port').optional(3000); // The port where the app is being hosted. i.e. a docker bound port publicPort = env.number('public_port').optional(this.port); @@ -95,13 +90,17 @@ export const makeConfig = (env: EnvironmentService) => jwtKey = env.string('JWT_AUTH_KEY').optional('cord-field'); - createEmailOptions = () => { + emailDriver = (() => { const send = env.boolean('EMAIL_SEND').optional(false); + const from = env + .string('EMAIL_FROM') + .optional('CORD Field '); + const replyTo = env.string('EMAIL_REPLY_TO').optional() || undefined; // falsy -> undefined return { - from: env - .string('EMAIL_FROM') - .optional('CORD Field '), - replyTo: env.string('EMAIL_REPLY_TO').optional() || undefined, // falsy -> undefined + defaultHeaders: { + from, + ...(replyTo && { replyTo }), + }, send, open: this.jest ? false @@ -109,12 +108,8 @@ export const makeConfig = (env: EnvironmentService) => ses: { region: env.string('SES_REGION').optional(), }, - wrappers: [ - FrontendUrlWrapper(this.frontendUrl), - DefaultTimezoneWrapper(this.defaultTimeZone), - ], - } satisfies EmailModuleOptions; - }; + } satisfies EmailOptions; + })(); email = { notifyDistributionLists: env @@ -134,7 +129,7 @@ export const makeConfig = (env: EnvironmentService) => progressReportStatusChange = { enabled: env .boolean('NOTIFY_PROGRESS_REPORT_STATUS_CHANGES') - .optional(this.createEmailOptions().send), + .optional(this.emailDriver.send), notifyExtraEmails: { forTransitions: env .map('PROGRESS_REPORT_EMAILS_FOR_TRANSITIONS', { diff --git a/src/core/core.module.ts b/src/core/core.module.ts index 6349cc86ac..ea95e16c50 100644 --- a/src/core/core.module.ts +++ b/src/core/core.module.ts @@ -7,10 +7,10 @@ import { AwsS3Factory } from './aws-s3.factory'; import { CacheModule } from './cache/cache.module'; import { CliModule } from './cli/cli.module'; import { ConfigModule } from './config/config.module'; -import { ConfigService } from './config/config.service'; import { CoreController } from './core.controller'; import { DataLoaderConfig } from './data-loader/data-loader.config'; import { DatabaseModule } from './database/database.module'; +import { EmailConfig } from './email/email.config'; import { EventsModule } from './events'; import { ExceptionFilter } from './exception/exception.filter'; import { ExceptionNormalizer } from './exception/exception.normalizer'; @@ -35,7 +35,7 @@ import { WaitResolver } from './wait.resolver'; DatabaseModule, DataLoaderModule.registerAsync({ useClass: DataLoaderConfig }), GelModule, - EmailModule.forRootAsync({ useExisting: ConfigService }), + EmailModule.registerAsync({ useClass: EmailConfig }), GraphqlModule, EventsModule, TracingModule, diff --git a/src/core/email/email.config.ts b/src/core/email/email.config.ts new file mode 100644 index 0000000000..1c91bacc8b --- /dev/null +++ b/src/core/email/email.config.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { type EmailModuleOptions } from '@seedcompany/nestjs-email'; +import { ConfigService } from '~/core/config/config.service'; + +@Injectable() +export class EmailConfig { + constructor(private readonly config: ConfigService) {} + + create(): EmailModuleOptions { + const options = this.config.emailDriver; + return options; + } +} diff --git a/src/core/email/index.ts b/src/core/email/index.ts new file mode 100644 index 0000000000..909c4d5a26 --- /dev/null +++ b/src/core/email/index.ts @@ -0,0 +1,7 @@ +export { MailerService, EmailMessage } from '@seedcompany/nestjs-email'; +export * from '@seedcompany/nestjs-email/templates'; +export * as Mjml from '@seedcompany/nestjs-email/templates/mjml'; +export * from './templates/base'; +export * from './templates/formatted-date-time'; +export * from './templates/frontend-url'; +export * from './templates/user-ref'; diff --git a/src/core/email/templates/base.tsx b/src/core/email/templates/base.tsx index 73ae51f85d..9e28071b3c 100644 --- a/src/core/email/templates/base.tsx +++ b/src/core/email/templates/base.tsx @@ -1,24 +1,5 @@ -import { - All, - Attributes, - Body, - Button, - Column, - Divider, - Font, - Head, - HideInText, - Image, - InText, - Mjml, - Preview, - Raw, - Section, - Style, - Text, - Title, - Wrapper, -} from '@seedcompany/nestjs-email/templates'; +import * as Meta from '@seedcompany/nestjs-email/templates'; +import * as Mjml from '@seedcompany/nestjs-email/templates/mjml'; import { type ComponentProps, Fragment, @@ -36,22 +17,23 @@ export const EmailTemplate = ({ preview?: ReactNode; children: ReactNode; }) => ( - - - {title} + + + + {title} {preview != null && ( - - + + {preview} {/* Fill the remaining space with nothing-ness so the email context is avoided */} {[...Array(140).keys()].map((i) => ( ͏‌  ))} - - + + )} -