webspresso 0.0.100 → 0.0.102
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/.agents/ADMIN.md +47 -4
- package/.agents/AGENTS.md +3 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/AUTH.md +63 -0
- package/.agents/CLI.md +81 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/EMAIL.md +84 -2
- package/.agents/EVENTS.md +179 -0
- package/.agents/ORM.md +59 -0
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +163 -3
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +7 -0
- package/README.md +18 -5
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/db-makemigrations.js +83 -0
- package/bin/commands/list.js +96 -0
- package/bin/commands/make-command.js +93 -0
- package/bin/commands/make-event.js +80 -0
- package/bin/commands/make-factory.js +72 -0
- package/bin/commands/make-mail.js +93 -0
- package/bin/commands/make-policy.js +106 -0
- package/bin/commands/routes.js +8 -0
- package/bin/commands/run.js +195 -0
- package/bin/webspresso.js +17 -0
- package/core/applySchema.js +18 -1
- package/core/auth/bouncer.js +196 -0
- package/core/auth/index.js +10 -0
- package/core/auth/middleware.js +15 -1
- package/core/commands/define-command.js +48 -0
- package/core/commands/index.js +18 -0
- package/core/commands/registry.js +156 -0
- package/core/commands/runner.js +76 -0
- package/core/compression/compressible.js +1 -1
- package/core/compression/middleware.js +35 -251
- package/core/content/service.js +8 -2
- package/core/events/discovery.js +142 -0
- package/core/events/dispatcher.js +552 -0
- package/core/events/index.js +23 -0
- package/core/events/signal.js +92 -0
- package/core/forms/flash.js +234 -0
- package/core/forms/form-builder.js +214 -0
- package/core/forms/index.js +16 -0
- package/core/mail/index.js +23 -0
- package/core/mail/mailable.js +250 -0
- package/core/mail/mailer.js +184 -0
- package/core/mail/transports.js +238 -0
- package/core/orm/factory.js +250 -0
- package/core/orm/index.js +6 -0
- package/core/orm/migrations/diff.js +185 -0
- package/core/orm/migrations/index.js +22 -4
- package/core/orm/model.js +4 -0
- package/index.d.ts +422 -1
- package/index.js +111 -16
- package/package.json +9 -5
- package/plugins/admin-panel/api.js +509 -3
- package/plugins/admin-panel/index.js +29 -0
- package/plugins/content/models/content-entry.js +4 -3
- package/plugins/content/models/content-type.js +4 -3
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +1 -1
- package/plugins/email/index.js +48 -0
- package/plugins/email/preview-handler.js +555 -0
- package/plugins/email/service.js +23 -12
- package/plugins/email/transport.js +30 -11
- package/plugins/file-manager/index.js +13 -0
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- package/plugins/redirect/index.js +1 -1
- package/src/api/api-loader.js +18 -2
- package/src/api/validation-i18n.js +231 -0
- package/src/app-context.js +48 -6
- package/src/dev-reload/index.js +9 -1
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +80 -5
- package/src/file-router.js +153 -91
- package/src/helpers.js +143 -0
- package/src/njk-frontmatter.js +17 -1
- package/src/pages/page-loader.js +1 -1
- package/src/plugin-manager.js +12 -0
- package/src/routing/cascading.js +14 -10
- package/src/routing/route-table.js +1 -1
- package/src/server.js +243 -34
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/template/precompile.js +247 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative Mailable Base Class (AdonisJS & Laravel-inspired)
|
|
3
|
+
* @module core/mail/mailable
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Base class for declarative, reusable email templates
|
|
8
|
+
*/
|
|
9
|
+
class Mailable {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.message = {
|
|
12
|
+
to: [],
|
|
13
|
+
from: null,
|
|
14
|
+
replyTo: null,
|
|
15
|
+
cc: [],
|
|
16
|
+
bcc: [],
|
|
17
|
+
subject: '',
|
|
18
|
+
template: null,
|
|
19
|
+
html: null,
|
|
20
|
+
text: null,
|
|
21
|
+
mjml: null,
|
|
22
|
+
data: {},
|
|
23
|
+
attachments: [],
|
|
24
|
+
headers: {},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Add recipient(s)
|
|
30
|
+
* @param {string|string[]|Object} recipient
|
|
31
|
+
* @param {string} [name]
|
|
32
|
+
* @returns {this}
|
|
33
|
+
*/
|
|
34
|
+
to(recipient, name) {
|
|
35
|
+
if (Array.isArray(recipient)) {
|
|
36
|
+
this.message.to.push(...recipient);
|
|
37
|
+
} else if (name && typeof recipient === 'string') {
|
|
38
|
+
this.message.to.push({ address: recipient, name });
|
|
39
|
+
} else if (recipient) {
|
|
40
|
+
this.message.to.push(recipient);
|
|
41
|
+
}
|
|
42
|
+
return this;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Set sender address
|
|
47
|
+
* @param {string} address
|
|
48
|
+
* @param {string} [name]
|
|
49
|
+
* @returns {this}
|
|
50
|
+
*/
|
|
51
|
+
from(address, name) {
|
|
52
|
+
this.message.from = name ? { address, name } : address;
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Set reply-to address
|
|
58
|
+
* @param {string} address
|
|
59
|
+
* @returns {this}
|
|
60
|
+
*/
|
|
61
|
+
replyTo(address) {
|
|
62
|
+
this.message.replyTo = address;
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Add CC recipient(s)
|
|
68
|
+
* @param {string|string[]} recipient
|
|
69
|
+
* @returns {this}
|
|
70
|
+
*/
|
|
71
|
+
cc(recipient) {
|
|
72
|
+
if (Array.isArray(recipient)) {
|
|
73
|
+
this.message.cc.push(...recipient);
|
|
74
|
+
} else if (recipient) {
|
|
75
|
+
this.message.cc.push(recipient);
|
|
76
|
+
}
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Add BCC recipient(s)
|
|
82
|
+
* @param {string|string[]} recipient
|
|
83
|
+
* @returns {this}
|
|
84
|
+
*/
|
|
85
|
+
bcc(recipient) {
|
|
86
|
+
if (Array.isArray(recipient)) {
|
|
87
|
+
this.message.bcc.push(...recipient);
|
|
88
|
+
} else if (recipient) {
|
|
89
|
+
this.message.bcc.push(recipient);
|
|
90
|
+
}
|
|
91
|
+
return this;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Set email subject line
|
|
96
|
+
* @param {string} subj
|
|
97
|
+
* @returns {this}
|
|
98
|
+
*/
|
|
99
|
+
subject(subj) {
|
|
100
|
+
this.message.subject = subj;
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Set template name / identifier
|
|
106
|
+
* @param {string} tplName
|
|
107
|
+
* @returns {this}
|
|
108
|
+
*/
|
|
109
|
+
template(tplName) {
|
|
110
|
+
this.message.template = tplName;
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Set raw HTML body
|
|
116
|
+
* @param {string} htmlContent
|
|
117
|
+
* @returns {this}
|
|
118
|
+
*/
|
|
119
|
+
html(htmlContent) {
|
|
120
|
+
this.message.html = htmlContent;
|
|
121
|
+
return this;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Set plain text body
|
|
126
|
+
* @param {string} textContent
|
|
127
|
+
* @returns {this}
|
|
128
|
+
*/
|
|
129
|
+
text(textContent) {
|
|
130
|
+
this.message.text = textContent;
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Set MJML template string
|
|
136
|
+
* @param {string} mjmlContent
|
|
137
|
+
* @returns {this}
|
|
138
|
+
*/
|
|
139
|
+
mjml(mjmlContent) {
|
|
140
|
+
this.message.mjml = mjmlContent;
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Set template view and/or view data
|
|
146
|
+
* @param {string|Object} templateOrData
|
|
147
|
+
* @param {Object} [data]
|
|
148
|
+
* @returns {this}
|
|
149
|
+
*/
|
|
150
|
+
view(templateOrData, data) {
|
|
151
|
+
if (typeof templateOrData === 'string') {
|
|
152
|
+
this.message.template = templateOrData;
|
|
153
|
+
if (data && typeof data === 'object') {
|
|
154
|
+
Object.assign(this.message.data, data);
|
|
155
|
+
}
|
|
156
|
+
} else if (templateOrData && typeof templateOrData === 'object') {
|
|
157
|
+
Object.assign(this.message.data, templateOrData);
|
|
158
|
+
}
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Assign template data
|
|
164
|
+
* @param {string|Object} keyOrObj
|
|
165
|
+
* @param {*} [value]
|
|
166
|
+
* @returns {this}
|
|
167
|
+
*/
|
|
168
|
+
data(keyOrObj, value) {
|
|
169
|
+
if (typeof keyOrObj === 'string') {
|
|
170
|
+
this.message.data[keyOrObj] = value;
|
|
171
|
+
} else if (keyOrObj && typeof keyOrObj === 'object') {
|
|
172
|
+
Object.assign(this.message.data, keyOrObj);
|
|
173
|
+
}
|
|
174
|
+
return this;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Add attachment
|
|
179
|
+
* @param {Object} attachment
|
|
180
|
+
* @returns {this}
|
|
181
|
+
*/
|
|
182
|
+
attach(attachment) {
|
|
183
|
+
if (attachment) {
|
|
184
|
+
this.message.attachments.push(attachment);
|
|
185
|
+
}
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Set custom header
|
|
191
|
+
* @param {string} key
|
|
192
|
+
* @param {string} value
|
|
193
|
+
* @returns {this}
|
|
194
|
+
*/
|
|
195
|
+
header(key, value) {
|
|
196
|
+
this.message.headers[key] = value;
|
|
197
|
+
return this;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Subclass hook executed before sending
|
|
202
|
+
*/
|
|
203
|
+
prepare() {}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Subclass hook executed to build email definition
|
|
207
|
+
*/
|
|
208
|
+
build() {}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Convert to normalized message payload object
|
|
212
|
+
* @returns {Object}
|
|
213
|
+
*/
|
|
214
|
+
toMessage() {
|
|
215
|
+
// Copy any class field declarations (e.g. `template = 'welcome'`, `subjectText = 'Hi'`)
|
|
216
|
+
if (!this.message.template && typeof this.template === 'string') {
|
|
217
|
+
this.message.template = this.template;
|
|
218
|
+
}
|
|
219
|
+
if (!this.message.subject && (this.subjectText || this._subject)) {
|
|
220
|
+
this.message.subject = this.subjectText || this._subject;
|
|
221
|
+
}
|
|
222
|
+
if (!this.message.from && this.fromAddress) {
|
|
223
|
+
this.message.from = this.fromAddress;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.prepare();
|
|
227
|
+
this.build();
|
|
228
|
+
|
|
229
|
+
const msg = {
|
|
230
|
+
...this.message,
|
|
231
|
+
data: { ...this.message.data },
|
|
232
|
+
attachments: [...this.message.attachments],
|
|
233
|
+
headers: { ...this.message.headers },
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (Array.isArray(msg.to)) {
|
|
237
|
+
if (msg.to.length === 1) {
|
|
238
|
+
msg.to = msg.to[0];
|
|
239
|
+
} else if (msg.to.length === 0) {
|
|
240
|
+
msg.to = null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return msg;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
Mailable,
|
|
250
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core Mailer Service (AdonisJS & Django-inspired)
|
|
3
|
+
* @module core/mail/mailer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { Mailable } = require('./mailable');
|
|
7
|
+
const { resolveTransport, MemoryTransport } = require('./transports');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mailer service for managing email dispatch, previewing, and outbox tracking
|
|
11
|
+
*/
|
|
12
|
+
class Mailer {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} [options={}]
|
|
15
|
+
* @param {Object} [options.transport] - Custom transport or options for resolveTransport
|
|
16
|
+
* @param {Object} [options.defaults] - Default from/replyTo
|
|
17
|
+
* @param {Function} [options.renderer] - Template renderer function (template, data) => Promise<{ html, text }>
|
|
18
|
+
* @param {Object} [options.events] - Optional event emitter / signal dispatcher
|
|
19
|
+
* @param {Function} [options.logger] - Optional logger
|
|
20
|
+
*/
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.options = options;
|
|
23
|
+
this.transport = resolveTransport(options);
|
|
24
|
+
this.defaults = options.defaults || {};
|
|
25
|
+
this.renderer = options.renderer || null;
|
|
26
|
+
this.events = options.events || null;
|
|
27
|
+
this.logger = options.logger || console;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Send an email using a Mailable instance or raw options object
|
|
32
|
+
* @param {Mailable|Object} mailableOrOptions
|
|
33
|
+
* @returns {Promise<Object>}
|
|
34
|
+
*/
|
|
35
|
+
async send(mailableOrOptions) {
|
|
36
|
+
if (!mailableOrOptions) {
|
|
37
|
+
throw new Error('mailableOrOptions is required to send email');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const msg = mailableOrOptions instanceof Mailable
|
|
41
|
+
? mailableOrOptions.toMessage()
|
|
42
|
+
: { ...mailableOrOptions };
|
|
43
|
+
|
|
44
|
+
if (!msg.to || (Array.isArray(msg.to) && msg.to.length === 0)) {
|
|
45
|
+
throw new Error('Email recipient "to" is required');
|
|
46
|
+
}
|
|
47
|
+
if (!msg.subject) {
|
|
48
|
+
throw new Error('Email "subject" is required');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Default from
|
|
52
|
+
if (!msg.from && this.defaults.from) {
|
|
53
|
+
msg.from = this.defaults.from;
|
|
54
|
+
}
|
|
55
|
+
if (!msg.replyTo && this.defaults.replyTo) {
|
|
56
|
+
msg.replyTo = this.defaults.replyTo;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Render template if template identifier provided and renderer configured
|
|
60
|
+
if (msg.template && this.renderer && typeof this.renderer === 'function') {
|
|
61
|
+
const rendered = await this.renderer(msg.template, msg.data || {});
|
|
62
|
+
if (rendered) {
|
|
63
|
+
msg.html = rendered.html || msg.html;
|
|
64
|
+
msg.text = rendered.text || msg.text;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fallback simple text from HTML if text not provided
|
|
69
|
+
if (!msg.text && typeof msg.html === 'string') {
|
|
70
|
+
msg.text = msg.html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = await this.transport.sendMail(msg);
|
|
75
|
+
|
|
76
|
+
if (this.events && typeof this.events.emit === 'function') {
|
|
77
|
+
this.events.emit('mail:sent', { message: msg, result });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
messageId: result.messageId,
|
|
83
|
+
response: result.response,
|
|
84
|
+
accepted: result.accepted,
|
|
85
|
+
rejected: result.rejected,
|
|
86
|
+
result,
|
|
87
|
+
};
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (this.events && typeof this.events.emit === 'function') {
|
|
90
|
+
this.events.emit('mail:failed', { message: msg, error: err });
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Preview a Mailable without sending
|
|
98
|
+
* @param {Mailable|Object} mailableOrOptions
|
|
99
|
+
* @returns {Promise<Object>}
|
|
100
|
+
*/
|
|
101
|
+
async preview(mailableOrOptions) {
|
|
102
|
+
if (!mailableOrOptions) {
|
|
103
|
+
throw new Error('mailableOrOptions is required to preview email');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const msg = mailableOrOptions instanceof Mailable
|
|
107
|
+
? mailableOrOptions.toMessage()
|
|
108
|
+
: { ...mailableOrOptions };
|
|
109
|
+
|
|
110
|
+
if (msg.template && this.renderer && typeof this.renderer === 'function') {
|
|
111
|
+
const rendered = await this.renderer(msg.template, msg.data || {});
|
|
112
|
+
if (rendered) {
|
|
113
|
+
msg.html = rendered.html || msg.html;
|
|
114
|
+
msg.text = rendered.text || msg.text;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
to: msg.to,
|
|
120
|
+
from: msg.from || this.defaults.from,
|
|
121
|
+
subject: msg.subject,
|
|
122
|
+
html: msg.html || '',
|
|
123
|
+
text: msg.text || '',
|
|
124
|
+
data: msg.data || {},
|
|
125
|
+
attachments: msg.attachments || [],
|
|
126
|
+
headers: msg.headers || {},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get messages from in-memory outbox (if MemoryTransport active)
|
|
132
|
+
* @returns {Array<Object>}
|
|
133
|
+
*/
|
|
134
|
+
getOutbox() {
|
|
135
|
+
if (this.transport && typeof this.transport.getOutbox === 'function') {
|
|
136
|
+
return this.transport.getOutbox();
|
|
137
|
+
}
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Clear in-memory outbox
|
|
143
|
+
*/
|
|
144
|
+
clearOutbox() {
|
|
145
|
+
if (this.transport && typeof this.transport.clearOutbox === 'function') {
|
|
146
|
+
this.transport.clearOutbox();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get latest sent message from outbox
|
|
152
|
+
* @returns {Object|null}
|
|
153
|
+
*/
|
|
154
|
+
lastMessage() {
|
|
155
|
+
if (this.transport && typeof this.transport.lastMessage === 'function') {
|
|
156
|
+
return this.transport.lastMessage();
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check connection/transport health
|
|
163
|
+
*/
|
|
164
|
+
async verify() {
|
|
165
|
+
if (this.transport && typeof this.transport.verify === 'function') {
|
|
166
|
+
return this.transport.verify();
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Factory to create Mailer instance
|
|
174
|
+
* @param {Object} options
|
|
175
|
+
* @returns {Mailer}
|
|
176
|
+
*/
|
|
177
|
+
function createMailer(options = {}) {
|
|
178
|
+
return new Mailer(options);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
Mailer,
|
|
183
|
+
createMailer,
|
|
184
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail Transports / Backends (Django Email Backends & AdonisJS-inspired)
|
|
3
|
+
* @module core/mail/transports
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
let nodemailer;
|
|
11
|
+
try {
|
|
12
|
+
nodemailer = require('nodemailer');
|
|
13
|
+
} catch {
|
|
14
|
+
nodemailer = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-Memory Transport for testing & local development dev inbox (Django MemoryEmailBackend)
|
|
19
|
+
*/
|
|
20
|
+
class MemoryTransport {
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.name = 'memory';
|
|
23
|
+
this.outbox = [];
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Send mail into in-memory outbox array
|
|
29
|
+
* @param {Object} mailOptions
|
|
30
|
+
* @returns {Promise<Object>}
|
|
31
|
+
*/
|
|
32
|
+
async sendMail(mailOptions) {
|
|
33
|
+
const id = mailOptions.messageId || `msg_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
34
|
+
const sentEntry = {
|
|
35
|
+
id,
|
|
36
|
+
messageId: id,
|
|
37
|
+
...mailOptions,
|
|
38
|
+
sentAt: new Date(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
this.outbox.push(sentEntry);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
messageId: id,
|
|
45
|
+
accepted: Array.isArray(mailOptions.to) ? mailOptions.to : [mailOptions.to],
|
|
46
|
+
rejected: [],
|
|
47
|
+
response: '250 OK: Message stored in MemoryTransport outbox',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getOutbox() {
|
|
52
|
+
return [...this.outbox];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
clearOutbox() {
|
|
56
|
+
this.outbox = [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
lastMessage() {
|
|
60
|
+
return this.outbox.length > 0 ? this.outbox[this.outbox.length - 1] : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
findById(id) {
|
|
64
|
+
return this.outbox.find((msg) => msg.id === id || msg.messageId === id) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async verify() {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Console Transport for development inspection (Django ConsoleEmailBackend)
|
|
74
|
+
*/
|
|
75
|
+
class ConsoleTransport {
|
|
76
|
+
constructor(options = {}) {
|
|
77
|
+
this.name = 'console';
|
|
78
|
+
this.logger = options.logger || console;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async sendMail(mailOptions) {
|
|
82
|
+
const id = `console_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
|
|
83
|
+
const border = '='.repeat(60);
|
|
84
|
+
|
|
85
|
+
this.logger.log(`\n${border}`);
|
|
86
|
+
this.logger.log(`📧 [EMAIL SENT VIA CONSOLE TRANSPORT] (${id})`);
|
|
87
|
+
this.logger.log(`From: ${mailOptions.from || '(default)'}`);
|
|
88
|
+
this.logger.log(`To: ${Array.isArray(mailOptions.to) ? mailOptions.to.join(', ') : mailOptions.to}`);
|
|
89
|
+
if (mailOptions.cc) this.logger.log(`Cc: ${mailOptions.cc}`);
|
|
90
|
+
if (mailOptions.bcc) this.logger.log(`Bcc: ${mailOptions.bcc}`);
|
|
91
|
+
this.logger.log(`Subject: ${mailOptions.subject}`);
|
|
92
|
+
this.logger.log(`Date: ${new Date().toISOString()}`);
|
|
93
|
+
this.logger.log('-'.repeat(60));
|
|
94
|
+
if (mailOptions.text) {
|
|
95
|
+
this.logger.log('--- Plain Text ---');
|
|
96
|
+
this.logger.log(mailOptions.text);
|
|
97
|
+
}
|
|
98
|
+
if (mailOptions.html) {
|
|
99
|
+
this.logger.log('--- HTML Content ---');
|
|
100
|
+
this.logger.log(mailOptions.html.length > 300 ? `${mailOptions.html.slice(0, 300)}... (truncated)` : mailOptions.html);
|
|
101
|
+
}
|
|
102
|
+
this.logger.log(`${border}\n`);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
messageId: id,
|
|
106
|
+
accepted: Array.isArray(mailOptions.to) ? mailOptions.to : [mailOptions.to],
|
|
107
|
+
rejected: [],
|
|
108
|
+
response: '250 OK: Logged to console',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async verify() {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* File-based Transport for offline inspection (Django FileEmailBackend)
|
|
119
|
+
*/
|
|
120
|
+
class FileTransport {
|
|
121
|
+
constructor(options = {}) {
|
|
122
|
+
this.name = 'file';
|
|
123
|
+
this.dir = options.dir || path.join(process.cwd(), '.cache', 'emails');
|
|
124
|
+
if (!fs.existsSync(this.dir)) {
|
|
125
|
+
try {
|
|
126
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async sendMail(mailOptions) {
|
|
134
|
+
const id = `mail_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
135
|
+
const filePath = path.join(this.dir, `${id}.json`);
|
|
136
|
+
const data = {
|
|
137
|
+
id,
|
|
138
|
+
...mailOptions,
|
|
139
|
+
sentAt: new Date().toISOString(),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.warn('[FileTransport] Failed to write file:', err.message);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
messageId: id,
|
|
150
|
+
accepted: Array.isArray(mailOptions.to) ? mailOptions.to : [mailOptions.to],
|
|
151
|
+
rejected: [],
|
|
152
|
+
filePath,
|
|
153
|
+
response: `250 OK: Written to ${filePath}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async verify() {
|
|
158
|
+
return fs.existsSync(this.dir);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build SMTP config from env vars
|
|
164
|
+
* @returns {Object|null}
|
|
165
|
+
*/
|
|
166
|
+
function smtpFromEnv() {
|
|
167
|
+
const host = process.env.SMTP_HOST;
|
|
168
|
+
if (!host) return null;
|
|
169
|
+
|
|
170
|
+
const port = parseInt(process.env.SMTP_PORT || '587', 10);
|
|
171
|
+
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
|
|
172
|
+
const user = process.env.SMTP_USER;
|
|
173
|
+
const pass = process.env.SMTP_PASS;
|
|
174
|
+
|
|
175
|
+
const config = { host, port, secure };
|
|
176
|
+
if (user && pass) {
|
|
177
|
+
config.auth = { user, pass };
|
|
178
|
+
}
|
|
179
|
+
return config;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Factory to resolve or create appropriate email transport
|
|
184
|
+
* @param {Object} [options={}]
|
|
185
|
+
* @returns {Object} Transport instance with sendMail()
|
|
186
|
+
*/
|
|
187
|
+
function resolveTransport(options = {}) {
|
|
188
|
+
// 1. Direct custom transport provided
|
|
189
|
+
if (options.transport) {
|
|
190
|
+
return options.transport;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const backend = options.backend || options.type;
|
|
194
|
+
|
|
195
|
+
// 2. Explicit transport types
|
|
196
|
+
if (backend === 'memory' || options.memory === true) {
|
|
197
|
+
return new MemoryTransport(options);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (backend === 'console' || options.console === true) {
|
|
201
|
+
return new ConsoleTransport(options);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (backend === 'file' || options.fileDir) {
|
|
205
|
+
return new FileTransport({ dir: options.fileDir || options.dir });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. SMTP configuration
|
|
209
|
+
const smtp = options.smtp || smtpFromEnv();
|
|
210
|
+
if (smtp && nodemailer) {
|
|
211
|
+
return nodemailer.createTransport(smtp);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 4. Fallback defaults by environment
|
|
215
|
+
const env = process.env.NODE_ENV || 'development';
|
|
216
|
+
if (env === 'test') {
|
|
217
|
+
return new MemoryTransport(options);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!smtp) {
|
|
221
|
+
// If running in development with no SMTP, gracefully use MemoryTransport
|
|
222
|
+
return new MemoryTransport(options);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!nodemailer) {
|
|
226
|
+
throw new Error('nodemailer is required for SMTP transport. Install it with: npm install nodemailer');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return nodemailer.createTransport(smtp);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
MemoryTransport,
|
|
234
|
+
ConsoleTransport,
|
|
235
|
+
FileTransport,
|
|
236
|
+
resolveTransport,
|
|
237
|
+
smtpFromEnv,
|
|
238
|
+
};
|