feature: nodemailer set

This commit is contained in:
Anushlinux
2025-09-03 18:54:55 +05:30
parent 972ed81192
commit 425475c027
9 changed files with 1235 additions and 21 deletions

13
apps/admin/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Mailer Setup
Configure the following environment variables (e.g., in `.env` at repo root used by `dotenv-cli`):
- SMTP_HOST
- SMTP_PORT (465 for SSL, 587 for TLS)
- SMTP_USER
- SMTP_PASS
- SMTP_FROM (optional, defaults to SMTP_USER)
- SMTP_MAX_CONNECTIONS (optional)
- SMTP_MAX_MESSAGES (optional)
The status update API at `app/api/applications/[applicationId]/status/route.ts` sends an email whenever the status is updated. Templates live in `lib/mail-templates.ts`.

View File

@@ -36,6 +36,7 @@ export default function ApplicationsTable({ applicants }: { applicants: Applican
const [selected, setSelected] = useState<number[]>([]);
const [bulkStatus, setBulkStatus] = useState<string>('');
const [rows, setRows] = useState<Applicant[]>(applicants);
const [sendEmail, setSendEmail] = useState<boolean>(false);
const [isPending, startTransition] = useTransition();
const filtered = useMemo(() => {
@@ -61,17 +62,40 @@ export default function ApplicationsTable({ applicants }: { applicants: Applican
const targets = new Set(selected);
startTransition(async () => {
const updates = rows.filter((r) => targets.has(r.applicationId));
let notifiedCount = 0;
let errorCount = 0;
for (const r of updates) {
await fetch(`/api/applications/${r.applicationId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: bulkStatus, studentId: r.studentId ?? 0 }),
});
try {
const res = await fetch(`/api/applications/${r.applicationId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: bulkStatus, studentId: r.studentId ?? 0, notify: sendEmail }),
});
const data = await res.json();
if (!res.ok) {
errorCount += 1;
} else if (sendEmail) {
if (data?.notified) notifiedCount += 1;
if (data?.emailError) errorCount += 1;
}
} catch (_e) {
errorCount += 1;
}
}
setRows((prev) =>
prev.map((r) => (targets.has(r.applicationId) ? { ...r, status: bulkStatus } : r)),
);
setSelected([]);
if (sendEmail) {
if (errorCount === 0) {
alert(`Updated ${updates.length} and emailed ${notifiedCount} student(s).`);
} else {
alert(`Updated ${updates.length}. Emails sent: ${notifiedCount}. Errors: ${errorCount}.`);
}
} else {
alert(`Updated ${updates.length}.`);
}
});
};
@@ -103,6 +127,14 @@ export default function ApplicationsTable({ applicants }: { applicants: Applican
))}
</SelectContent>
</Select>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
/>
Send email notifications
</label>
<Button onClick={onBulkUpdate} disabled={!bulkStatus || selected.length === 0 || isPending}>
Update {selected.length || ''}
</Button>

View File

@@ -1,6 +1,8 @@
import { db, applications } from '@workspace/db';
import { db, applications, students } from '@workspace/db';
import { eq } from '@workspace/db/drizzle';
import { NextRequest, NextResponse } from 'next/server';
import { sendEmail } from '@/lib/mailer';
import { statusUpdateHtml, statusUpdateSubject, statusUpdateText } from '@/lib/mail-templates';
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ applicationId: string }> }) {
const { applicationId: applicationIdParam } = await params;
@@ -9,18 +11,63 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ ap
return NextResponse.json({ error: 'Invalid applicationId' }, { status: 400 });
}
const { status } = await req.json();
const { status, studentId, notify } = await req.json();
if (!status) {
return NextResponse.json({ error: 'Missing status' }, { status: 400 });
}
const result = await db.update(applications)
.set({ status })
.where(eq(applications.id, applicationId));
let result;
try {
result = await db
.update(applications)
.set({ status })
.where(eq(applications.id, applicationId));
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown database error';
return NextResponse.json({ error: 'Database update failed', detail: message }, { status: 500 });
}
if (result.rowCount === 0) {
if (!result || result.rowCount === 0) {
return NextResponse.json({ error: 'Application not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
const shouldNotify = notify === undefined ? true : Boolean(notify);
let emailError: string | undefined;
let notified = false;
try {
if (shouldNotify) {
// studentId is provided by client components; if missing, try to infer
let effectiveStudentId: number | null = Number.isFinite(Number(studentId)) && Number(studentId) > 0 ? Number(studentId) : null;
if (!effectiveStudentId) {
const app = await db.query.applications.findFirst({
where: eq(applications.id, applicationId),
columns: { studentId: true },
});
effectiveStudentId = app?.studentId ?? null;
}
if (effectiveStudentId) {
const student = await db.query.students.findFirst({
where: eq(students.id, effectiveStudentId),
columns: { email: true, firstName: true, lastName: true },
});
if (student?.email) {
const name = `${student.firstName ?? ''} ${student.lastName ?? ''}`.trim() || 'Student';
await sendEmail({
to: student.email,
subject: statusUpdateSubject(status),
text: statusUpdateText(name, status),
html: statusUpdateHtml(name, status),
});
notified = true;
}
}
}
} catch (err) {
emailError = err instanceof Error ? err.message : 'Unknown email error';
console.error('Failed to send status update email', { applicationId, status, error: emailError });
}
return NextResponse.json({ success: true, emailError: emailError ?? null, notified });
}

View File

@@ -1,15 +1,43 @@
import { NextResponse } from 'next/server'
import { getMailer } from '@/lib/mailer'
import { db } from '@workspace/db'
import { sql } from '@workspace/db/drizzle'
export async function GET() {
try {
// You can add additional health checks here
// For example, database connectivity, external service checks, etc.
const envSeen = {
SMTP_URL: Boolean(process.env.SMTP_URL || process.env.MAIL_URL),
SMTP_HOST: Boolean(process.env.SMTP_HOST || process.env.MAIL_HOST),
SMTP_PORT: Boolean(process.env.SMTP_PORT || process.env.MAIL_PORT),
SMTP_USER: Boolean(process.env.SMTP_USER || process.env.SMTP_USERNAME || process.env.MAIL_USER || process.env.MAIL_USERNAME),
SMTP_PASS: Boolean(process.env.SMTP_PASS || process.env.SMTP_PASSWORD || process.env.MAIL_PASS || process.env.MAIL_PASSWORD),
SMTP_FROM: Boolean(process.env.SMTP_FROM),
}
let smtp: { ok: boolean; message?: string } = { ok: false }
try {
await getMailer().verify()
smtp = { ok: true }
} catch (e) {
smtp = { ok: false, message: e instanceof Error ? e.message : 'unknown error' }
}
let database: { ok: boolean; message?: string } = { ok: false }
try {
await db.execute(sql`select 1`)
database = { ok: true }
} catch (e) {
database = { ok: false, message: e instanceof Error ? e.message : 'unknown error' }
}
return NextResponse.json(
{
status: 'ok',
timestamp: new Date().toISOString(),
service: 'admin-app'
service: 'admin-app',
smtp,
database,
envSeen
},
{ status: 200 }
)

View File

@@ -0,0 +1,28 @@
export function statusUpdateSubject(status: string): string {
return `Your application status has been updated to ${status}`;
}
export function statusUpdateText(name: string, status: string): string {
return `Hi ${name},\n\nYour application status has been updated to ${status}.\n\nIf you have any questions, please reply to this email.\n\nBest regards,\nPlacement Cell`;
}
export function statusUpdateHtml(name: string, status: string): string {
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', Arial, sans-serif; color: #0f172a;">
<h2 style="margin: 0 0 16px; font-weight: 600;">Application Status Updated</h2>
<p style="margin: 0 0 12px;">Hi <strong>${escapeHtml(name)}</strong>,</p>
<p style="margin: 0 0 12px;">Your application status has been updated to <strong>${escapeHtml(status)}</strong>.</p>
<p style="margin: 0 0 12px;">If you have any questions, please reply to this email.</p>
<p style="margin-top: 24px; color: #475569;">Best regards,<br/>Placement Cell</p>
</div>
`;
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

85
apps/admin/lib/mailer.ts Normal file
View File

@@ -0,0 +1,85 @@
import nodemailer, { Transporter } from 'nodemailer';
/**
* Singleton pooled transporter for server-side usage
*/
let cachedTransporter: Transporter | null = null;
function getEnv(name: string, fallbacks: string[] = []): string | undefined {
const keys = [name, ...fallbacks];
for (const key of keys) {
const val = process.env[key];
if (val && String(val).length > 0) return val;
}
return undefined;
}
function createTransporter(): Transporter {
// Support URL-style config first
const smtpUrl = getEnv('SMTP_URL', ['MAIL_URL']);
const host = getEnv('SMTP_HOST', ['MAIL_HOST']);
const portStr = getEnv('SMTP_PORT', ['MAIL_PORT']);
const user = getEnv('SMTP_USER', ['SMTP_USERNAME', 'MAIL_USER', 'MAIL_USERNAME']);
const pass = getEnv('SMTP_PASS', ['SMTP_PASSWORD', 'MAIL_PASS', 'MAIL_PASSWORD']);
const secureStr = getEnv('SMTP_SECURE');
if (smtpUrl) {
return nodemailer.createTransport({
pool: true,
url: smtpUrl,
maxConnections: Number(process.env.SMTP_MAX_CONNECTIONS || 5),
maxMessages: Number(process.env.SMTP_MAX_MESSAGES || 100),
} as any);
}
const port = Number(portStr || 587);
const isSecure = secureStr ? /^(1|true|yes)$/i.test(secureStr) : port === 465;
if (!host || !user || !pass) {
const missing: string[] = [];
if (!host) missing.push('SMTP_HOST');
if (!user) missing.push('SMTP_USER');
if (!pass) missing.push('SMTP_PASS');
throw new Error(
`SMTP configuration missing. Please set ${missing.join(', ')} (alternatives supported: SMTP_URL, SMTP_USERNAME/SMTP_PASSWORD, MAIL_*).`
);
}
return nodemailer.createTransport({
pool: true,
host,
port,
secure: isSecure,
auth: { user, pass },
maxConnections: Number(process.env.SMTP_MAX_CONNECTIONS || 5),
maxMessages: Number(process.env.SMTP_MAX_MESSAGES || 100),
});
}
export function getMailer(): Transporter {
if (!cachedTransporter) {
cachedTransporter = createTransporter();
}
return cachedTransporter;
}
export type SendEmailParams = {
to: string;
subject: string;
html?: string;
text?: string;
fromOverride?: string;
};
export async function sendEmail({ to, subject, html, text, fromOverride }: SendEmailParams): Promise<void> {
const transporter = getMailer();
const from = fromOverride || process.env.SMTP_FROM || process.env.SMTP_USER || 'no-reply@example.com';
await transporter.sendMail({
from,
to,
subject,
text,
html,
});
}

View File

@@ -28,6 +28,7 @@
"next": "^15.3.4",
"next-auth": "5.0.0-beta.29",
"next-themes": "^0.4.6",
"nodemailer": "^6.10.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.59.0",
@@ -36,6 +37,7 @@
},
"devDependencies": {
"@types/node": "^20.19.4",
"@types/nodemailer": "^6.4.15",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@workspace/eslint-config": "workspace:^",

View File

@@ -32,5 +32,8 @@
"esbuild",
"sharp"
]
},
"dependencies": {
"nodemailer": "^7.0.6"
}
}

986
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff