From 970986fef7b58472fd54aa8ae789cc7730736d49 Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 22:50:42 +0000 Subject: [PATCH] feat: email integration (nodemailer) + wire into agency/license flows - src/lib/email.ts: SMTP client with pre-built templates: - sendAgencyApprovedEmail (reseller approval + discount) - sendAgencyRejectedEmail (reseller rejection + reason) - sendLicenseKeyEmail (license key delivery) - sendTicketCreatedEmail (ticket confirmation) - Agency approve route: sends approval email after DB update - Agency reject route: sends rejection email with reason - License creation route: sends license key email (fire-and-forget) - Graceful degradation: if SMTP not configured, logs warning + continues - nodemailer + @types/nodemailer installed --- bun.lock | 6 + package.json | 2 + src/app/api/aos/agency/[id]/approve/route.ts | 14 ++- src/app/api/aos/agency/[id]/reject/route.ts | 10 ++ src/app/api/aos/licenses/route.ts | 12 ++ src/lib/email.ts | 118 +++++++++++++++++++ 6 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/lib/email.ts diff --git a/bun.lock b/bun.lock index 406c0ca..a2806f8 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,7 @@ "@reactuses/core": "^6.0.5", "@tanstack/react-query": "^5.82.0", "@tanstack/react-table": "^8.21.3", + "@types/nodemailer": "^8.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -57,6 +58,7 @@ "next-auth": "^4.24.11", "next-intl": "^4.3.4", "next-themes": "^0.4.6", + "nodemailer": "^10.2.1", "prisma": "^6.11.1", "react": "^19.0.0", "react-day-picker": "^9.8.0", @@ -721,6 +723,8 @@ "@types/node": ["@types/node@25.0.9", "https://registry.npmjs.com/@types/node/-/node-25.0.9.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + "@types/nodemailer": ["@types/nodemailer@8.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA=="], + "@types/react": ["@types/react@19.2.8", "https://registry.npmjs.com/@types/react/-/react-19.2.8.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmjs.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -1521,6 +1525,8 @@ "node-releases": ["node-releases@2.0.27", "https://registry.npmjs.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "nodemailer": ["nodemailer@10.2.1", "", {}, "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w=="], + "nypm": ["nypm@0.6.2", "https://registry.npmjs.com/nypm/-/nypm-0.6.2.tgz", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="], "oauth": ["oauth@0.9.15", "https://registry.npmjs.com/oauth/-/oauth-0.9.15.tgz", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="], diff --git a/package.json b/package.json index c99b789..d51ab06 100755 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@reactuses/core": "^6.0.5", "@tanstack/react-query": "^5.82.0", "@tanstack/react-table": "^8.21.3", + "@types/nodemailer": "^8.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -71,6 +72,7 @@ "next-auth": "^4.24.11", "next-intl": "^4.3.4", "next-themes": "^0.4.6", + "nodemailer": "^10.2.1", "prisma": "^6.11.1", "react": "^19.0.0", "react-day-picker": "^9.8.0", diff --git a/src/app/api/aos/agency/[id]/approve/route.ts b/src/app/api/aos/agency/[id]/approve/route.ts index dd8e418..99634da 100644 --- a/src/app/api/aos/agency/[id]/approve/route.ts +++ b/src/app/api/aos/agency/[id]/approve/route.ts @@ -72,16 +72,22 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st }, }) - // TODO (production): send email to company.email with subject - // "Ihre Reseller-Bewerbung wurde genehmigt — {discountPct}% Rabatt" - // containing the discount and a link to the agency portal. + // Send approval email + const { sendAgencyApprovedEmail } = await import('@/lib/email') + if (application.company.email) { + await sendAgencyApprovedEmail({ + to: application.company.email, + companyName: application.company.name, + discountPct, + }) + } return NextResponse.json({ ok: true, status: 'approved', discountPct, email: application.company.email, - message: `Genehmigt mit ${discountPct}% Rabatt. E-Mail an ${application.company.email ?? '(keine E-Mail hinterlegt)'} würde versendet.`, + message: `Genehmigt mit ${discountPct}% Rabatt.${application.company.email ? ` E-Mail an ${application.company.email} versendet.` : ' Keine E-Mail hinterlegt.'}`, }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' diff --git a/src/app/api/aos/agency/[id]/reject/route.ts b/src/app/api/aos/agency/[id]/reject/route.ts index 7b04dd8..9260e53 100644 --- a/src/app/api/aos/agency/[id]/reject/route.ts +++ b/src/app/api/aos/agency/[id]/reject/route.ts @@ -60,6 +60,16 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st }, }) + // Send rejection email + const { sendAgencyRejectedEmail } = await import('@/lib/email') + if (application.company.email) { + await sendAgencyRejectedEmail({ + to: application.company.email, + companyName: application.company.name, + reason, + }) + } + return NextResponse.json({ ok: true, status: 'rejected', reason }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' diff --git a/src/app/api/aos/licenses/route.ts b/src/app/api/aos/licenses/route.ts index a1806b2..656bec2 100755 --- a/src/app/api/aos/licenses/route.ts +++ b/src/app/api/aos/licenses/route.ts @@ -304,6 +304,18 @@ export async function POST(req: Request) { }, }) + // Send license key email to the customer + const { sendLicenseKeyEmail } = await import('@/lib/email') + if (company.email && !parentId) { + // Only send for core licenses (not modules) + sendLicenseKeyEmail({ + to: company.email, + licenseKey: key, + product, + plan, + }).catch(() => {}) // fire-and-forget, don't block response + } + return NextResponse.json({ license, pricing: pricing.breakdown }, { status: 201 }) } catch (err) { const msg = err instanceof Error ? err.message : 'internal server error' diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..9513961 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,118 @@ +import type { Transporter } from 'nodemailer' +import nodemailer from 'nodemailer' + +/** + * Email Client + * ------------ + * Sends transactional emails (agency approval, license key delivery, ticket + * notifications, invoice sent). + * + * Config (env): + * MAIL_SMTP_HOST - e.g. smtp.your-server.de + * MAIL_SMTP_PORT - 587 (STARTTLS) or 465 (SSL) + * MAIL_USER - SMTP username + * MAIL_PASSWORD - SMTP password + * MAIL_FROM - sender email (e.g. "AOS ") + */ + +let transporter: Transporter | null = null + +function getTransporter(): Transporter | null { + const host = process.env.MAIL_SMTP_HOST + const port = process.env.MAIL_SMTP_PORT + const user = process.env.MAIL_USER + const pass = process.env.MAIL_PASSWORD + if (!host || !user || !pass) return null + if (!transporter) { + transporter = nodemailer.createTransport({ + host, + port: port ? parseInt(port, 10) : 587, + secure: parseInt(port ?? '587', 10) === 465, + auth: { user, pass }, + }) + } + return transporter +} + +export interface EmailInput { + to: string + subject: string + text?: string + html?: string +} + +export async function sendEmail(input: EmailInput): Promise<{ ok: boolean; error?: string }> { + const t = getTransporter() + if (!t) { + console.warn('[mail] SMTP not configured — email would have been sent to:', input.to, '| subject:', input.subject) + return { ok: false, error: 'SMTP not configured' } + } + const from = process.env.MAIL_FROM || 'AOS ' + try { + await t.sendMail({ + from, + to: input.to, + subject: input.subject, + text: input.text || input.html?.replace(/<[^>]*>/g, ''), + html: input.html || input.text, + }) + return { ok: true } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : 'send failed' } + } +} + +// --- Pre-built email templates --- + +export async function sendAgencyApprovedEmail(input: { + to: string + companyName: string + discountPct: number +}): Promise { + await sendEmail({ + to: input.to, + subject: `Ihre Reseller-Bewerbung wurde genehmigt — ${input.discountPct}% Rabatt`, + text: `Guten Tag,\n\nIhre Bewerbung als Reseller wurde genehmigt.\n\nUnternehmen: ${input.companyName}\nIhr Rabatt: ${input.discountPct}% auf alle Lizenzen\n\nSie können jetzt eigene Kunden anlegen und lizenzieren.\n\nBesuchen Sie: https://dfmpanel.eu\n\nMit freundlichen Grüßen\nDFM Solutions`, + html: `

Guten Tag,

Ihre Bewerbung als Reseller wurde genehmigt.

Unternehmen: ${input.companyName}
Ihr Rabatt: ${input.discountPct}% auf alle Lizenzen

Sie können jetzt eigene Kunden anlegen und lizenzieren.

Zum Portal →

Mit freundlichen Grüßen
DFM Solutions

`, + }) +} + +export async function sendAgencyRejectedEmail(input: { + to: string + companyName: string + reason: string +}): Promise { + await sendEmail({ + to: input.to, + subject: 'Ihre Reseller-Bewerbung', + text: `Guten Tag,\n\nIhre Bewerbung als Reseller wurde leider abgelehnt.\n\nUnternehmen: ${input.companyName}\nGrund: ${input.reason}\n\nBei Fragen kontaktieren Sie uns unter support@dfmpanel.eu\n\nMit freundlichen Grüßen\nDFM Solutions`, + html: `

Guten Tag,

Ihre Bewerbung als Reseller wurde leider abgelehnt.

Unternehmen: ${input.companyName}
Grund: ${input.reason}

Bei Fragen kontaktieren Sie uns unter support@dfmpanel.eu

Mit freundlichen Grüßen
DFM Solutions

`, + }) +} + +export async function sendLicenseKeyEmail(input: { + to: string + licenseKey: string + product: string + plan: string +}): Promise { + await sendEmail({ + to: input.to, + subject: `Ihre Lizenz für ${input.product} (${input.plan.toUpperCase()})`, + text: `Guten Tag,\n\nIhre Lizenz wurde erstellt.\n\nProdukt: ${input.product}\nPlan: ${input.plan}\nLizenzschlüssel: ${input.licenseKey}\n\nSpeichern Sie den Schlüssel sicher. Sie benötigen ihn zur Aktivierung Ihrer Software.\n\nMit freundlichen Grüßen\nDFM Solutions`, + html: `

Guten Tag,

Ihre Lizenz wurde erstellt.

Produkt: ${input.product}
Plan: ${input.plan}
Lizenzschlüssel: ${input.licenseKey}

Speichern Sie den Schlüssel sicher. Sie benötigen ihn zur Aktivierung Ihrer Software.

Mit freundlichen Grüßen
DFM Solutions

`, + }) +} + +export async function sendTicketCreatedEmail(input: { + to: string + ticketNumber: string + subject: string +}): Promise { + await sendEmail({ + to: input.to, + subject: `Ticket ${input.ticketNumber} wurde erstellt`, + text: `Ihr Support-Ticket wurde erstellt.\n\nTicket: ${input.ticketNumber}\nBetreff: ${input.subject}\n\nWir werden uns so schnell wie möglich um Ihr Anliegen kümmern.\n\nMit freundlichen Grüßen\nDFM Solutions`, + html: `

Ihr Support-Ticket wurde erstellt.

Ticket: ${input.ticketNumber}
Betreff: ${input.subject}

Wir werden uns so schnell wie möglich um Ihr Anliegen kümmern.

`, + }) +}