Repository navigation
Expand file tree
/
Copy pathemail-plugin.mail-settings.test.ts
More file actions
469 lines (412 loc) · 20.3 KB
/
Copy pathemail-plugin.mail-settings.test.ts
File metadata and controls
469 lines (412 loc) · 20.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
//
// EmailServicePlugin ↔ `mail` settings namespace (#5087).
//
// The gap this pins: the settings page offers a full SMTP form and SMTP is its
// DEFAULT provider, while `applyMailSettings` used to answer "transport
// unchanged" and `mail/test` answered "Configuration looks valid … wire
// @objectstack/plugin-mail for actual delivery" — a success toast for a mail
// nobody sent. Every assertion below is about that pair: a saved SMTP config
// must actually become the live transport, and every way it can fail to must
// be LOUD (error log + a failing test action), never a LogTransport reporting
// success.
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EmailServicePlugin } from './email-plugin.js';
import { EmailService, LogTransport } from './email-service.js';
import { SmtpTransport } from './transports/smtp.js';
import { ResendTransport } from './transports/resend.js';
const nm = vi.hoisted(() => ({ createTransport: vi.fn(), sendMail: vi.fn() }));
vi.mock('nodemailer', () => ({
default: { createTransport: nm.createTransport },
createTransport: nm.createTransport,
}));
// ── harness ────────────────────────────────────────────────────────────────
interface Resolved { value: unknown; source?: string }
function fakeSettings(values: Record<string, Resolved>) {
const listeners: Array<() => void> = [];
const actions = new Map<string, (a: any) => Promise<any>>();
return {
createClient: () => ({}),
getNamespace: async () => ({ values }),
subscribe: (_ns: string, cb: () => void) => { listeners.push(cb); },
registerAction: (ns: string, id: string, fn: (a: any) => Promise<any>) => {
actions.set(`${ns}/${id}`, fn);
},
/** Simulate a save: patch the snapshot and emit settings:changed. */
async save(patch: Record<string, Resolved>) {
Object.assign(values, patch);
for (const l of listeners) l();
// let the async re-apply settle
await new Promise((r) => setTimeout(r, 0));
},
action: (id: string) => actions.get(`mail/${id}`),
};
}
function fakeEngine() {
const inserted: Array<{ object: string; row: any }> = [];
const updated: Array<{ object: string; patch: any }> = [];
return {
inserted,
updated,
async insert(object: string, row: any) { inserted.push({ object, row }); return { id: row.id }; },
async update(object: string, patch: any) { updated.push({ object, patch }); },
async find() { return []; },
};
}
function fakeCtx(services: Record<string, unknown>) {
const hooks: Record<string, Array<() => Promise<void> | void>> = {};
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
return {
logger,
getService: <T,>(name: string): T => {
if (!(name in services)) throw new Error(`service '${name}' not registered`);
return services[name] as T;
},
registerService: (name: string, svc: unknown) => { services[name] = svc; },
hook: (name: string, fn: () => Promise<void> | void) => { (hooks[name] ??= []).push(fn); },
fire: async (name: string) => { for (const fn of hooks[name] ?? []) await fn(); },
};
}
const MAIL_DEFAULTS: Record<string, Resolved> = {
provider: { value: 'smtp', source: 'default' },
smtp_port: { value: 587, source: 'default' },
smtp_secure: { value: true, source: 'default' },
from_email: { value: 'no-reply@example.test', source: 'global' },
from_name: { value: 'ObjectStack', source: 'default' },
};
/** Boot the plugin against fake services and run its kernel:ready hook. */
async function boot(mailValues: Record<string, Resolved>, opts: any = {}) {
const engine = fakeEngine();
const settings = fakeSettings({ ...MAIL_DEFAULTS, ...mailValues });
const services: Record<string, unknown> = {
manifest: { register: () => {} },
objectql: engine,
settings,
};
const ctx = fakeCtx(services);
const plugin = new EmailServicePlugin({ ...opts });
await plugin.init(ctx as never);
await plugin.start(ctx as never);
await ctx.fire('kernel:ready');
const service = services.email as EmailService;
return { plugin, ctx, engine, settings, service };
}
const transportOf = (service: EmailService) => service.options.transport;
beforeEach(() => {
nm.createTransport.mockReset();
nm.sendMail.mockReset();
nm.createTransport.mockImplementation(() => ({ sendMail: nm.sendMail, close: vi.fn() }));
nm.sendMail.mockResolvedValue({ messageId: '<wire-1@smtp>', response: '250 2.0.0 Ok' });
});
// ── hot swap ───────────────────────────────────────────────────────────────
describe('applyMailSettings — provider=smtp', () => {
it('replaces the LogTransport with a real SmtpTransport built from the saved settings', async () => {
const { service, ctx } = await boot({
provider: { value: 'smtp', source: 'global' },
smtp_host: { value: 'smtp.exmail.qq.com', source: 'global' },
smtp_port: { value: 465, source: 'global' },
smtp_secure: { value: true, source: 'global' },
smtp_user: { value: 'ops@example.cn', source: 'global' },
smtp_password: { value: 'sekrit', source: 'global' },
});
const transport = transportOf(service);
expect(transport).toBeInstanceOf(SmtpTransport);
expect((transport as SmtpTransport).describe()).toMatchObject({
host: 'smtp.exmail.qq.com',
port: 465,
secure: true,
auth: { user: 'ops@example.cn' },
});
expect(ctx.logger.error).not.toHaveBeenCalled();
});
it('re-swaps the transport when the settings are saved again (no restart)', async () => {
const { service, settings } = await boot({
provider: { value: 'smtp', source: 'global' },
smtp_host: { value: 'smtp.first.test', source: 'global' },
});
expect((transportOf(service) as SmtpTransport).describe()).toMatchObject({ host: 'smtp.first.test' });
await settings.save({ smtp_host: { value: 'smtp.second.test', source: 'global' } });
expect(transportOf(service)).toBeInstanceOf(SmtpTransport);
expect((transportOf(service) as SmtpTransport).describe()).toMatchObject({ host: 'smtp.second.test' });
});
it('delivers through the swapped-in transport and still records sys_email', async () => {
const { service, engine } = await boot({
provider: { value: 'smtp', source: 'global' },
smtp_host: { value: 'smtp.163.com', source: 'global' },
});
const res = await service.send({
to: 'user@example.test',
subject: '【ObjectStack】您的验证码',
html: '<p>你好,世界</p>',
});
expect(res.status).toBe('sent');
expect(nm.sendMail).toHaveBeenCalledTimes(1);
// The message handed to nodemailer keeps the authored UTF-8 text intact —
// MIME/RFC 2047 encoding is nodemailer's job (proved on the wire in
// smtp.wire.test.ts), never a lossy transform of ours.
expect(nm.sendMail.mock.calls[0][0]).toMatchObject({
subject: '【ObjectStack】您的验证码',
html: '<p>你好,世界</p>',
to: ['user@example.test'],
from: 'ObjectStack <no-reply@example.test>',
});
// sys_email persistence survives the transport swap.
const row = engine.inserted.find((r) => r.object === 'sys_email');
expect(row?.row).toMatchObject({
subject: '【ObjectStack】您的验证码',
body_html: '<p>你好,世界</p>',
status: 'queued',
});
expect(engine.updated[engine.updated.length - 1]?.patch).toMatchObject({
status: 'sent',
message_id: '<wire-1@smtp>',
});
});
});
describe('applyMailSettings — provider=smtp that cannot be built', () => {
it('keeps the previous transport and logs at ERROR when SMTP was actually selected', async () => {
const { service, ctx } = await boot({
provider: { value: 'smtp', source: 'global' },
// no smtp_host
});
expect(transportOf(service)).toBeInstanceOf(LogTransport);
expect(ctx.logger.error).toHaveBeenCalledTimes(1);
const line = ctx.logger.error.mock.calls[0][0] as string;
// An error owes the consequence and the fix.
expect(line).toMatch(/NO mail is delivered over SMTP/);
expect(line).toMatch(/Settings → Mail → Host|OS_MAIL_SMTP_HOST/);
});
it('keeps a boot-configured SMTP transport when the settings page carries no host', async () => {
// The deployment shape this feature enables: SMTP set through
// OS_EMAIL_SMTP_* at boot, settings page never touched. Mail IS being
// delivered, so there is nothing to report — an error here would be a
// false alarm on every start.
const { service, ctx } = await boot(
{ provider: { value: 'smtp', source: 'global' } },
{ provider: 'smtp', providerOptions: { host: 'smtp.boot.test' } },
);
expect((transportOf(service) as SmtpTransport).describe()).toMatchObject({ host: 'smtp.boot.test' });
expect(ctx.logger.error).not.toHaveBeenCalled();
});
it('does not shout on the out-of-the-box default (provider never configured)', async () => {
const { service, ctx } = await boot({});
expect(transportOf(service)).toBeInstanceOf(LogTransport);
expect(ctx.logger.error).not.toHaveBeenCalled();
expect(ctx.logger.info.mock.calls.some((args: unknown[]) => /out-of-the-box state/.test(String(args[0]))))
.toBe(true);
});
});
// ── stored provider values with no transport (#5094) ───────────────────────
//
// `sendgrid` and `ses` sat in the settings dropdown for several releases with
// no transport behind either. #5094 removed the options; it cannot remove the
// rows. A workspace that saved one still resolves `provider: 'sendgrid'` on
// every boot, so this is the one part of that change with live data behind it:
// the read must not throw, must not quietly look configured, and must say what
// to do — SendGrid and SES both publish SMTP endpoints, which is now the route.
describe('applyMailSettings — a stored provider this build cannot deliver with', () => {
const RETIRED: Array<[string, RegExp]> = [
['sendgrid', /smtp\.sendgrid\.net/],
['ses', /email-smtp\.<region>\.amazonaws\.com/],
];
for (const [provider, migration] of RETIRED) {
it(`keeps the transport, never throws, and names the SMTP migration for provider=${provider}`, async () => {
const { service, ctx } = await boot({
provider: { value: provider, source: 'global' },
api_key: { value: 'legacy-key', source: 'global' },
});
// A settings row written by an older release must never be able to kill
// a running server: previous transport kept, boot completed.
expect(transportOf(service)).toBeInstanceOf(LogTransport);
expect(ctx.logger.error).toHaveBeenCalledTimes(1);
const line = ctx.logger.error.mock.calls[0][0] as string;
expect(line).toContain(`provider='${provider}'`);
// Consequence…
expect(line).toMatch(/NO mail is delivered through it/);
// …and the fix, in the same line (AGENTS.md degradation-log-level).
expect(line).toMatch(/Fix:/);
expect(line).toMatch(migration);
});
}
it('reports the missing transport, not a missing api_key, when both are absent', async () => {
// "Set an API key" is the wrong instruction for a provider that has
// nothing to hand the key to — so the unsupported-provider check runs
// before the api_key check.
const { ctx } = await boot({ provider: { value: 'sendgrid', source: 'global' } });
const line = ctx.logger.error.mock.calls[0][0] as string;
expect(line).not.toMatch(/api_key is empty/);
expect(line).toMatch(/smtp\.sendgrid\.net/);
});
it('leaves a working boot-configured SMTP transport in place', async () => {
// Mail may well still be going out (OS_EMAIL_SMTP_* configured the
// transport at boot). The stored provider is still unusable and the
// operator still has to fix it, so it is reported — but nothing that
// currently delivers is torn down on the way.
const { service, ctx } = await boot(
{ provider: { value: 'ses', source: 'global' }, api_key: { value: 'k', source: 'global' } },
{ provider: 'smtp', providerOptions: { host: 'smtp.boot.test' } },
);
expect((transportOf(service) as SmtpTransport).describe()).toMatchObject({ host: 'smtp.boot.test' });
expect(ctx.logger.error).toHaveBeenCalledTimes(1);
});
it('treats a typo the same way, naming the providers that do work', async () => {
const { ctx } = await boot({
provider: { value: 'postmarkk', source: 'global' },
api_key: { value: 'k', source: 'global' },
});
const line = ctx.logger.error.mock.calls[0][0] as string;
expect(line).toMatch(/log \/ resend \/ postmark \/ smtp/);
});
it('recovers on the next save — the bad value is not sticky', async () => {
const { service, settings } = await boot({
provider: { value: 'sendgrid', source: 'global' },
api_key: { value: 'legacy-key', source: 'global' },
});
expect(transportOf(service)).toBeInstanceOf(LogTransport);
await settings.save({
provider: { value: 'smtp', source: 'global' },
smtp_host: { value: 'smtp.sendgrid.net', source: 'global' },
smtp_user: { value: 'apikey', source: 'global' },
smtp_password: { value: 'legacy-key', source: 'global' },
});
expect((transportOf(service) as SmtpTransport).describe()).toMatchObject({
host: 'smtp.sendgrid.net',
auth: { user: 'apikey' },
});
});
});
describe('applyMailSettings — provider=resend', () => {
it('builds the transport the settings page can finally select', async () => {
// The reverse half of the same invariant: `resend` had a working transport
// all along and was missing from the dropdown (#5094). Now that it can be
// picked, prove picking it does something.
const { service, ctx } = await boot({
provider: { value: 'resend', source: 'global' },
api_key: { value: 're_live_key', source: 'global' },
});
expect(transportOf(service)).toBeInstanceOf(ResendTransport);
expect(ctx.logger.error).not.toHaveBeenCalled();
});
});
describe('EmailServicePlugin constructor path (CLI / os serve)', () => {
it('THROWS when provider=smtp has no host — a boot that cannot deliver fails loudly', async () => {
const ctx = fakeCtx({ manifest: { register: () => {} } });
const plugin = new EmailServicePlugin({ provider: 'smtp' });
await expect(plugin.init(ctx as never)).rejects.toThrow(/requires a host/);
});
it('builds the SMTP transport from providerOptions', async () => {
const services: Record<string, unknown> = { manifest: { register: () => {} } };
const ctx = fakeCtx(services);
const plugin = new EmailServicePlugin({
provider: 'smtp',
providerOptions: { host: 'smtp.aliyun.test', port: 465 },
});
await plugin.init(ctx as never);
expect(transportOf(services.email as EmailService)).toBeInstanceOf(SmtpTransport);
});
});
// ── mail/test ──────────────────────────────────────────────────────────────
describe('mail/test action', () => {
it('performs a REAL send through the form\'s SMTP settings', async () => {
const { settings, engine } = await boot({
provider: { value: 'smtp', source: 'global' },
smtp_host: { value: 'smtp.example.cn', source: 'global' },
});
const result = await settings.action('test')!({
values: { provider: 'smtp', smtp_host: 'smtp.example.cn', from_email: 'no-reply@example.test' },
payload: { to: 'admin@example.test' },
});
expect(nm.sendMail).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({ ok: true, severity: 'info' });
expect(result.message).toContain('smtp.example.cn');
// A test send is a send: it lands in sys_email like any other.
expect(engine.inserted.some((r) => r.object === 'sys_email')).toBe(true);
});
it('reports the SMTP server\'s own error when authentication fails', async () => {
nm.sendMail.mockRejectedValue(new Error('Invalid login: 535 5.7.8 Error: authentication failed'));
const { settings } = await boot({
provider: { value: 'smtp', source: 'global' },
smtp_host: { value: 'smtp.example.cn', source: 'global' },
});
const result = await settings.action('test')!({
values: { provider: 'smtp', smtp_host: 'smtp.example.cn', smtp_user: 'u', smtp_password: 'bad', from_email: 'no-reply@example.test' },
payload: { to: 'admin@example.test' },
});
expect(result.ok).toBe(false);
expect(result.severity).toBe('error');
expect(result.message).toMatch(/535 5\.7\.8 Error: authentication failed/);
});
it('refuses to "test" an SMTP config with no host', async () => {
const { settings } = await boot({ provider: { value: 'smtp', source: 'global' } });
const result = await settings.action('test')!({
values: { provider: 'smtp', from_email: 'no-reply@example.test' },
payload: { to: 'admin@example.test' },
});
expect(result).toMatchObject({ ok: false, severity: 'error' });
expect(result.message).toMatch(/SMTP host is required/);
expect(nm.sendMail).not.toHaveBeenCalled();
});
it.each(['sendgrid', 'ses'])(
'refuses to "test" a stored provider=%s and points at SMTP instead',
async (provider) => {
const { settings } = await boot({
provider: { value: provider, source: 'global' },
api_key: { value: 'legacy-key', source: 'global' },
});
const result = await settings.action('test')!({
values: { provider, api_key: 'legacy-key', from_email: 'no-reply@example.test' },
payload: { to: 'admin@example.test' },
});
expect(result).toMatchObject({ ok: false, severity: 'error' });
// Not "Failed to build sendgrid transport: unknown provider" — the
// operator needs the route that works, not the internal symptom.
expect(result.message).toMatch(provider === 'sendgrid' ? /smtp\.sendgrid\.net/ : /email-smtp/);
expect(result.message).toMatch(/NOTHING was sent/);
expect(nm.sendMail).not.toHaveBeenCalled();
// ...and it does not ask for an API key it cannot use.
expect(result.message).not.toMatch(/api_key is required/);
},
);
it('never reports success while only the LogTransport is active', async () => {
const { settings } = await boot({ provider: { value: 'log', source: 'global' } });
const result = await settings.action('test')!({
values: { provider: 'log', from_email: 'no-reply@example.test' },
payload: { to: 'admin@example.test' },
});
expect(result.ok).toBe(false);
expect(result.severity).toBe('warning');
expect(result.message).toMatch(/only logged/);
// ...and the old lie is gone for good.
expect(result.message).not.toMatch(/Configuration looks valid|plugin-mail/);
});
});
describe('registered email service — isConfigured follows the mail settings', () => {
it('answers false on the out-of-the-box boot (LogTransport, mail never configured)', async () => {
const { service } = await boot({});
expect(transportOf(service)).toBeInstanceOf(LogTransport);
expect(service.isConfigured()).toBe(false);
});
it('turns true when a saved SMTP config replaces the LogTransport at runtime', async () => {
const { service, settings } = await boot({ provider: { value: 'log', source: 'global' } });
expect(service.isConfigured()).toBe(false);
await settings.save({
provider: { value: 'smtp', source: 'global' },
smtp_host: { value: 'smtp.live.test', source: 'global' },
});
expect(transportOf(service)).toBeInstanceOf(SmtpTransport);
expect(service.isConfigured()).toBe(true);
});
it('stays true across a swap from one live transport to another', async () => {
const { service, settings } = await boot({
provider: { value: 'smtp', source: 'global' },
smtp_host: { value: 'smtp.live.test', source: 'global' },
});
expect(service.isConfigured()).toBe(true);
await settings.save({
provider: { value: 'resend', source: 'global' },
api_key: { value: 're_test_key', source: 'global' },
});
expect(transportOf(service)).toBeInstanceOf(ResendTransport);
expect(service.isConfigured()).toBe(true);
});
});