feature: nodemailer set
This commit is contained in:
13
apps/admin/README.md
Normal file
13
apps/admin/README.md
Normal 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`.
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
28
apps/admin/lib/mail-templates.ts
Normal file
28
apps/admin/lib/mail-templates.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
85
apps/admin/lib/mailer.ts
Normal file
85
apps/admin/lib/mailer.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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:^",
|
||||
|
||||
@@ -32,5 +32,8 @@
|
||||
"esbuild",
|
||||
"sharp"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"nodemailer": "^7.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
986
pnpm-lock.yaml
generated
986
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user