task(libs/accounts): Port email sending code to libs

Because:
- We want need a standalone library that can be used to send emails

This Commit:
- Ports the email sending code from auth-server into libs

task(libs/accounts): Port email sending code to libs

Because:
- We want need a standalone library that can be used to send emails

This Commit:
- Ports the email sending code from auth-server into libs

task(libs/accounts): Port email rendering code to libs

Because:
- We need a standalone library that can be used to render emails

This Commit:
- Ports the email rendering code from auth-server to libs
- Converts to typescript
- Cleans up code and improves consistency
- Creates work around for copying nxignored l10n assets
This commit is contained in:
dschom
2025-11-04 20:51:46 -08:00
parent c7dd0a076e
commit dda643a253
507 changed files with 14158 additions and 634 deletions

4
.gitignore vendored
View File

@@ -87,6 +87,10 @@ Thumbs.db
# command is called
.venv
## Lib Specific
libs/accounts/email-renderer/public
libs/accounts/email-renderer/src/css
## Package-specific ##
# circleci

View File

@@ -4,7 +4,8 @@
"stylelint-config-recommended-scss"
],
"ignoreFiles": [
"../packages/**/tailwind.out.scss"
"../**/tailwind.out.scss",
"../libs/accounts/email-renderer/src/**/*.{scss,css}"
],
"rules": {
"declaration-empty-line-before": "never",

View File

@@ -16,6 +16,10 @@ const frozen: Array<{ pattern: string; reason: string }> = [
pattern: 'packages/fxa-auth-server/lib/senders/email.js',
reason: 'Files moved to libs/accounts/email-sender',
},
{
pattern: 'packages/fxa-auth-server/lib/senders/(emails|renderer)/.*',
reason: 'Files moved to libs/accounts/email-renderer',
},
];
export const getChangedFiles = () => {

View File

@@ -1,4 +1,6 @@
#!/bin/bash -e
set -euo pipefail
shopt -s nullglob
# Pulls the latest localization files into a target workspace.
@@ -37,16 +39,21 @@ mkdir -p "$TARGET_FOLDER"
# Loop through all files and combine
cd "$ROOT_FOLDER/external/l10n/locale";
for d in */; do
cd "$d";
locale=$(echo $d | sed 's/_/-/' | sed 's/\/$//')
count=$(ls | grep .ftl | wc -l)
if [[ $((count)) == 0 ]]; then
echo "$PREFIX: $locale has no .ftl files"
else
mkdir -p "$ROOT_FOLDER/$TARGET_FOLDER/$locale"
cp *.ftl "$ROOT_FOLDER/$TARGET_FOLDER/$locale/"
fi
cd ..
cd "$d"
locale=${d%/}
locale=${locale//_/-}
files=( *.ftl )
if ((${#files[@]} == 0)); then
echo "$PREFIX: $locale has no .ftl files"
else
dest="$ROOT_FOLDER/$TARGET_FOLDER/$locale"
mkdir -p "$dest"
cp -- "${files[@]}" "$dest/"
fi
cd ..
done
# Record the current git version

View File

@@ -0,0 +1,21 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": [
"!**/*",
"*.scss"
],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@@ -0,0 +1,66 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const path = require('path');
export default {
framework: {
name: '@storybook/html-webpack5',
options: {},
},
stories: ['../src/**/*.stories.ts'],
staticDirs: process.env.STORYBOOK_BUILD !== 'true' ? ['..'] : undefined,
addons: [
'@storybook/addon-webpack5-compiler-babel',
'@storybook/addon-docs',
'@storybook/addon-controls',
'@storybook/addon-toolbars',
],
core: {
builder: 'webpack5',
},
babel: async (options) => {
const babelConfig = {
...options,
sourceType: 'unambiguous',
presets: [
[
'@babel/preset-env',
{
targets: '> 0.25%, last 2 versions, not dead',
},
],
'@babel/preset-typescript',
],
plugins: [],
};
console.log('babel config!');
return babelConfig;
},
features: { storyStoreV7: true },
// Added to resolve path aliases set in <projectRoot>/tsconfig.base.json
// tsconfig.storybook.json is necessary to replace the *.ts extension in tsconfig.base.json
// with a *.js extension. Other than that it should remain the same.
async webpackFinal(config, { configType }) {
config.resolve.plugins = [
new TsconfigPathsPlugin({
configFile: path.resolve(__dirname, './tsconfig.storybook.json'),
}),
];
config.resolve.fallback = {
// fs: false, // Keep this as it's the standard Storybook approach.
// // This is often needed when using internal Node libs in the browser:
// path: false,
// os: false,
// "http": require.resolve("stream-http"),
// "https": require.resolve("https-browserify"),
// // **Potential fix for fs** - by adding 'process' which is another common Node dependency
// process: require.resolve('process/browser'),
};
return config;
},
};

View File

@@ -0,0 +1,32 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import '../src/storybook.css';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
};
export const globalTypes = {
direction: {
name: 'Text directionality',
description: 'Set text to LTR or RTL direction',
defaultValue: 'ltr',
toolbar: {
icon: 'transfer',
items: [
{
value: 'ltr',
right: '➡️',
title: 'Left to Right',
},
{
value: 'rtl',
right: '⬅️',
title: 'Right to Left',
},
],
},
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}

View File

@@ -0,0 +1,55 @@
# account email renderer
This library was generated with [Nx](https://nx.dev).
## Building
Run `nx build accounts-email-renderer` to build the library.
## Running unit tests
Run `nx test-unit accounts-email-renderer` to execute the unit tests via [Jest](https://jestjs.io).
## Viewing Story Books
Run `nx storybook accounts-email-renderer`
## Using this library in your service
The install is like most other libs, except there's a good chance you'll have to copy assests from this lib into your build's dist folder.
The simplest way to do this is with the copyfiles utility. Here's an example gist, from admin-server's package.json
`"copy-email-assets": "copyfiles --up 1 '../../libs/accounts/email-renderer/**/*.{mjml,ftl,txt,css}' dist/libs/ ",`
### Other Gotchas
One tricky thing about how this project is crafted is that the lib is designed to be used in Node on the server server side, but the storybooks are run in a web browser context. Be careful not include the `node-bindings.ts` from storybook. Doing so will result in weird errors about missing polyfils! Don't simply try adding this polyfils. Instead, make sure `node-bindings.ts` isn't accidentally getting imported by storybook.
### Adding a new Email
To add a new email, do the following.
1. Go to `src/templates`,` and copy one of the existing folders renaming it as desired.
2. Next update the `index.mjml` to construct your template.
3. Next update `index.txt` to construct your text version of the email.
4. Next update `en.ftl` and make sure all l10n id's are in place.
5. Next update `index.ts`. You should have:
a. Strongly typed `TemplateData` showing the property the template expected
b. A template const that reflects the template names, and matches the folder name.
c. A version const that reflects the current 'version' of the template
d. A layout const that reflects the intended layout.
d. Includes, which help us render a subject, action, or preview of the email
6. Next create `index.stories.ts` filling out the various states and render states as needed.
7. Next open the `fxa-email-render.ts` and follow the pattern there.
a. Import the new template folder
b. Create method corresponding to the template folder
c. Follow the established pattern to wire up the template and expose it.
8. Finally run `nx storybook accounts-email-renderer` to preview what the email looks.
## View Git File History
This code was ported from auth-server. The code's history should more or less be retained. To view
a files full history use the git `--follow` option. e.g.
`git log --follow -M -- libs/accounts/email-renderer/src/templates/cadReminderFirst/index.mjml`

View File

@@ -0,0 +1,21 @@
/* eslint-disable */
export default {
displayName: 'accounts-email-renderer',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/accounts/email-renderer',
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: 'artifacts/tests/lib/accounts/email-renderer',
outputName: 'accounts-email-renderer-jest-unit-results.xml',
},
],
],
};

View File

@@ -0,0 +1,9 @@
{
"name": "@fxa/accounts/email-renderer",
"version": "0.0.1",
"dependencies": {},
"type": "commonjs",
"main": "./index.cjs",
"types": "./index.d.ts",
"private": true
}

View File

@@ -0,0 +1,97 @@
{
"name": "accounts-email-renderer",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/accounts/email-renderer/src",
"projectType": "library",
"tags": ["scope:shared:lib"],
"targets": {
"build": {
"dependsOn": ["build-css", "build-ts"],
"executor": "nx:run-commands",
"options": {
"commands": [
"# Important!!! We have to do this cause these are .gitingored, which means nx ingores them too.",
"echo Copying l10n assets...",
"cp -r libs/accounts/email-renderer/public dist/libs/accounts/email-renderer/.",
"echo Copying css assets...",
"cp -r libs/accounts/email-renderer/src/css dist/libs/accounts/email-renderer/src/css"
]
}
},
"build-ts": {
"executor": "@nx/esbuild:esbuild",
"outputs": ["{options.outputPath}"],
"dependsOn": ["l10n-prime"],
"options": {
"outputPath": "dist/libs/accounts/email-renderer",
"main": "libs/accounts/email-renderer/src/index.ts",
"tsConfig": "libs/accounts/email-renderer/tsconfig.lib.json",
"assets": ["libs/accounts/email-renderer/*.md"],
"format": ["cjs"],
"generatePackageJson": true
}
},
"build-css": {
"executor": "nx:run-commands",
"options": {
"commands": [
"ts-node libs/accounts/email-renderer/src/sass-compile-files.ts"
]
}
},
"test-unit": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/accounts/email-renderer/jest.config.ts",
"testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"]
}
},
"test-integration": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/accounts/email-renderer/jest.config.ts",
"testPathPattern": ["\\.in\\.spec\\.ts$"]
}
},
"build-storybook": {
"executor": "@nx/storybook:build",
"outputs": ["{options.outputDir}"],
"dependsOn": ["build-css", "build-ts"],
"options": {
"outputDir": "dist/storybook/accounts/email-renderer",
"configDir": "libs/accounts/email-renderer/.storybook"
}
},
"storybook": {
"executor": "@nx/storybook:storybook",
"dependsOn": ["build-css", "build-ts"],
"options": {
"port": 4400,
"configDir": "libs/accounts/email-renderer/.storybook",
"browserTarget": "accounts-email-renderer:build"
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"l10n-copy": {
"executor": "nx:run-commands",
"options": {
"commands": [
"mkdir -p dist/libs/accounts/email-renderer",
"cp -r libs/accounts/email-renderer/public dist/libs/accounts/email-renderer/."
]
}
},
"l10n-prime": {
"executor": "nx:run-commands",
"options": {
"commands": ["_scripts/l10n/prime.sh libs/accounts/email-renderer"]
}
}
}
}

View File

@@ -0,0 +1,189 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// NOTE: This file handled with browser ESLint bindings
// instead of NodeJS for DOM typings support
/* eslint-env browser */
import { LocalizerOpts, ILocalizerBindings } from './l10n';
// Supporting Types
export type EjsOpts = {
root?: string;
};
export type MjmlOpts = {
validationLevel?: 'strict' | 'soft' | 'skip';
filePath?: string;
ignoreIncludes?: boolean;
minify?: boolean;
};
export type TemplateOpts = {
basePath: string;
cssPath: string;
};
export type RenderOpts = {
templates: TemplateOpts;
ejs: EjsOpts;
mjml: MjmlOpts;
};
type TemplateContextValue =
| string
| Record<string, any>
| number
| Date
| null
| undefined;
// Eventually we can list all available values here, or separate them by template
export type TemplateValues = {
numberRemaining?: number;
subscriptions?: Record<string, any>[];
[key: string]: TemplateContextValue;
};
export type ComponentTarget = 'index' | 'strapi';
export interface TemplateContext {
acceptLanguage?: string;
template: string;
version: number;
layout?: string;
target?: ComponentTarget;
subject?: string;
templateValues?: TemplateValues;
}
export interface RendererContext extends TemplateContext, TemplateValues {
// cssPath is relative to where rendering occurs
cssPath: string;
subject: string;
action?: string;
preview?: string;
clientName?: string;
}
export type EjsComponent = {
mjml: string;
text: string;
};
export type TemplateResult = {
html: string;
text: string;
rootElement: Element;
};
export type RendererOpts = RenderOpts & LocalizerOpts;
type ComponentType = 'templates' | 'layouts';
/**
* Abstraction for binding the renderer to different contexts, e.g. node vs browser.
*/
export abstract class RendererBindings implements ILocalizerBindings {
/**
* Customized options for the renderer
*/
abstract opts: RendererOpts;
/**
* Renders a mjml template with support for fluent localization.
* @param template Name of template
* @param context Contains either values sent through mailer.send or mock values from Storybook
* @param layout Optional layout, which acts as wrapper for for template
* @returns Rendered template
*/
async renderTemplate(
template: string,
context: TemplateContext,
layout?: string,
target: ComponentTarget = 'index'
): Promise<TemplateResult> {
context = { ...context, template };
let component = this.renderEjsComponent(
await this.getComponent('templates', template, target),
context
);
// Wrap component with layout
if (layout) {
component = this.renderEjsComponent(
await this.getComponent('layouts', layout, target),
context,
component
);
}
const { mjml, text } = component;
const html = this.mjml2html(mjml);
const rootElement = this.produceRootElement(html);
return { html, text, rootElement };
}
protected async getComponent(
type: ComponentType,
name: string,
target: ComponentTarget
) {
const path = `${this.opts.templates.basePath}/${type}/${name}`;
const [mjml, text] = await Promise.all([
this.fetchResource(`${path}/${target}.mjml`),
this.fetchResource(`${path}/${target}.txt`),
]);
return { mjml, text };
}
/**
* Renders an EJS template
* @param component Component to render
* @param context Context used to fill template variables.
* @param body Optional body to wrap
*/
protected renderEjsComponent(
component: EjsComponent,
context: TemplateContext,
body?: EjsComponent
): EjsComponent {
const { mjml, text } = component;
return {
mjml: this.renderEjs(mjml, context, body?.mjml),
text: this.renderEjs(text, context, body?.text),
};
}
/**
* Fetches a resource
* @param path Path to resource
*/
abstract fetchResource(path: string): Promise<string>;
/**
* Renders EJS
* @param ejsTemplate Raw template to render
* @param context Context to fill template with
* @param body Optional body to wrap
* @returns Rendered EJS template
*/
abstract renderEjs(
ejsTemplate: string,
context: TemplateContext,
body?: string
): string;
/**
* Renders MJML into HTML
* @param mjml MJML markup
* @returns HTML
*/
protected abstract mjml2html(mjml: string): string;
/**
* Produces a DOM like element from an html string
* @param html HTML to parse
* @returns DOM like element
*/
protected abstract produceRootElement(html: string): Element;
}

View File

@@ -0,0 +1,197 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// Fonts
$font-sans: sans-serif;
// Colors
$blue-500: #0060df;
$white: #fff;
$black: #000;
$grey-50: #f0f0f4;
$grey-100: #e7e7e7;
$grey-400: #6d6d6e;
$grey-500: #4b5563;
$grey-600: #464646;
$purple-600: #7542e5;
// Spacing values for margin and padding
$s-0: 0px;
$s-1: 4px;
$s-2: 8px;
$s-3: 12px;
$s-4: 16px;
$s-5: 20px;
$s-6: 24px;
$s-8: 32px;
$s-10: 40px;
// Font-size and line-height
.text {
&-xs {
font-size: 12px !important;
line-height: 20px !important;
}
&-sm {
font-size: 14px !important;
line-height: 22px !important;
}
&-md {
font-size: 16px !important;
line-height: 24px !important;
}
&-lg {
font-size: 18px !important;
line-height: 26px !important;
}
&-xl {
font-size: 20px !important;
line-height: 28px !important;
}
&-2xl {
font-size: 22px !important;
line-height: 30px !important;
}
&-3xl {
font-size: 32px !important;
line-height: 36px !important;
}
}
// Utility classes
.font-sans {
font-family: $font-sans !important;
}
.text-blue-500 {
color: $blue-500;
}
.mt {
&-2 {
margin-top: $s-2 !important;
}
&-4 {
margin-top: $s-4 !important;
}
&-5 {
margin-top: $s-5 !important;
}
&-6 {
margin-top: $s-6 !important;
}
&-8 {
margin-top: $s-8 !important;
}
&-10 {
margin-top: $s-10 !important;
}
}
.mb {
&-0 {
margin-bottom: $s-0 !important;
}
&-2 {
margin-bottom: $s-2 !important;
}
&-3 {
margin-bottom: $s-3 !important;
}
&-4 {
margin-bottom: $s-4 !important;
}
&-5 {
margin-bottom: $s-5 !important;
}
&-6 {
margin-bottom: $s-6 !important;
}
&-8 {
margin-bottom: $s-8 !important;
}
}
.px {
&-6 {
padding-left: $s-6 !important;
padding-right: $s-6 !important;
}
}
.p {
&-4 {
padding: $s-4 !important;
}
&b-1 {
padding-bottom: $s-1 !important;
}
&b-2 {
padding-bottom: $s-2 !important;
}
}
// Global styles
tbody > td:first-child,
tr > td:first-child {
padding: $s-0 !important;
}
.link-blue {
@extend .text-blue-500;
text-decoration: underline;
font-family: $font-sans;
}
%text-header-common {
@extend .font-sans;
text-align: center !important;
}
%text-body-common {
@extend .font-sans;
}
%banner-common {
max-width: 640px !important;
width: 100% !important;
}
%text-banner-common {
@extend %text-header-common;
@extend .text-xs;
@extend .p-4;
color: $black !important;
font-weight: 700 !important;
}
%link-banner-common {
color: $black !important;
text-decoration: underline !important;
}
.align-left div {
text-align: left !important;
}
.hidden {
display: none;
width: 0;
height: 0;
max-height: 0;
line-height: 0;
overflow: hidden;
}
.text-footer div {
@extend .font-sans;
text-align: center !important;
color: $grey-400 !important;
}
.text-footer-automatedEmail {
@extend .text-footer;
div {
@extend .mb-3;
@extend .mt-10;
}
}

View File

@@ -0,0 +1,43 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { NodeRendererBindings } from './renderer/bindings-node';
import { FxaEmailRenderer } from './renderer';
describe('emails', () => {
it('can render email', async () => {
const r = new FxaEmailRenderer(new NodeRendererBindings());
const email = await r.renderAdminResetAccounts(
{
status: [{ locator: 'foo@mozilla.com', status: 'Success' }],
},
{
logoAltText: 'mock-logo-alt-text',
logoUrl: 'https://mozilla.org/mock-logo-url',
logoWidth: '100px',
privacyUrl: 'https://mozilla.org/mock-privacy-url',
sync: false,
}
);
expect(email).toBeDefined();
expect(email.subject).toEqual('Fxa Admin: Accounts Reset');
expect(email.preview).toEqual('');
expect(email.text).toContain("Here's the account reset status");
expect(email.text).toContain('foo@mozilla.com - Success');
expect(email.text).toContain('Mozilla Accounts Privacy Notice');
expect(email.text).toContain('https://mozilla.org/mock-privacy-url');
expect(email.html).toContain('<title>Fxa Admin: Accounts Reset</title>');
expect(email.html).toContain("Here's the account reset status");
expect(email.html).toContain('foo@mozilla.com');
expect(email.html).toContain('Success');
expect(email.html).toContain('Mozilla Accounts Privacy Notice');
expect(email.html).toContain('https://mozilla.org/mock-privacy-url');
});
// TODO: Port over other tests, FXA-12579
});

View File

@@ -0,0 +1,8 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export * from './renderer';
export * from './renderer/bindings-node';
export * from './bindings';
export * from './templates';

View File

@@ -0,0 +1,179 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { DOMLocalization, Localization } from '@fluent/dom';
import { FluentBundle, FluentResource } from '@fluent/bundle';
import { determineLocale, parseAcceptLanguage } from '@fxa/shared/l10n';
export type LocalizerOpts = {
translations: {
basePath: string;
};
templates: {
cssPath: string;
};
};
export interface ILocalizerBindings {
opts: LocalizerOpts;
fetchResource(path: string): Promise<string>;
renderEjs(ejsTemplate: string, context: any): string;
renderTemplate(
template: string,
context: any,
layout?: string,
target?: ComponentTarget
): Promise<{ text: string; rootElement: Element }>;
}
export type ComponentTarget = 'index' | 'strapi';
export interface ILocalizerBindings {
opts: LocalizerOpts;
fetchResource(path: string): Promise<string>;
renderEjs(ejsTemplate: string, context: any): string;
renderTemplate(
template: string,
context: any,
layout?: string,
target?: ComponentTarget
): Promise<{ text: string; rootElement: Element }>;
}
/**
* Represents a Fluent (FTL) message
* @param id - unique identifier for the message
* @param message - a fallback message in case the localized string cannot be found
* @param vars - optional arguments to be interpolated into the localized string
*/
export interface FtlIdMsg {
id: string;
message: string;
vars?: Record<string, string>;
}
interface LocalizedStrings {
[id: string]: string;
}
class Localizer {
protected readonly bindings: ILocalizerBindings;
constructor(bindings: ILocalizerBindings) {
this.bindings = bindings;
}
protected async fetchMessages(currentLocales: string[]) {
const fetchedPending: Record<string, Promise<string>> = {};
const fetched: Record<string, string> = {};
for (const locale of currentLocales) {
fetchedPending[locale] = this.fetchTranslatedMessages(locale);
}
// All we're doing here is taking `{ localeName: pendingLocaleMessagesPromise }` objects and
// parallelizing the promise resolutions instead of waiting for them to finish syncronously. We
// then return the result in the same `{ localeName: messages }` format for fulfilled promises.
const fetchedLocales = await Promise.allSettled(
Object.keys(fetchedPending).map(async (locale) => ({
locale,
fetchedLocale: await fetchedPending[locale],
}))
);
fetchedLocales.forEach((fetchedLocale) => {
if (fetchedLocale.status === 'fulfilled') {
fetched[fetchedLocale.value.locale] = fetchedLocale.value.fetchedLocale;
}
});
return fetched;
}
protected createBundleGenerator(fetched: Record<string, string>) {
async function* generateBundles(currentLocales: string[]) {
for (const locale of currentLocales) {
const source = fetched[locale];
if (source) {
const bundle = new FluentBundle(locale, {
useIsolating: false,
});
const resource = new FluentResource(source);
bundle.addResource(resource);
yield bundle;
}
}
}
return generateBundles;
}
async getLocalizerDeps(acceptLanguage?: string) {
const currentLocales = parseAcceptLanguage(acceptLanguage || '');
const selectedLocale = determineLocale(acceptLanguage || '');
const messages = await this.fetchMessages(currentLocales);
const generateBundles = this.createBundleGenerator(messages);
return { currentLocales, messages, generateBundles, selectedLocale };
}
async setupDomLocalizer(acceptLanguage?: string) {
const { currentLocales, generateBundles, selectedLocale } =
await this.getLocalizerDeps(acceptLanguage);
const l10n = new DOMLocalization(currentLocales, generateBundles);
return { l10n, selectedLocale };
}
async setupLocalizer(acceptLanguage?: string) {
const { currentLocales, generateBundles, selectedLocale } =
await this.getLocalizerDeps(acceptLanguage);
const l10n = new Localization(currentLocales, generateBundles);
return { l10n, selectedLocale };
}
/**
* Returns the set of translated strings for the specified locale.
* @param locale Locale to use, defaults to en.
*/
protected async fetchTranslatedMessages(locale?: string) {
const results: string[] = [];
// Note: 'en' auth.ftl only exists for browser bindings / Storybook. The fallback
// English strings within the templates are tested and are shown in other envs
const authPath = `${this.bindings.opts.translations.basePath}/${
locale || 'en'
}/auth.ftl`;
results.push(await this.bindings.fetchResource(authPath));
const brandingPath = `${this.bindings.opts.translations.basePath}/${
locale || 'en'
}/branding.ftl`;
results.push(await this.bindings.fetchResource(brandingPath));
return results.join('\n\n\n');
}
async localizeStrings(
acceptLanguage = 'en',
ftlIdMsgs: FtlIdMsg[]
): Promise<LocalizedStrings> {
const { l10n } = await this.setupLocalizer(acceptLanguage);
const localizedFtlIdMsgs = await Promise.all(
ftlIdMsgs.map(async (ftlIdMsg) => {
const { id, message, vars } = ftlIdMsg;
let localizedMessage;
try {
localizedMessage = (await l10n.formatValue(id, vars)) || message;
} catch (e) {
localizedMessage = message;
}
return Promise.resolve({
[id]: localizedMessage,
});
})
);
return Object.assign({}, ...localizedFtlIdMsgs);
}
}
export default Localizer;

View File

@@ -0,0 +1,11 @@
## Email content
## Emails do not contain buttons, only links. Emails have a rich HTML version and a plaintext
## version. The strings are usually identical but sometimes they differ slightly.
fxa-header-mozilla-logo = <img data-l10n-name="mozilla-logo" alt="{ -brand-mozilla } logo">
fxa-header-sync-devices-image = <img data-l10n-name="sync-devices-image" alt="Sync devices">
body-devices-image = <img data-l10n-name="devices-image" alt="Devices">
fxa-privacy-url = { -brand-mozilla } Privacy Policy
moz-accounts-privacy-url-2 = { -product-mozilla-accounts(capitalization:"uppercase") } Privacy Notice
moz-accounts-terms-url = { -product-mozilla-accounts(capitalization:"uppercase") } Terms of Service

View File

@@ -0,0 +1,66 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mjml>
<mj-head>
<mj-raw>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</mj-raw>
<mj-title><%- locals.subject %></mj-title>
<% if (locals.preview) { %>
<mj-preview><%= locals.preview %></mj-preview>
<% } %>
<%- include('/partials/images.mjml') %>
<%- include('/partials/metadata.mjml') %>
</mj-head>
<mj-body>
<mj-include path="<%- locals.cssPath %>/global.css" type="css" css-inline="inline" />
<mj-include path="<%- locals.cssPath %>/fxa/index.css" type="css" css-inline="inline" />
<mj-include path="<%- locals.cssPath %>/locale-dir.css" type="css" />
<%- include('/partials/brandMessaging/index.mjml') %>
<% if (locals.showBannerWarning === true) { %>
<%- include('/partials/bannerWarning/index.mjml') %>
<% } %>
<mj-wrapper css-class="body">
<mj-section>
<mj-column>
<% if (!locals.sync) { %>
<mj-image css-class="mozilla-logo"
width="120px"
src="https://cdn.accounts.firefox.com/product-icons/mozilla-logo.png"
alt="Mozilla logo"
title="Mozilla logo">
</mj-image>
<% } else { %>
<mj-image css-class="sync-img"
width="300px"
src="https://cdn.accounts.firefox.com/other/sync-devices.png"
alt="Sync devices"
title="Sync devices">
</mj-image>
<% } %>
</mj-column>
</mj-section>
<%- body %>
<mj-section>
<mj-column>
<mj-text css-class="text-footer">Mozilla. 149 New Montgomery St, 4th Floor, San Francisco, CA 94105</mj-text>
<mj-text css-class="text-footer">
<a class="link-blue" data-l10n-id="moz-accounts-privacy-url-2" href="<%- privacyUrl %>">Mozilla Accounts Privacy Notice</a>
</mj-text>
<mj-text css-class="text-footer">
<a class="link-blue" data-l10n-id="moz-accounts-terms-url" href="https://www.mozilla.org/about/legal/terms/services/">Mozilla Accounts Terms of Service</a>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>

View File

@@ -0,0 +1,115 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@use '../../global.scss';
.body {
max-width: 310px !important;
margin: global.$s-0 auto !important;
}
.mozilla-logo {
padding: global.$s-10 global.$s-0 !important;
margin: 0 auto !important;
display: block !important;
align-items: center;
}
.sync-img {
padding: global.$s-5 global.$s-0 !important;
height: 137px;
margin: 0 auto !important;
display: block !important;
}
.text-header div {
@extend %text-header-common;
@extend .text-xl;
@extend .mb-3;
}
%text-body-common {
@extend %text-body-common;
@extend .text-sm;
text-align: center !important;
}
.text-body div {
@extend %text-body-common;
@extend .mb-5;
}
.text-body-subtext div {
@extend .text-xs;
@extend .mt-5;
@extend .mb-5;
text-align: center !important;
}
.text-body-no-margin div {
@extend %text-body-common;
}
.text-body-top-margin div {
@extend %text-body-common;
@extend .mt-5;
}
.text-body-lg-bottom-margin div {
@extend %text-body-common;
@extend .mb-8;
}
.text-sub-body div {
@extend %text-body-common;
@extend .mb-3;
}
.text-footer div {
@extend .text-xs;
}
.text-footer-automatedEmail {
@extend .text-footer;
}
.sync-logo img {
margin: 0 auto;
@extend .mb-5;
@extend .mt-2;
}
.graphic-devices img {
margin: 0 auto;
@extend .mb-5;
@extend .mt-2;
}
%code-common {
text-align: center !important;
font-family: monospace;
@extend .mb-6;
}
.code {
&-medium div {
@extend %code-common;
@extend .text-lg;
}
&-large div {
@extend %code-common;
@extend .text-3xl;
}
}
.info-block {
margin: 28px 0 0 0;
background-color: global.$grey-50;
border-radius: 10px;
}
.info-block div {
padding: 20px 32px 0;
}

View File

@@ -0,0 +1,54 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from './mocks';
export default {
title: 'FxA Emails/Layout',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'The FxA email base layout.',
{
sync: false,
subject: 'N/A',
},
includes
);
export const NotThroughSyncFlow = createStory(
{},
'Email not triggered through sync flow'
);
export const ThroughSyncFlow = createStory(
{
sync: true,
},
'Email triggered through sync flow'
);
export const MessagingNotThroughSyncFlowWithBrandMessaging = createStory(
{
brandMessagingMode: 'postlaunch',
},
'Email not triggered through sync flow with brand messaging'
);
export const MessagingThroughSyncFlowWithBrandMessaging = createStory(
{
brandMessagingMode: 'postlaunch',
},
'Email triggered through sync flow with brand messaging'
);
export const FlowWithWarning = createStory(
{
showBannerWarning: true,
},
'Email triggered through web or AMO flow with banner warning'
);

View File

@@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export type TemplateData = {
/** An optional logo url override. This will default to the mozilla logo if not provided. */
logoUrl?: string;
/** An optional logo alt text. This will default to 'Mozilla logo' if not provided. */
logoAltText?: string;
/** An optional logo width. This will default to 120px if not provided. */
logoWidth?: string;
/** The current privacy url. */
privacyUrl: string;
/** Whether or not this is a 'sync' specific email. These emails have a slightly different styling */
sync: boolean;
};

View File

@@ -0,0 +1,14 @@
<%- include('/partials/brandMessaging/index.txt') %>
<% if (locals.showBannerWarning === true) { %>
<%- include('/partials/bannerWarning/index.txt', { accountsEmail:"accounts@firefox.com" }) %>
<% } %>
<%- body %>
Mozilla. 149 New Montgomery St, 4th Floor, San Francisco, CA 94105
moz-accounts-privacy-url-2 = "Mozilla Accounts Privacy Notice"
<%- privacyUrl %>
moz-accounts-terms-url = "Mozilla Accounts Terms of Service"
https://www.mozilla.org/about/legal/terms/services/

View File

@@ -0,0 +1,10 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export const includes = {
subject: {
id: 'mock-fxa-layout-subject',
message: 'Mock Fxa Layout Subject',
},
};

View File

@@ -0,0 +1,59 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mjml>
<mj-head>
<mj-raw>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</mj-raw>
<mj-title><%- locals.subject %></mj-title>
<%- include('/partials/images.mjml') %>
<%- include('/partials/metadata.mjml') %>
</mj-head>
<mj-body>
<mj-include path="<%- locals.cssPath %>/global.css" type="css" css-inline="inline" />
<mj-include path="<%- locals.cssPath %>/fxa/index.css" type="css" css-inline="inline" />
<mj-include path="<%- locals.cssPath %>/locale-dir.css" type="css" />
<mj-wrapper css-class="body">
<mj-section>
<mj-column>
<% if (locals.logoUrl) { %>
<mj-image css-class="mozilla-logo"
width="<%- locals.logoWidth ?? '280px' %>"
src="<%- locals.logoUrl %>"
alt="<%- locals.logoAltText %>"
title="<%- locals.logoAltText %>">
</mj-image>
<% } else { %>
<mj-image css-class="mozilla-logo"
width="120px"
src="https://accounts-static.cdn.mozilla.net/product-icons/mozilla-logo.png"
alt="Mozilla logo"
title="Mozilla logo">
</mj-image>
<% } %>
</mj-column>
</mj-section>
<%- body %>
<mj-section>
<mj-column>
<mj-text css-class="text-footer">Mozilla. 149 New Montgomery St, 4th Floor, San Francisco, CA 94105</mj-text>
<mj-text css-class="text-footer">
<a class="link-blue" data-l10n-id="moz-accounts-privacy-url-2" href="<%- privacyUrl %>">Mozilla Accounts Privacy Notice</a>
</mj-text>
<mj-text css-class="text-footer">
<a class="link-blue" data-l10n-id="moz-accounts-terms-url" href="https://www.mozilla.org/about/legal/terms/services/">Mozilla Accounts Terms of Service</a>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>

View File

@@ -0,0 +1,29 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from './mocks';
export default {
title: 'FxA Emails/Layout',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'The Strapi email layout.',
{
sync: false,
subject: 'N/A',
logoUrl:
'https://accounts-cdn.stage.mozaws.net/product-icons/monitor-logo-email.png',
logoAltText: 'Monitor logo',
logoWidth: '280px',
},
includes,
'fxa',
'strapi'
);
export const CMS = createStory({}, 'CMS customized email');

View File

@@ -0,0 +1,10 @@
<%- body %>
Mozilla. 149 New Montgomery St, 4th Floor, San Francisco, CA 94105
moz-accounts-privacy-url-2 = "Mozilla Accounts Privacy Notice"
<%- privacyUrl %>
moz-accounts-terms-url = "Mozilla Accounts Terms of Service"
https://www.mozilla.org/about/legal/terms/services/

View File

@@ -0,0 +1,36 @@
subplat-header-mozilla-logo-2 = <img data-l10n-name="subplat-mozilla-logo" alt="{ -brand-mozilla } logo">
subplat-footer-mozilla-logo-2 = <img data-l10n-name="mozilla-logo-footer" alt="{ -brand-mozilla } logo">
subplat-automated-email = This is an automated email; if you received it in error, no action is required.
subplat-privacy-notice = Privacy notice
subplat-privacy-plaintext = Privacy notice:
subplat-update-billing-plaintext = { subplat-update-billing }:
# Variables:
# $email (String) - A user's primary email address
# $productName (String) - The name of the subscribed product, e.g. Mozilla VPN
subplat-explainer-specific-2 = Youre receiving this email because { $email } has a { -product-mozilla-account } and you signed up for { $productName }.
# Variables:
# $email (String) - A user's primary email address
subplat-explainer-reminder-form-2 = Youre receiving this email because { $email } has a { -product-mozilla-account }.
subplat-explainer-multiple-2 = Youre receiving this email because { $email } has a { -product-mozilla-account } and you have subscribed to multiple products.
subplat-explainer-was-deleted-2 = Youre receiving this email because { $email } was registered for a { -product-mozilla-account }.
subplat-manage-account-2 = Manage your { -product-mozilla-account } settings by visiting your <a data-l10n-name="subplat-account-page">account page</a>.
# Variables:
# $accountSettingsUrl (String) - URL to Account Settings
subplat-manage-account-plaintext-2 = Manage your { -product-mozilla-account } settings by visiting your account page: { $accountSettingsUrl }
subplat-terms-policy = Terms and cancellation policy
subplat-terms-policy-plaintext = { subplat-terms-policy }:
subplat-cancel = Cancel subscription
subplat-cancel-plaintext = { subplat-cancel }:
subplat-reactivate = Reactivate subscription
subplat-reactivate-plaintext = { subplat-reactivate }:
subplat-update-billing = Update billing information
subplat-privacy-policy = { -brand-mozilla } Privacy Policy
subplat-privacy-policy-2 = { -product-mozilla-accounts(capitalization:"uppercase") } Privacy Notice
subplat-privacy-policy-plaintext = { subplat-privacy-policy }:
subplat-privacy-policy-plaintext-2 = { subplat-privacy-policy-2 }:
subplat-moz-terms = { -product-mozilla-accounts(capitalization:"uppercase") } Terms of Service
subplat-moz-terms-plaintext = { subplat-moz-terms }:
subplat-legal = Legal
subplat-legal-plaintext = { subplat-legal }:
subplat-privacy = Privacy
subplat-privacy-website-plaintext = { subplat-privacy }:

View File

@@ -0,0 +1,116 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mjml>
<mj-head>
<mj-raw>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</mj-raw>
<mj-title><%- locals.subject %></mj-title>
<mj-preview><%- locals.preview %></mj-preview>
<%- include('/partials/images.mjml') %>
<%- include('/partials/metadata.mjml') %>
</mj-head>
<mj-body>
<mj-include path="<%- locals.cssPath %>/global.css" type="css" css-inline="inline" />
<mj-include path="<%- locals.cssPath %>/subscription/index.css" type="css" css-inline="inline" />
<mj-include path="<%- locals.cssPath %>/locale-dir.css" type="css" />
<%- include('/partials/brandMessaging/index.mjml') %>
<mj-wrapper css-class="body">
<mj-section css-class="header-container">
<mj-column>
<mj-image css-class="subplat-mozilla-logo"
width="120px"
align="left"
href="https://www.mozilla.org"
src="https://cdn.accounts.firefox.com/product-icons/mozilla-logo.png"
alt="Mozilla logo"
title="Mozilla logo">
</mj-image>
</mj-column>
</mj-section>
<%- body %>
<mj-section css-class="footer-container">
<mj-column>
<mj-text css-class="footer-text-bottom-margin">
<% if (locals.productName) { %>
<span data-l10n-id="subplat-explainer-specific-2" data-l10n-args="<%= JSON.stringify({ email, productName }) %>">
Youre receiving this email because <%- email %> has a Mozilla account and you signed up for <%- productName %>.
</span>
<% } else if (locals.reminderShortForm) { %>
<span data-l10n-id="subplat-explainer-reminder-form-2" data-l10n-args="<%= JSON.stringify({ email }) %>">
Youre receiving this email because <%- email %> has a Mozilla account.
</span>
<% } else if (locals.wasDeleted) { %>
<span data-l10n-id="subplat-explainer-was-deleted-2" data-l10n-args="<%= JSON.stringify({ email }) %>">
Youre receiving this email because <%- email %> was registered for a Mozilla account.
</span>
<% } else { %>
<span data-l10n-id="subplat-explainer-multiple-2" data-l10n-args="<%= JSON.stringify({ email }) %>">
Youre receiving this email because <%- email %> has a Mozilla account and you have subscribed to multiple products.
</span>
<% } %>
</mj-text>
<% if (!locals.reminderShortForm && !locals.wasDeleted) { %>
<mj-text css-class="footer-text-bottom-margin">
<span data-l10n-id="subplat-manage-account-2">
Manage your Mozilla account settings by visiting your <a href="<%- accountSettingsUrl %>" class="footer-link" data-l10n-name="subplat-account-page">account page</a>.
</span>
</mj-text>
<mj-text css-class="footer-text">
<% if (locals.productName || locals.subscriptions?.length > 0) { %>
<a href="<%- subscriptionTermsUrl %>" class="footer-link" data-l10n-id="subplat-terms-policy">Terms and cancellation policy</a>
&nbsp;&nbsp;&bull;&nbsp;&nbsp;
<a href="<%- subscriptionPrivacyUrl %>" class="footer-link" data-l10n-id="subplat-privacy-notice">Privacy notice</a>
<% } %>
<% if (!locals.isFinishSetup) { %>
&nbsp;&nbsp;&bull;&nbsp;&nbsp;
<% if (locals.isCancellationEmail) { %>
<a href="<%- reactivateSubscriptionUrl %>" class="footer-link" data-l10n-id="subplat-reactivate">Reactivate subscription</a>
&nbsp;&nbsp;&bull;&nbsp;&nbsp;
<% } else { %>
<a href="<%- cancelSubscriptionUrl %>" class="footer-link" data-l10n-id="subplat-cancel">Cancel subscription</a>
&nbsp;&nbsp;&bull;&nbsp;&nbsp;
<% } %>
<a href="<%- updateBillingUrl %>" class="footer-link" data-l10n-id="subplat-update-billing">Update billing information</a>
<% } %>
</mj-text>
<% } else { %>
<mj-text css-class="footer-text">
<a href="<%- privacyUrl %>" class="footer-link" data-l10n-id="subplat-privacy-policy-2">Mozilla Accounts Privacy Notice</a>
&nbsp;&nbsp;&bull;&nbsp;&nbsp;
<a href="<%- subscriptionTermsUrl %>" class="footer-link" data-l10n-id="subplat-moz-terms">Mozilla Accounts Terms of Service</a>
</mj-text>
<% } %>
<mj-image css-class="mozilla-logo-footer"
width="120px"
href="https://www.mozilla.org"
src="https://cdn.accounts.firefox.com/product-icons/mozilla-logo-w.png"
alt="Mozilla logo"
title="Mozilla">
</mj-image>
<mj-text css-class="footer-text-bottom-margin">149 New Montgomery St, 4th Floor, San Francisco, CA 94105</mj-text>
<mj-text css-class="footer-text">
<a href="https://www.mozilla.org/about/legal/terms/services/" class="footer-link" data-l10n-id="subplat-legal">Legal</a>
&nbsp;&nbsp;&bull;&nbsp;&nbsp;
<a href="https://www.mozilla.org/privacy/websites/" class="footer-link" data-l10n-id="subplat-privacy">Privacy</a>
</mj-text>
</mj-column>
</mj-section>
</mg-wrapper>
</mj-body>
</mjml>

View File

@@ -0,0 +1,169 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@use '../../global.scss';
.body {
max-width: 640px !important;
margin: global.$s-0 auto !important;
}
.text-header div {
@extend %text-header-common;
@extend .text-2xl;
@extend .mb-4;
font-weight: 700;
color: global.$grey-600 !important;
}
%text-body-common {
@extend %text-body-common;
@extend .text-md;
color: global.$grey-500 !important;
}
.text-body div {
@extend %text-body-common;
@extend .mb-6;
}
.text-body-mb-4 div {
@extend %text-body-common;
@extend .mb-4;
}
.text-title {
@extend .font-sans;
font-size: 18px !important;
font-weight: 600 !important;
margin-top: 48px !important;
}
.text-title-table div {
@extend .text-title;
@extend .mb-4;
color: global.$grey-500 !important;
}
.text-title-link div {
@extend .text-title;
}
.text-body-top-margin div {
@extend %text-body-common;
@extend .mt-4;
color: global.$grey-500 !important;
padding: global.$s-1 0;
}
.text-link {
@extend %text-body-common;
@extend .mt-2;
color: global.$blue-500 !important;
text-decoration: underline !important;
}
.text-body-link div {
@extend .text-link;
}
.text-body-bottom-link div {
@extend .text-link;
@extend .mb-6;
}
// NOTE: appeared purple in at least one instance/email client in stage
.text-body li {
color: global.$grey-500 !important;
}
.text-body-no-bottom-margin div {
@extend %text-body-common;
@extend .mb-0;
}
.text-body-table-no-bottom-margin td {
@extend %text-body-common;
@extend .mb-0;
padding: global.$s-1 0;
}
.header-container {
max-width: 640px !important;
@extend .mb-6;
}
.subplat-mozilla-logo {
padding: global.$s-5 global.$s-0 !important;
display: block !important;
// overrides the 13px inline font-size default for MJML images
// see https://github.com/mozilla/fxa/pull/12956#discussion_r878556004
a,
img {
font-size: global.$s-3 !important;
}
}
.mozilla-logo-footer {
padding: global.$s-8 global.$s-0 !important;
height: 34px !important;
display: block !important;
margin: 0 auto !important;
}
.footer-container {
background-color: global.$black;
padding: global.$s-10 global.$s-6 !important;
@extend .mt-6;
}
.footer-text div {
@extend .text-sm;
@extend .font-sans;
color: global.$white !important;
text-align: center !important;
}
.footer-text-bottom-margin {
@extend .footer-text;
div {
@extend .mb-3;
}
}
.footer-link {
color: global.$white !important;
}
.primary-button-subplat {
height: 56px !important;
max-width: 310px !important;
a {
@extend .font-sans;
@extend .text-lg;
background: global.$blue-500 !important;
max-width: 310px !important;
color: global.$white;
display: unset !important;
padding: global.$s-3 20px !important;
border-radius: 6px !important;
}
}
.product-icon {
display: block;
margin: 0 auto;
height: 58px;
}
.text-footer div {
@extend .text-sm;
}
.text-footer-automatedEmail {
@extend .text-footer;
}

View File

@@ -0,0 +1,88 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { includes } from './mocks';
import { subplatStoryWithProps } from '../../storybook-email';
const createStory = subplatStoryWithProps(
'_storybook',
'The Subscription Platform email base layout.',
{
subject: 'N/A',
brandMessagingMode: 'none',
},
includes
);
export const LayoutNoProduct = createStory(
{
reminderShortForm: true,
},
'Reminder short form - no specified product'
);
export const LayoutNoProductWithBrandMessaging = createStory(
{
reminderShortForm: true,
brandMessagingMode: 'postlaunch',
},
'Reminder short form - no specified product - with brand messaging'
);
export const LayoutMultipleProducts = createStory(
{
subscriptions: [
{
productName: 'Firefox Fortress',
},
{
productName: 'Mozilla VPN',
},
],
},
'Multiple products - No brand messaging'
);
export const LayoutMultipleProductsWithBrandMessaging = createStory(
{
subscriptions: [
{
productName: 'Firefox Fortress',
},
{
productName: 'Mozilla VPN',
},
],
brandMessagingMode: 'postlaunch',
},
'Multiple products - With brand messaging'
);
export const LayoutWithProduct = createStory(
{
productName: 'Mozilla VPN',
},
'Specific product'
);
export const LayoutWithProductCancellation = createStory(
{
productName: 'Mozilla VPN',
isCancellationEmail: true,
},
'Cancellation email'
);
export const LayoutWithWasDeleted = createStory(
{
wasDeleted: true,
},
'Fraudulent account deletion'
);
export default {
title: 'SubPlat Emails/Layout',
component: LayoutNoProduct,
} as Meta;

View File

@@ -0,0 +1,15 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export type TemplateData = {
email: string;
subscriptionTermsUrl: string;
subscriptionPrivacyUrl: string;
cancelSubscriptionUrl: string;
updateBillingUrl: string;
reactivateSubscriptionUrl: string;
accountSettingsUrl: string;
cancellationSurveyUrl: string;
mozillaSupportUrl: string;
};

View File

@@ -0,0 +1,61 @@
<% if (locals.brandMessagingMode == 'postlaunch') { %>
brand-banner-message = "Did you know we changed our name from Firefox accounts to Mozilla accounts? Learn more"
https://support.mozilla.org/kb/firefox-accounts-renamed-mozilla-accounts
<% } %>
<%- body %>
<% if (!locals.wasDeleted) { %>
subplat-automated-email = "This is an automated email; if you received it in error, no action is required."
<% } %>
<% if (locals.productName) { %>
subplat-explainer-specific-2 = "Youre receiving this email because <%- email %> has a Mozilla account and you signed up for <%- productName %>."
<% } else if (locals.reminderShortForm) { %>
subplat-explainer-reminder-form-2 = "Youre receiving this email because <%- email %> has a Mozilla account."
<% } else if (locals.wasDeleted) { %>
subplat-explainer-was-deleted-2 = "Youre receiving this email because <%- email %> was registered for a Mozilla account."
<% } else { %>
subplat-explainer-multiple-2 = "Youre receiving this email because <%- email %> has a Mozilla account and you have subscribed to multiple products."
<% } %>
<% if (!locals.reminderShortForm && !locals.wasDeleted) { %>
subplat-manage-account-plaintext-2 = "Manage your Mozilla account settings by visiting your account: <%- accountSettingsUrl %>"
<% if (locals.productName || locals.subscriptions?.length > 0) { %>
subplat-terms-policy-plaintext = "Terms and cancellation policy:"
<%- subscriptionTermsUrl %>
subplat-privacy-plaintext = "Privacy notice:"
<%- subscriptionPrivacyUrl %>
<% } %>
<% if (!locals.isFinishSetup && !locals.wasDeleted) { %>
<% if (locals.isCancellationEmail) { %>
subplat-reactivate-plaintext = "Reactivate subscription:"
<%- reactivateSubscriptionUrl %>
<% } else { %>
subplat-cancel-plaintext = "Cancel subscription:"
<%- cancelSubscriptionUrl %>
<% } %>
subplat-update-billing-plaintext = "Update billing information:"
<%- updateBillingUrl %>
<% } %>
<% } else { %>
subplat-privacy-policy-plaintext-2 = "Mozilla Accounts Privacy Notice:"
<%- privacyUrl %>
subplat-moz-terms-plaintext = "Mozilla Accounts Terms Of Service:"
<%- subscriptionTermsUrl %>
<% } %>
Mozilla Corporation
149 New Montgomery St, 4th Floor, San Francisco, CA 94105
subplat-legal-plaintext = "Legal:"
https://www.mozilla.org/about/legal/terms/services/
subplat-privacy-website-plaintext = "Privacy:"
https://www.mozilla.org/privacy/websites/

View File

@@ -0,0 +1,10 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export const includes = {
subject: {
id: 'mock-subscriptions-layout-subject',
message: 'Mock Subscriptions Layout Subject',
},
};

View File

@@ -0,0 +1,17 @@
.ltr {
div {
direction: ltr !important;
}
td {
direction: ltr !important;
}
}
.rtl {
div {
direction: rtl !important;
}
td {
direction: rtl !important;
}
}

View File

@@ -0,0 +1,114 @@
type MjIncludeTag = { path?: string; inline: boolean; type?: string };
/**
* Important! At the current momement, mjml-browser does not support <mj-include>.
* See: https://github.com/mjmlio/mjml/tree/master/packages/mjml-browser#unavailable-features
*
* Until this is supported, we will convert mj-incude tags into mj-style tags with
* ejs includes. The allows parity between mjml and mjml-browser.
* @param mjml mjml document
* @returns mjml document that can be processed by mjml-browser
*/
export function transformMjIncludeTags(mjml: string): string {
// Must be mjml document
const hasOpeningMjmlTag = /<mjml/.test(mjml);
const hasClosingMjmlTag = /<\/mjml>/.test(mjml);
if (!hasOpeningMjmlTag) {
throw new Error('Missing <mjml> tag');
} else if (!hasClosingMjmlTag) {
throw new Error('Missing </mjml> tag');
}
// <mj-style> tags must go in header. Create one if possible,
// otherwise error out.
const hasOpeningMjHeadTag = /<mj-head>/.test(mjml);
const hasClosingMjHeadTag = /<\/mj-head>/.test(mjml);
if (!hasOpeningMjHeadTag && !hasClosingMjHeadTag) {
mjml = mjml.replace('<mjml>', `<mjml> <mj-head> </mj-head> `);
} else if (!hasOpeningMjHeadTag) {
throw new Error('Missing <mj-head> tag');
} else if (!hasClosingMjHeadTag) {
throw new Error('Missing </mj-head> tag');
}
// Parse mjml and build style statements using ejs includes
const includes = extractMjIncludeTags(mjml);
// Append includes to end of header
if (includes.length) {
// Update the header tag, appending the includes
mjml = mjml.replace(
/<\/mj-head>/,
`${includes.map((x) => toMjStyle(x)).join('')}</mj-head>`
);
}
return mjml;
}
function extractMjIncludeTags(mjml: string): MjIncludeTag[] {
let chomp = false;
let include = '';
const includes = new Array<MjIncludeTag>();
mjml
.replace(/<mj-include/g, ' <mj-include')
.split(/\n|\s/g)
.forEach((x) => {
if (chomp && /<mj-/.test(x)) {
throw new Error('Malformed <mj-include> tag');
}
// Keep adding text while, chomping
if (chomp) {
include += x;
}
// Find start tag and begin chomping
else if (/<mj-include/.test(x)) {
include = x;
chomp = true;
}
// If current line has end tag, end chomp
if (/\/>/.test(include)) {
chomp = false;
}
// If done chomping and include tag, parse it
if (include && !chomp) {
includes.push(parseMjIncludeTag(include));
include = '';
}
});
// Inidcates /> is missing
if (chomp) {
throw new Error('Malformed <mj-include> tag');
}
return includes;
}
function parseMjIncludeTag(include: string): MjIncludeTag {
const res = {
path: /path=("[^"]*|'[^']*)/g.exec(include)?.[1]?.substring(1),
inline: /css-inline=("inline"|'inline')/g.test(include),
type: /type=("[^"]*|'[^']*)/g.exec(include)?.[1]?.substring(1),
};
// Convert relative paths. The requests will now be made to the root
// of the webserver.
res.path = res.path?.replace(/\.\//, '/');
return res;
}
function toMjStyle(tag: MjIncludeTag) {
const { inline, path, type } = tag;
if (type !== 'css') return '';
if (!path) return '';
if (inline) {
return `<mj-style inline="inline" > <%- include('${path}') %> </mj-style>`;
}
return `<mj-style> <%- include('${path}') %> </mj-style>`;
}

View File

@@ -0,0 +1,4 @@
account-deletion-info-block-communications = If your account is deleted, youll still receive emails from Mozilla Corporation and Mozilla Foundation, unless you <a data-l10n-name="unsubscribeLink">ask to unsubscribe</a>.
account-deletion-info-block-support = If you have any questions or need assistance, feel free to contact our <a data-l10n-name="supportLink">support team</a>.
account-deletion-info-block-communications-plaintext = If your account is deleted, youll still receive emails from Mozilla Corporation and Mozilla Foundation, unless you ask to unsubscribe:
account-deletion-info-block-support-plaintext = If you have any questions or need assistance, feel free to contact our support team:

View File

@@ -0,0 +1,20 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column css-class="info-block">
<mj-text css-class="text-sub-body">
<span data-l10n-id="account-deletion-info-block-communications">
If your account is deleted, youll still receive emails from Mozilla Corporation and Mozilla Foundation, unless you <a class="link-blue" href="<%- unsubscribeUrl %>" data-l10n-name="unsubscribeLink">ask to unsubscribe</a>.
</span>
</mj-text>
<%# css-class is not supported for mj-divider and styles must be applied inline. Border-color is $grey-100 %>
<mj-divider border-color="#E7E7E7" border-width="1px"></mj-divider>
<mj-text css-class="text-sub-body pb-2">
<span data-l10n-id="account-deletion-info-block-support">
If you have any questions or need assistance, feel free to contact our <a class="link-blue" href="<%- supportUrl %>" data-l10n-name="supportLink">support team</a>.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,24 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from '../mocks';
export default {
title: 'Partials/accountDeletionInfoBlock',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'This partial is used in account deletion emails to provide information and assistance about the deletion process.',
{
layout: null,
subject: 'N/A',
partial: 'accountDeletionInfoBlock',
},
includes
);
export const accountDeletionInfoBlock = createStory();

View File

@@ -0,0 +1,5 @@
account-deletion-info-block-communications-plaintext = "If your account is deleted, youll still receive emails from Mozilla Corporation and Mozilla Foundation, unless you ask to unsubscribe:"
<%- unsubscribeUrl %>
account-deletion-info-block-support-plaintext = "If you have any questions or need assistance, feel free to contact our support team:"
<%- supportUrl %>

View File

@@ -0,0 +1,21 @@
# Variables:
# $productName (String) - The name of the product to be downloaded, e.g. Mozilla VPN, or Firefox
body-android-badge = <img data-l10n-name="google-play-badge" alt="Download { $productName } on { -google-play }">
# Variables:
# $productName (String) - The name of the product to be downloaded, e.g. Mozilla VPN, or Firefox
body-ios-badge = <img data-l10n-name="apple-app-badge" alt="Download { $productName } on the { -app-store }">
# Variables:
# $productName (String) - The name of the product to be downloaded, e.g. Mozilla VPN, or Firefox
another-desktop-device-2 = Install { $productName } on <a data-l10n-name="anotherDeviceLink">another desktop device</a>.
# Variables:
# $productName (String) - The name of the product to be downloaded, e.g. Mozilla VPN, or Firefox
another-device-2 = Install { $productName } on <a data-l10n-name="anotherDeviceLink">another device</a>.
# Variables:
# $productName (String) - The name of the product to be downloaded, e.g. Mozilla VPN, or Firefox
android-download-plaintext = Get { $productName } on Google Play:
# Variables:
# $productName (String) - The name of the product to be downloaded, e.g. Mozilla VPN, or Firefox
ios-download-plaintext = Download { $productName } on the App Store:
# Variables:
# $productName (String) - The name of the product to be downloaded, e.g. Mozilla VPN, or Firefox
another-device-plaintext = Install { $productName } on another device:

View File

@@ -0,0 +1,55 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-include path="<%- locals.cssPath %>/appBadges/index.css" type="css" css-inline="inline" />
<% locals.iosUrl = locals.iosUrl || locals.appStoreLink %>
<% locals.androidUrl = locals.androidUrl || locals.playStoreLink %>
<mj-section>
<mj-group css-class="app-badges">
<% if (locals.iosUrl) { %>
<mj-column>
<mj-image
width="152px"
css-class="app-badge app-badge-ios"
href="<%- iosUrl %>"
src="https://cdn.accounts.firefox.com/product-icons/apple-app-store.png"
alt="Download <%- locals.productName %> on the App Store"
>
</mj-image>
</mj-column>
<% } %>
<% if (locals.androidUrl) { %>
<mj-column>
<mj-image
width="152px"
css-class="app-badge app-badge-android"
href="<%- androidUrl %>"
src="https://cdn.accounts.firefox.com/product-icons/google-play.png"
alt="Download <%- locals.productName %> on Google Play"
>
</mj-image>
</mj-column>
<% } %>
</mj-group>
</mj-section>
<% if (!locals.hideDeviceLink) { %>
<mj-section>
<mj-column>
<% if (locals.onDesktopOrTabletDevice) { %>
<mj-text css-class="text-footer-appBadges"><span data-l10n-id="another-desktop-device-2" data-l10n-args="<%= JSON.stringify({ productName }) %>"> Install <%- locals.productName %> on
<a href="<%- desktopLink %>" class="link-blue" data-l10n-name="anotherDeviceLink">another desktop device</a></span>
</mj-text>
<% } else { %>
<mj-text css-class="text-footer-appBadges"><span data-l10n-id="another-device-2" data-l10n-args="<%= JSON.stringify({ productName }) %>"> Install <%- locals.productName %>
on <a href="<%- link %>" class="link-blue" data-l10n-name="anotherDeviceLink">another device</a></span>
</mj-text>
<% } %>
</mj-column>
</mj-section>
<% } %>

View File

@@ -0,0 +1,23 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@use '../../global.scss';
.app-badges {
max-width: 310px !important;
text-align: center !important;
}
.app-badge {
padding: global.$s-10 global.$s-0 global.$s-5 !important;
img {
height: 44px !important;
margin: 0 auto !important;
}
}
.text-footer-appBadges {
@extend .text-footer;
}

View File

@@ -0,0 +1,8 @@
ios-download-plaintext = "Download <%- productName %> on the App Store:"
<%- locals.iosUrl %>
android-download-plaintext = "Get <%- productName %> on Google Play:"
<%- locals.androidUrl %>
another-device-plaintext = "Install <%- productName %> on another device:"
<%- locals.link %>

View File

@@ -0,0 +1,6 @@
automated-email-change-2 = If you didnt take this action, <a data-l10n-name="passwordChangeLink">change your password</a> right away.
automated-email-support = For more info, visit <a data-l10n-name="supportLink">{ -brand-mozilla } Support</a>.
# After the colon, there's a link to https://accounts.firefox.com/settings/change_password
automated-email-change-plaintext-2 = If you didnt take this action, change your password right away:
# After the colon, there's a link to https://support.mozilla.org/kb/im-having-problems-my-firefox-account
automated-email-support-plaintext = For more info, visit { -brand-mozilla } Support:

View File

@@ -0,0 +1,16 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column>
<mj-text css-class="text-footer-automatedEmail">
<span data-l10n-id="automated-email-change-2">
If you didnt take this action, <a class="link-blue" href="<%- passwordChangeLink %>" data-l10n-name="passwordChangeLink">change your password</a> right away.
</span>
<span data-l10n-id="automated-email-support">
For more info, visit <a class="link-blue" href="<%- supportUrl %>" data-l10n-name="supportLink">Mozilla Support</a>.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from '../mocks';
export default {
title: 'Partials/footers/automatedEmailChangePassword',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'This partial is used in footers for automated emails recommending a password change.',
{
layout: null,
subject: 'N/A',
partial: 'automatedEmailChangePassword',
passwordChangeLink: 'http://localhost:3030/settings/change_password',
},
includes
);
export const AutomatedEmailChangePassword = createStory();

View File

@@ -0,0 +1,5 @@
automated-email-change-plaintext-2 = "If you didnt take this action, change your password right away:"
<%- passwordChangeLink %>
automated-email-support-plaintext = "For more info, visit Mozilla Support:"
<%- supportUrl %>

View File

@@ -0,0 +1 @@
automated-email-inactive-account = This is an automated email. You are receiving it because you have a { -product-mozilla-account } and it has been 2 years since your last sign-in.

View File

@@ -0,0 +1,13 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column>
<mj-text css-class="text-footer-automatedEmail">
<span data-l10n-id="automated-email-inactive-account">
This is an automated email. You are receiving it because you have a Mozilla account and it has been 2 years since your last sign-in.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,24 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from '../mocks';
export default {
title: 'Partials/footers/automatedEmailInactiveAccount',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'This partial is used in footers for automated inactive account email notifications.',
{
layout: null,
subject: 'N/A',
partial: 'automatedEmailInactiveAccount',
},
includes
);
export const AutomatedEmailInactiveAccount = createStory();

View File

@@ -0,0 +1 @@
automated-email-inactive-account = "This is an automated email. You are receiving it because you have a Mozilla account and it has been 2 years since your last sign-in."

View File

@@ -0,0 +1,3 @@
# supportLink - https://support.mozilla.org/kb/im-having-problems-my-firefox-account
automated-email-no-action = { automated-email-no-action-plaintext } For more info, visit <a data-l10n-name="supportLink">{ -brand-mozilla } Support</a>.
automated-email-no-action-plaintext = This is an automated email. If you received it by mistake, you dont need to do anything.

View File

@@ -0,0 +1,14 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column>
<mj-text css-class="text-footer-automatedEmail">
<span data-l10n-id="automated-email-no-action">
This is an automated email. If you received it by mistake, you dont need to do anything. For more info, visit
<a class="link-blue" href="<%- supportUrl %>" data-l10n-name="supportLink">Mozilla Support</a>.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,24 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from '../mocks';
export default {
title: 'Partials/footers/automatedEmailNoAction',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'This partial is used in footers for automated emails where no action is recommended.',
{
layout: null,
subject: 'N/A',
partial: 'automatedEmailNoAction',
},
includes
);
export const AutomatedEmailNoAction = createStory();

View File

@@ -0,0 +1 @@
automated-email-no-action-plaintext = "This is an automated email. If you received it by mistake, you dont need to do anything."

View File

@@ -0,0 +1,2 @@
# After the colon, there's a link to https://accounts.firefox.com/settings/change_password
automated-email-not-authorized-plaintext = This is an automated email; if you did not authorize this action, then please change your password:

View File

@@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from '../mocks';
export default {
title: 'Partials/footers/automatedEmailNotAuthorized',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'This partial is used in footers for automated emails - text format only',
{
layout: null,
subject: 'N/A',
partial: 'automatedEmailNotAuthorized',
passwordChangeLink: 'http://localhost:3030/settings/change_password',
},
includes
);
export const AutomatedEmailNotAuthorized = createStory();

View File

@@ -0,0 +1,2 @@
automated-email-not-authorized-plaintext = "This is an automated email; if you did not authorize this action, then please change your password:"
<%- passwordChangeLink %>

View File

@@ -0,0 +1,44 @@
# "This request" refers to a modification (addition, change or removal) to the account recovery key.
# Variables:
# - $uaBrowser: the user agent's browser (e.g., Firefox Nightly)
# - $uaOS: the user agent's operating system (e.g, MacOS)
# - $uaOSVersion - the user agent's operating system version
automatedEmailRecoveryKey-origin-device-all = This request came from { $uaBrowser } on { $uaOS } { $uaOSVersion }.
# "This request" refers to a modification (addition, change or removal) to the account recovery key.
# Variables:
# - $uaBrowser: the user agent's browser (e.g., Firefox Nightly)
# - $uaOS: the user agent's operating system (e.g, MacOS)
automatedEmailRecoveryKey-origin-device-browser-os = This request came from { $uaBrowser } on { $uaOS }.
# "This request" refers to a modification (addition, change or removal) to the account recovery key.
# Variables:
# - $uaBrowser: the user agent's browser (e.g., Firefox Nightly)
automatedEmailRecoveryKey-origin-device-browser-only = This request came from { $uaBrowser }.
# "This request" refers to a modification (addition, change or removal) to the account recovery key.
# Variables:
# - $uaOS: the user agent's operating system (e.g, MacOS)
# - $uaOSVersion - the user agent's operating system version
automatedEmailRecoveryKey-origin-device-OS-version-only = This request came from { $uaOS } { $uaOSVersion }.
# "This request" refers to a modification (addition, change or removal) to the account recovery key.
# Variables:
# - $uaOS: the user agent's operating system (e.g, MacOS)
automatedEmailRecoveryKey-origin-device-OS-only = This request came from { $uaOS }.
automatedEmailRecoveryKey-delete-key-change-pwd = If this wasnt you, <a data-l10n-name="revokeAccountRecoveryLink">delete the new key</a> and <a data-l10n-name="passwordChangeLink">change your password</a>.
automatedEmailRecoveryKey-change-pwd-only = If this wasnt you, <a data-l10n-name="passwordChangeLink">change your password</a>.
automatedEmailRecoveryKey-more-info = For more info, visit <a data-l10n-name="supportLink">{ -brand-mozilla } Support</a>.
# Colon is followed by user device info on a separate line (e.g., "Firefox Nightly on Mac OSX 10.11")
automatedEmailRecoveryKey-origin-plaintext = This request came from:
# Colon is followed by a URL to the account recovery key section of account settings
automatedEmailRecoveryKey-notyou-delete-key-plaintext = If this wasnt you, delete the new key:
# Colon is followed by a URL to the change password section of account settings
automatedEmailRecoveryKey-notyou-change-pwd-only-plaintext = If this wasnt you, change your password:
# This string is shown on its own line, after automatedEmailRecoveryKey-notyou-delete-key-plaintext and its URL
# Colon is followed by a URL to the change password section of account settings
automatedEmailRecoveryKey-notyou-change-pwd-plaintext = and change your password:
# Colon is followed by a URL to Mozilla Support's "I'm having problems with my account" page
automatedEmailRecoveryKey-more-info-plaintext = For more info, visit { -brand-mozilla } Support:

View File

@@ -0,0 +1,45 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column>
<mj-text css-class="text-body-subtext">
<% if (locals.device) { %>
<%# Request origin will only be shown if device info is available - otherwise it will be omitted %>
<% const { uaBrowser, uaOS, uaOSVersion } = device; %>
<% if (uaBrowser) { %>
<% if (uaOS) { %>
<% if (uaOSVersion) { %>
<span data-l10n-id="automatedEmailRecoveryKey-origin-device-all" data-l10n-args="<%= JSON.stringify({uaBrowser, uaOS, uaOSVersion}) %>"><%- `This request came from ${uaBrowser} on ${uaOS} ${uaOSVersion}.` %></span>
<% } else { %>
<span data-l10n-id="automatedEmailRecoveryKey-origin-device-browser-os" data-l10n-args="<%= JSON.stringify({uaBrowser, uaOS})%>"><%- `This request came from ${uaBrowser} on ${uaOS}.` %></span>
<% } %>
<% } else { %>
<span data-l10n-id="automatedEmailRecoveryKey-origin-device-browser-only" data-l10n-args="<%= JSON.stringify({uaBrowser})%>"><%- `This request came from ${uaBrowser}.` %></span>
<% } %>
<% } else if (uaOS) { %>
<% if (uaOSVersion) { %>
<span data-l10n-id="automatedEmailRecoveryKey-origin-device-OS-version-only" data-l10n-args="<%= JSON.stringify({uaOS, uaOSVersion})%>"><%- `This request came from ${uaOS} ${uaOSVersion}.` %></span>
<% } else { %>
<span data-l10n-id="automatedEmailRecoveryKey-origin-device-OS-only" data-l10n-args="<%= JSON.stringify({uaOS})%>"><%- `This request came from ${uaOS}.` %></span>
<% } %>
<% } %>
<% } %>
<% if (locals.keyExists === true ) { %>
<span data-l10n-id="automatedEmailRecoveryKey-delete-key-change-pwd">
If this wasnt you, <a href="<%- revokeAccountRecoveryLink %>" css-class="link-blue" data-l10n-name="revokeAccountRecoveryLink">delete the new key</a> and <a href="<%- passwordChangeLink %>" css-class="link-blue" data-l10n-name="passwordChangeLink">change your password</a>.
</span>
<% } else { %>
<span data-l10n-id="automatedEmailRecoveryKey-change-pwd-only">
If this wasnt you, <a href="<%- passwordChangeLink %>" css-class="link-blue" data-l10n-name="passwordChangeLink">change your password</a>.
</span>
<% } %>
<span data-l10n-id="automatedEmailRecoveryKey-more-info">
For more info, visit <a href="<%- supportUrl %>" css-class="link-blue" data-l10n-name="supportLink">Mozilla Support</a>.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,44 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { MOCK_DEVICE_ALL } from '../userDevice/mocks';
import { includes } from '../mocks';
export default {
title: 'Partials/footers/automatedEmailRecoveryKey',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'This partial is used in footers for automated emails when the action involved an account recovery key.',
{
layout: null,
subject: 'N/A',
partial: 'automatedEmailRecoveryKey',
passwordChangeLink: 'http://localhost:3030/settings/change_password',
},
includes
);
export const AutomatedEmailRecoveryKey = createStory(
{},
'When no recovery key exists for the account.'
);
export const AutomatedEmailRecoveryKeyExists = createStory(
{
keyExists: true,
revokeAccountRecoveryLink: 'http://localhost:3030/settings/#recovery-key',
},
'When recovery key exists for the account.'
);
export const AutomatedEmailRecoveryKeyInclDeviceInfo = createStory(
{
device: MOCK_DEVICE_ALL,
},
'With device information.'
);

View File

@@ -0,0 +1,23 @@
<%# Request origin will only be shown if device info is available - otherwise it will be omitted %>
<% if (locals.device) { %>
automatedEmailRecoveryKey-origin-plaintext = "This request came from:"
<% const device = include('/partials/userDevice/index.txt') %><%- device.trim() %>
<% } %>
<% if (locals.keyExists === true) { %>
automatedEmailRecoveryKey-notyou-delete-key-plaintext = "If this wasnt you, delete the new key:"
<%- revokeAccountRecoveryLink %>
automatedEmailRecoveryKey-notyou-change-pwd-plaintext = "and change your password:"
<%- passwordChangeLink %>
<% } else { %>
automatedEmailRecoveryKey-notyou-change-pwd-only-plaintext = "If this wasnt you, change your password:"
<%- passwordChangeLink %>
<% } %>
automatedEmailRecoveryKey-more-info-plaintext = "For more info, visit Mozilla Support:"
<%- supportUrl %>

View File

@@ -0,0 +1,5 @@
automated-email-reset = This is an automated email; if you did not authorize this action, then <a data-l10n-name="resetLink">please reset your password</a>.
For more information, please visit <a data-l10n-name="supportLink">{ -brand-mozilla } Support</a>.
# Variables:
# $resetLink (String) - Link to https://accounts.firefox.com/reset_password
automated-email-reset-plaintext-v2 = If you did not authorize this action, please reset your password now at { $resetLink }

View File

@@ -0,0 +1,16 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column>
<mj-text css-class="text-footer-automatedEmail">
<span data-l10n-id="automated-email-reset">
This is an automated email; if you did not authorize this action, then
<a class="link-blue" href="<%- resetLink %>" data-l10n-name="resetLink">please reset your password</a>.
For more information, please visit
<a class="link-blue" href="<%- supportUrl %>" data-l10n-name="supportLink">Mozilla Support</a>.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from '../mocks';
export default {
title: 'Partials/footers/automatedEmailResetPassword',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'This partial is used in footers for automated emails where password reset is recommended.',
{
layout: null,
subject: 'N/A',
partial: 'automatedEmailResetPassword',
resetLink: 'http://localhost:3030/reset_password',
},
includes
);
export const AutomatedEmailResetPassword = createStory();

View File

@@ -0,0 +1,4 @@
automated-email-reset-plaintext-v2 = "If you did not authorize this action, please reset your password now at <%- resetLink %>"
automated-email-support-plaintext = "For more info, visit Mozilla Support:"
<%- supportUrl %>

View File

@@ -0,0 +1,8 @@
# This message is used by multiple automated emails that notify users of security events on their account
# "this action" is meant to be a generic term, and could, for example, refer to using a backup authentication code to confirm a password reset
automated-email-reset-pwd-two-factor = If you didnʼt take this action, then <a data-l10n-name="resetLink">reset your password</a> and <a data-l10n-name="twoFactorSettingsLink">reset two-step authentication</a> right away.
For more information, please visit <a data-l10n-name="supportLink">{ -brand-mozilla } Support</a>.
# Followed by link to https://accounts.firefox.com/reset_password
automated-email-reset-pwd-plaintext-v3 = If you didnʼt take this action, then reset your password right away at:
# Followed by link to https://accounts.firefox.com/settings#two-step-authentication
automated-email-reset-two-factor-plaintext = Also, reset two-step authentication at:

View File

@@ -0,0 +1,17 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column>
<mj-text css-class="text-footer-automatedEmail">
<span data-l10n-id="automated-email-reset-pwd-two-factor">
If you didnʼt take this action, then
<a class="link-blue" href="<%- resetLink %>" data-l10n-name="resetLink">reset your password</a> and
<a class="link-blue" href="<%- twoFactorSettingsLink %>" data-l10n-name="twoFactorSettingsLink">reset two-step authentication</a> right away.
For more information, please visit
<a class="link-blue" href="<%- supportUrl %>" data-l10n-name="supportLink">Mozilla Support</a>.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,27 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Meta } from '@storybook/html';
import { storyWithProps } from '../../storybook-email';
import { includes } from '../mocks';
export default {
title: 'Partials/footers/automatedEmailResetPasswordTwoFactor',
} as Meta;
const createStory = storyWithProps(
'_storybook',
'This partial is used in footers for automated emails where password reset is recommended.',
{
layout: null,
subject: 'N/A',
partial: 'automatedEmailResetPasswordTwoFactor',
resetLink: 'http://localhost:3030/reset_password',
twoFactorSettingsLink:
'http://localhost:3030/settings#two-step-authentication',
},
includes
);
export const AutomatedEmailResetPassword = createStory();

View File

@@ -0,0 +1,8 @@
automated-email-reset-pwd-plaintext-v3 = "If you didnʼt take this action, then reset your password right away at:"
<%- resetLink %>
automated-email-reset-two-factor-plaintext = "Also, reset two-step authentication at:"
<%- twoFactorSettingsLink %>
automated-email-support-plaintext = "For more info, visit Mozilla Support:"
<%- supportUrl %>

View File

@@ -0,0 +1,4 @@
# $accountsEmail is the Mozilla accounts sender email address (e.g. accounts@firefox.com)
banner-warning-message = { -brand-firefox } add-on developers have been targeted by phishing email attacks recently. Well only send emails about your { -product-mozilla-account } from <a data-l10n-name="accountsEmailLink">{ $accountsEmail }</a>.
banner-warning-message-plaintext = { -brand-firefox } add-on developers have been targeted by phishing email attacks recently. Well only send emails about your { -product-mozilla-account } from this email address:
banner-warning-check = Check to make sure the device and location you signed in to is correct.

View File

@@ -0,0 +1,24 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-include path="<%- locals.cssPath %>/bannerWarning/index.css" type="css" css-inline="inline" />
<mj-section css-class="banner-warning-container">
<mj-column>
<mj-text css-class="banner-warning-text pb-1">
<!-- Note, we would prefer not to have this mailto link but email clients will add it
automatically regardless. Since we need custom styling that must be inlined when there
is a link, we add it ourselves for styling consistency. -->
<span data-l10n-id="banner-warning-message" data-l10n-args="<%= JSON.stringify({accountsEmail: "accounts@firefox.com"})%>">
Firefox add-on developers have been targeted by phishing email attacks recently. Well only send emails about your Mozilla account from <a href="mailto:accounts@firefox.com" data-l10n-name="accountsEmailLink">accounts@firefox.com</a>.
</span>
</mj-text>
<mj-text css-class="banner-warning-text">
<span data-l10n-id="banner-warning-check">
Check to make sure the device and location you signed in to is correct.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,22 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@use '../../global.scss';
.banner-warning-container {
@extend %banner-common;
@extend .p-4;
background-color: global.$purple-600;
}
.banner-warning-text > div {
@extend %text-header-common;
@extend .text-xs;
color: global.$white !important;
}
.banner-warning-text div span a {
text-decoration: none !important;
color: global.$white !important;
}

View File

@@ -0,0 +1,3 @@
banner-warning-message-plaintext = "Firefox add-on developers have been targeted by phishing email attacks recently. Well only send emails about your Mozilla account from this email address:"
<%- accountsEmail %>
banner-warning-check = "Check to make sure the device and location you signed in to is correct."

View File

@@ -0,0 +1 @@
brand-banner-message = Did you know we changed our name from { -product-firefox-accounts } to { -product-mozilla-accounts }? <a data-l10n-name="learnMore">Learn more</a>

View File

@@ -0,0 +1,19 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<% if (locals.brandMessagingMode == 'postlaunch') { %>
<mj-include path="<%- locals.cssPath %>/brandMessaging/index.css" type="css" css-inline="inline" />
<mj-section css-class="brand-message-container">
<mj-column>
<mj-text css-class="brand-message-text">
<span data-l10n-id="brand-banner-message">
Did you know we changed our name from Firefox accounts to Mozilla accounts?
<a href="https://support.mozilla.org/kb/firefox-accounts-renamed-mozilla-accounts" class="link-blue" data-l10n-name="learnMore">Learn more</a>
</span>
</mj-text>
</mj-column>
</mj-section>
<% } %>

View File

@@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@use '../../global.scss';
.brand-message-container {
@extend %banner-common;
background: linear-gradient(
88.76deg,
#e4eaf6 3.37%,
#dbeef8 39.93%,
#daf3f4 65.09%,
#e3f6ed 102.21%
);
background-color: #dbeef8;
}
.brand-message-text > div {
@extend %text-banner-common;
}
.brand-message-text div span a {
@extend %link-banner-common;
}

View File

@@ -0,0 +1,6 @@
<% if (locals.brandMessagingMode == 'postlaunch') { %>
brand-banner-message = "Did you know we changed our name from Firefox accounts to Mozilla accounts? Learn more"
https://support.mozilla.org/kb/firefox-accounts-renamed-mozilla-accounts
<% } %>

View File

@@ -0,0 +1,13 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-include path="<%- locals.cssPath %>/button/index.css" type="css" css-inline="inline" />
<mj-section>
<mj-column css-class="<%= locals.cssClass || undefined %>">
<mj-button css-class="primary-button" href="<%- link %>">
<span data-l10n-id="<%- locals.buttonL10nId %>"><%- locals.buttonText %></span>
</mj-button>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
@use '../../global.scss';
.primary-button {
height: 56px !important;
max-width: 310px !important;
a {
@extend .font-sans;
@extend .text-lg;
background: global.$blue-500 !important;
max-width: 310px !important;
color: global.$white;
padding: global.$s-2 global.$s-4 !important;
border-radius: 4px !important;
}
}

View File

@@ -0,0 +1,3 @@
cancellationSurvey = Please help us improve our services by taking this <a data-l10n-name="cancellationSurveyUrl">short survey</a>.
# After the colon, there's a link to https://survey.alchemer.com/s3/6534408/Privacy-Security-Product-Cancellation-of-Service-Q4-21
cancellationSurvey-plaintext = Please help us improve our services by taking this short survey:

View File

@@ -0,0 +1,13 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section>
<mj-column>
<mj-text css-class="text-body">
<span data-l10n-id="cancellationSurvey">
Please help us improve our services by taking this <a data-l10n-name="cancellationSurveyUrl" href="<%- cancellationSurveyUrl %>">short survey</a>.
</span>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,2 @@
cancellationSurvey-plaintext = "Please help us improve our services by taking this short survey:"
<%- cancellationSurveyUrl %>

View File

@@ -0,0 +1 @@
change-password-plaintext = If you suspect that someone is trying to gain access to your account, please change your password.

View File

@@ -0,0 +1,2 @@
change-password-plaintext = "If you suspect that someone is trying to gain access to your account, please change your password."
<%- passwordChangeLink %>

View File

@@ -0,0 +1,18 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-section css-class="mb-8">
<% if (locals.productIconURLNew) { %>
<mj-column>
<mj-image width="58px" src="<%- productIconURLNew %>" alt="<%- productName %>" title="<%- productName %>"
css-class="product-icon">
</mj-image>
</mj-column>
<% } else { %>
<mj-column>
<mj-image width="58px" src="<%- icon %>" alt="<%- productName %>" title="<%- productName %>"
css-class="product-icon"></mj-image>
</mj-column>
<% } %>
</mj-section>

View File

@@ -0,0 +1,63 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<% if (!locals.productName) { locals.productName = 'Firefox' %><% } %>
<mj-html-attributes>
<mj-selector path=".mozilla-logo td">
<mj-html-attribute name="data-l10n-id">fxa-header-mozilla-logo</mj-html-attribute>
</mj-selector>
<mj-selector path=".mozilla-logo img">
<mj-html-attribute name="data-l10n-name">mozilla-logo</mj-html-attribute>
</mj-selector>
<mj-selector path=".sync-img td">
<mj-html-attribute name="data-l10n-id">fxa-header-sync-devices-image</mj-html-attribute>
</mj-selector>
<mj-selector path=".sync-img img">
<mj-html-attribute name="data-l10n-name">sync-devices-image</mj-html-attribute>
</mj-selector>
<mj-selector path=".subplat-mozilla-logo a">
<mj-html-attribute name="data-l10n-id">subplat-header-mozilla-logo-2</mj-html-attribute>
</mj-selector>
<mj-selector path=".subplat-mozilla-logo img">
<mj-html-attribute name="data-l10n-name">subplat-mozilla-logo</mj-html-attribute>
</mj-selector>
<mj-selector path=".mozilla-logo-footer a">
<mj-html-attribute name="data-l10n-id">subplat-footer-mozilla-logo-2</mj-html-attribute>
</mj-selector>
<mj-selector path=".mozilla-logo-footer img">
<mj-html-attribute name="data-l10n-name">mozilla-logo-footer</mj-html-attribute>
</mj-selector>
<mj-selector path=".graphic-devices td">
<mj-html-attribute name="data-l10n-id">body-devices-image</mj-html-attribute>
</mj-selector>
<mj-selector path=".graphic-devices img">
<mj-html-attribute name="data-l10n-name">devices-image</mj-html-attribute>
</mj-selector>
<mj-selector path=".sync-logo td">
<mj-html-attribute name="data-l10n-id">body-devices-image</mj-html-attribute>
</mj-selector>
<mj-selector path=".sync-logo img">
<mj-html-attribute name="data-l10n-name">devices-image</mj-html-attribute>
</mj-selector>
<mj-selector path=".app-badge-android a">
<mj-html-attribute name="data-l10n-id">body-android-badge</mj-html-attribute>
<mj-html-attribute name="data-l10n-args"><%= JSON.stringify({productName}) %></mj-html-attribute>
</mj-selector>
<mj-selector path=".app-badge-android img">
<mj-html-attribute name="data-l10n-name">google-play-badge</mj-html-attribute>
</mj-selector>
<mj-selector path=".app-badge-ios a">
<mj-html-attribute name="data-l10n-id">body-ios-badge</mj-html-attribute>
<mj-html-attribute name="data-l10n-args"><%= JSON.stringify({productName}) %></mj-html-attribute>
</mj-selector>
<mj-selector path=".app-badge-ios img">
<mj-html-attribute name="data-l10n-name">apple-app-badge</mj-html-attribute>
</mj-selector>
</mj-html-attributes>

View File

@@ -0,0 +1,2 @@
manage-account = Manage account
manage-account-plaintext = { manage-account }:

View File

@@ -0,0 +1,2 @@
manage-account-plaintext = "Manage account:"
<%- link %>

View File

@@ -0,0 +1,26 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-raw>
<% if (locals.oneClickLink) { %>
<script type="application/ld+json">
{
"@context": "http://schema.org",
"@type": "EmailMessage",
"description": "<%- locals.subject %>",
"potentialAction": {
"@type": "ViewAction",
"name": "<%- locals.action %>",
"target": "<%- oneClickLink %>",
"url": "<%- oneClickLink %>"
},
"publisher": {
"@type": "Organization",
"name": "Mozilla accounts",
"url": "https://accounts.firefox.com/"
}
}
</script>
<% } %>
</mj-raw>

View File

@@ -0,0 +1,10 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export const includes = {
subject: {
id: 'mock-id',
message: 'Mock Subject',
},
};

View File

@@ -0,0 +1,11 @@
payment-details = Payment details:
# Variables:
# $invoiceNumber (String) - The invoice number of the subscription invoice, e.g. 8675309
payment-plan-invoice-number = Invoice Number: { $invoiceNumber }
# Variables:
# $invoiceDateOnly (String) - The date of the invoice, e.g. 01/20/2016
# $invoiceTotal (String) - The amount of the subscription invoice, including currency, e.g. $10.00
payment-plan-charged = Charged: { $invoiceTotal } on { $invoiceDateOnly }
# Variables
# $nextInvoiceDateOnly (String) - The date of the next invoice, e.g. 01/20/2016
payment-plan-next-invoice = Next Invoice: { $nextInvoiceDateOnly }

View File

@@ -0,0 +1,30 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<mj-text css-class="text-body">
<b data-l10n-id="payment-details">Payment details:</b>
<ul>
<li data-l10n-args="<%= JSON.stringify({productName}) %>">
<%- productName %>
</li>
<% if (locals.invoiceNumber) { %>
<li data-l10n-id="payment-plan-invoice-number" data-l10n-args="<%= JSON.stringify({invoiceNumber}) %>">
Invoice Number: <%- invoiceNumber %>
</li>
<% } %>
<% if (locals.invoiceDateOnly && locals.invoiceTotal) { %>
<li data-l10n-id="payment-plan-charged" data-l10n-args="<%= JSON.stringify({invoiceDateOnly, invoiceTotal}) %>">
Charged: <%- invoiceTotal %> on <%- invoiceDateOnly %>
</li>
<% } %>
<% if (locals.nextInvoiceDateOnly) { %>
<li data-l10n-id="payment-plan-next-invoice" data-l10n-args="<%= JSON.stringify({nextInvoiceDateOnly}) %>">
Next Invoice: <%- nextInvoiceDateOnly %>
</li>
<% } %>
</ul>
</mj-text>

View File

@@ -0,0 +1,6 @@
payment-details = "Payment details:"
<% if (locals.productName) { %><%- productName %><% } %>
<% if (locals.invoiceNumber) { %>payment-plan-invoice-number = "Invoice Number: <%- invoiceNumber %>"<% } %>
<% if (locals.invoiceDateOnly && locals.invoiceTotal) { %>payment-plan-charged = "Charged: <%- invoiceTotal %> on <%- invoiceDateOnly %>"<% } %>
<% if (locals.nextInvoiceDateOnly) { %>payment-plan-next-invoice = "Next Invoice: <%- nextInvoiceDateOnly %>"<% } %>

View File

@@ -0,0 +1,14 @@
## $paymentProviderName (String) - The brand name of the payment method, e.g. PayPal, Apple Pay, Google Pay, Link
payment-method-payment-provider = <b>Payment method:</b> { $paymentProviderName }
payment-method-payment-provider-plaintext = Payment method: { $paymentProviderName }
## This string displays when the type of credit card is known
## https://stripe.com/docs/payments/cards/supported-card-brands
## Variables:
## $cardName (String) - The brand name of the credit card, e.g. American Express
## $lastFour (String) - The last four digits of the credit card, e.g. 5309
payment-provider-card-name-ending-in-plaintext = Payment method: { $cardName } ending in { $lastFour }
payment-provider-card-ending-in-plaintext = Payment method: Card ending in { $lastFour }
payment-provider-card-ending-in = <b>Payment method:</b> Card ending in { $lastFour }
payment-provider-card-ending-in-card-name = <b>Payment method:</b> { $cardName } ending in { $lastFour }

View File

@@ -0,0 +1,27 @@
<%# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/. %>
<% if (invoiceAmountDueInCents > 0) { %>
<% if (paymentProviderName) { %>
<mj-text css-class="text-body-no-bottom-margin">
<span data-l10n-id="payment-method-payment-provider" data-l10n-args="<%= JSON.stringify({paymentProviderName}) %>">
<b>Payment method:</b> <%- paymentProviderName %>
</span>
</mj-text>
<% } else { %>
<% if (cardName && lastFour) { %>
<mj-text css-class="text-body-no-bottom-margin">
<span data-l10n-id="payment-provider-card-ending-in-card-name" data-l10n-args="<%= JSON.stringify({cardName, lastFour}) %>">
<b>Payment method:</b> <%- cardName %> ending in <%- lastFour %>
</span>
</mj-text>
<% } else { %>
<mj-text css-class="text-body-no-bottom-margin">
<span data-l10n-id="payment-provider-card-ending-in" data-l10n-args="<%= JSON.stringify({lastFour}) %>">
<b>Payment method:</b> Card ending in <%- lastFour %>
</span>
</mj-text>
<% } %>
<% } %>
<% } %>

View File

@@ -0,0 +1,11 @@
<% if (invoiceAmountDueInCents > 0) { %>
<% if (paymentProviderName) { %>
payment-method-payment-provider-plaintext = "Payment method: { $paymentProviderName }"
<% } else { %>
<% if (cardName && lastFour) {%>
payment-provider-card-name-ending-in-plaintext = "Payment method: <%- cardName %> ending in <%- lastFour %>"
<% } else { %>
payment-provider-card-ending-in-plaintext = "Payment method: Card ending in <%- lastFour %>"
<% } %>
<% } %>
<% } %>

View File

@@ -0,0 +1,53 @@
subscription-charges-invoice-summary = Invoice Summary
# Variables:
## $invoiceNumber (String) - The invoice number of the subscription invoice, e.g. 8675309
## $invoiceDateOnly (String) - The date of the next invoice, e.g. August 28, 2025
subscription-charges-invoice-number = <b>Invoice number:</b> { $invoiceNumber }
subscription-charges-invoice-number-plaintext = Invoice number: { $invoiceNumber }
subscription-charges-invoice-date = <b>Date:</b> { $invoiceDateOnly }
subscription-charges-invoice-date-plaintext = Date: { $invoiceDateOnly }
subscription-charges-prorated-price = Prorated price
# $remainingAmountTotal (String) - The prorated amount of the subscription invoice, including currency, e.g. $4.00
subscription-charges-prorated-price-plaintext = Prorated price: { $remainingAmountTotal }
subscription-charges-list-price = List price
# $offeringPrice (String) - The list price of the subscription offering, including currency, e.g. $10.00
subscription-charges-list-price-plaintext = List price: { $offeringPrice }
subscription-charges-credit-from-unused-time = Credit from unused time
# $unusedAmountTotal (String) - The credit amount from unused time of the subscription invoice, including currency, e.g. $2.00
subscription-charges-credit-from-unused-time-plaintext = Credit from unused time: { $unusedAmountTotal }
subscription-charges-subtotal = <b>Subtotal</b>
# $invoiceSubtotal (String) - The amount, before discount, of the subscription invoice, including currency, e.g. $10.00
subscriptionFirstInvoiceDiscount-content-subtotal = Subtotal: { $invoiceSubtotal }
## $invoiceDiscountAmount (String) - The amount of the discount of the subscription invoice, including currency, e.g. $2.00
## $discountDuration - The duration of the discount in number of months, e.g. "3" if the discount is 3-months
subscription-charges-one-time-discount = One-time discount
subscription-charges-one-time-discount-plaintext = One-time discount: { $invoiceDiscountAmount }
subscription-charges-repeating-discount =
{ $discountDuration ->
*[other] { $discountDuration }-month discount
}
subscription-charges-repeating-discount-plaintext =
{ $discountDuration ->
*[other] { $discountDuration }-month discount: { $invoiceDiscountAmount }
}
subscription-charges-discount = Discount
subscription-charges-discount-plaintext = Discount: { $invoiceDiscountAmount }
subscription-charges-taxes = Taxes & fees
# $invoiceTaxAmount (String) - The amount of the tax of the subscription invoice, including currency, e.g. $2.00
subscriptionCharges-content-tax-plaintext = Taxes & fees: { $invoiceTaxAmount }
subscription-charges-total = <b>Total</b>
# $invoiceTotal (String) - The total amount of the subscription invoice, including currency, e.g. $10.00
subscription-charges-total-plaintext = Total: { $invoiceTotal }
subscription-charges-credit-applied = Credit applied
# $creditApplied (String) - The amount of credit applied to the subscription invoice, including currency, e.g. $2.00
subscription-charges-credit-applied-plaintext = Credit applied: { $creditApplied }
subscription-charges-amount-paid = <b>Amount paid</b>
# $invoiceAmountDue (String) - The total that the customer owes after all credits, discounts, and taxes have been applied, including currency, e.g. $8.00
subscription-charges-amount-paid-plaintext = Amount paid: { $invoiceAmountDue }
# $creditReceived (String) - The amount, after discount, of the subscription invoice, including currency, e.g. $8.00
subscription-charges-credit-received = You have received an account credit of { $creditReceived }, which will be applied to your future invoices.
##

View File

@@ -0,0 +1,207 @@
<mj-text css-class="text-title-table">
<span data-l10n-id="subscription-charges-invoice-summary">
Invoice Summary
</span>
</mj-text>
<mj-text css-class="text-body-no-bottom-margin">
<span data-l10n-id="subscription-charges-invoice-number"
data-l10n-args="<%= JSON.stringify({invoiceNumber}) %>">
<b>Invoice number:</b> <%- invoiceNumber %>
</span>
</mj-text>
<mj-text css-class="text-body-no-bottom-margin">
<span data-l10n-id="subscription-charges-invoice-date"
data-l10n-args="<%= JSON.stringify({invoiceDateOnly}) %>">
<b>Date:</b> <%- invoiceDateOnly %>
</span>
</mj-text>
<%- include ('/partials/paymentProvider/index.mjml') %>
<% if (remainingAmountTotalInCents && offeringPriceInCents !== remainingAmountTotalInCents) { %>
<mj-text css-class="text-body-top-margin">
<table width="100%">
<tr style="line-height: 24px;">
<td style="text-align: start;">
<span data-l10n-id="subscription-charges-prorated-price">
Prorated price
</span>
</td>
<td style="text-align: end;">
<span data-l10n-args="<%= JSON.stringify({remainingAmountTotal}) %>">
<%- remainingAmountTotal %>
</span>
</td>
</tr>
</table>
</mj-text>
<% } else { %>
<mj-text css-class="text-body-top-margin">
<table width="100%">
<tr style="line-height: 24px;">
<td style="text-align: start;">
<span data-l10n-id="subscription-charges-list-price">
List price
</span>
</td>
<td style="text-align: end;">
<span data-l10n-args="<%= JSON.stringify({offeringPrice}) %>">
<%- offeringPrice %>
</span>
</td>
</tr>
</table>
</mj-text>
<% } %>
<% if (!!unusedAmountTotalInCents) { %>
<mj-text css-class="text-body-table-no-bottom-margin">
<table width="100%">
<tr style="line-height: 24px; border-top: 1px solid #E0E0E6;">
<td style="text-align: start;">
<span data-l10n-id="subscription-charges-credit-from-unused-time">
Credit from unused time
</span>
</td>
<td style="text-align: end;">
<span data-l10n-args="<%= JSON.stringify({unusedAmountTotal}) %>">
<%- unusedAmountTotal %>
</span>
</td>
</tr>
</table>
</mj-text>
<% if (invoiceSubtotalInCents !== invoiceTotalInCents) { %>
<mj-text css-class="text-body-table-no-bottom-margin">
<table width="100%">
<tr style="line-height: 24px; border-top: 1px solid #E0E0E6;">
<td style="text-align: start;">
<span data-l10n-id="subscription-charges-subtotal">
<b>Subtotal</b>
</span>
</td>
<td style="text-align: end;">
<span data-l10n-args="<%= JSON.stringify({invoiceSubtotal}) %>">
<b><%- invoiceSubtotal %></b>
</span>
</td>
</tr>
</table>
</mj-text>
<% } %>
<% } %>
<% if (discountType && invoiceDiscountAmount) { %>
<mj-text css-class="text-body-table-no-bottom-margin">
<table width="100%">
<tr style="line-height: 24px; border-top: 1px solid #E0E0E6;">
<td style="text-align: start;">
<% if (discountType==='once' ) { %>
<span data-l10n-id="subscription-charges-one-time-discount">
One-time discount
</span>
<% } else if (discountType==='repeating' ) { %>
<span data-l10n-id="subscription-charges-repeating-discount"
data-l10n-args="<%= JSON.stringify({discountDuration}) %>">
<%discountDuration%>-month discount
</span>
<% } else { %>
<span data-l10n-id="subscription-charges-discount">
Discount
</span>
<% } %>
</td>
<td style="text-align: end;">
<span data-l10n-args="<%= JSON.stringify({invoiceDiscountAmount})%>">
<%- invoiceDiscountAmount %>
</span>
</td>
</tr>
</table>
</mj-text>
<% } %>
<% if (showTaxAmount && invoiceTaxAmountInCents > 0 ) { %>
<mj-text css-class="text-body-table-no-bottom-margin">
<table width="100%">
<tr style="line-height: 24px; border-top: 1px solid #E0E0E6;">
<td style="text-align: start;">
<span data-l10n-id="subscription-charges-taxes">
Taxes & fees
</span>
</td>
<td style="text-align: end;">
<span data-l10n-args="<%= JSON.stringify({invoiceTaxAmount}) %>">
<%- invoiceTaxAmount %>
</span>
</td>
</tr>
</table>
</mj-text>
<% } %>
<% if (invoiceTotalInCents !== invoiceAmountDueInCents) { %>
<mj-text css-class="text-body-table-no-bottom-margin">
<table width="100%">
<tr style="line-height: 24px; border-top: 1px solid grey;">
<td style="text-align: start;">
<span data-l10n-id="subscription-charges-total">
<b>Total</b>
</span>
</td>
<td style="text-align: end;">
<span data-l10n-args="<%= JSON.stringify({invoiceTotal}) %>">
<b><%- invoiceTotal %></b>
</span>
</td>
</tr>
</table>
</mj-text>
<% } %>
<% if (!!creditAppliedInCents && invoiceStartingBalance < 0) { %>
<mj-text css-class="text-body-table-no-bottom-margin">
<table width="100%">
<tr style="line-height: 24px; border-top: 1px solid #E0E0E6;">
<td style="text-align: start;">
<span data-l10n-id="subscription-charges-credit-applied">
Credit applied
</span>
</td>
<td style="text-align: end;">
<span data-l10n-args="<%= JSON.stringify({creditApplied}) %>">
<%- creditApplied %>
</span>
</td>
</tr>
</table>
</mj-text>
<% } %>
<mj-text css-class="text-body">
<table width="100%">
<tr style="line-height: 24px; border-top: 1px solid grey">
<td style="text-align: start; padding: 4px 0;">
<span data-l10n-id="subscription-charges-amount-paid">
<b>Amount paid</b>
</span>
</td>
<td style="text-align: end; padding: 4px 0; ">
<span data-l10n-args="<%= JSON.stringify({invoiceAmountDue}) %>">
<b><%- invoiceAmountDue %></b>
</span>
</td>
</tr>
</table>
</mj-text>
<% if (locals.invoiceTotalInCents < 0) { %>
<mj-text css-class="text-body">
<p data-l10n-id="subscription-charges-credit-received" data-l10n-args="<%= JSON.stringify({creditReceived}) %>">
You have received an account credit of <%- creditReceived %>, which will be applied to your future invoices.
</p>
</mj-text>
<% } %>

Some files were not shown because too many files have changed in this diff Show More