feat: integrate email-system module (nodemailer + queue)
Adds lib/mailer.ts (sendeKontaktEmail, MBO-Tech-IT branding), lib/email-queue.ts (Supabase-fallback, graceful no-op when not configured), lib/supabase.ts (stub for later integration), admin API routes for SMTP-test and queue management, and instrumentation.ts for the queue worker. Excludes modules/ from TypeScript compilation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e7962d7c0c
commit
1369e831ad
|
|
@ -0,0 +1,84 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Optionaler Schutz via ADMIN_SECRET env-Variable.
|
||||
// Wird durch lib/admin-auth ersetzt wenn Modul 02-admin-auth integriert ist.
|
||||
function checkAuth(request: Request): NextResponse | null {
|
||||
const secret = process.env.ADMIN_SECRET;
|
||||
if (!secret) return null;
|
||||
const auth = request.headers.get("authorization");
|
||||
if (auth !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSupabaseConfigured() {
|
||||
return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY);
|
||||
}
|
||||
|
||||
function supabaseNotReadyResponse() {
|
||||
return NextResponse.json(
|
||||
{ error: "Supabase nicht konfiguriert – Email-Queue nicht verfügbar" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// GET: Queue-Status abrufen
|
||||
export async function GET(request: Request) {
|
||||
const authError = checkAuth(request);
|
||||
if (authError) return authError;
|
||||
if (!isSupabaseConfigured()) return supabaseNotReadyResponse();
|
||||
|
||||
const { createServiceClient } = await import("@/lib/supabase");
|
||||
const supabase = createServiceClient();
|
||||
const { data, error } = await supabase
|
||||
.from("email_queue")
|
||||
.select("id, created_at, next_retry_at, retry_count, status, error_last, mail_to, subject")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
// POST: Alle pending Mails sofort neu versuchen
|
||||
export async function POST(request: Request) {
|
||||
const authError = checkAuth(request);
|
||||
if (authError) return authError;
|
||||
if (!isSupabaseConfigured()) return supabaseNotReadyResponse();
|
||||
|
||||
const { createServiceClient } = await import("@/lib/supabase");
|
||||
const supabase = createServiceClient();
|
||||
const { error, count } = await supabase
|
||||
.from("email_queue")
|
||||
.update({ next_retry_at: new Date().toISOString() })
|
||||
.eq("status", "pending");
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
try {
|
||||
const { startEmailQueueWorker } = await import("@/lib/email-queue");
|
||||
startEmailQueueWorker();
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json({ ok: true, updated: count ?? 0 });
|
||||
}
|
||||
|
||||
// DELETE: Fehlgeschlagene Mails löschen
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = checkAuth(request);
|
||||
if (authError) return authError;
|
||||
if (!isSupabaseConfigured()) return supabaseNotReadyResponse();
|
||||
|
||||
const { createServiceClient } = await import("@/lib/supabase");
|
||||
const supabase = createServiceClient();
|
||||
const { error, count } = await supabase
|
||||
.from("email_queue")
|
||||
.delete()
|
||||
.eq("status", "failed");
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ ok: true, deleted: count ?? 0 });
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Optionaler Schutz via ADMIN_SECRET env-Variable.
|
||||
// Wird durch lib/admin-auth ersetzt wenn Modul 02-admin-auth integriert ist.
|
||||
function checkAuth(request: Request): NextResponse | null {
|
||||
const secret = process.env.ADMIN_SECRET;
|
||||
if (!secret) return null;
|
||||
const auth = request.headers.get("authorization");
|
||||
if (auth !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = checkAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const config = {
|
||||
host: process.env.SMTP_HOST ?? "(nicht gesetzt)",
|
||||
port: Number(process.env.SMTP_PORT ?? 587),
|
||||
user: process.env.SMTP_USER ?? "(nicht gesetzt)",
|
||||
from: process.env.SMTP_FROM ?? "(nicht gesetzt)",
|
||||
to: process.env.SMTP_TO ?? "(nicht gesetzt)",
|
||||
};
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: false,
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 8000,
|
||||
socketTimeout: 12000,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
try {
|
||||
await transport.verify();
|
||||
await transport.sendMail({
|
||||
from: `"MBO Tech IT TEST" <${process.env.SMTP_FROM}>`,
|
||||
to: process.env.SMTP_TO,
|
||||
subject: `✓ SMTP-Test erfolgreich – ${new Date().toLocaleString("de-DE")}`,
|
||||
text: "Diese Test-Mail wurde automatisch von /api/admin/smtp-test gesendet.\n\nSMTP-Verbindung und Mail-Versand funktionieren korrekt.",
|
||||
html: `<p style="font-family:sans-serif">Test von <code>/api/admin/smtp-test</code></p><p style="color:green;font-weight:bold">✓ SMTP-Verbindung und Mail-Versand funktionieren korrekt.</p>`,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: "SMTP-Verbindung erfolgreich – Test-Mail gesendet",
|
||||
config: { ...config, pass: "***" },
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as Error & { code?: string; command?: string };
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: e.message,
|
||||
code: e.code,
|
||||
command: e.command,
|
||||
config: { ...config, pass: "***" },
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { startEmailQueueWorker } from "@/lib/email-queue";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
startEmailQueueWorker();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Email-Queue: Speichert fehlgeschlagene E-Mails in der Datenbank
|
||||
* und versucht sie regelmäßig erneut zu senden.
|
||||
*
|
||||
* Retry-Strategie: exponentielles Backoff
|
||||
* Versuch 1 → sofort, Versuch 2 → 1 Min, Versuch 3 → 2 Min ...
|
||||
* bis max. 60 Min zwischen Versuchen, dann Status "failed"
|
||||
*
|
||||
* Supabase-Guard: Alle DB-Operationen sind No-Ops solange SUPABASE_URL
|
||||
* und SUPABASE_SERVICE_ROLE_KEY nicht gesetzt sind.
|
||||
*/
|
||||
|
||||
import nodemailer from "nodemailer";
|
||||
import { createServiceClient } from "./supabase";
|
||||
|
||||
export interface QueuedMail {
|
||||
mail_from: string;
|
||||
mail_to: string;
|
||||
reply_to?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
body_text: string;
|
||||
}
|
||||
|
||||
function isSupabaseConfigured(): boolean {
|
||||
return !!(process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY);
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
return createServiceClient();
|
||||
}
|
||||
|
||||
// ─── Datenbank-Operationen ────────────────────────────────────────────────
|
||||
|
||||
async function dbInsert(mail: QueuedMail): Promise<boolean> {
|
||||
if (!isSupabaseConfigured()) return false;
|
||||
try {
|
||||
const db = getDb();
|
||||
const { error } = await db.from("email_queue").insert({
|
||||
...mail,
|
||||
status: "pending",
|
||||
retry_count: 0,
|
||||
next_retry_at: new Date().toISOString(),
|
||||
});
|
||||
if (error) throw new Error(`Queue-Insert fehlgeschlagen: ${error.message}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[EmailQueue] dbInsert fehlgeschlagen:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function dbFetchPending(): Promise<Array<QueuedMail & { id: string; retry_count: number }>> {
|
||||
if (!isSupabaseConfigured()) return [];
|
||||
try {
|
||||
const db = getDb();
|
||||
const now = new Date().toISOString();
|
||||
const { data, error } = await db
|
||||
.from("email_queue")
|
||||
.select("*")
|
||||
.eq("status", "pending")
|
||||
.lte("next_retry_at", now)
|
||||
.order("next_retry_at", { ascending: true })
|
||||
.limit(20);
|
||||
if (error) {
|
||||
console.error("[EmailQueue] Fetch pending fehlgeschlagen:", error.message);
|
||||
return [];
|
||||
}
|
||||
return (data ?? []) as Array<QueuedMail & { id: string; retry_count: number }>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function dbMarkSent(id: string) {
|
||||
if (!isSupabaseConfigured()) return;
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.from("email_queue").update({ status: "sent", error_last: null }).eq("id", id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function dbMarkRetry(id: string, retryCount: number, error: string) {
|
||||
if (!isSupabaseConfigured()) return;
|
||||
try {
|
||||
const db = getDb();
|
||||
const nextCount = retryCount + 1;
|
||||
const maxRetries = 10;
|
||||
|
||||
if (nextCount >= maxRetries) {
|
||||
await db
|
||||
.from("email_queue")
|
||||
.update({ status: "failed", retry_count: nextCount, error_last: error.slice(0, 500) })
|
||||
.eq("id", id);
|
||||
console.error(`[EmailQueue] Mail ${id} endgültig fehlgeschlagen nach ${nextCount} Versuchen`);
|
||||
return;
|
||||
}
|
||||
|
||||
const minutesDelay = Math.min(Math.pow(2, nextCount - 1), 60);
|
||||
const nextRetry = new Date(Date.now() + minutesDelay * 60 * 1000).toISOString();
|
||||
await db
|
||||
.from("email_queue")
|
||||
.update({ retry_count: nextCount, next_retry_at: nextRetry, error_last: error.slice(0, 500) })
|
||||
.eq("id", id);
|
||||
|
||||
console.warn(
|
||||
`[EmailQueue] Versuch ${nextCount}/${maxRetries} fehlgeschlagen – nächster Retry in ${minutesDelay} Min.`
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ─── Queue-Eintrag speichern ───────────────────────────────────────────────
|
||||
|
||||
export async function queueEmail(mail: QueuedMail): Promise<void> {
|
||||
if (!isSupabaseConfigured()) {
|
||||
console.warn(
|
||||
`[EmailQueue] Supabase nicht konfiguriert – Mail nicht gespeichert: "${mail.subject}" → "${mail.mail_to}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const saved = await dbInsert(mail);
|
||||
if (saved) {
|
||||
console.log(`[EmailQueue] "${mail.subject}" → "${mail.mail_to}" in Queue gespeichert`);
|
||||
processQueue().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Worker ───────────────────────────────────────────────────────────────
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT ?? 587),
|
||||
secure: false,
|
||||
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
||||
connectionTimeout: 15000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 20000,
|
||||
tls: { rejectUnauthorized: false, ciphers: "SSLv3" },
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
async function processQueue(): Promise<void> {
|
||||
const pending = await dbFetchPending();
|
||||
if (pending.length === 0) return;
|
||||
|
||||
console.log(`[EmailQueue] Verarbeite ${pending.length} ausstehende Mail(s)...`);
|
||||
|
||||
for (const mail of pending) {
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: mail.mail_from,
|
||||
to: mail.mail_to,
|
||||
replyTo: mail.reply_to,
|
||||
subject: mail.subject,
|
||||
html: mail.html,
|
||||
text: mail.body_text,
|
||||
});
|
||||
await dbMarkSent(mail.id);
|
||||
console.log(`[EmailQueue] ✓ "${mail.subject}" → "${mail.mail_to}" gesendet`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await dbMarkRetry(mail.id, mail.retry_count, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let workerStarted = false;
|
||||
|
||||
export function startEmailQueueWorker(): void {
|
||||
if (workerStarted) return;
|
||||
workerStarted = true;
|
||||
|
||||
if (!isSupabaseConfigured()) {
|
||||
console.warn("[EmailQueue] Worker nicht gestartet – Supabase nicht konfiguriert");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[EmailQueue] Worker gestartet – prüft alle 60 Sekunden");
|
||||
processQueue().catch((e) => console.error("[EmailQueue] Initialer Lauf fehlgeschlagen:", e));
|
||||
setInterval(() => {
|
||||
processQueue().catch((e) => console.error("[EmailQueue] Lauf fehlgeschlagen:", e));
|
||||
}, 60_000);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import nodemailer from "nodemailer";
|
||||
import { queueEmail } from "./email-queue";
|
||||
|
||||
// Port 587 = STARTTLS, Port 465 = SSL/TLS
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT ?? 587),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
connectionTimeout: 15000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 20000,
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
ciphers: "SSLv3",
|
||||
},
|
||||
});
|
||||
|
||||
export interface KontaktEmailData {
|
||||
name: string;
|
||||
anrede?: string;
|
||||
telefon: string;
|
||||
email: string;
|
||||
betreff: string;
|
||||
nachricht?: string;
|
||||
}
|
||||
|
||||
interface MailOptions {
|
||||
from: string;
|
||||
to: string | undefined;
|
||||
replyTo?: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
async function sendWithFallback(options: MailOptions, label: string) {
|
||||
if (!options.to) {
|
||||
console.error(`[Mailer] Kein Empfänger für "${label}" – Mail übersprungen`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await transporter.sendMail(options);
|
||||
console.log(`[Mailer] ✓ Mail "${label}" an "${options.to}" gesendet`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[Mailer] ✗ Mail "${label}" fehlgeschlagen (${msg}) – in Queue gespeichert`);
|
||||
await queueEmail({
|
||||
mail_from: options.from,
|
||||
mail_to: options.to,
|
||||
reply_to: options.replyTo,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
body_text: options.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendeKontaktEmail(data: KontaktEmailData) {
|
||||
const anrede = data.anrede
|
||||
? `${data.anrede.charAt(0).toUpperCase() + data.anrede.slice(1)} `
|
||||
: "";
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head><meta charset="UTF-8"></head>
|
||||
<body style="font-family:system-ui,sans-serif;color:#e2e8f0;max-width:600px;margin:0 auto;padding:0">
|
||||
<div style="background:#18212f;padding:20px 24px;border-bottom:2px solid #f97316">
|
||||
<h1 style="color:#f97316;margin:0;font-size:20px;font-weight:700">MBO Tech IT</h1>
|
||||
<p style="color:rgba(255,255,255,0.6);margin:4px 0 0;font-size:13px">Neue Kontaktanfrage</p>
|
||||
</div>
|
||||
<div style="padding:24px;background:#1e2a3b">
|
||||
<h2 style="margin:0 0 16px;font-size:18px;color:#f8fafc">Kontaktanfrage von ${anrede}${data.name}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px">
|
||||
<tr><td style="padding:6px 0;color:#94a3b8;width:120px">Name</td><td style="padding:6px 0;font-weight:600;color:#f8fafc">${anrede}${data.name}</td></tr>
|
||||
<tr><td style="padding:6px 0;color:#94a3b8">Telefon</td><td style="padding:6px 0"><a href="tel:${data.telefon}" style="color:#f97316">${data.telefon}</a></td></tr>
|
||||
<tr><td style="padding:6px 0;color:#94a3b8">E-Mail</td><td style="padding:6px 0"><a href="mailto:${data.email}" style="color:#60a5fa">${data.email}</a></td></tr>
|
||||
<tr><td style="padding:6px 0;color:#94a3b8">Betreff</td><td style="padding:6px 0;color:#f8fafc">${data.betreff}</td></tr>
|
||||
</table>
|
||||
${
|
||||
data.nachricht
|
||||
? `<div style="padding:12px 16px;background:#111925;border-left:3px solid #f97316"><p style="margin:0;font-size:14px;white-space:pre-wrap;color:#cbd5e1">${data.nachricht}</p></div>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<div style="padding:12px 24px;background:#111925;border-top:1px solid rgba(255,255,255,0.1)">
|
||||
<p style="margin:0;font-size:11px;color:#64748b">MBO Tech IT · ${process.env.APP_URL ?? "https://mbo-tech-it.de"}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
await sendWithFallback(
|
||||
{
|
||||
from: `"MBO Tech IT" <${process.env.SMTP_FROM}>`,
|
||||
to: process.env.SMTP_TO,
|
||||
replyTo: data.email,
|
||||
subject: `Kontaktanfrage: ${anrede}${data.name} – ${data.betreff}`,
|
||||
text: `Neue Kontaktanfrage\n\nName: ${anrede}${data.name}\nTelefon: ${data.telefon}\nE-Mail: ${data.email}\nBetreff: ${data.betreff}${data.nachricht ? `\n\nNachricht:\n${data.nachricht}` : ""}`,
|
||||
html,
|
||||
},
|
||||
`Kontaktanfrage ${data.name}`
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// STUB – wird ersetzt wenn Supabase eingerichtet ist (Modul: Supabase-Integration)
|
||||
// Aktuell wirft createServiceClient() einen Fehler – email-queue.ts fängt diesen ab.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type SupabaseServiceClient = any;
|
||||
|
||||
export function createServiceClient(): SupabaseServiceClient {
|
||||
throw new Error(
|
||||
"Supabase noch nicht konfiguriert. lib/supabase.ts implementieren und @supabase/supabase-js installieren."
|
||||
);
|
||||
}
|
||||
|
|
@ -9,11 +9,13 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"next": "^15.2.0",
|
||||
"nodemailer": "^8.0.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10",
|
||||
|
|
@ -741,6 +743,16 @@
|
|||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
|
||||
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
|
|
@ -1395,6 +1407,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.6",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.6.tgz",
|
||||
"integrity": "sha512-Nm2XeuDwwy2wi5A+8jPWwQwNzcjNjhWdE3pVLoXEusxJqCnAPAgnBGkSmiLknbnWuOF9qraRpYZjfxqtKZ4tPw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -10,16 +10,18 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"next": "^15.2.0",
|
||||
"nodemailer": "^8.0.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"typescript": "^5",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10",
|
||||
"postcss": "^8",
|
||||
"autoprefixer": "^10"
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,5 +23,5 @@
|
|||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "modules"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue