-
Notifications
You must be signed in to change notification settings - Fork 2
[DEV-3700][DEV-3701] Refactor cognito-functions email templating to use build-time precompiled templates #2139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Sebastiano-Bertolin
wants to merge
11
commits into
main
Choose a base branch
from
DEV-3700-replace-html-minifier-with-html-minifier-next
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
17161b4
Replace html-minifier with html-minifier-next and update email templa…
Sebastiano-Bertolin c13e29b
Merge branch 'main' into DEV-3700-replace-html-minifier-with-html-min…
Sebastiano-Bertolin 43d48b9
Refacor to remove mjml from runtime and have script generating templa…
Sebastiano-Bertolin f6a8ac2
Add changeset
Sebastiano-Bertolin 013e437
Refactor email template generation to TypeScript and update locale ha…
Sebastiano-Bertolin 772568e
Merge branch 'main' into DEV-3700-replace-html-minifier-with-html-min…
Sebastiano-Bertolin d3d2206
Fix email subject fallback to use DEFAULT_LOCALE in custom message ha…
Sebastiano-Bertolin 45ba03b
Refactor email subject handling to ensure proper locale type casting
Sebastiano-Bertolin fd44e0c
Revert changes
Sebastiano-Bertolin 724c5c6
Merge remote-tracking branch 'origin/main' into DEV-3700-replace-html…
Sebastiano-Bertolin adfda0f
Update apps/cognito-functions/tsconfig.scripts.json
Sebastiano-Bertolin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "cognito-functions": major | ||
| --- | ||
|
|
||
| Precompile Cognito email templates at build time instead of rendering MJML at runtime. This removes the runtime dependency on `mjml`, switches template minification to `html-minifier-next`, and centralizes the default locale used when a localized template is not available. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| # cognito-functions | ||
|
|
||
| AWS Lambda handlers used by the DevPortal Cognito User Pool. | ||
|
|
||
| This package contains the Cognito trigger functions for sign-up, custom authentication, OTP verification, and post-confirmation emails. It is bundled as a single Lambda artifact and deployed with different handlers wired to different Cognito triggers. | ||
|
|
||
| ## What this app does | ||
|
|
||
| The package exports these handlers from `src/main.ts`: | ||
|
|
||
| - `customMessageHandler`: builds Cognito email messages for sign-up confirmation, resend code, forgot password, and email change confirmation. | ||
| - `postConfirmationHandler`: sends the welcome email after successful sign-up confirmation. | ||
| - `defineAuthChallengeHandler`: defines the custom auth flow sequence after SRP and password verification. | ||
| - `createAuthChallengeHandler`: generates the OTP, emails it to the user through SES, and stores the expected verification code in the private challenge parameters. | ||
| - `verifyAuthChallengeHandler`: validates the OTP provided by the user. | ||
| - `createPreSignUpHandler`: blocks sign-up attempts from email domains that are not explicitly allowed. | ||
|
|
||
| ## Runtime configuration | ||
|
|
||
| The handlers read their configuration from environment variables. | ||
|
|
||
| | Variable | Required by | Purpose | | ||
| | --- | --- | --- | | ||
| | `DOMAIN` | `customMessageHandler`, `postConfirmationHandler`, `createAuthChallengeHandler` | Public DevPortal domain used to build links and image URLs in emails. | | ||
| | `FROM_EMAIL_ADDRESS` | `postConfirmationHandler`, `createAuthChallengeHandler` | Sender address used by SES for OTP and welcome emails. | | ||
| | `SIGNUP_ALLOWED_EMAIL_DOMAINS` | `createPreSignUpHandler` | JSON array of allowed email domains, for example `["example.com"]`. | | ||
|
|
||
| Notes: | ||
|
|
||
| - `customMessageHandler` returns HTML and subject back to Cognito. It does not send email directly. | ||
| - `postConfirmationHandler` and `createAuthChallengeHandler` send email with AWS SES. | ||
| - Locale selection uses `custom:preferred_language` and falls back to `it`. | ||
|
|
||
| ## Email templates | ||
|
|
||
| Email HTML is not rendered from MJML at runtime. | ||
|
|
||
| Instead, the package uses a build-time pipeline: | ||
|
|
||
| 1. MJML source templates live in `src/templates/template-sources.ts`. | ||
| 2. `npm run generate:templates` compiles MJML to HTML and minifies it. | ||
| 3. The generated output is written to `src/templates/generated/precompiled-email-templates.ts`. | ||
| 4. Runtime code loads the precompiled HTML and replaces placeholders with escaped values such as OTP, links, first name, and domain. | ||
|
|
||
| This design keeps `mjml` and `html-minifier-next` out of the Lambda runtime dependency graph while preserving the current email authoring model. | ||
|
|
||
| If you change email copy or layout, regenerate the templates before building or testing. The package scripts already do this automatically. | ||
|
|
||
| ## Scripts | ||
|
|
||
| Run commands from the workspace root with `-w cognito-functions`, or from this directory directly. | ||
|
|
||
| | Command | Purpose | | ||
| | --- | --- | | ||
| | `npm run generate:templates` | Compile MJML source templates into the generated HTML module. | | ||
| | `npm run clean` | Remove `dist`, `out`, and generated templates. | | ||
| | `npm run compile` | Generate templates, then run TypeScript compilation. | | ||
| | `npm run build` | Generate templates, bundle the Lambda with esbuild, and zip the artifact in `out/cognito-functions.zip`. | | ||
| | `npm run test` | Generate templates, compile TypeScript, and run the Jest suite. | | ||
| | `npm run lint` | Run ESLint on `src`. | | ||
|
|
||
| ## Local development | ||
|
|
||
| Typical workflow: | ||
|
|
||
| 1. Update handler or template code. | ||
| 2. Run `npm run test -w cognito-functions`. | ||
| 3. Run `npm run build -w cognito-functions`. | ||
| 4. Use `out/cognito-functions.zip` as the deployment artifact. | ||
|
|
||
| If you are only changing email templates, `npm run generate:templates -w cognito-functions` is enough to validate the precompilation step. | ||
|
|
||
| ## Auth flow summary | ||
|
|
||
| The custom authentication flow is: | ||
|
|
||
| 1. Cognito performs `SRP_A`. | ||
| 2. `defineAuthChallengeHandler` requests `PASSWORD_VERIFIER`. | ||
| 3. After successful password verification, `defineAuthChallengeHandler` requests `CUSTOM_CHALLENGE`. | ||
| 4. `createAuthChallengeHandler` generates a 6-digit OTP and sends it through SES. | ||
| 5. `verifyAuthChallengeHandler` compares the user answer with the expected OTP. | ||
| 6. `defineAuthChallengeHandler` issues tokens only when the custom challenge succeeds. | ||
|
|
||
| `OTP_DURATION_MINUTES` is currently set to `15` in `src/create-auth-challenge-handler.ts`. | ||
|
|
||
| ## Testing | ||
|
|
||
| The package uses Jest with `ts-jest`. Existing tests cover: | ||
|
|
||
| - Custom message email generation | ||
| - OTP email creation and subject localization | ||
| - Post-confirmation email sending | ||
| - Define and verify auth challenge behavior | ||
|
|
||
| Run: | ||
|
|
||
| ```sh | ||
| npm test -w cognito-functions | ||
| ``` | ||
|
|
||
| ## Deployment artifact | ||
|
|
||
| The build produces: | ||
|
|
||
| ```sh | ||
| apps/cognito-functions/out/cognito-functions.zip | ||
| ``` | ||
|
|
||
| Infrastructure code can then point multiple Cognito triggers at different exported handlers inside the same bundle. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,28 +1,9 @@ | ||
| /* eslint-disable functional/no-expression-statements */ | ||
| import * as esbuild from 'esbuild'; | ||
|
|
||
| /** | ||
| * mjml-core has html-minifier as dependency. | ||
| * html-minifier has uglify-js as dependency. | ||
| * The bundle generated had issues on uglify-js dependency (no module found). | ||
| * This is a workaround that returns an empty module for uglify-js node file. | ||
| * | ||
| * To solve this problem, we took inspiration from https://github.com/mjmlio/mjml/issues/2132#issuecomment-1004713444 | ||
| * | ||
| */ | ||
| const emptyUglifyPlugin = { | ||
| name: 'empty mjml uglify plugin', | ||
| setup(build) { | ||
| build.onLoad({ filter: /uglify-js\/tools\/node.js$/ }, () => ({ | ||
| contents: '{}', | ||
| loader: 'js', | ||
| })); | ||
| }, | ||
| }; | ||
|
|
||
| await esbuild.build({ | ||
| entryPoints: ['src/main.ts'], | ||
| bundle: true, | ||
| outfile: 'out/main.js', | ||
| platform: 'node', | ||
| plugins: [emptyUglifyPlugin] | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
97 changes: 97 additions & 0 deletions
97
apps/cognito-functions/scripts/generate-email-templates.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| /* eslint-disable functional/immutable-data */ | ||
| /* eslint-disable functional/no-loop-statements */ | ||
| /* eslint-disable functional/no-expression-statements */ | ||
| /* eslint-disable functional/no-throw-statements */ | ||
| import fs from 'node:fs'; | ||
| import path from 'node:path'; | ||
|
|
||
| import mjml2html from 'mjml'; | ||
| import { minify } from 'html-minifier-next'; | ||
|
|
||
| import { SUPPORTED_LOCALES } from '../src/i18n/locales'; | ||
| import { EMAIL_TEMPLATE_BUILDERS } from '../src/templates/template-sources'; | ||
|
|
||
| const defaultMinificationOptions = { | ||
| caseSensitive: true, | ||
| collapseWhitespace: true, | ||
| minifyCSS: true, | ||
| removeEmptyAttributes: true, | ||
| } as const; | ||
|
|
||
| const outputFilePath = path.join( | ||
| __dirname, | ||
| '..', | ||
| 'src', | ||
| 'templates', | ||
| 'generated', | ||
| 'precompiled-email-templates.ts' | ||
| ); | ||
|
|
||
| type BuildTemplate = (locale: string) => string; | ||
|
|
||
| const compileTemplate = async ( | ||
| buildTemplate: BuildTemplate, | ||
| locale: string | ||
| ): Promise<string> => { | ||
| const { errors, html } = mjml2html(buildTemplate(locale), { | ||
| validationLevel: 'soft', | ||
| }); | ||
|
|
||
| if (html.length === 0) { | ||
| throw new Error( | ||
| `Unable to compile ${ | ||
| buildTemplate.name || 'email template' | ||
| } for locale ${locale}` | ||
| ); | ||
| } | ||
|
|
||
| if (errors.length > 0) { | ||
| console.warn( | ||
| `MJML validation warnings for ${ | ||
| buildTemplate.name || 'email template' | ||
| } (${locale}): ${errors.map(({ message }) => message).join(', ')}` | ||
| ); | ||
| } | ||
|
|
||
| return minify(html, defaultMinificationOptions); | ||
| }; | ||
|
|
||
| const buildTemplates = async (): Promise< | ||
| Record<string, Record<string, string>> | ||
| > => { | ||
| const precompiledTemplates: Record<string, Record<string, string>> = {}; | ||
|
|
||
| for (const [templateId, buildTemplate] of Object.entries( | ||
| EMAIL_TEMPLATE_BUILDERS | ||
| ) as ReadonlyArray<readonly [string, BuildTemplate]>) { | ||
| precompiledTemplates[templateId] = {}; | ||
|
|
||
| for (const locale of SUPPORTED_LOCALES) { | ||
| precompiledTemplates[templateId][locale] = await compileTemplate( | ||
| buildTemplate, | ||
| locale | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return precompiledTemplates; | ||
| }; | ||
|
|
||
| const writeTemplatesFile = async (): Promise<void> => { | ||
| const precompiledTemplates = await buildTemplates(); | ||
| const outputContents = `/* This file is generated by scripts/generate-email-templates.ts. */ | ||
| export const PRECOMPILED_EMAIL_TEMPLATES = ${JSON.stringify( | ||
| precompiledTemplates, | ||
| null, | ||
| 2 | ||
| )} as const; | ||
| `; | ||
|
|
||
| fs.mkdirSync(path.dirname(outputFilePath), { recursive: true }); | ||
| fs.writeFileSync(outputFilePath, outputContents); | ||
| }; | ||
|
|
||
| writeTemplatesFile().catch((error: unknown) => { | ||
| console.error(error); | ||
| process.exitCode = 1; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,3 @@ | ||
| export const DEFAULT_LOCALE = 'it'; | ||
|
|
||
| export const SUPPORTED_LOCALES: readonly string[] = ['it', 'en']; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't there a dedicated workflow?