Add mail outbox with SMTP delivery worker and templates
MailService renders transactional mails (verify-email, reset-password) from the new de/en `mails` i18n namespace — text plus minimal HTML with escaped interpolation — and enqueues them into mail_outbox. MailWorker delivers pending rows every 15s through an injectable transport (nodemailer; faked in tests) with quadratic backoff and a permanent FAILED state after five attempts, logged as a warning. SMTP_* and APP_BASE_URL join the environment schema with defaults matching the new Mailpit container in the dev overlay. Closes #12 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
31f23c12a2
commit
f00fb19f32
@ -22,6 +22,7 @@
|
||||
"argon2": "^0.44.0",
|
||||
"i18next": "^26.3.4",
|
||||
"nestjs-pino": "^4.3.0",
|
||||
"nodemailer": "^9.0.3",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.4.0",
|
||||
"prisma": "^6.3.0",
|
||||
@ -33,6 +34,7 @@
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@swc/core": "^1.10.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"supertest": "^7.0.0",
|
||||
|
||||
@ -6,6 +6,7 @@ import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||
import { AppConfig } from './config/app-config.service';
|
||||
import { ConfigModule } from './config/config.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
@ -15,6 +16,7 @@ import { UsersModule } from './users/users.module';
|
||||
ConfigModule,
|
||||
PrismaModule,
|
||||
RateLimitModule,
|
||||
MailModule,
|
||||
UsersModule,
|
||||
LoggerModule.forRootAsync({
|
||||
inject: [AppConfig],
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||
import deMails from '@dorfteich/shared/i18n/de/mails.json';
|
||||
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||
import enMails from '@dorfteich/shared/i18n/en/mails.json';
|
||||
import { createInstance, type i18n as I18n } from 'i18next';
|
||||
|
||||
/**
|
||||
@ -11,8 +13,8 @@ export const apiI18n: I18n = createInstance();
|
||||
|
||||
void apiI18n.init({
|
||||
resources: {
|
||||
en: { errors: enErrors },
|
||||
de: { errors: deErrors },
|
||||
en: { errors: enErrors, mails: enMails },
|
||||
de: { errors: deErrors, mails: deMails },
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['de', 'en'],
|
||||
|
||||
53
apps/api/src/mail/mail-templates.ts
Normal file
53
apps/api/src/mail/mail-templates.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { apiI18n } from '../i18n/api-i18n';
|
||||
|
||||
export type MailTemplate = 'verifyEmail' | 'resetPassword';
|
||||
|
||||
export interface RenderedMail {
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the layout-light transactional mails (text + simple HTML) from
|
||||
* the `mails` i18n namespace. Every template gets: greeting, body text,
|
||||
* one action link, expiry note, ignore hint, signoff.
|
||||
*/
|
||||
export function renderMail(
|
||||
template: MailTemplate,
|
||||
params: { displayName: string; link: string },
|
||||
locale: 'de' | 'en',
|
||||
): RenderedMail {
|
||||
const t = (key: string, options: Record<string, string> = {}): string =>
|
||||
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
|
||||
|
||||
const greeting = t('common.greeting', { displayName: params.displayName });
|
||||
const body = t(`${template}.body`);
|
||||
const action = t(`${template}.action`);
|
||||
const expiry = t(`${template}.expiry`);
|
||||
const ignore = t('common.ignoreHint');
|
||||
const signoff = t('common.signoff');
|
||||
|
||||
const text = [greeting, '', body, '', params.link, '', expiry, ignore, '', signoff].join('\n');
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html><body style="font-family: system-ui, sans-serif; color: #1f2933; max-width: 32rem; margin: 0 auto; padding: 1.5rem;">
|
||||
<p>${escapeHtml(greeting)}</p>
|
||||
<p>${escapeHtml(body)}</p>
|
||||
<p style="margin: 1.5rem 0;">
|
||||
<a href="${params.link}" style="background: #2f6f4f; color: #ffffff; padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none;">${escapeHtml(action)}</a>
|
||||
</p>
|
||||
<p style="font-size: 0.85rem; color: #616e7c;">${escapeHtml(expiry)}<br>${escapeHtml(ignore)}</p>
|
||||
<p>${escapeHtml(signoff)}</p>
|
||||
</body></html>`;
|
||||
|
||||
return { subject: t(`${template}.subject`), text, html };
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
104
apps/api/src/mail/mail-worker.service.ts
Normal file
104
apps/api/src/mail/mail-worker.service.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { MailOutbox } from '@prisma/client';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/** Minimal transport contract — nodemailer in production, a fake in tests. */
|
||||
export interface MailTransport {
|
||||
sendMail(mail: {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}): Promise<unknown>;
|
||||
}
|
||||
|
||||
export const MAIL_TRANSPORT = 'MAIL_TRANSPORT';
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const BATCH_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Delivers pending outbox mail with exponential backoff. Single-instance
|
||||
* by design (one api container per stage, ADR 0002); the M2 jobs table
|
||||
* will absorb this loop once it exists.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailWorker implements OnModuleInit, OnModuleDestroy {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: AppConfig,
|
||||
@Inject(MAIL_TRANSPORT) private readonly transport: MailTransport,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(MailWorker.name);
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.config.env.NODE_ENV === 'test') return;
|
||||
this.timer = setInterval(() => void this.deliverDueMail(), POLL_INTERVAL_MS);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
/** One delivery pass; public so tests and later admin tooling can drive it. */
|
||||
async deliverDueMail(): Promise<void> {
|
||||
if (this.running) return; // never overlap two passes
|
||||
this.running = true;
|
||||
try {
|
||||
const due = await this.prisma.mailOutbox.findMany({
|
||||
where: { status: 'PENDING', nextAttemptAt: { lte: new Date() } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: BATCH_SIZE,
|
||||
});
|
||||
for (const mail of due) {
|
||||
await this.deliverOne(mail);
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverOne(mail: MailOutbox): Promise<void> {
|
||||
try {
|
||||
await this.transport.sendMail({
|
||||
from: this.config.env.SMTP_FROM,
|
||||
to: mail.toAddress,
|
||||
subject: mail.subject,
|
||||
text: mail.textBody,
|
||||
html: mail.htmlBody,
|
||||
});
|
||||
await this.prisma.mailOutbox.update({
|
||||
where: { id: mail.id },
|
||||
data: { status: 'SENT', sentAt: new Date(), attempts: mail.attempts + 1 },
|
||||
});
|
||||
} catch (error) {
|
||||
const attempts = mail.attempts + 1;
|
||||
const failedForGood = attempts >= MAX_ATTEMPTS;
|
||||
// Backoff: 30s, 2min, 4.5min, 8min — then give up loudly.
|
||||
const delayMs = 30_000 * attempts * attempts;
|
||||
await this.prisma.mailOutbox.update({
|
||||
where: { id: mail.id },
|
||||
data: {
|
||||
attempts,
|
||||
status: failedForGood ? 'FAILED' : 'PENDING',
|
||||
nextAttemptAt: new Date(Date.now() + delayMs),
|
||||
lastError: error instanceof Error ? error.message.slice(0, 500) : String(error),
|
||||
},
|
||||
});
|
||||
if (failedForGood) {
|
||||
this.logger.warn({ mailId: mail.id, attempts }, 'mail delivery failed permanently');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
apps/api/src/mail/mail.db.test.ts
Normal file
105
apps/api/src/mail/mail.db.test.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||
import { MailTransport, MailWorker } from './mail-worker.service';
|
||||
import { renderMail } from './mail-templates';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
describe('mail templates', () => {
|
||||
it('renders both languages with link, action, and greeting', () => {
|
||||
for (const locale of ['de', 'en'] as const) {
|
||||
const mail = renderMail(
|
||||
'verifyEmail',
|
||||
{ displayName: 'Uma', link: 'https://example.org/verify?token=abc' },
|
||||
locale,
|
||||
);
|
||||
expect(mail.subject.length).toBeGreaterThan(5);
|
||||
expect(mail.text).toContain('https://example.org/verify?token=abc');
|
||||
expect(mail.text).toContain('Uma');
|
||||
expect(mail.html).toContain('href="https://example.org/verify?token=abc"');
|
||||
}
|
||||
});
|
||||
|
||||
it('escapes HTML in interpolated values', () => {
|
||||
const mail = renderMail(
|
||||
'resetPassword',
|
||||
{ displayName: '<script>x</script>', link: 'https://example.org/r' },
|
||||
'en',
|
||||
);
|
||||
expect(mail.html).not.toContain('<script>x</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('MailWorker (database)', () => {
|
||||
const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!;
|
||||
const suffix = uniqueSuffix();
|
||||
const address = `worker-${suffix}@example.org`;
|
||||
let config: AppConfig;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.DATABASE_URL ??= process.env.TEST_DATABASE_URL;
|
||||
config = new AppConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!hasTestDb) return;
|
||||
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function makeWorker(transport: MailTransport): MailWorker {
|
||||
const logger = { setContext: vi.fn(), warn: vi.fn() } as unknown as PinoLogger;
|
||||
return new MailWorker(prisma, config, transport, logger);
|
||||
}
|
||||
|
||||
it('delivers queued mail and marks it sent', async () => {
|
||||
await new MailService(prisma).enqueue(
|
||||
address,
|
||||
'verifyEmail',
|
||||
{ displayName: 'Uma', link: 'https://example.org/v' },
|
||||
'de',
|
||||
);
|
||||
const sent: string[] = [];
|
||||
await makeWorker({
|
||||
sendMail: async (mail) => {
|
||||
sent.push(mail.to);
|
||||
},
|
||||
}).deliverDueMail();
|
||||
|
||||
expect(sent).toContain(address);
|
||||
const row = await prisma.mailOutbox.findFirst({ where: { toAddress: address } });
|
||||
expect(row?.status).toBe('SENT');
|
||||
expect(row?.sentAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('retries with backoff and fails permanently after five attempts', async () => {
|
||||
const failing = `fail-${suffix}@example.org`;
|
||||
await new MailService(prisma).enqueue(
|
||||
failing,
|
||||
'resetPassword',
|
||||
{ displayName: 'Uma', link: 'https://example.org/r' },
|
||||
'en',
|
||||
);
|
||||
const worker = makeWorker({
|
||||
sendMail: async () => {
|
||||
throw new Error('smtp down');
|
||||
},
|
||||
});
|
||||
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
// Make the row due again regardless of the stored backoff.
|
||||
await prisma.mailOutbox.updateMany({
|
||||
where: { toAddress: failing },
|
||||
data: { nextAttemptAt: new Date() },
|
||||
});
|
||||
await worker.deliverDueMail();
|
||||
const row = await prisma.mailOutbox.findFirst({ where: { toAddress: failing } });
|
||||
expect(row?.attempts).toBe(attempt);
|
||||
expect(row?.status).toBe(attempt < 5 ? 'PENDING' : 'FAILED');
|
||||
expect(row?.lastError).toContain('smtp down');
|
||||
}
|
||||
});
|
||||
});
|
||||
28
apps/api/src/mail/mail.module.ts
Normal file
28
apps/api/src/mail/mail.module.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { createTransport } from 'nodemailer';
|
||||
|
||||
import { AppConfig } from '../config/app-config.service';
|
||||
import { MAIL_TRANSPORT, MailWorker } from './mail-worker.service';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
MailService,
|
||||
MailWorker,
|
||||
{
|
||||
provide: MAIL_TRANSPORT,
|
||||
inject: [AppConfig],
|
||||
useFactory: (config: AppConfig) =>
|
||||
createTransport({
|
||||
host: config.env.SMTP_HOST,
|
||||
port: config.env.SMTP_PORT,
|
||||
secure: config.env.SMTP_SECURE,
|
||||
auth: config.env.SMTP_USER
|
||||
? { user: config.env.SMTP_USER, pass: config.env.SMTP_PASS }
|
||||
: undefined,
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
30
apps/api/src/mail/mail.service.ts
Normal file
30
apps/api/src/mail/mail.service.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MailTemplate, renderMail } from './mail-templates';
|
||||
|
||||
/**
|
||||
* Enqueues transactional mail into the outbox. Actual delivery happens
|
||||
* asynchronously in MailWorker — callers never wait on SMTP.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async enqueue(
|
||||
to: string,
|
||||
template: MailTemplate,
|
||||
params: { displayName: string; link: string },
|
||||
locale: 'de' | 'en',
|
||||
): Promise<void> {
|
||||
const rendered = renderMail(template, params, locale);
|
||||
await this.prisma.mailOutbox.create({
|
||||
data: {
|
||||
toAddress: to,
|
||||
subject: rendered.subject,
|
||||
textBody: rendered.text,
|
||||
htmlBody: rendered.html,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -56,6 +56,14 @@ services:
|
||||
# instances (5432 system, 5433 wochenplan-staging).
|
||||
- '127.0.0.1:5434:5432'
|
||||
|
||||
# Local SMTP catcher: UI on http://localhost:8025, SMTP on 1025 —
|
||||
# matches the api's SMTP_* defaults.
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
- '127.0.0.1:1025:1025'
|
||||
- '127.0.0.1:8025:8025'
|
||||
|
||||
volumes:
|
||||
web-root-modules:
|
||||
web-app-modules:
|
||||
|
||||
19
packages/shared/i18n/de/mails.json
Normal file
19
packages/shared/i18n/de/mails.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"common": {
|
||||
"greeting": "Hallo {{displayName}},",
|
||||
"signoff": "Dein Dorfteich",
|
||||
"ignoreHint": "Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren."
|
||||
},
|
||||
"verifyEmail": {
|
||||
"subject": "Bestätige deine E-Mail-Adresse",
|
||||
"body": "willkommen im Dorfteich! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren:",
|
||||
"action": "E-Mail-Adresse bestätigen",
|
||||
"expiry": "Der Link ist 24 Stunden gültig."
|
||||
},
|
||||
"resetPassword": {
|
||||
"subject": "Passwort zurücksetzen",
|
||||
"body": "jemand (hoffentlich du) hat das Zurücksetzen des Passworts für dein Konto angefordert. Über diesen Link kannst du ein neues Passwort setzen:",
|
||||
"action": "Neues Passwort setzen",
|
||||
"expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast."
|
||||
}
|
||||
}
|
||||
19
packages/shared/i18n/en/mails.json
Normal file
19
packages/shared/i18n/en/mails.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"common": {
|
||||
"greeting": "Hello {{displayName}},",
|
||||
"signoff": "Your Dorfteich",
|
||||
"ignoreHint": "If you did not request this e-mail, you can safely ignore it."
|
||||
},
|
||||
"verifyEmail": {
|
||||
"subject": "Confirm your e-mail address",
|
||||
"body": "welcome to Dorfteich! Please confirm your e-mail address to activate your account:",
|
||||
"action": "Confirm e-mail address",
|
||||
"expiry": "The link is valid for 24 hours."
|
||||
},
|
||||
"resetPassword": {
|
||||
"subject": "Reset your password",
|
||||
"body": "someone (hopefully you) requested a password reset for your account. Use this link to set a new password:",
|
||||
"action": "Set new password",
|
||||
"expiry": "The link is valid for one hour. Your current password stays valid until you set a new one."
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,22 @@ export const apiEnvSchema = z.object({
|
||||
.enum(['true', 'false'])
|
||||
.default('true')
|
||||
.transform((value) => value === 'true'),
|
||||
/** Public base URL of this instance — used in e-mail links. */
|
||||
APP_BASE_URL: z.string().url().default('http://localhost:5173'),
|
||||
/**
|
||||
* SMTP delivery. Defaults match the Mailpit container from the dev
|
||||
* overlay; production instances configure their real relay here (the
|
||||
* M8 setup wizard writes these).
|
||||
*/
|
||||
SMTP_HOST: z.string().default('localhost'),
|
||||
SMTP_PORT: z.coerce.number().int().default(1025),
|
||||
SMTP_SECURE: z
|
||||
.enum(['true', 'false'])
|
||||
.default('false')
|
||||
.transform((value) => value === 'true'),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('Dorfteich <no-reply@localhost>'),
|
||||
});
|
||||
|
||||
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@ -53,6 +53,9 @@ importers:
|
||||
nestjs-pino:
|
||||
specifier: ^4.3.0
|
||||
version: 4.6.1(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.2)
|
||||
nodemailer:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
pino:
|
||||
specifier: ^9.6.0
|
||||
version: 9.14.0
|
||||
@ -81,6 +84,9 @@ importers:
|
||||
'@types/express':
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.6
|
||||
'@types/nodemailer':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.3
|
||||
@ -1426,6 +1432,9 @@ packages:
|
||||
'@types/node@26.1.0':
|
||||
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
|
||||
|
||||
'@types/nodemailer@8.0.1':
|
||||
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||
|
||||
'@types/qs@6.15.1':
|
||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||
|
||||
@ -2630,6 +2639,10 @@ packages:
|
||||
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nodemailer@9.0.3:
|
||||
resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
nypm@0.6.8:
|
||||
resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==}
|
||||
engines: {node: '>=18'}
|
||||
@ -4545,6 +4558,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/nodemailer@8.0.1':
|
||||
dependencies:
|
||||
'@types/node': 26.1.0
|
||||
|
||||
'@types/qs@6.15.1': {}
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
@ -5840,6 +5857,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.50: {}
|
||||
|
||||
nodemailer@9.0.3: {}
|
||||
|
||||
nypm@0.6.8:
|
||||
dependencies:
|
||||
citty: 0.2.2
|
||||
|
||||
Loading…
Reference in New Issue
Block a user