@travetto/email-compiler 3.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 ArcSine Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ <!-- This file was generated by @travetto/doc and should not be modified directly -->
2
+ <!-- Please modify https://github.com/travetto/travetto/tree/main/module/email-compiler/DOC.tsx and execute "npx trv doc" to rebuild -->
3
+ # Email Compilation Support
4
+
5
+ ## Email compiling module
6
+
7
+ **Install: @travetto/email-compiler**
8
+ ```bash
9
+ npm install @travetto/email-compiler
10
+
11
+ # or
12
+
13
+ yarn add @travetto/email-compiler
14
+ ```
15
+
16
+ This is primarily a set of command line tools for compiling and developing templates. The inputs are compiled files, generally under the `support/` folder, that represents the necessary input for the email compilation. [Email Inky Templates](https://github.com/travetto/travetto/tree/main/module/email-inky#readme "Email Inky templating module") shows this pattern by leveraging [JSX](https://en.wikipedia.org/wiki/JSX_(JavaScript)) bindings for the [inky](https://github.com/zurb/inky) framework, allowing for compile-time checked templates.
17
+
18
+ ## Asset Management
19
+ The templating process involves loading various assets (html, css, images), and so there is provision for asset management and loading. The templating config allows for specifying asset paths, with the following paths (in order of precedence):
20
+ 1. `%ROOT%/resources/email`
21
+ 1. `@travetto/email-{engine}/resources/email`
22
+ When looking up a resources, every asset folder is consulted, in order, and the first to resolve an asset wins. This allows for overriding of default templating resources, as needed. The compilation process will convert `.email.html` files into `.compiled.html`, `.compiled.text` and `.compiled.subject` suffixes to generate the outputs respectively.
23
+
24
+ ## Template Extension
25
+ The template extension points are defined at:
26
+ 1. `email/main.scss` - The entry point for adding, and overriding any [sass](https://github.com/sass/dart-sass)
27
+ 1. `email/{engine}.wrapper.html` - The html wrapper for the specific templating engine implementation.
28
+
29
+ ## Template Compilation
30
+ The general process is as follows:
31
+ 1. Load in the email template.
32
+ 1. Resolve any associated stylings for said template.
33
+ 1. Optimize styles against provided html, removing all unused stylings
34
+ 1. Render template into html, text, and subject outputs.
35
+ 1. Inline and optimize all images for html email transmission.
36
+
37
+ ## Images
38
+ When referencing an image from the `resources` folder in a template, e.g.
39
+
40
+ **Code: Sample Image Reference**
41
+ ```html
42
+ <img src="/email/logo.png">
43
+ ```
44
+
45
+ The image will be extracted out and embedded in the email as a multi part message. This allows for compression and optimization of images as well as externalizing resources that may not be immediately public. The currently supported set of image types are:
46
+ * jpeg
47
+ * png
48
+
49
+ ## Template Development
50
+ The module provides [Command Line Interface](https://github.com/travetto/travetto/tree/main/module/cli#readme "CLI infrastructure for Travetto framework") and [VSCode plugin](https://marketplace.visualstudio.com/items?itemName=arcsine.travetto-plugin) support for email template development.
51
+
52
+ ## CLI Compilation
53
+ The module provides [Command Line Interface](https://github.com/travetto/travetto/tree/main/module/cli#readme "CLI infrastructure for Travetto framework") support for email template compilation also. Running
54
+
55
+ **Terminal: Running template compilation**
56
+ ```bash
57
+ $ trv email:compile -h
58
+
59
+ Usage: email:compile [options]
60
+
61
+ Options:
62
+ -w, --watch Compile in watch mode
63
+ -m, --module <string> Module to run for
64
+ -h, --help display help for command
65
+ ```
66
+
67
+ Will convert all `.email.html` files into the appropriate `.compiled.html`, `.compiled.text` and `.compiled.subject` files. These will be used during the running of the application. By default these files are added to the `.gitignore` as they are generally not intended to be saved but to be generated during the build process.
68
+
69
+ ## VSCode Plugin
70
+ In addition to command line tools, the [VSCode plugin](https://marketplace.visualstudio.com/items?itemName=arcsine.travetto-plugin) also supports:
71
+ * automatic compilation on change
72
+ * real-time rendering of changes visually
73
+ * ability to send test emails during development
74
+ * ability to define custom context for template rendering
package/__index__.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './src/resource';
2
+ export * from './src/compiler';
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@travetto/email-compiler",
3
+ "version": "3.1.20",
4
+ "description": "Email compiling module",
5
+ "keywords": [
6
+ "email",
7
+ "email compiler",
8
+ "travetto",
9
+ "typescript"
10
+ ],
11
+ "homepage": "https://travetto.io",
12
+ "license": "MIT",
13
+ "author": {
14
+ "email": "travetto.framework@gmail.com",
15
+ "name": "Travetto Framework"
16
+ },
17
+ "files": [
18
+ "__index__.ts",
19
+ "src",
20
+ "!resources",
21
+ "support"
22
+ ],
23
+ "main": "__index__.ts",
24
+ "repository": {
25
+ "url": "https://github.com/travetto/travetto.git",
26
+ "directory": "module/email-compiler"
27
+ },
28
+ "dependencies": {
29
+ "@travetto/base": "^3.1.2",
30
+ "@travetto/config": "^3.1.6",
31
+ "@travetto/di": "^3.1.3",
32
+ "@travetto/email": "^3.1.12",
33
+ "@travetto/image": "^3.1.3",
34
+ "@types/inline-css": "^3.0.1",
35
+ "html-entities": "^2.3.3",
36
+ "inline-css": "^4.0.2",
37
+ "sass": "^1.62.1",
38
+ "purgecss": "^5.0.0"
39
+ },
40
+ "peerDependencies": {
41
+ "@travetto/cli": "^3.1.7"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@travetto/cli": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "travetto": {
49
+ "displayName": "Email Compilation Support"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
@@ -0,0 +1,187 @@
1
+ import fs from 'fs/promises';
2
+ import util from 'util';
3
+ import { decode as decodeEntities } from 'html-entities';
4
+
5
+ import { ImageConverter } from '@travetto/image';
6
+ import { RootIndex, path } from '@travetto/manifest';
7
+ import { StreamUtil } from '@travetto/base';
8
+ import { MessageCompilationSource, MessageCompiled } from '@travetto/email';
9
+
10
+ import { EmailCompilerResource } from './resource';
11
+
12
+ /**
13
+ * Utilities for templating
14
+ */
15
+ export class EmailCompiler {
16
+
17
+ static HTML_CSS_IMAGE_URLS = [
18
+ /(?<pre><img[^>]src=\s*["'])(?<src>[^"]+)/g,
19
+ /(?<pre>background(?:-image)?:\s*url[(]['"]?)(?<src>[^"')]+)/g
20
+ ];
21
+
22
+ static async readText(text: Promise<string> | string): Promise<string> {
23
+ return decodeEntities((await text).replace(/&#xA0;/g, ' '));
24
+ }
25
+
26
+ /**
27
+ * Compile SCSS content with roots as search paths for additional assets
28
+ */
29
+ static async compileSass(src: { data: string } | { file: string }, roots: string[]): Promise<string> {
30
+ const sass = await import('sass');
31
+ const result = await util.promisify(sass.render)({
32
+ ...src,
33
+ sourceMap: false,
34
+ includePaths: roots
35
+ });
36
+ return result!.css.toString();
37
+ }
38
+
39
+ resources: EmailCompilerResource;
40
+
41
+ constructor(resources: EmailCompilerResource) {
42
+ this.resources = resources;
43
+ }
44
+
45
+ /**
46
+ * Inline image sources
47
+ */
48
+ async inlineImageSource(html: string): Promise<string> {
49
+ const { tokens, finalize } = await this.resources.tokenizeResources(html, EmailCompiler.HTML_CSS_IMAGE_URLS);
50
+ const pendingImages: Promise<[token: string, content: string]>[] = [];
51
+
52
+ for (const [token, src] of tokens) {
53
+ const ext = path.extname(src).replace(/^[.]/, '');
54
+ const stream = await this.resources.readStream(src);
55
+
56
+ switch (ext) {
57
+ case 'jpg':
58
+ case 'jpeg':
59
+ case 'png': {
60
+ pendingImages.push(
61
+ ImageConverter.optimize(ext === 'png' ? 'png' : 'jpeg', stream)
62
+ .then(img => StreamUtil.streamToBuffer(img))
63
+ .then(data => [token, `data:image/${ext};base64,${data.toString('base64')}`])
64
+ );
65
+ break;
66
+ }
67
+ default: {
68
+ pendingImages.push(
69
+ StreamUtil.streamToBuffer(stream)
70
+ .then(data => [token, `data:image/${ext};base64,${data.toString('base64')}`])
71
+ );
72
+ }
73
+ }
74
+ }
75
+
76
+ const imageMap = new Map(await Promise.all(pendingImages));
77
+
78
+ return finalize(token => imageMap.get(token)!);
79
+ }
80
+
81
+ async pruneCss(html: string, css: string): Promise<string> {
82
+ const { PurgeCSS } = await import('purgecss');
83
+ const purge = new PurgeCSS();
84
+ const result = await purge.purge({
85
+ content: [{ raw: html, extension: 'html' }],
86
+ css: [{ raw: css }],
87
+ });
88
+ return result[0].css;
89
+ }
90
+
91
+ async inlineCss(html: string, css: string): Promise<string> {
92
+ // Inline css
93
+ const { default: inlineCss } = await import('inline-css');
94
+ html = html.replace('</head>', `<style>${css}</style></head>`);
95
+ // Style needs to be in head to preserve media queries
96
+ html = (await inlineCss(html, {
97
+ url: 'https://app.dev',
98
+ preserveMediaQueries: true,
99
+ removeStyleTags: true,
100
+ removeLinkTags: true,
101
+ applyStyleTags: true,
102
+ }));
103
+ return html;
104
+ }
105
+
106
+ /**
107
+ * Compile all
108
+ */
109
+ async compileAll(persist = false): Promise<MessageCompiled[]> {
110
+ const keys = await this.resources.findAllTemplates();
111
+ return Promise.all(keys.map(src => this.compile(src, persist)));
112
+ }
113
+
114
+ /**
115
+ * Compile template
116
+ */
117
+ async compile(src: string | MessageCompilationSource, persist = false): Promise<MessageCompiled> {
118
+ if (typeof src === 'string') {
119
+ src = await this.resources.loadTemplate(src);
120
+ }
121
+
122
+ const subject = await EmailCompiler.readText(src.subject());
123
+ const text = await EmailCompiler.readText(src.text());
124
+
125
+ let html = (await src.html())
126
+ .replace(/<(meta|img|link|hr|br)[^>]*>/g, a => a.replace('>', '/>')) // Fix self closing
127
+ .replace(/&apos;/g, '&#39;'); // Fix apostrophes, as outlook hates them
128
+
129
+ if (src.inlineStyles !== false) {
130
+ const styles: string[] = [];
131
+
132
+ if (src.styles?.global) {
133
+ styles.push(src.styles.global);
134
+ }
135
+
136
+ const main = await this.resources.read('/email/main.scss').then(d => d, () => '');
137
+ if (main) {
138
+ styles.push(main);
139
+ }
140
+
141
+ if (styles.length) {
142
+ const compiled = await EmailCompiler.compileSass(
143
+ { data: styles.join('\n') },
144
+ [...src.styles?.search ?? [], ...this.resources.getAllPaths()]);
145
+
146
+ // Remove all unused styles
147
+ const finalStyles = await this.pruneCss(html, compiled);
148
+
149
+ // Apply styles
150
+ html = await this.inlineCss(html, finalStyles);
151
+ }
152
+ }
153
+
154
+ // Fix up style behaviors
155
+ html = html
156
+ .replace(/(background(?:-color)?:\s*)([#0-9a-f]{6,8})([^>.#,]+)>/ig,
157
+ (all, p, col, rest) => `${p}${col}${rest} bgcolor="${col}">`) // Inline bg-color
158
+ .replace(/<([^>]+vertical-align:\s*(top|bottom|middle)[^>]+)>/g,
159
+ (a, tag, valign) => tag.indexOf('valign') ? `<${tag}>` : `<${tag} valign="${valign}">`) // Vertically align if it has the style
160
+ .replace(/<(table[^>]+expand[^>]+width:\s*)(100%\s+!important)([^>]+)>/g,
161
+ (a, left, size, right) => `<${left}100%${right}>`); // Drop important as a fix for outlook
162
+
163
+ if (src.inlineImages !== false) {
164
+ // Inline Images
165
+ html = await this.inlineImageSource(html);
166
+ }
167
+
168
+ // Write to disk, if desired
169
+ if (persist) {
170
+ const outs = this.resources.getOutputs(src.file!, path.join(RootIndex.mainModule.sourcePath, 'resources'));
171
+ await Promise.all([
172
+ [outs.text, text],
173
+ [outs.html, html],
174
+ [outs.subject, subject]
175
+ ].map(async ([file, content]) => {
176
+ if (content) {
177
+ await fs.mkdir(path.dirname(file), { recursive: true });
178
+ await fs.writeFile(file, content, { encoding: 'utf8' });
179
+ } else {
180
+ await fs.unlink(file).catch(() => { }); // Remove file if data not provided
181
+ }
182
+ }));
183
+ }
184
+
185
+ return { html, text, subject };
186
+ }
187
+ }
@@ -0,0 +1,111 @@
1
+ import { FileQueryProvider } from '@travetto/base';
2
+ import { MessageCompilationSource, MessageCompiled } from '@travetto/email';
3
+ import { RootIndex, path } from '@travetto/manifest';
4
+
5
+ /**
6
+ * Resource management for email templating
7
+ */
8
+ export class EmailCompilerResource extends FileQueryProvider {
9
+ static PATH_PREFIX = /.*\/resources\//;
10
+ static EXT = /[.]email[.][jt]sx$/;
11
+
12
+ get ext(): RegExp {
13
+ return EmailCompilerResource.EXT;
14
+ }
15
+
16
+ constructor(paths: string[] = ['@travetto/email-compiler#support/resources']) {
17
+ super({ paths, includeCommon: true });
18
+ }
19
+
20
+ buildOutputPath(file: string, suffix: string, prefix?: string): string {
21
+ let res = file.replace(/.*(support|src)\//, '').replace(this.ext, suffix);
22
+ if (prefix) {
23
+ res = path.join(prefix, res);
24
+ }
25
+ return res;
26
+ }
27
+
28
+ /**
29
+ * Get the different parts from the file name
30
+ * @returns
31
+ */
32
+ getOutputs(file: string, prefix?: string): MessageCompiled {
33
+ return {
34
+ html: this.buildOutputPath(file, '.compiled.html', prefix),
35
+ subject: this.buildOutputPath(file, '.compiled.subject', prefix),
36
+ text: this.buildOutputPath(file, '.compiled.text', prefix),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Is this a valid template file?
42
+ */
43
+ isTemplateFile(file: string): boolean {
44
+ return this.ext.test(file);
45
+ }
46
+
47
+ /**
48
+ * Get the sending email key from a template file
49
+ * @param file
50
+ */
51
+ async templateFileToKey(file: string): Promise<string> {
52
+ return this.buildOutputPath(file, '');
53
+ }
54
+
55
+ async loadTemplate(imp: string): Promise<MessageCompilationSource> {
56
+ const entry = RootIndex.getEntry(imp) ?? RootIndex.getFromImport(imp);
57
+ if (!entry) {
58
+ throw new Error();
59
+ }
60
+ const root = (await import(entry.outputFile)).default;
61
+ return { ...await root.wrap(), file: entry.sourceFile };
62
+ }
63
+
64
+ /**
65
+ * Grab list of all available templates
66
+ */
67
+ async findAllTemplates(): Promise<MessageCompilationSource[]> {
68
+ const items = RootIndex.findSupport({
69
+ filter: (f) => this.ext.test(f)
70
+ });
71
+ const out: Promise<MessageCompilationSource>[] = [];
72
+ for (const item of items) {
73
+ out.push(this.loadTemplate(item.import));
74
+ }
75
+ return Promise.all(out);
76
+ }
77
+
78
+
79
+ /**
80
+ * Run through text and match/resolve resource urls, producing tokens
81
+ *
82
+ * @param text
83
+ * @param patterns
84
+ * @returns
85
+ */
86
+ async tokenizeResources(
87
+ text: string,
88
+ patterns: RegExp[]
89
+ ): Promise<{
90
+ text: string;
91
+ tokens: Map<string, string>;
92
+ finalize: (onToken: (token: string) => string) => string;
93
+ }> {
94
+ let id = 0;
95
+ const tokens = new Map();
96
+ for (const pattern of patterns) {
97
+ for (const { [0]: all, groups: { pre, src } = { pre: '', src: '' } } of text.matchAll(pattern)) {
98
+ if (src.includes('://')) { // No urls
99
+ continue;
100
+ }
101
+ await this.describe(src);
102
+ const token = `@@${id += 1}@@`;
103
+ tokens.set(token, src);
104
+ text = text.replace(all, `${pre}${token}`);
105
+ }
106
+ }
107
+ const finalize = (onToken: (token: string) => string): string => text.replace(/@@[^@]+@@/g, t => onToken(t));
108
+
109
+ return { text, tokens, finalize };
110
+ }
111
+ }
@@ -0,0 +1,79 @@
1
+ import fs from 'fs/promises';
2
+
3
+ import { path } from '@travetto/manifest';
4
+ import { YamlUtil } from '@travetto/yaml';
5
+
6
+ interface ConfigType {
7
+ to: string;
8
+ from: string;
9
+ context?: Record<string, unknown>;
10
+ sender?: {
11
+ port?: number;
12
+ host?: string;
13
+ auth?: {
14
+ user?: string;
15
+ pass?: string;
16
+ };
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Configuration utils
22
+ */
23
+ export class $EditorConfig {
24
+
25
+ #configFile = path.resolve('resources/email/dev.yml');
26
+ #defaultConfig = {
27
+ to: 'my-email@gmail.com',
28
+ from: 'from-email@gmail.com',
29
+ context: {
30
+ key: 'value'
31
+ },
32
+ sender: {
33
+ port: 587,
34
+ host: 'smtp.ethereal.email',
35
+ auth: {
36
+ user: 'email@blah.com',
37
+ pass: 'password'
38
+ },
39
+ },
40
+ };
41
+
42
+ /**
43
+ *
44
+ */
45
+ async get(): Promise<ConfigType> {
46
+ try {
47
+ const content = await fs.readFile(this.#configFile, 'utf8');
48
+ return YamlUtil.parse<ConfigType>(content);
49
+ } catch {
50
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
51
+ return {} as ConfigType;
52
+ }
53
+ }
54
+
55
+ async getContext(): Promise<Exclude<ConfigType['context'], undefined>> {
56
+ const conf = await this.get();
57
+ return conf.context ?? {};
58
+ }
59
+
60
+ async getSenderConfig(): Promise<Exclude<ConfigType['sender'], undefined>> {
61
+ const conf = await this.get();
62
+ return conf.sender ?? {};
63
+ }
64
+
65
+ getDefaultConfig(): string {
66
+ return YamlUtil.serialize(this.#defaultConfig);
67
+ }
68
+
69
+ async ensureConfig(): Promise<string> {
70
+ const file = this.#configFile;
71
+ if (!(await fs.stat(file).catch(() => { }))) {
72
+ await fs.mkdir(path.dirname(file), { recursive: true });
73
+ await fs.writeFile(file, this.getDefaultConfig(), { encoding: 'utf8' });
74
+ }
75
+ return file;
76
+ }
77
+ }
78
+
79
+ export const EditorConfig = new $EditorConfig();
@@ -0,0 +1,108 @@
1
+ import { EmailCompilationManager } from './manager';
2
+ import { EditorSendService } from './send';
3
+ import { EditorConfig } from './config';
4
+
5
+ type InboundMessage =
6
+ { type: 'configure' } |
7
+ { type: 'redraw', file: string } |
8
+ { type: 'send', file: string, from?: string, to?: string };
9
+
10
+ type OutboundMessage =
11
+ { type: 'configured', file: string } |
12
+ { type: 'sent', to: string, file: string, url?: string | false } |
13
+ { type: 'changed', file: string, content: Record<'html' | 'subject' | 'text', string> } |
14
+ { type: 'sent-failed', message: string, stack: Error['stack'], to: string, file: string } |
15
+ { type: 'changed-failed', message: string, stack: Error['stack'], file: string };
16
+
17
+ /**
18
+ * Utils for interacting with editors
19
+ */
20
+ export class EditorState {
21
+
22
+ #lastFile = '';
23
+ #sender: EditorSendService;
24
+ #template: EmailCompilationManager;
25
+
26
+ constructor(template: EmailCompilationManager) {
27
+ this.#template = template;
28
+ this.#sender = new EditorSendService();
29
+ }
30
+
31
+ async renderFile(file: string): Promise<void> {
32
+ file = this.#template.resources.isTemplateFile(file) ? file : this.#lastFile;
33
+ if (file) {
34
+ try {
35
+ const content = await this.#template.resolveCompiledTemplate(
36
+ file, await EditorConfig.getContext()
37
+ );
38
+ this.response({
39
+ type: 'changed',
40
+ file,
41
+ content
42
+ });
43
+ } catch (err) {
44
+ if (err && err instanceof Error) {
45
+ this.response({ type: 'changed-failed', message: err.message, stack: err.stack, file });
46
+ } else {
47
+ console.error(err);
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ response(response: OutboundMessage): void {
54
+ if (process.send) {
55
+ process.send(response);
56
+ }
57
+ }
58
+
59
+ async onConfigure(msg: InboundMessage & { type: 'configure' }): Promise<void> {
60
+ this.response({ type: 'configured', file: await EditorConfig.ensureConfig() });
61
+ }
62
+
63
+ async #onRedraw(msg: InboundMessage & { type: 'redraw' }): Promise<void> {
64
+ try {
65
+ await this.#template.compiler.compile(msg.file, true);
66
+ await this.renderFile(msg.file);
67
+ } catch (err) {
68
+ if (err && err instanceof Error) {
69
+ this.response({ type: 'changed-failed', message: err.message, stack: err.stack, file: msg.file });
70
+ } else {
71
+ console.error(err);
72
+ }
73
+ }
74
+ }
75
+
76
+ async onSend(msg: InboundMessage & { type: 'send' }): Promise<void> {
77
+ const cfg = await EditorConfig.get();
78
+ const to = msg.to || cfg.to;
79
+ const from = msg.from || cfg.from;
80
+ const key = this.#template.resources.buildOutputPath(msg.file, '');
81
+ try {
82
+ const url = await this.#sender.sendEmail(key, from, to, await EditorConfig.getContext());
83
+ this.response({ type: 'sent', to, file: msg.file, ...url });
84
+ } catch (err) {
85
+ if (err && err instanceof Error) {
86
+ this.response({ type: 'sent-failed', message: err.message, stack: err.stack, to, file: msg.file });
87
+ } else {
88
+ console.error(err);
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Initialize context, and listeners
95
+ */
96
+ async init(): Promise<void> {
97
+ process.on('message', (msg: InboundMessage) => {
98
+ switch (msg.type) {
99
+ case 'configure': this.onConfigure(msg); break;
100
+ case 'redraw': this.#onRedraw(msg); break;
101
+ case 'send': this.onSend(msg); break;
102
+ }
103
+ });
104
+ for await (const f of this.#template.watchCompile()) {
105
+ await this.renderFile(f);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,111 @@
1
+ import type { MailTemplateEngine, MessageCompiled } from '@travetto/email';
2
+ import { TypedObject } from '@travetto/base';
3
+ import { DependencyRegistry } from '@travetto/di';
4
+ import { RootIndex, WatchEvent, WatchStream } from '@travetto/manifest';
5
+
6
+ import { MailTemplateEngineTarget } from '@travetto/email/src/internal/types';
7
+ import { DynamicFileLoader } from '@travetto/base/src/internal/file-loader';
8
+
9
+ import type { EmailCompiler } from '../../src/compiler';
10
+ import type { EmailCompilerResource } from '../../src/resource';
11
+
12
+ const VALID_FILE = (file: string): boolean => /[.](scss|css|png|jpe?g|gif|ya?ml)$/.test(file) && !/[.]compiled[.]/.test(file);
13
+
14
+ /**
15
+ *
16
+ */
17
+ export class EmailCompilationManager {
18
+
19
+ static async createInstance(): Promise<EmailCompilationManager> {
20
+ const { EmailCompiler: Compiler } = await import('../../src/compiler.js');
21
+ const { EmailCompilerResource: Res } = await import('../../src/resource.js');
22
+
23
+ return new EmailCompilationManager(
24
+ await DependencyRegistry.getInstance<MailTemplateEngine>(MailTemplateEngineTarget),
25
+ new Compiler(new Res())
26
+ );
27
+ }
28
+
29
+ compiler: EmailCompiler;
30
+ engine: MailTemplateEngine;
31
+
32
+ constructor(engine: MailTemplateEngine, compiler: EmailCompiler) {
33
+ this.engine = engine;
34
+ this.compiler = compiler;
35
+ }
36
+
37
+ get resources(): EmailCompilerResource {
38
+ return this.compiler.resources;
39
+ }
40
+
41
+ /**
42
+ * Resolve template
43
+ */
44
+ async resolveTemplateParts(file: string): Promise<MessageCompiled> {
45
+ const files = this.resources.getOutputs(file);
46
+ const missing = await Promise.all(Object.values(files).map(x => this.resources.describe(x).catch(() => { })));
47
+
48
+ if (missing.some(x => x === undefined)) {
49
+ await this.compiler.compile(file, true);
50
+ }
51
+
52
+ const parts = await Promise.all(
53
+ TypedObject.entries(files).map(
54
+ ([key, subRel]) => this.resources.read(subRel)
55
+ .then(content => [key, content] as const)
56
+ )
57
+ );
58
+ return TypedObject.fromEntries<keyof MessageCompiled, string>(parts);
59
+ }
60
+
61
+ /**
62
+ * Render
63
+ * @param rel
64
+ */
65
+ async resolveCompiledTemplate(rel: string, context: Record<string, unknown>): Promise<MessageCompiled> {
66
+ const { html, text, subject } = await this.resolveTemplateParts(rel);
67
+
68
+ return {
69
+ html: await this.engine.template(html, context),
70
+ text: await this.engine.template(text, context),
71
+ subject: await this.engine.template(subject, context),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Watch compilation
77
+ */
78
+ async * watchCompile(): AsyncIterable<string> {
79
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
80
+ const stream = this.resources.watchFiles() as (
81
+ WatchStream & {
82
+ add(item: WatchEvent | WatchEvent[]): void;
83
+ }
84
+ );
85
+ DynamicFileLoader.onLoadEvent((ev) => {
86
+ const src = RootIndex.getEntry(ev.file);
87
+ if (src && this.resources.isTemplateFile(src.sourceFile)) {
88
+ stream.add({ ...ev, file: src.sourceFile });
89
+ }
90
+ });
91
+ for await (const { file, action } of stream) {
92
+ if (action === 'delete') {
93
+ continue;
94
+ }
95
+
96
+ try {
97
+ if (this.resources.isTemplateFile(file)) {
98
+ await this.compiler.compile(file, true);
99
+ yield file;
100
+ } else if (VALID_FILE(file)) {
101
+ await this.compiler.compileAll(true);
102
+ for (const el of await this.resources.findAllTemplates()) {
103
+ yield el.file!;
104
+ }
105
+ }
106
+ } catch (err) {
107
+ console.error(`Error in compiling ${file}`, err && err instanceof Error ? err.message : `${err}`);
108
+ }
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,73 @@
1
+ import { MailService, SentMessage } from '@travetto/email';
2
+ import { MailTransportTarget } from '@travetto/email/src/internal/types';
3
+ import { DependencyRegistry } from '@travetto/di';
4
+
5
+ import { EditorConfig } from './config';
6
+
7
+ /**
8
+ * Util for sending emails
9
+ */
10
+ export class EditorSendService {
11
+
12
+ #svc: MailService;
13
+
14
+ /**
15
+ * Get mail service
16
+ */
17
+ async getMailService(): Promise<MailService> {
18
+ if (!this.#svc) {
19
+ const senderConfig = await EditorConfig.getSenderConfig();
20
+
21
+ if (senderConfig?.host?.includes('ethereal.email')) {
22
+ const cls = class { };
23
+ const { NodemailerTransport } = await import('@travetto/email-nodemailer');
24
+ DependencyRegistry.registerFactory({
25
+ fn: () => new NodemailerTransport(senderConfig),
26
+ target: MailTransportTarget,
27
+ src: cls,
28
+ id: 'nodemailer',
29
+ });
30
+
31
+ DependencyRegistry.install(cls, { curr: cls, type: 'added' });
32
+ } else if (!DependencyRegistry.getCandidateTypes(MailTransportTarget).length) {
33
+ const errorMessage = `
34
+ Please configure your email setup and/or credentials for testing. In the file \`email/dev.yml\`, you can specify \`sender\` configuration.
35
+ Email sending will not work until the above is fixed. A sample configuration would look like:
36
+
37
+ ${EditorConfig.getDefaultConfig()}`.trim();
38
+ console.error(errorMessage);
39
+ throw new Error(errorMessage);
40
+ }
41
+
42
+ this.#svc = await DependencyRegistry.getInstance(MailService);
43
+ }
44
+ return this.#svc;
45
+ }
46
+
47
+ /**
48
+ * Resolve template
49
+ */
50
+ async sendEmail(key: string, from: string, to: string, context: Record<string, unknown>): Promise<{
51
+ url?: string | false;
52
+ }> {
53
+ try {
54
+ console.log('Sending email', { to });
55
+ // Let the engine template
56
+ const svc = await this.getMailService();
57
+ if (!svc) {
58
+ throw new Error('Node mailer support is missing');
59
+ }
60
+
61
+ const info = await svc.send<{ host?: string } & SentMessage>({ to, from }, key, context);
62
+ console.log('Sent email', { to });
63
+
64
+ const senderConfig = await EditorConfig.getSenderConfig();
65
+ return senderConfig.host?.includes('ethereal.email') ? {
66
+ url: (await import('nodemailer')).getTestMessageUrl(info as any)
67
+ } : {};
68
+ } catch (err) {
69
+ console.warn('Failed to send email', { to, error: err });
70
+ throw err;
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,38 @@
1
+ import { RootRegistry } from '@travetto/registry';
2
+ import { CliCommandShape, CliCommand, cliTpl } from '@travetto/cli';
3
+
4
+ import { EmailCompilationManager } from './bin/manager';
5
+ import { GlobalEnvConfig } from '@travetto/base';
6
+
7
+ /**
8
+ * CLI Entry point for running the email server
9
+ */
10
+ @CliCommand({ fields: ['module'] })
11
+ export class EmailCompileCommand implements CliCommandShape {
12
+
13
+ /** Compile in watch mode */
14
+ watch?: boolean;
15
+
16
+ envInit(): GlobalEnvConfig {
17
+ return {
18
+ debug: false,
19
+ dynamic: this.watch
20
+ };
21
+ }
22
+
23
+ async main(): Promise<void> {
24
+ await RootRegistry.init();
25
+
26
+ const template = await EmailCompilationManager.createInstance();
27
+
28
+ // Let the engine template
29
+ const all = await template.compiler.compileAll(true);
30
+ console!.log(cliTpl`Successfully compiled ${{ param: `${all.length}` }} templates`);
31
+
32
+ if (this.watch) {
33
+ for await (const _ of template.watchCompile()) {
34
+ // Iterate until done
35
+ }
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,29 @@
1
+ import { GlobalEnvConfig, ShutdownManager } from '@travetto/base';
2
+ import { CliCommand } from '@travetto/cli';
3
+ import { RootIndex } from '@travetto/manifest';
4
+ import { RootRegistry } from '@travetto/registry';
5
+
6
+ import { EditorState } from './bin/editor';
7
+ import { EmailCompilationManager } from './bin/manager';
8
+
9
+ /** The email editor compilation service and output serving */
10
+ @CliCommand()
11
+ export class EmailEditorCommand {
12
+
13
+ envInit(): GlobalEnvConfig {
14
+ return {
15
+ envName: 'dev',
16
+ resourcePaths: [`${RootIndex.getModule('@travetto/email-compiler')!.sourcePath}/resources`]
17
+ };
18
+ }
19
+
20
+ async main(): Promise<void> {
21
+ await RootRegistry.init();
22
+ const editor = new EditorState(await EmailCompilationManager.createInstance());
23
+ await editor.init();
24
+ if (process.send) {
25
+ process.on('disconnect', () => ShutdownManager.execute());
26
+ process.send('ready');
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,28 @@
1
+ import { path } from '@travetto/manifest';
2
+ import { RootRegistry } from '@travetto/registry';
3
+ import { CliCommandShape, CliCommand } from '@travetto/cli';
4
+ import { GlobalEnvConfig } from '@travetto/base';
5
+
6
+ import { EmailCompilationManager } from './bin/manager';
7
+ import { EditorState } from './bin/editor';
8
+
9
+ /**
10
+ * CLI Entry point for running the email server
11
+ */
12
+ @CliCommand()
13
+ export class EmailTestCommand implements CliCommandShape {
14
+
15
+ envInit(): GlobalEnvConfig {
16
+ return { envName: 'dev' };
17
+ }
18
+
19
+ async main(file: string, to: string): Promise<void> {
20
+ file = path.resolve(file);
21
+ await RootRegistry.init();
22
+ const template = await EmailCompilationManager.createInstance();
23
+ await template.compiler.compile(file, true);
24
+ const editor = new EditorState(await EmailCompilationManager.createInstance());
25
+ await editor.onConfigure({ type: 'configure' });
26
+ await editor.onSend({ type: 'send', file, to });
27
+ }
28
+ }
File without changes