@adaptivestone/framework 5.0.0-alpha.9 → 5.0.0-beta.10

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +123 -1
  2. package/Cli.js +3 -4
  3. package/cluster.js +0 -2
  4. package/commands/CreateUser.js +35 -0
  5. package/commands/Documentation.js +4 -0
  6. package/commands/DropIndex.js +14 -5
  7. package/commands/GenerateRandomBytes.js +21 -0
  8. package/commands/GetOpenApiJson.js +17 -2
  9. package/commands/SyncIndexes.js +1 -0
  10. package/commands/migration/Create.js +21 -10
  11. package/commands/migration/Migrate.js +1 -0
  12. package/config/auth.js +4 -1
  13. package/config/ipDetector.js +14 -0
  14. package/controllers/Auth.js +22 -31
  15. package/controllers/Home.js +1 -1
  16. package/eslint.config.js +68 -0
  17. package/folderConfig.js +0 -1
  18. package/helpers/files.js +9 -12
  19. package/helpers/logger.js +0 -1
  20. package/helpers/yup.js +2 -4
  21. package/jsconfig.json +4 -4
  22. package/models/Lock.js +107 -0
  23. package/models/User.js +26 -3
  24. package/modules/AbstractCommand.js +26 -2
  25. package/modules/AbstractController.js +19 -26
  26. package/modules/AbstractModel.d.ts +48 -0
  27. package/modules/AbstractModel.js +37 -10
  28. package/modules/Base.d.ts +4 -4
  29. package/modules/Base.js +13 -2
  30. package/modules/BaseCli.js +99 -11
  31. package/package.json +28 -25
  32. package/server.d.ts +9 -7
  33. package/server.js +45 -11
  34. package/services/cache/Cache.d.ts +1 -1
  35. package/services/cache/Cache.js +9 -7
  36. package/services/http/HttpServer.js +11 -16
  37. package/services/http/middleware/AbstractMiddleware.js +3 -3
  38. package/services/http/middleware/GetUserByToken.js +3 -2
  39. package/services/http/middleware/I18n.js +22 -23
  40. package/services/http/middleware/IpDetector.js +59 -0
  41. package/services/http/middleware/Pagination.js +7 -6
  42. package/services/http/middleware/RateLimiter.js +10 -4
  43. package/services/http/middleware/RequestLogger.js +3 -3
  44. package/services/http/middleware/RequestParser.js +1 -1
  45. package/services/messaging/email/templates/.gitkeep +0 -0
  46. package/services/validate/ValidateService.js +5 -5
  47. package/services/validate/drivers/AbstractValidator.js +2 -2
  48. package/services/validate/drivers/CustomValidator.js +2 -2
  49. package/services/validate/drivers/YupValidator.js +3 -3
  50. package/tests/setup.js +8 -6
  51. package/tests/setupVitest.js +9 -7
  52. package/types/ICommandArguments.d.ts +41 -0
  53. package/types/TFoldersConfig.d.ts +7 -4
  54. package/vitest.config.js +4 -3
  55. package/.eslintrc.cjs +0 -41
  56. package/commands/Generate.js +0 -14
  57. package/config/mail.js +0 -29
  58. package/controllers/Auth.test.js +0 -451
  59. package/controllers/Home.test.js +0 -12
  60. package/models/Migration.test.js +0 -20
  61. package/models/Sequence.test.js +0 -43
  62. package/models/User.test.js +0 -143
  63. package/modules/Modules.test.js +0 -18
  64. package/services/cache/Cache.test.js +0 -81
  65. package/services/http/middleware/Auth.test.js +0 -57
  66. package/services/http/middleware/Cors.test.js +0 -147
  67. package/services/http/middleware/GetUserByToken.test.js +0 -108
  68. package/services/http/middleware/I18n.test.js +0 -96
  69. package/services/http/middleware/PrepareAppInfo.test.js +0 -26
  70. package/services/http/middleware/RateLimiter.test.js +0 -233
  71. package/services/http/middleware/RequestParser.test.js +0 -121
  72. package/services/http/middleware/Role.test.js +0 -93
  73. package/services/messaging/email/index.js +0 -217
  74. package/services/messaging/email/templates/emptyTemplate/html.pug +0 -9
  75. package/services/messaging/email/templates/emptyTemplate/subject.pug +0 -1
  76. package/services/messaging/email/templates/emptyTemplate/text.pug +0 -1
  77. package/services/messaging/index.js +0 -3
  78. package/services/validate/ValidateService.test.js +0 -107
@@ -1,233 +0,0 @@
1
- import { setTimeout } from 'node:timers/promises';
2
- import crypto from 'node:crypto';
3
- import { beforeAll, afterAll, describe, it, expect } from 'vitest';
4
-
5
- import RateLimiter from './RateLimiter.js';
6
-
7
- let mongoRateLimiter;
8
-
9
- describe('rate limiter methods', () => {
10
- beforeAll(async () => {
11
- await setTimeout(20);
12
-
13
- mongoRateLimiter = new RateLimiter(global.server.app, {
14
- driver: 'mongo',
15
- limiterOptions: {
16
- keyPrefix: `mongo_${Date.now()}_${crypto.randomUUID()}}`,
17
- },
18
- });
19
- });
20
-
21
- afterAll(async () => {
22
- // we need to wait because redis mongo ask mongo to create indexes
23
- await setTimeout(200);
24
- });
25
- it('have description fields', async () => {
26
- expect.assertions(1);
27
- const middleware = new RateLimiter(global.server.app, {
28
- driver: 'redis',
29
- });
30
- expect(middleware.constructor.description).toBeDefined();
31
- });
32
-
33
- it('can create redis rateLimiter', async () => {
34
- expect.assertions(1);
35
-
36
- const redisRateLimiter = new RateLimiter(global.server.app, {
37
- driver: 'redis',
38
- });
39
-
40
- expect(redisRateLimiter.limiter).toBeDefined();
41
- });
42
-
43
- it('can not create rateLimiter with unknown driver', async () => {
44
- expect.assertions(1);
45
-
46
- const rateLimiter = new RateLimiter(global.server.app, {
47
- driver: 'unknown',
48
- });
49
-
50
- expect(rateLimiter.limiter).toBeNull();
51
- });
52
-
53
- it('generateConsumeKey works correctly', async () => {
54
- expect.assertions(1);
55
-
56
- const redisRateLimiter = new RateLimiter(global.server.app, {
57
- driver: 'redis',
58
- });
59
-
60
- const res = await redisRateLimiter.gerenateConsumeKey({
61
- ip: '192.168.0.0',
62
- appInfo: {
63
- user: {
64
- id: 'someId',
65
- },
66
- },
67
- });
68
-
69
- expect(res).toBe('192.168.0.0__someId');
70
- });
71
-
72
- it('generateConsumeKey with request works correctly', async () => {
73
- expect.assertions(1);
74
-
75
- const redisRateLimiter = new RateLimiter(global.server.app, {
76
- driver: 'redis',
77
- consumeKeyComponents: {
78
- request: ['email'],
79
- },
80
- });
81
-
82
- const res = await redisRateLimiter.gerenateConsumeKey({
83
- ip: '192.168.0.0',
84
- body: {
85
- email: 'foo@example.com',
86
- },
87
- });
88
-
89
- expect(res).toBe('192.168.0.0__foo@example.com');
90
- });
91
-
92
- it('middleware without driver should fail', async () => {
93
- expect.assertions(2);
94
- const rateLimiter = new RateLimiter(global.server.app, {
95
- driver: 'unknown',
96
- });
97
- const req = {
98
- appInfo: {},
99
- };
100
- let status;
101
- let isSend;
102
- await rateLimiter.middleware(
103
- req,
104
- {
105
- status(statusCode) {
106
- status = statusCode;
107
- return this;
108
- },
109
- json() {
110
- isSend = true;
111
- },
112
- },
113
- () => {},
114
- );
115
- expect(status).toBe(500);
116
- expect(isSend).toBeTruthy();
117
- });
118
-
119
- const makeOneRequest = async ({ rateLimiter, driver, request }) => {
120
- let realRateLimiter = rateLimiter;
121
- if (!realRateLimiter) {
122
- realRateLimiter = new RateLimiter(global.server.app, {
123
- driver,
124
- });
125
- }
126
- const req = {
127
- appInfo: {},
128
- ...request,
129
- };
130
- let status;
131
- let isSend = false;
132
- let isNextCalled = false;
133
- await realRateLimiter.middleware(
134
- req,
135
- {
136
- status(statusCode) {
137
- status = statusCode;
138
- return this;
139
- },
140
- json() {
141
- isSend = true;
142
- },
143
- },
144
- () => {
145
- isNextCalled = true;
146
- },
147
- );
148
- return { status, isSend, isNextCalled };
149
- };
150
-
151
- it('middleware should works with a mongo drivers', async () => {
152
- expect.assertions(1);
153
- const { isNextCalled } = await makeOneRequest({
154
- rateLimiter: mongoRateLimiter,
155
- request: { ip: '10.10.0.1' },
156
- });
157
- expect(isNextCalled).toBeTruthy();
158
- });
159
-
160
- it('middleware should works with a memory drivers', async () => {
161
- expect.assertions(1);
162
- const { isNextCalled } = await makeOneRequest({
163
- driver: 'memory',
164
- request: { ip: '10.10.0.1' },
165
- });
166
- expect(isNextCalled).toBeTruthy();
167
- });
168
-
169
- it('middleware should works with a redis drivers', async () => {
170
- expect.assertions(1);
171
- const { isNextCalled } = await makeOneRequest({
172
- driver: 'redis',
173
- request: { ip: '10.10.0.1' },
174
- });
175
- expect(isNextCalled).toBeTruthy();
176
- });
177
-
178
- it('middleware should rate limits for us. mongo driver', async () => {
179
- expect.assertions(2);
180
-
181
- const middlewares = Array.from({ length: 20 }, () =>
182
- makeOneRequest({ rateLimiter: mongoRateLimiter }),
183
- );
184
-
185
- const data = await Promise.all(middlewares);
186
-
187
- const status = data.find((obj) => obj.status === 429);
188
- const isSend = data.find((obj) => obj.isSend);
189
-
190
- expect(status.status).toBe(429);
191
- expect(isSend.isSend).toBeTruthy();
192
- });
193
-
194
- it('middleware should rate limits for us. memory driver', async () => {
195
- expect.assertions(2);
196
-
197
- const rateLimiter = new RateLimiter(global.server.app, {
198
- driver: 'memory',
199
- });
200
-
201
- const middlewares = Array.from({ length: 20 }, () =>
202
- makeOneRequest({ rateLimiter }),
203
- );
204
-
205
- const data = await Promise.all(middlewares);
206
-
207
- const status = data.find((obj) => obj.status === 429);
208
- const isSend = data.find((obj) => obj.isSend);
209
-
210
- expect(status.status).toBe(429);
211
- expect(isSend.isSend).toBeTruthy();
212
- });
213
-
214
- it('middleware should rate limits for us. redis driver', async () => {
215
- expect.assertions(2);
216
-
217
- const rateLimiter = new RateLimiter(global.server.app, {
218
- driver: 'redis',
219
- });
220
-
221
- const middlewares = Array.from({ length: 20 }, () =>
222
- makeOneRequest({ rateLimiter }),
223
- );
224
-
225
- const data = await Promise.all(middlewares);
226
-
227
- const status = data.find((obj) => obj.status === 429);
228
- const isSend = data.find((obj) => obj.isSend);
229
-
230
- expect(status.status).toBe(429);
231
- expect(isSend.isSend).toBeTruthy();
232
- });
233
- });
@@ -1,121 +0,0 @@
1
- import { createServer } from 'node:http';
2
- import { describe, it, expect } from 'vitest';
3
- import { PersistentFile } from 'formidable';
4
-
5
- import RequestParser from './RequestParser.js';
6
-
7
- describe('reqest parser limiter methods', () => {
8
- it('have description fields', async () => {
9
- expect.assertions(1);
10
- const middleware = new RequestParser(global.server.app);
11
- expect(middleware.constructor.description).toBeDefined();
12
- });
13
- it('middleware that works', async () => {
14
- expect.assertions(4);
15
-
16
- await new Promise((done) => {
17
- // from https://github.com/node-formidable/formidable/blob/master/test-node/standalone/promise.test.js
18
-
19
- const server = createServer(async (req, res) => {
20
- req.appInfo = {};
21
- const middleware = new RequestParser(global.server.app);
22
- middleware.middleware(req, {}, (err) => {
23
- expect(err).toBeUndefined();
24
- expect(req.body.title).toBeDefined();
25
- expect(req.body.multipleFiles).toBeDefined();
26
- expect(
27
- req.body.multipleFiles[0] instanceof PersistentFile,
28
- ).toBeTruthy();
29
-
30
- res.writeHead(200);
31
- res.end('ok');
32
- });
33
- });
34
- server.listen(null, async () => {
35
- const chosenPort = server.address().port;
36
- const body = `----13068458571765726332503797717\r
37
- Content-Disposition: form-data; name="title"\r
38
- \r
39
- a\r
40
- ----13068458571765726332503797717\r
41
- Content-Disposition: form-data; name="multipleFiles"; filename="x.txt"\r
42
- Content-Type: application/x-javascript\r
43
- \r
44
- \r
45
- \r
46
- a\r
47
- b\r
48
- c\r
49
- d\r
50
- \r
51
- ----13068458571765726332503797717--\r
52
- `;
53
- await fetch(String(new URL(`http:localhost:${chosenPort}/`)), {
54
- method: 'POST',
55
-
56
- headers: {
57
- 'Content-Length': body.length,
58
- Host: `localhost:${chosenPort}`,
59
- 'Content-Type':
60
- 'multipart/form-data; boundary=--13068458571765726332503797717',
61
- },
62
- body,
63
- }).catch((err) => {
64
- console.error(err);
65
- done(err);
66
- });
67
- server.close(() => {
68
- done();
69
- });
70
- });
71
- });
72
- });
73
- it('middleware with a problem', async () => {
74
- expect.assertions(1);
75
-
76
- await new Promise((done) => {
77
- // from https://github.com/node-formidable/formidable/blob/master/test-node/standalone/promise.test.js
78
-
79
- const server = createServer(async (req, res) => {
80
- req.appInfo = {};
81
- const middleware = new RequestParser(global.server.app);
82
- let status;
83
-
84
- const resp = {
85
- status: (code) => {
86
- status = code;
87
- return resp;
88
- },
89
- json: () => resp,
90
- };
91
- await middleware.middleware(req, resp, () => {});
92
- expect(status).toBe(400);
93
- // expect(err).toBeDefined();
94
-
95
- res.writeHead(200);
96
- res.end('ok');
97
- });
98
- server.listen(null, async () => {
99
- const chosenPort = server.address().port;
100
- const body = 'someBadBody';
101
-
102
- await fetch(String(new URL(`http:localhost:${chosenPort}/`)), {
103
- method: 'POST',
104
-
105
- headers: {
106
- 'Content-Length': body.length,
107
- Host: `localhost:${chosenPort}`,
108
- 'Content-Type': 'badContentType',
109
- },
110
- body,
111
- }).catch((err) => {
112
- console.error(err);
113
- done(err);
114
- });
115
- server.close(() => {
116
- done();
117
- });
118
- });
119
- });
120
- });
121
- });
@@ -1,93 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import Role from './Role.js';
3
-
4
- describe('role middleware methods', () => {
5
- it('have description fields', async () => {
6
- expect.assertions(1);
7
- const middleware = new Role(global.server.app);
8
- expect(middleware.constructor.description).toBeDefined();
9
- });
10
-
11
- it('middleware pass when user presented with a right role', async () => {
12
- expect.assertions(1);
13
- let isCalled = false;
14
- const nextFunction = () => {
15
- isCalled = true;
16
- };
17
- const req = {
18
- appInfo: {
19
- user: {
20
- roles: ['role1', 'role2'],
21
- },
22
- },
23
- };
24
- const middleware = new Role(global.server.app, {
25
- roles: ['admin', 'role1'],
26
- });
27
-
28
- await middleware.middleware(req, {}, nextFunction);
29
- expect(isCalled).toBeTruthy();
30
- });
31
-
32
- it('middleware NOT pass when user NOT presented', async () => {
33
- expect.assertions(3);
34
- let isCalled = false;
35
- let status;
36
- let isSend;
37
- const nextFunction = () => {
38
- isCalled = true;
39
- };
40
- const req = {
41
- appInfo: {}, // no user
42
- };
43
- const middleware = new Role(global.server.app);
44
- await middleware.middleware(
45
- req,
46
- {
47
- status(statusCode) {
48
- status = statusCode;
49
- return this;
50
- },
51
- json() {
52
- isSend = true;
53
- },
54
- },
55
- nextFunction,
56
- );
57
- expect(isCalled).toBeFalsy();
58
- expect(status).toBe(401);
59
- expect(isSend).toBeTruthy();
60
- });
61
-
62
- it('middleware NOT pass when user have a wrong role', async () => {
63
- expect.assertions(3);
64
- let isCalled = false;
65
- let status;
66
- let isSend;
67
- const nextFunction = () => {
68
- isCalled = true;
69
- };
70
- const req = {
71
- appInfo: {
72
- user: { roles: ['role1', 'role2'] },
73
- },
74
- };
75
- const middleware = new Role(global.server.app, { roles: ['admin'] });
76
- await middleware.middleware(
77
- req,
78
- {
79
- status(statusCode) {
80
- status = statusCode;
81
- return this;
82
- },
83
- json() {
84
- isSend = true;
85
- },
86
- },
87
- nextFunction,
88
- );
89
- expect(isCalled).toBeFalsy();
90
- expect(status).toBe(403);
91
- expect(isSend).toBeTruthy();
92
- });
93
- });
@@ -1,217 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import * as url from 'node:url';
4
- import { promisify } from 'node:util';
5
- import nodemailer from 'nodemailer';
6
- import sendMail from 'nodemailer-sendmail-transport';
7
- import stub from 'nodemailer-stub-transport';
8
- import pug from 'pug';
9
- import juice from 'juice';
10
- import { convert } from 'html-to-text';
11
- import Base from '../../../modules/Base.js';
12
-
13
- const mailTransports = {
14
- sendMail,
15
- stub,
16
- smtp: (data) => data,
17
- };
18
-
19
- class Mail extends Base {
20
- /**
21
- * Construct mail class
22
- * @param {object} app
23
- * @param {string} template template name
24
- * @param {object} [templateData={}] data to render in template. Object with value that available inside template
25
- * @param {object} [i18n] data to render in template
26
- */
27
- constructor(app, template, templateData = {}, i18n = null) {
28
- super(app);
29
- const dirname = url.fileURLToPath(new URL('.', import.meta.url));
30
- if (!path.isAbsolute(template)) {
31
- if (
32
- fs.existsSync(
33
- `${this.app.foldersConfig.emails}/${path.basename(template)}`,
34
- )
35
- ) {
36
- this.template = `${this.app.foldersConfig.emails}/${path.basename(
37
- template,
38
- )}`;
39
- } else if (
40
- fs.existsSync(
41
- path.join(dirname, `/templates/${path.basename(template)}`),
42
- )
43
- ) {
44
- this.template = path.join(
45
- dirname,
46
- `/templates/${path.basename(template)}`,
47
- );
48
- } else {
49
- this.template = path.join(dirname, `/templates/emptyTemplate`);
50
- this.logger.error(
51
- `Template '${template}' not found. Using 'emptyTemplate' as a fallback`,
52
- );
53
- }
54
- }
55
- this.templateData = templateData;
56
- this.i18n = i18n ?? {
57
- t: (str) => str,
58
- locale: 'en', // todo change it to config
59
- };
60
- this.locale = this.i18n?.language;
61
- }
62
-
63
- /**
64
- * Render template
65
- * @param {object} type and fullpath
66
- * @param {object} templateData
67
- * @returns string
68
- */
69
- // eslint-disable-next-line class-methods-use-this
70
- async #renderTemplateFile({ type, fullPath } = {}, templateData = {}) {
71
- if (!type) {
72
- return null;
73
- }
74
-
75
- switch (type) {
76
- case 'html':
77
- case 'text':
78
- case 'css':
79
- return fs.promises.readFile(fullPath, { encoding: 'utf8' });
80
- case 'pug': {
81
- const compiledFunction = pug.compileFile(fullPath);
82
- return compiledFunction(templateData);
83
- }
84
- default:
85
- throw new Error(`Template type ${type} is not supported`);
86
- }
87
- }
88
-
89
- /**
90
- * Render template
91
- * @return {Promise}
92
- */
93
- async renderTemplate() {
94
- const files = await fs.promises.readdir(this.template);
95
- const templates = {};
96
- for (const file of files) {
97
- const [name, extension] = file.split('.');
98
- templates[name] = {
99
- type: extension,
100
- fullPath: path.join(this.template, file),
101
- };
102
- }
103
-
104
- if (!templates.html || !templates.subject) {
105
- throw new Error(
106
- 'Template HTML and Subject must be provided. Please follow documentation for details https://framework.adaptivestone.com/docs/email',
107
- );
108
- }
109
- const mailConfig = this.app.getConfig('mail');
110
-
111
- const templateDataToRender = {
112
- locale: this.locale,
113
- t: this.i18n.t.bind(this.i18n),
114
- ...mailConfig.globalVariablesToTemplates,
115
- ...this.templateData,
116
- };
117
-
118
- const [htmlRendered, subjectRendered, textRendered, extraCss] =
119
- await Promise.all([
120
- this.#renderTemplateFile(templates.html, templateDataToRender),
121
- this.#renderTemplateFile(templates.subject, templateDataToRender),
122
- this.#renderTemplateFile(templates.text, templateDataToRender),
123
- this.#renderTemplateFile(templates.style),
124
- ]);
125
-
126
- juice.tableElements = ['TABLE'];
127
-
128
- const juiceResourcesAsync = promisify(juice.juiceResources);
129
-
130
- const inlinedHTML = await juiceResourcesAsync(htmlRendered, {
131
- preserveImportant: true,
132
- webResources: mailConfig.webResources,
133
- extraCss,
134
- });
135
- return {
136
- htmlRaw: htmlRendered,
137
- subject: subjectRendered,
138
- text: textRendered,
139
- inlinedHTML,
140
- };
141
- }
142
-
143
- /**
144
- * Send email
145
- * @param {string} to email send to
146
- * @param {string} [from = mailConfig.from]
147
- * @param {object} [aditionalNodemailerOptions = {}] additional option to nodemailer
148
- * @return {Promise}
149
- */
150
- async send(to, from = null, aditionalNodemailerOptions = {}) {
151
- const { subject, text, inlinedHTML } = await this.renderTemplate();
152
-
153
- return this.constructor.sendRaw(
154
- this.app,
155
- to,
156
- subject,
157
- inlinedHTML,
158
- text,
159
- from,
160
- aditionalNodemailerOptions,
161
- );
162
- }
163
-
164
- /**
165
- * Send provided text (html) to email. Low level function. All data should be prepared before sending (like inline styles)
166
- * @param {import('../../../server.js').default['app']} app application
167
- * @param {string} to send to
168
- * @param {string} subject email topic
169
- * @param {string} html hmlt body of emain
170
- * @param {string} [text] if not provided will be generated from html string
171
- * @param {string} [from = mailConfig.from] from. If not provided will be grabbed from config
172
- * @param {object} [additionalNodeMailerOption = {}] any otipns to pass to nodemailer https://nodemailer.com/message/
173
- */
174
- static async sendRaw(
175
- app,
176
- to,
177
- subject,
178
- html,
179
- text = null,
180
- from = null,
181
- additionalNodeMailerOption = {},
182
- ) {
183
- if (!app || !to || !subject || !html) {
184
- throw new Error('App, to, subject and html is required fields.');
185
- }
186
- const mailConfig = app.getConfig('mail');
187
- if (!from) {
188
- // eslint-disable-next-line no-param-reassign
189
- from = mailConfig.from;
190
- }
191
-
192
- if (!text) {
193
- // eslint-disable-next-line no-param-reassign
194
- text = convert(html, {
195
- selectors: [{ selector: 'img', format: 'skip' }],
196
- });
197
- }
198
- const transportConfig = mailConfig.transports[mailConfig.transport];
199
- const transport = mailTransports[mailConfig.transport];
200
- const transporter = nodemailer.createTransport(transport(transportConfig));
201
-
202
- return transporter.sendMail({
203
- from,
204
- to,
205
- subject,
206
- text,
207
- html,
208
- ...additionalNodeMailerOption,
209
- });
210
- }
211
-
212
- static get loggerGroup() {
213
- return 'email_';
214
- }
215
- }
216
-
217
- export default Mail;
@@ -1,9 +0,0 @@
1
- doctype html
2
- html(lang='en')
3
- head
4
- meta(charset='UTF-8')
5
- title New message
6
- body
7
- h1 Good day
8
- p
9
- | Sorry, message template not found
@@ -1 +0,0 @@
1
- = `New message`
@@ -1,3 +0,0 @@
1
- import email from './email/index.js';
2
-
3
- export { email };