@travetto/email-compiler 4.0.0-rc.0 → 4.0.0-rc.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/email-compiler",
3
- "version": "4.0.0-rc.0",
3
+ "version": "4.0.0-rc.2",
4
4
  "description": "Email compiling module",
5
5
  "keywords": [
6
6
  "email",
@@ -26,20 +26,20 @@
26
26
  "directory": "module/email-compiler"
27
27
  },
28
28
  "dependencies": {
29
- "@travetto/base": "^4.0.0-rc.0",
30
- "@travetto/config": "^4.0.0-rc.0",
31
- "@travetto/di": "^4.0.0-rc.0",
32
- "@travetto/email": "^4.0.0-rc.0",
33
- "@travetto/image": "^4.0.0-rc.0",
34
- "@travetto/worker": "^4.0.0-rc.0",
29
+ "@travetto/base": "^4.0.0-rc.2",
30
+ "@travetto/config": "^4.0.0-rc.2",
31
+ "@travetto/di": "^4.0.0-rc.2",
32
+ "@travetto/email": "^4.0.0-rc.2",
33
+ "@travetto/image": "^4.0.0-rc.2",
34
+ "@travetto/worker": "^4.0.0-rc.2",
35
35
  "@types/inline-css": "^3.0.3",
36
36
  "html-entities": "^2.4.0",
37
37
  "inline-css": "^4.0.2",
38
38
  "purgecss": "^5.0.0",
39
- "sass": "^1.69.5"
39
+ "sass": "^1.70.0"
40
40
  },
41
41
  "peerDependencies": {
42
- "@travetto/cli": "^4.0.0-rc.0"
42
+ "@travetto/cli": "^4.0.0-rc.2"
43
43
  },
44
44
  "peerDependenciesMeta": {
45
45
  "@travetto/cli": {
@@ -47,7 +47,10 @@
47
47
  }
48
48
  },
49
49
  "travetto": {
50
- "displayName": "Email Compilation Support"
50
+ "displayName": "Email Compilation Support",
51
+ "roles": [
52
+ "build"
53
+ ]
51
54
  },
52
55
  "publishConfig": {
53
56
  "access": "public"
package/src/compiler.ts CHANGED
@@ -1,53 +1,27 @@
1
1
  import fs from 'node:fs/promises';
2
2
 
3
- import { FileLoader, TypedObject, WatchEvent, watchCompiler } from '@travetto/base';
4
- import { EmailCompileSource, EmailCompiled, EmailCompileContext, MailUtil } from '@travetto/email';
3
+ import { TypedObject, Util, watchCompiler } from '@travetto/base';
4
+ import { EmailCompiled, MailUtil, EmailTemplateImport, EmailTemplateModule } from '@travetto/email';
5
5
  import { RuntimeIndex, path } from '@travetto/manifest';
6
- import { WorkQueue } from '@travetto/worker';
7
6
 
8
7
  import { EmailCompileUtil } from './util';
9
8
 
10
- const VALID_FILE = (file: string): boolean => /[.](scss|css|png|jpe?g|gif|ya?ml)$/.test(file) && !/[.]compiled[.]/.test(file);
11
-
12
9
  /**
13
10
  * Email compilation support
14
11
  */
15
12
  export class EmailCompiler {
16
13
 
17
14
  /**
18
- * Watch folders as needed
15
+ * Load Template
19
16
  */
20
- static async #watchFolders(folders: string[], handler: (ev: WatchEvent) => void, signal: AbortSignal): Promise<void> {
21
- for (const src of folders) {
22
- (async (): Promise<void> => {
23
- for await (const ev of fs.watch(src, {
24
- recursive: true,
25
- signal,
26
- persistent: false
27
- })) {
28
- const exists = ev.filename ? await fs.stat(ev.filename).catch(() => false) : false;
29
- handler({ action: !!exists ? 'create' : 'delete', file: path.toPosix(ev.filename!), folder: src });
30
- }
31
- })();
32
- }
33
- }
34
-
35
- /** Load Template */
36
- static async loadTemplate(file: string): Promise<EmailCompileContext> {
17
+ static async loadTemplate(file: string): Promise<EmailTemplateModule> {
37
18
  const entry = RuntimeIndex.getEntry(file);
38
- if (!entry) {
19
+ const mod = entry ? RuntimeIndex.getModule(entry.module) : undefined;
20
+ if (!entry || !mod) {
39
21
  throw new Error(`Unable to find template for ${file}`);
40
22
  }
41
- const root = (await import(entry.outputFile)).default;
42
- const og: EmailCompileSource = await root.wrap();
43
- const res: EmailCompileContext = {
44
- file: entry.sourceFile,
45
- module: entry.module,
46
- images: {},
47
- styles: {},
48
- ...og
49
- };
50
- return res;
23
+ const root: EmailTemplateImport = (await import(entry.outputFile)).default;
24
+ return await root.prepare({ file, module: mod.name });
51
25
  }
52
26
 
53
27
  /**
@@ -86,10 +60,10 @@ export class EmailCompiler {
86
60
  const outs = this.getOutputFiles(file);
87
61
  await Promise.all(TypedObject.keys(outs).map(async k => {
88
62
  if (msg[k]) {
89
- await fs.mkdir(path.dirname(outs[k]), { recursive: true });
90
- await fs.writeFile(outs[k], MailUtil.buildBrand(file, msg[k], 'trv email:compile'), { encoding: 'utf8' });
63
+ const content = MailUtil.buildBrand(file, msg[k], 'trv email:compile');
64
+ await Util.bufferedFileWrite(outs[k], content);
91
65
  } else {
92
- await fs.unlink(outs[k]).catch(() => { }); // Remove file if data not provided
66
+ await fs.rm(outs[k], { force: true }); // Remove file if data not provided
93
67
  }
94
68
  }));
95
69
  }
@@ -97,12 +71,10 @@ export class EmailCompiler {
97
71
  /**
98
72
  * Compile a file given a resource provider
99
73
  */
100
- static async compile(file: string, persist: boolean = false): Promise<EmailCompiled> {
101
- const src = await this.loadTemplate(file);
102
- const compiled = await EmailCompileUtil.compile(src);
103
- if (persist) {
104
- await this.writeTemplate(file, compiled);
105
- }
74
+ static async compile(file: string): Promise<EmailCompiled> {
75
+ const tpl = await this.loadTemplate(file);
76
+ const compiled = await EmailCompileUtil.compile(tpl);
77
+ await this.writeTemplate(file, compiled);
106
78
  return compiled;
107
79
  }
108
80
 
@@ -111,49 +83,24 @@ export class EmailCompiler {
111
83
  */
112
84
  static async compileAll(mod?: string): Promise<string[]> {
113
85
  const keys = this.findAllTemplates(mod);
114
- await Promise.all(keys.map(src => this.compile(src, true)));
86
+ await Promise.all(keys.map(src => this.compile(src)));
115
87
  return keys;
116
88
  }
117
89
 
118
90
  /**
119
91
  * Watch compilation
120
92
  */
121
- static async * watchCompile(): AsyncIterable<string> {
122
- const all = FileLoader.resolvePaths(
123
- this.findAllTemplates().map(x => `${RuntimeIndex.getEntry(x)!.module}#resources`)
124
- );
125
-
126
- const ctrl = new AbortController();
127
- const stream = new WorkQueue<WatchEvent>([], ctrl.signal);
128
-
129
- // watch resources
130
- this.#watchFolders(all, ev => stream.add(ev), ctrl.signal);
131
-
93
+ static async * watchCompile(signal?: AbortSignal): AsyncIterable<string> {
132
94
  // Watch template files
133
- watchCompiler(ev => {
134
- const src = RuntimeIndex.getEntry(ev.file);
135
- if (src && EmailCompileUtil.isTemplateFile(src.sourceFile)) {
136
- setTimeout(() => stream.add({ ...ev, file: src.sourceFile }), 100); // Wait for it to be loaded
137
- }
138
- }, { signal: ctrl.signal });
139
-
140
- for await (const { file, action } of stream) {
141
- if (action === 'delete') {
95
+ for await (const { file, action } of watchCompiler({ signal })) {
96
+ const src = RuntimeIndex.getEntry(file);
97
+ if (!src || !EmailCompileUtil.isTemplateFile(src.sourceFile) || action === 'delete') {
142
98
  continue;
143
99
  }
144
-
145
100
  try {
146
- if (EmailCompileUtil.isTemplateFile(file)) {
147
- await this.compile(file, true);
148
- console.log(`Successfully compiled ${1} templates`, { changed: [file] });
149
- yield file;
150
- } else if (VALID_FILE(file)) {
151
- const rootFile = file.replace(/\/resources.*/, '/package.json');
152
- const mod = RuntimeIndex.getFromSource(rootFile)!.module;
153
- const changed = await this.compileAll(mod);
154
- console.log(`Successfully compiled ${changed.length} templates`, { changed, file });
155
- yield* changed;
156
- }
101
+ await this.compile(file);
102
+ console.log('Successfully compiled template', { changed: [file] });
103
+ yield file;
157
104
  } catch (err) {
158
105
  console.error(`Error in compiling ${file}`, err && err instanceof Error ? err.message : `${err}`);
159
106
  }
package/src/util.ts CHANGED
@@ -1,11 +1,8 @@
1
1
  import util from 'node:util';
2
- import { Readable } from 'node:stream';
2
+ import { pipeline } from 'node:stream/promises';
3
3
 
4
- import { ResourceLoader, StreamUtil } from '@travetto/base';
5
- import {
6
- EmailTemplateImageConfig, EmailTemplateStyleConfig,
7
- EmailCompiled, EmailCompileContext
8
- } from '@travetto/email';
4
+ import { MemoryWritable } from '@travetto/base';
5
+ import { EmailCompiled, EmailTemplateModule, EmailTemplateResource } from '@travetto/email';
9
6
  import { ImageConverter } from '@travetto/image';
10
7
  import { path } from '@travetto/manifest';
11
8
 
@@ -80,12 +77,12 @@ export class EmailCompileUtil {
80
77
  /**
81
78
  * Compile SCSS content with roots as search paths for additional assets
82
79
  */
83
- static async compileSass(src: { data: string } | { file: string }, roots: string[] | readonly string[]): Promise<string> {
80
+ static async compileSass(src: { data: string } | { file: string }, opts: EmailTemplateResource): Promise<string> {
84
81
  const sass = await import('sass');
85
82
  const result = await util.promisify(sass.render)({
86
83
  ...src,
87
84
  sourceMap: false,
88
- includePaths: roots.slice(0)
85
+ includePaths: opts.loader.searchPaths.slice(0)
89
86
  });
90
87
  return result!.css.toString();
91
88
  }
@@ -134,22 +131,27 @@ export class EmailCompileUtil {
134
131
  /**
135
132
  * Inline image sources
136
133
  */
137
- static async inlineImages(html: string, opts: EmailTemplateImageConfig): Promise<string> {
134
+ static async inlineImages(html: string, opts: EmailTemplateResource): Promise<string> {
138
135
  const { tokens, finalize } = await this.tokenizeResources(html, this.#HTML_CSS_IMAGE_URLS);
139
- const pendingImages: [token: string, ext: string, stream: Readable | Promise<Readable>][] = [];
140
- const resource = new ResourceLoader(opts.search);
136
+ const pendingImages: [token: string, ext: string, stream: Buffer | Promise<Buffer>][] = [];
141
137
 
142
138
  for (const [token, src] of tokens) {
143
139
  const ext = path.extname(src);
144
- const stream = await resource.readStream(src);
145
- pendingImages.push([token, ext, /^[.](jpe?g|png)$/.test(ext) ?
146
- ImageConverter.optimize(ext === '.png' ? 'png' : 'jpeg', stream) : stream]);
140
+ if (/^[.](jpe?g|png)$/.test(ext)) {
141
+ const output = await ImageConverter.optimize(
142
+ ext === '.png' ? 'png' : 'jpeg', await opts.loader.readStream(src)
143
+ );
144
+ const buffer = new MemoryWritable();
145
+ await pipeline(output, buffer);
146
+ pendingImages.push([token, ext, buffer.toBuffer()]);
147
+ } else {
148
+ pendingImages.push([token, ext, opts.loader.read(src, true)]);
149
+ }
147
150
  }
148
151
 
149
- const imageMap = new Map(await Promise.all(pendingImages.map(async ([token, ext, stream]) => {
150
- const data = await StreamUtil.streamToBuffer(await stream);
151
- return [token, `data:image/${ext.replace('.', '')};base64,${data.toString('base64')}`] as const;
152
- })));
152
+ const imageMap = new Map(await Promise.all(pendingImages.map(async ([token, ext, data]) =>
153
+ [token, `data:image/${ext.replace('.', '')};base64,${data.toString('base64')}`] as const
154
+ )));
153
155
 
154
156
  return finalize(token => imageMap.get(token)!);
155
157
  }
@@ -176,22 +178,14 @@ export class EmailCompileUtil {
176
178
  /**
177
179
  * Apply styles into a given html document
178
180
  */
179
- static async applyStyles(html: string, opts: EmailTemplateStyleConfig): Promise<string> {
180
- const styles: string[] = [];
181
-
182
- if (opts.global) {
183
- styles.push(opts.global);
184
- }
185
-
186
- const resource = new ResourceLoader(opts.search);
187
- const main = await resource.read('/email/main.scss').then(d => d, () => '');
188
-
189
- if (main) {
190
- styles.push(main);
191
- }
181
+ static async applyStyles(html: string, opts: EmailTemplateResource): Promise<string> {
182
+ const styles = [
183
+ opts.globalStyles ?? '',
184
+ await opts.loader.read('/email/main.scss').catch(() => '')
185
+ ].filter(x => !!x).join('\n');
192
186
 
193
187
  if (styles.length) {
194
- const compiled = await this.compileSass({ data: styles.join('\n') }, resource.searchPaths);
188
+ const compiled = await this.compileSass({ data: styles }, opts);
195
189
 
196
190
  // Remove all unused styles
197
191
  const finalStyles = await this.pruneCss(html, compiled);
@@ -203,21 +197,21 @@ export class EmailCompileUtil {
203
197
  return html;
204
198
  }
205
199
 
206
- static async compile(src: EmailCompileContext): Promise<EmailCompiled> {
207
- const subject = await this.simplifiedText(await src.subject(src));
208
- const text = await this.simplifiedText(await src.text(src));
200
+ static async compile(src: EmailTemplateModule): Promise<EmailCompiled> {
201
+ const subject = await this.simplifiedText(await src.subject());
202
+ const text = await this.simplifiedText(await src.text());
209
203
 
210
- let html = await src.html(src);
204
+ let html = await src.html();
211
205
 
212
- if (src.styles?.inline !== false) {
213
- html = await this.applyStyles(html, src.styles!);
206
+ if (src.inlineStyle !== false) {
207
+ html = await this.applyStyles(html, src);
214
208
  }
215
209
 
216
210
  // Fix up html edge cases
217
211
  html = this.handleHtmlEdgeCases(html);
218
212
 
219
- if (src.images?.inline !== false) {
220
- html = await this.inlineImages(html, src.images!);
213
+ if (src.inlineImages !== false) {
214
+ html = await this.inlineImages(html, src);
221
215
  }
222
216
 
223
217
  return { html, subject, text };
@@ -1,29 +1,19 @@
1
1
  import fs from 'node:fs/promises';
2
2
 
3
- import { IndexedModule, RuntimeIndex, path } from '@travetto/manifest';
3
+ import { RuntimeContext } from '@travetto/manifest';
4
4
  import { YamlUtil } from '@travetto/yaml';
5
+ import { Util } from '@travetto/base';
5
6
 
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
- }
7
+ import { EditorConfigType } from './types';
8
+
9
+ export const CONFIG_FILE = 'resources/email/local.yml';
19
10
 
20
11
  /**
21
12
  * Configuration utils
22
13
  */
23
- export class $EditorConfig {
14
+ export class EditorConfig {
24
15
 
25
- #configFile: Record<string, string> = {};
26
- #defaultConfig = {
16
+ static DEFAULT_CONFIG = {
27
17
  to: 'my-email@gmail.com',
28
18
  from: 'from-email@gmail.com',
29
19
  context: {
@@ -39,49 +29,32 @@ export class $EditorConfig {
39
29
  },
40
30
  };
41
31
 
42
- #getEmailConfig(mod: IndexedModule): string {
43
- return this.#configFile[mod.name] ??= path.resolve(mod.sourcePath, 'resources/email/local.yml');
44
- }
45
-
46
32
  /**
47
33
  *
48
34
  */
49
- async get(file: string): Promise<ConfigType> {
35
+ static async get<K extends keyof EditorConfigType>(key: K): Promise<Exclude<EditorConfigType[K], undefined>>;
36
+ static async get(): Promise<EditorConfigType>;
37
+ static async get<K extends keyof EditorConfigType>(key?: K): Promise<EditorConfigType | EditorConfigType[K]> {
50
38
  try {
51
- const mod = RuntimeIndex.getModuleFromSource(file)!;
52
- const resolved = this.#getEmailConfig(mod);
39
+ const resolved = RuntimeContext.workspaceRelative(CONFIG_FILE);
53
40
  const content = await fs.readFile(resolved, 'utf8');
54
- return YamlUtil.parse<ConfigType>(content);
41
+ const data = YamlUtil.parse<EditorConfigType>(content);
42
+ return key ? data[key] : data;
55
43
  } catch {
56
44
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
57
- return {} as ConfigType;
45
+ return {} as EditorConfigType;
58
46
  }
59
47
  }
60
48
 
61
- async getContext(file: string): Promise<Exclude<ConfigType['context'], undefined>> {
62
- const conf = await this.get(file);
63
- return conf.context ?? {};
49
+ static getDefaultConfig(): string {
50
+ return YamlUtil.serialize(this.DEFAULT_CONFIG);
64
51
  }
65
52
 
66
- async getSenderConfig(file: string): Promise<Exclude<ConfigType['sender'], undefined>> {
67
- const conf = await this.get(file);
68
- return conf.sender ?? {};
69
- }
70
-
71
- getDefaultConfig(): string {
72
- return YamlUtil.serialize(this.#defaultConfig);
73
- }
74
-
75
- async ensureConfig(file: string): Promise<string> {
76
- console.log('Ensuring config', file);
77
- const mod = RuntimeIndex.getModuleFromSource(file)!;
78
- const resolved = this.#getEmailConfig(mod);
53
+ static async ensureConfig(): Promise<string> {
54
+ const resolved = RuntimeContext.workspaceRelative(CONFIG_FILE);
79
55
  if (!(await fs.stat(resolved).catch(() => { }))) {
80
- await fs.mkdir(path.dirname(resolved), { recursive: true });
81
- await fs.writeFile(resolved, this.getDefaultConfig(), { encoding: 'utf8' });
56
+ await Util.bufferedFileWrite(resolved, this.getDefaultConfig());
82
57
  }
83
58
  return resolved;
84
59
  }
85
- }
86
-
87
- export const EditorConfig = new $EditorConfig();
60
+ }
@@ -1,118 +1,97 @@
1
- import { ExecUtil } from '@travetto/base';
1
+ import { Inject, Injectable } from '@travetto/di';
2
+ import { MailUtil, EmailCompiled, MailInterpolator } from '@travetto/email';
3
+ import { AppError, TypedObject } from '@travetto/base';
2
4
 
3
- import { EmailCompilationManager } from './manager';
4
5
  import { EditorSendService } from './send';
5
6
  import { EditorConfig } from './config';
7
+ import { EditorRequest, EditorResponse } from './types';
6
8
 
7
9
  import { EmailCompiler } from '../../src/compiler';
8
- import { EmailCompileUtil } from '../../src/util';
9
-
10
- type InboundMessage =
11
- { type: 'configure', file: string } |
12
- { type: 'redraw', file: string } |
13
- { type: 'send', file: string, from?: string, to?: string };
14
-
15
- type OutboundMessage =
16
- { type: 'configured', file: string } |
17
- { type: 'sent', to: string, file: string, url?: string | false } |
18
- { type: 'changed', file: string, content: Record<'html' | 'subject' | 'text', string> } |
19
- { type: 'sent-failed', message: string, stack: Error['stack'], to: string, file: string } |
20
- { type: 'changed-failed', message: string, stack: Error['stack'], file: string };
21
10
 
22
11
  /**
23
12
  * Utils for interacting with editors
24
13
  */
25
- export class EditorState {
14
+ @Injectable()
15
+ export class EditorService {
26
16
 
27
- #lastFile = '';
28
- #template: EmailCompilationManager;
17
+ @Inject()
18
+ sender: EditorSendService;
29
19
 
30
- constructor(template: EmailCompilationManager) {
31
- this.#template = template;
32
- }
20
+ @Inject()
21
+ engine: MailInterpolator;
33
22
 
34
- async renderFile(file: string): Promise<void> {
35
- file = EmailCompileUtil.isTemplateFile(file) ? file : this.#lastFile;
36
- if (file) {
37
- try {
38
- const content = await this.#template.resolveCompiledTemplate(
39
- file, await EditorConfig.getContext(file)
40
- );
41
- this.response({
42
- type: 'changed',
43
- file,
44
- content
45
- });
46
- } catch (err) {
47
- if (err && err instanceof Error) {
48
- this.response({ type: 'changed-failed', message: err.message, stack: err.stack, file });
49
- } else {
50
- console.error(err);
51
- }
52
- }
53
- }
23
+ async #interpolate(text: string, context: Record<string, unknown>): Promise<string> {
24
+ return Promise.resolve(this.engine.render(text, context)).then(MailUtil.purgeBrand);
54
25
  }
55
26
 
56
- response(response: OutboundMessage): void {
57
- if (process.connected) {
58
- process.send?.(response);
59
- }
27
+ async #renderTemplate(rel: string, context: Record<string, unknown>): Promise<EmailCompiled> {
28
+ const p = await EmailCompiler.compile(rel);
29
+ return TypedObject.fromEntries(
30
+ await Promise.all(TypedObject.entries(p).map(([k, v]) => this.#interpolate(v, context).then((t) => [k, t])))
31
+ );
60
32
  }
61
33
 
62
- async onConfigure(msg: InboundMessage & { type: 'configure' }): Promise<void> {
63
- this.response({ type: 'configured', file: await EditorConfig.ensureConfig(msg.file) });
34
+ async #renderFile(file: string): Promise<{ content: EmailCompiled, file: string }> {
35
+ const content = await this.#renderTemplate(file, await EditorConfig.get('context'));
36
+ return { content, file };
64
37
  }
65
38
 
66
- async #onRedraw(msg: InboundMessage & { type: 'redraw' }): Promise<void> {
39
+ async #response<T>(op: Promise<T>, success: (v: T) => EditorResponse, fail?: (err: Error) => EditorResponse): Promise<void> {
67
40
  try {
68
- await EmailCompiler.compile(msg.file, true);
69
- await this.renderFile(msg.file);
41
+ const res = await op;
42
+ if (process.connected) { process.send?.(success(res)); }
70
43
  } catch (err) {
71
- if (err && err instanceof Error) {
72
- this.response({ type: 'changed-failed', message: err.message, stack: err.stack, file: msg.file });
44
+ if (fail && process.connected && err && err instanceof Error) {
45
+ process.send?.(fail(err));
73
46
  } else {
74
47
  console.error(err);
75
48
  }
76
49
  }
77
50
  }
78
51
 
79
- async onSend(msg: InboundMessage & { type: 'send' }): Promise<void> {
80
- const cfg = await EditorConfig.get(msg.file);
81
- const to = msg.to || cfg.to;
82
- const from = msg.from || cfg.from;
83
- const content = await this.#template.resolveCompiledTemplate(
84
- msg.file, await EditorConfig.getContext(msg.file)
85
- );
86
-
87
- try {
88
- const url = await EditorSendService.sendEmail(msg.file, { from, to, ...content, });
89
- this.response({ type: 'sent', to, file: msg.file, ...url });
90
- } catch (err) {
91
- if (err && err instanceof Error) {
92
- this.response({ type: 'sent-failed', message: err.message, stack: err.stack, to, file: msg.file });
93
- } else {
94
- console.error(err);
95
- }
96
- }
52
+ async sendFile(file: string, to?: string): Promise<{ to: string, file: string, url?: string | false | undefined }> {
53
+ const cfg = await EditorConfig.get();
54
+ to ||= cfg.to;
55
+ const content = await this.#renderTemplate(file, cfg.context ?? {});
56
+ return { to, file, ...await this.sender.send({ from: cfg.from, to, ...content, }) };
97
57
  }
98
58
 
99
59
  /**
100
60
  * Initialize context, and listeners
101
61
  */
102
- async init(): Promise<void> {
103
- process.on('message', (msg: InboundMessage) => {
62
+ async listen(): Promise<void> {
63
+ if (!process.connected || !process.send) {
64
+ throw new AppError('Unable to run email editor, missing ipc channel', 'permissions');
65
+ }
66
+ process.on('message', async (msg: EditorRequest) => {
104
67
  switch (msg.type) {
105
- case 'configure': this.onConfigure(msg); break;
106
- case 'redraw': this.#onRedraw(msg); break;
107
- case 'send': this.onSend(msg); break;
68
+ case 'configure': {
69
+ return await this.#response(EditorConfig.ensureConfig(), file => ({ type: 'configured', file }));
70
+ }
71
+ case 'compile': {
72
+ return await this.#response(this.#renderFile(msg.file),
73
+ res => ({ type: 'compiled', ...res }),
74
+ err => ({ type: 'compiled-failed', message: err.message, stack: err.stack, file: msg.file })
75
+ );
76
+ }
77
+ case 'send': {
78
+ return await this.#response(
79
+ this.sendFile(msg.file, msg.to),
80
+ res => ({ type: 'sent', ...res }),
81
+ err => ({ type: 'sent-failed', message: err.message, stack: err.stack, to: msg.to!, file: msg.file })
82
+ );
83
+ }
108
84
  }
109
85
  });
110
86
 
111
- ExecUtil.exitOnDisconnect();
112
- process.send?.('ready');
87
+ process.once('disconnect', () => process.exit());
88
+ process.send({ type: 'init' });
113
89
 
114
- for await (const f of EmailCompiler.watchCompile()) {
115
- await this.renderFile(f);
90
+ for await (const file of EmailCompiler.watchCompile()) {
91
+ await this.#response(this.#renderFile(file),
92
+ res => ({ type: 'compiled', ...res }),
93
+ err => ({ type: 'compiled-failed', message: err.message, stack: err.stack, file })
94
+ );
116
95
  }
117
96
  }
118
97
  }
@@ -1,72 +1,54 @@
1
1
  import { MailService, EmailOptions, SentEmail } from '@travetto/email';
2
2
  import { MailTransportTarget } from '@travetto/email/src/internal/types';
3
- import { DependencyRegistry } from '@travetto/di';
3
+ import { DependencyRegistry, Injectable } from '@travetto/di';
4
4
 
5
5
  import { EditorConfig } from './config';
6
- import { RuntimeIndex } from '@travetto/manifest';
7
6
 
8
7
  /**
9
- * Util for sending emails
8
+ * Editor mail sender
10
9
  */
10
+ @Injectable()
11
11
  export class EditorSendService {
12
12
 
13
- static #svc: Record<string, MailService> = {};
13
+ ethereal = false;
14
14
 
15
- /**
16
- * Get mail service
17
- */
18
- static async getMailService(file: string): Promise<MailService> {
19
- const mod = RuntimeIndex.getModuleFromSource(file)!.name;
20
-
21
- if (!this.#svc[mod]) {
22
- const senderConfig = await EditorConfig.getSenderConfig(file);
15
+ async service(): Promise<MailService> {
16
+ const transports = DependencyRegistry.getCandidateTypes(MailTransportTarget);
23
17
 
24
- if (senderConfig?.host?.includes('ethereal.email')) {
25
- const cls = class { };
18
+ if (!transports.length) {
19
+ try {
26
20
  const { NodemailerTransport } = await import('@travetto/email-nodemailer');
21
+ const senderConfig = await EditorConfig.get('sender');
22
+ const cls = class { };
27
23
  DependencyRegistry.registerFactory({
28
24
  fn: () => new NodemailerTransport(senderConfig),
29
25
  target: MailTransportTarget,
30
26
  src: cls,
31
27
  id: 'nodemailer',
32
28
  });
33
-
34
29
  DependencyRegistry.install(cls, { curr: cls, type: 'added' });
35
- } else if (!DependencyRegistry.getCandidateTypes(MailTransportTarget).length) {
36
- const errorMessage = `
37
- Please configure your email setup and/or credentials for testing. In the file \`email/local.yml\`, you can specify \`sender\` configuration.
38
- Email sending will not work until the above is fixed. A sample configuration would look like:
39
30
 
40
- ${EditorConfig.getDefaultConfig()}`.trim();
41
- console.error(errorMessage);
42
- throw new Error(errorMessage);
31
+ this.ethereal = !!senderConfig.host?.includes('ethereal.email');
32
+ } catch (err) {
33
+ console.error('A mail transport is currently needed to support sending emails. Please install @travetto/email-nodemailer or any other compatible transport');
34
+ throw new Error('A mail transport is currently needed to support sending emails. Please install @travetto/email-nodemailer or any other compatible transport');
43
35
  }
44
-
45
- this.#svc[mod] = await DependencyRegistry.getInstance(MailService);
46
36
  }
47
- return this.#svc[mod];
37
+ return await DependencyRegistry.getInstance(MailService);
48
38
  }
49
39
 
50
40
  /**
51
- * Resolve template
41
+ * Send email
52
42
  */
53
- static async sendEmail(file: string, message: EmailOptions): Promise<{
54
- url?: string | false;
55
- }> {
43
+ async send(message: EmailOptions): Promise<{ url?: string | false }> {
56
44
  const to = message.to!;
57
45
  try {
58
46
  console.log('Sending email', { to });
59
- // Let the engine template
60
- const svc = await this.getMailService(file);
61
- if (!svc) {
62
- throw new Error('Node mailer support is missing');
63
- }
64
-
47
+ const svc = await this.service();
65
48
  const info = await svc.send<{ host?: string } & SentEmail>(message);
66
49
  console.log('Sent email', { to });
67
50
 
68
- const senderConfig = await EditorConfig.getSenderConfig(file);
69
- return senderConfig.host?.includes('ethereal.email') ? {
51
+ return this.ethereal ? {
70
52
  // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
71
53
  url: (await import('nodemailer')).getTestMessageUrl(info as any)
72
54
  } : {};
@@ -0,0 +1,28 @@
1
+ export type EditorRequest =
2
+ { type: 'configure', file: string } |
3
+ { type: 'compile', file: string } |
4
+ { type: 'send', file: string, from?: string, to?: string };
5
+
6
+ export type EditorResponse =
7
+ { type: 'configured', file: string } |
8
+ { type: 'sent', to: string, file: string, url?: string | false } |
9
+ { type: 'compiled', file: string, content: Record<'html' | 'subject' | 'text', string> } |
10
+ { type: 'sent-failed', message: string, stack: Error['stack'], to: string, file: string } |
11
+ { type: 'compiled-failed', message: string, stack: Error['stack'], file: string } |
12
+ { type: 'init' };
13
+
14
+ export type EditorSender = {
15
+ port?: number;
16
+ host?: string;
17
+ auth?: {
18
+ user?: string;
19
+ pass?: string;
20
+ };
21
+ };
22
+
23
+ export interface EditorConfigType {
24
+ to: string;
25
+ from: string;
26
+ context?: Record<string, unknown>;
27
+ sender?: EditorSender;
28
+ }
@@ -1,6 +1,7 @@
1
1
  import { RootRegistry } from '@travetto/registry';
2
2
  import { CliCommandShape, CliCommand, cliTpl } from '@travetto/cli';
3
3
  import { Env } from '@travetto/base';
4
+ import { RuntimeContext } from '@travetto/manifest';
4
5
 
5
6
  import { EmailCompiler } from '../src/compiler';
6
7
 
@@ -15,6 +16,7 @@ export class EmailCompileCommand implements CliCommandShape {
15
16
 
16
17
  preMain(): void {
17
18
  Env.DEBUG.set(false);
19
+ Env.TRV_ROLE.set('build');
18
20
  Env.TRV_DYNAMIC.set(this.watch);
19
21
  }
20
22
 
@@ -25,11 +27,10 @@ export class EmailCompileCommand implements CliCommandShape {
25
27
  const all = await EmailCompiler.compileAll();
26
28
  console!.log(cliTpl`Successfully compiled ${{ param: `${all.length}` }} templates`);
27
29
  for (const el of all) {
28
- console!.log(cliTpl` * ${{ param: el }}`);
30
+ console!.log(cliTpl` * ${{ param: el.replace(`${RuntimeContext.workspace.path}/`, '') }}`);
29
31
  }
30
32
 
31
33
  if (this.watch) {
32
-
33
34
  for await (const _ of EmailCompiler.watchCompile()) {
34
35
  // Iterate until done
35
36
  }
@@ -1,9 +1,9 @@
1
1
  import { Env } from '@travetto/base';
2
2
  import { CliCommand, CliUtil } from '@travetto/cli';
3
3
  import { RootRegistry } from '@travetto/registry';
4
+ import { DependencyRegistry } from '@travetto/di';
4
5
 
5
- import { EditorState } from './bin/editor';
6
- import { EmailCompilationManager } from './bin/manager';
6
+ import { EditorService } from './bin/editor';
7
7
 
8
8
  /** The email editor compilation service and output serving */
9
9
  @CliCommand({ addEnv: true })
@@ -11,6 +11,7 @@ export class EmailEditorCommand {
11
11
 
12
12
  preMain(): void {
13
13
  Env.TRV_DYNAMIC.set(true);
14
+ Env.TRV_ROLE.set('build');
14
15
  }
15
16
 
16
17
  async main(): Promise<void> {
@@ -19,6 +20,7 @@ export class EmailEditorCommand {
19
20
  }
20
21
 
21
22
  await RootRegistry.init();
22
- await new EditorState(await EmailCompilationManager.createInstance()).init();
23
+ const service = await DependencyRegistry.getInstance(EditorService);
24
+ await service.listen();
23
25
  }
24
26
  }
@@ -1,12 +1,10 @@
1
1
  import { path } from '@travetto/manifest';
2
2
  import { RootRegistry } from '@travetto/registry';
3
3
  import { CliCommandShape, CliCommand } from '@travetto/cli';
4
+ import { DependencyRegistry } from '@travetto/di';
5
+ import { Env } from '@travetto/base';
4
6
 
5
- import { EmailCompilationManager } from './bin/manager';
6
- import { EditorConfig } from './bin/config';
7
- import { EditorSendService } from './bin/send';
8
-
9
- import { EmailCompiler } from '../src/compiler';
7
+ import { EditorService } from './bin/editor';
10
8
 
11
9
  /**
12
10
  * CLI Entry point for running the email server
@@ -14,15 +12,14 @@ import { EmailCompiler } from '../src/compiler';
14
12
  @CliCommand({ addEnv: true })
15
13
  export class EmailTestCommand implements CliCommandShape {
16
14
 
15
+ preMain(): void {
16
+ Env.TRV_ROLE.set('build');
17
+ }
18
+
17
19
  async main(file: string, to: string): Promise<void> {
18
20
  file = path.resolve(file);
19
21
  await RootRegistry.init();
20
- await EmailCompiler.compile(file, true);
21
-
22
- const mgr = await EmailCompilationManager.createInstance();
23
- const cfg = await EditorConfig.get(file);
24
- const content = await mgr.resolveCompiledTemplate(file, await EditorConfig.getContext(file));
25
-
26
- await EditorSendService.sendEmail(file, { from: cfg.from, to, ...content, });
22
+ const editor = await DependencyRegistry.getInstance(EditorService);
23
+ await editor.sendFile(file, to);
27
24
  }
28
25
  }
@@ -1,60 +0,0 @@
1
- import fs from 'node:fs/promises';
2
-
3
- import { type MailInterpolator, type EmailCompiled } from '@travetto/email';
4
- import { DependencyRegistry } from '@travetto/di';
5
- import { TypedObject } from '@travetto/base';
6
- import { MailInterpolatorTarget } from '@travetto/email/src/internal/types';
7
-
8
- import { EmailCompiler } from '../../src/compiler';
9
-
10
- /**
11
- *
12
- */
13
- export class EmailCompilationManager {
14
-
15
- static async createInstance(): Promise<EmailCompilationManager> {
16
- return new EmailCompilationManager(
17
- await DependencyRegistry.getInstance<MailInterpolator>(MailInterpolatorTarget),
18
- );
19
- }
20
-
21
- engine: MailInterpolator;
22
-
23
- constructor(engine: MailInterpolator) {
24
- this.engine = engine;
25
- }
26
-
27
- /**
28
- * Resolve template
29
- */
30
- async resolveTemplateParts(file: string): Promise<EmailCompiled> {
31
- const files = EmailCompiler.getOutputFiles(file);
32
- const missing = await Promise.all(Object.values(files).map(x => fs.stat(file).catch(() => { })));
33
-
34
- if (missing.some(x => x === undefined)) {
35
- await EmailCompiler.compile(file, true);
36
- }
37
-
38
- const parts = await Promise.all(
39
- TypedObject.entries(files).map(
40
- ([key, partFile]) => fs.readFile(partFile, 'utf8')
41
- .then(content => [key, content] as const)
42
- )
43
- );
44
- return TypedObject.fromEntries<keyof EmailCompiled, string>(parts);
45
- }
46
-
47
- /**
48
- * Render
49
- * @param rel
50
- */
51
- async resolveCompiledTemplate(rel: string, context: Record<string, unknown>): Promise<EmailCompiled> {
52
- const { MailUtil } = await import('@travetto/email');
53
- const { html, text, subject } = await this.resolveTemplateParts(rel);
54
-
55
- const get = (input: string): Promise<string> =>
56
- Promise.resolve(this.engine.render(input, context)).then(MailUtil.purgeBrand);
57
-
58
- return { html: await get(html), text: await get(text), subject: await get(subject) };
59
- }
60
- }