feat(email): route outbound mail through Proton Mail Bridge SMTP; feat(apply): route one-click apply to employer site

Email:
- Add nodemailer; rewrite lib/email.ts to send via configured SMTP (Proton
  Mail Bridge) first, Resend fallback, then dev simulation.
- Forward SMTP_* env to web and add protonmail-bridge service in compose.
- Document Proton Bridge SMTP vars in .env.example files; add test-smtp.mjs.

Apply:
- OneClickApplyModal now opens the job's real posting URL (company/ATS site)
  in a new tab as the primary action when jobUrl is present, and records the
  external application so the card shows 'Applied'.
- Internal one-click snapshot flow retained for jobs without an external URL.
- POST /api/applications accepts external:true (skips profile completeness)
  and records an external application.
This commit is contained in:
JobsBoard Deployer 2026-09-07 14:46:57 -04:00
parent bb4c93b11c
commit 50b8524775
12 changed files with 407 additions and 154 deletions

View file

@ -19,3 +19,24 @@ NODE_ENV=production
SCRAPE_INTERVAL_MINUTES=30 SCRAPE_INTERVAL_MINUTES=30
# SOCKS5_PROXY=socks5://username:password@host:port # SOCKS5_PROXY=socks5://username:password@host:port
# PROXY_URL=http://username:password@host:port # PROXY_URL=http://username:password@host:port
# =================================================================
# Outbound Email via Proton Mail Bridge (SMTP relay)
# =================================================================
# The web app sends mail through the `protonmail-bridge` container, which is a
# local SMTP relay for your Proton account. Set these to the Bridge login:
# SMTP_USER = your Proton login email (e.g. you@proton.me)
# SMTP_PASS = the Proton Mail Bridge app-password (NOT your account password)
# generate it at Proton > Settings > Bridge/IMAP/SMTP
# SMTP_FROM = a sending address you own in Proton (your custom domain)
# A one-time `docker compose run -it protonmail-bridge` login is required to
# persist the Bridge credentials in the protonmail_bridge_data volume.
SMTP_HOST=protonmail-bridge
SMTP_PORT=25
SMTP_USER=you@proton.me
SMTP_PASS=your-proton-bridge-app-password
SMTP_FROM="JobsBoard <notifications@yourdomain.com>"
# Optional: passphrase protecting the Bridge GPG keyring (recommended)
BRIDGE_KEYRING_PASSPHRASE=replace_with_a_secure_passphrase
# Host port to reach the Bridge SMTP for local testing (container-internal is 25)
BRIDGE_SMTP_PORT=1025

View file

@ -40,6 +40,11 @@ services:
NODE_ENV: "production" NODE_ENV: "production"
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}" OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}"
OPENROUTER_MODEL: "${OPENROUTER_MODEL:-google/gemini-2.5-flash}" OPENROUTER_MODEL: "${OPENROUTER_MODEL:-google/gemini-2.5-flash}"
SMTP_HOST: "${SMTP_HOST:-protonmail-bridge}"
SMTP_PORT: "${SMTP_PORT:-25}"
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASS: "${SMTP_PASS:-}"
SMTP_FROM: "${SMTP_FROM:-JobsBoard <notifications@jobsboard.internal>}"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@ -68,8 +73,24 @@ services:
- default - default
- mediaserver_default - mediaserver_default
# Proton Mail Bridge: local SMTP relay so the web app can send via Proton.
# Requires a one-time interactive login (see docs) to persist credentials.
protonmail-bridge:
image: dancwilliams/protonmail-bridge:latest
container_name: jobsboard_protonmail_bridge
restart: unless-stopped
environment:
KEYRING_PASSPHRASE: "${BRIDGE_KEYRING_PASSPHRASE:-}"
volumes:
- protonmail_bridge_data:/root
ports:
- "${BRIDGE_SMTP_PORT:-1025}:25"
networks:
- default
volumes: volumes:
postgres_data: postgres_data:
protonmail_bridge_data:
networks: networks:
default: default:

View file

@ -9,11 +9,14 @@ NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="generate-a-secure-random-secret-key-32-chars-minimum" NEXTAUTH_SECRET="generate-a-secure-random-secret-key-32-chars-minimum"
# SMTP Email Configuration (Alerts, Verification & Password Resets) # SMTP Email Configuration (Alerts, Verification & Password Resets)
SMTP_HOST="smtp.example.com" # In Docker this is supplied by docker-compose from the root .env. For local
SMTP_PORT="587" # dev without the Bridge, point SMTP_HOST at any SMTP server you control.
SMTP_USER="notifications@example.com" # With Proton Mail Bridge the relay is `protonmail-bridge` on port 25 (STARTTLS).
SMTP_PASS="your-smtp-password-here" SMTP_HOST="protonmail-bridge"
SMTP_FROM="JobsBoard Alerts <notifications@example.com>" SMTP_PORT="25"
SMTP_USER="you@proton.me"
SMTP_PASS="your-proton-bridge-app-password"
SMTP_FROM="JobsBoard <notifications@yourdomain.com>"
# Environment Mode # Environment Mode
NODE_ENV="development" NODE_ENV="development"

21
web/package-lock.json generated
View file

@ -16,6 +16,7 @@
"lucide-react": "^0.439.0", "lucide-react": "^0.439.0",
"next": "^14.2.8", "next": "^14.2.8",
"next-auth": "^4.24.7", "next-auth": "^4.24.7",
"nodemailer": "^7.0.7",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -26,6 +27,7 @@
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.16.5", "@types/node": "^20.16.5",
"@types/nodemailer": "^6.4.16",
"@types/pdfkit": "^0.17.6", "@types/pdfkit": "^0.17.6",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@ -675,6 +677,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "6.4.24",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
"integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pdfkit": { "node_modules/@types/pdfkit": {
"version": "0.17.6", "version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
@ -1748,6 +1760,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",

View file

@ -26,12 +26,14 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"unpdf": "^1.8.1" "unpdf": "^1.8.1",
"nodemailer": "^7.0.7"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1", "@playwright/test": "^1.62.1",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.16.5", "@types/node": "^20.16.5",
"@types/nodemailer": "^6.4.16",
"@types/pdfkit": "^0.17.6", "@types/pdfkit": "^0.17.6",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",

38
web/scripts/test-smtp.mjs Normal file
View file

@ -0,0 +1,38 @@
import nodemailer from "nodemailer";
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || "25", 10);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const from = process.env.SMTP_FROM || "JobsBoard <notifications@jobsboard.internal>";
const to = process.env.SMTP_TEST_TO || user;
if (!host || !user || !pass) {
console.error("Missing SMTP_HOST / SMTP_USER / SMTP_PASS in the environment.");
process.exit(1);
}
const transporter = nodemailer.createTransport({
host,
port,
secure: port === 465 || port === 10465,
requireTLS: true,
auth: { user, pass },
// Local relays such as Proton Mail Bridge present self-signed certs.
tls: { rejectUnauthorized: false },
});
try {
const info = await transporter.sendMail({
from,
to,
subject: "JobsBoard SMTP relay test",
text: "If you received this, the Proton Mail Bridge SMTP relay is working.",
html: "<p>If you received this, the Proton Mail Bridge SMTP relay is working.</p>",
});
console.log("SMTP test email sent:", info.messageId);
process.exit(0);
} catch (err) {
console.error("SMTP test failed:", err.message);
process.exit(1);
}

View file

@ -132,7 +132,7 @@ export async function POST(req: Request) {
try { try {
const body = await req.json(); const body = await req.json();
const { jobId, coverLetter, answersJson } = body; const { jobId, coverLetter, answersJson, external, appliedUrl } = body;
if (!jobId) { if (!jobId) {
return NextResponse.json({ error: "jobId is required" }, { status: 400 }); return NextResponse.json({ error: "jobId is required" }, { status: 400 });
@ -167,6 +167,51 @@ export async function POST(req: Request) {
); );
} }
// 2b. External application (user was routed to the employer's own site).
// No profile snapshot required; we just record that they applied.
if (external) {
const application = await prisma.application.create({
data: {
jobId,
applicantId: userId,
coverLetter: coverLetter?.trim() || null,
answersJson: answersJson ? JSON.stringify(answersJson) : null,
snapshotJson: JSON.stringify({ external: true, appliedUrl: appliedUrl || null }),
status: "APPLIED",
statusHistory: {
create: {
fromStatus: null,
toStatus: "APPLIED",
changedById: userId,
note: "Applied via external employer link",
},
},
},
include: {
job: true,
statusHistory: true,
},
});
await prisma.userJobInteraction.upsert({
where: {
userId_jobId: { userId, jobId },
},
create: { userId, jobId, status: "APPLIED" },
update: { status: "APPLIED" },
});
await createNotification({
userId,
type: "STATUS_CHANGE",
title: "Application Submitted",
message: `Your application for "${job.title}" at ${job.company} was sent through to the employer's site.`,
link: "/applications",
});
return NextResponse.json({ success: true, application, external: true });
}
// 3. Verify user & build snapshot // 3. Verify user & build snapshot
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },

View file

@ -16,6 +16,7 @@ interface JobItem {
salaryMin?: number; salaryMin?: number;
salaryMax?: number; salaryMax?: number;
datePosted?: string; datePosted?: string;
jobUrl?: string | null;
} }
interface ReviewItem { interface ReviewItem {
@ -573,7 +574,7 @@ export default function CompanyDetailPage() {
{/* Apply Modal */} {/* Apply Modal */}
{applyingJob && ( {applyingJob && (
<OneClickApplyModal <OneClickApplyModal
job={{ id: applyingJob.id, title: applyingJob.title, company: company.name }} job={{ id: applyingJob.id, title: applyingJob.title, company: company.name, jobUrl: applyingJob.jobUrl }}
onClose={() => setApplyingJob(null)} onClose={() => setApplyingJob(null)}
onSuccess={() => setApplyingJob(null)} onSuccess={() => setApplyingJob(null)}
/> />

View file

@ -573,7 +573,7 @@ export default function JobDetailPage() {
{showApplyModal && ( {showApplyModal && (
<OneClickApplyModal <OneClickApplyModal
job={{ id: job.id, title: job.title, company: job.company }} job={{ id: job.id, title: job.title, company: job.company, jobUrl: job.jobUrl }}
onClose={() => setShowApplyModal(false)} onClose={() => setShowApplyModal(false)}
onSuccess={() => { onSuccess={() => {
setIsApplied(true); setIsApplied(true);

View file

@ -274,7 +274,7 @@ export function JobCard({ job, onStatusChange, isAuthenticated }: JobCardProps)
{showApplyModal && ( {showApplyModal && (
<OneClickApplyModal <OneClickApplyModal
job={{ id: job.id, title: job.title, company: job.company }} job={{ id: job.id, title: job.title, company: job.company, jobUrl: job.jobUrl }}
onClose={() => setShowApplyModal(false)} onClose={() => setShowApplyModal(false)}
onSuccess={() => { onSuccess={() => {
setCurrentStatus("APPLIED"); setCurrentStatus("APPLIED");

View file

@ -8,6 +8,7 @@ interface OneClickApplyModalProps {
id: string; id: string;
title: string; title: string;
company: string; company: string;
jobUrl?: string | null;
}; };
onClose: () => void; onClose: () => void;
onSuccess: () => void; onSuccess: () => void;
@ -23,6 +24,18 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasExternal = Boolean(job.jobUrl && /^https?:\/\//i.test(job.jobUrl));
const hasInternalPath = !hasExternal;
const externalHost = hasExternal
? (() => {
try {
return new URL(job.jobUrl as string).host;
} catch {
return job.jobUrl;
}
})()
: "";
useEffect(() => { useEffect(() => {
fetchCompleteness(); fetchCompleteness();
}, []); }, []);
@ -44,6 +57,21 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod
} }
}; };
const handleExternalApply = () => {
// Open the employer's real application page (anchor target=_blank handles the
// new tab); record the external application best-effort so the card shows "Applied".
try {
fetch("/api/applications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId: job.id, external: true, appliedUrl: job.jobUrl }),
keepalive: true,
}).catch(() => {});
} catch {}
onSuccess();
onClose();
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSubmitting(true); setSubmitting(true);
@ -94,121 +122,151 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod
</div> </div>
)} )}
{loadingCompleteness ? ( {hasExternal && (
<div className="py-8 text-center text-xs text-zinc-500 animate-pulse"> <div className="bg-blue-50 border border-blue-200 p-4 rounded-md text-xs space-y-3">
Checking candidate profile completeness... <p className="text-blue-900 font-medium leading-relaxed">
</div> This role is listed on the employer&apos;s own site. We&apos;ll send you there to
) : isComplete === false ? ( apply directly &mdash; your application goes straight to {job.company}.
/* Incomplete Profile State */
<div className="bg-amber-50 border border-amber-200 p-4 rounded-md text-xs space-y-3">
<div className="flex items-center space-x-2 text-amber-900 font-bold">
<svg className="w-4 h-4 text-amber-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Profile Incomplete</span>
</div>
<p className="text-amber-800 leading-relaxed">
Employers require a minimum complete candidate profile or resume before receiving One-Click applications.
</p> </p>
<a
href={job.jobUrl as string}
target="_blank"
rel="noopener noreferrer"
onClick={handleExternalApply}
className="block text-center px-4 py-2 bg-zinc-900 text-white rounded text-xs font-semibold hover:bg-zinc-800 transition-colors"
>
Apply on {job.company}&apos;s site &rarr;
</a>
<p className="text-[10px] text-blue-700/80 break-all">Opens: {externalHost}</p>
</div>
)}
{missingFields.length > 0 && ( {hasInternalPath && (
<div className="text-[11px] text-amber-900"> <div className={hasExternal ? "pt-1 border-t border-zinc-200" : ""}>
<span className="font-semibold">Required missing details:</span> {hasExternal && (
<ul className="list-disc list-inside mt-1 space-y-0.5 font-mono text-amber-700"> <p className="text-[11px] font-semibold text-zinc-500 mb-2">
{missingFields.map((field, idx) => ( &mdash; or apply through JobsBoard &mdash;
<li key={idx}>{field}</li> </p>
))}
</ul>
</div>
)} )}
<div className="pt-2 flex items-center justify-end space-x-2"> {loadingCompleteness ? (
<Link <div className="py-8 text-center text-xs text-zinc-500 animate-pulse">
href="/profile" Checking candidate profile completeness...
className="px-3 py-1.5 bg-zinc-900 text-white font-medium rounded text-xs hover:bg-zinc-800 transition-colors" </div>
onClick={onClose} ) : isComplete === false ? (
> /* Incomplete Profile State */
Complete Profile → <div className="bg-amber-50 border border-amber-200 p-4 rounded-md text-xs space-y-3">
</Link> <div className="flex items-center space-x-2 text-amber-900 font-bold">
<Link <svg className="w-4 h-4 text-amber-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
href="/resume" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
className="px-3 py-1.5 bg-white border border-zinc-300 text-zinc-800 font-medium rounded text-xs hover:bg-zinc-50 transition-colors" </svg>
onClick={onClose} <span>Profile Incomplete</span>
> </div>
Upload/Edit Resume →
</Link> <p className="text-amber-800 leading-relaxed">
</div> Employers require a minimum complete candidate profile or resume before receiving
One-Click applications.
</p>
{missingFields.length > 0 && (
<div className="text-[11px] text-amber-900">
<span className="font-semibold">Required missing details:</span>
<ul className="list-disc list-inside mt-1 space-y-0.5 font-mono text-amber-700">
{missingFields.map((field, idx) => (
<li key={idx}>{field}</li>
))}
</ul>
</div>
)}
<div className="pt-2 flex items-center justify-end space-x-2">
<Link
href="/profile"
className="px-3 py-1.5 bg-zinc-900 text-white font-medium rounded text-xs hover:bg-zinc-800 transition-colors"
onClick={onClose}
>
Complete Profile &rarr;
</Link>
<Link
href="/resume"
className="px-3 py-1.5 bg-white border border-zinc-300 text-zinc-800 font-medium rounded text-xs hover:bg-zinc-50 transition-colors"
onClick={onClose}
>
Upload/Edit Resume &rarr;
</Link>
</div>
</div>
) : (
/* Ready to Apply State */
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-zinc-50 p-4 rounded-md border border-zinc-200 text-xs space-y-2">
<div className="flex justify-between items-center pb-2 border-b border-zinc-200 font-bold text-zinc-800">
<span> Application Snapshot Summary</span>
<span className="text-[10px] font-mono bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded font-semibold">
Profile Verified
</span>
</div>
{profileData?.headline && (
<p className="text-zinc-700 font-medium">{profileData.headline}</p>
)}
<div className="text-zinc-500 space-y-1 text-[11px]">
{profileData?.location && <div> Location: {profileData.location}</div>}
{profileData?.skills?.length > 0 && (
<div>
Skills:{" "}
{profileData.skills.slice(0, 4).map((s: any) => s.name).join(", ")}
</div>
)}
{activeResume ? (
<div className="text-blue-700 font-medium">
Active Resume: {activeResume.title}
</div>
) : (
<div className="text-zinc-600">
Profile details will be submitted directly to employer.
</div>
)}
</div>
<p className="text-[10px] text-zinc-400 italic pt-1 border-t border-zinc-200">
A frozen snapshot of this profile will be preserved for the employer upon submission.
</p>
</div>
<div>
<label className="block text-xs font-medium text-zinc-700 mb-1">
Cover Letter / Note to Hiring Manager <span className="text-zinc-400 font-normal">(Optional)</span>
</label>
<textarea
rows={4}
value={coverLetter}
onChange={(e) => setCoverLetter(e.target.value)}
placeholder="Briefly explain why you're a great fit for this role..."
className="w-full text-xs border border-zinc-300 rounded p-2 focus:ring-1 focus:ring-zinc-900 focus:outline-none"
/>
</div>
<div className="flex justify-end space-x-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 border border-zinc-300 text-zinc-700 rounded text-xs hover:bg-zinc-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-1.5 bg-zinc-900 text-white rounded text-xs font-semibold hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
{submitting ? "Submitting Application..." : "Submit One-Click Application"}
</button>
</div>
</form>
)}
</div> </div>
) : (
/* Ready to Apply State */
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-zinc-50 p-4 rounded-md border border-zinc-200 text-xs space-y-2">
<div className="flex justify-between items-center pb-2 border-b border-zinc-200 font-bold text-zinc-800">
<span> Application Snapshot Summary</span>
<span className="text-[10px] font-mono bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded font-semibold">
Profile Verified
</span>
</div>
{profileData?.headline && (
<p className="text-zinc-700 font-medium">{profileData.headline}</p>
)}
<div className="text-zinc-500 space-y-1 text-[11px]">
{profileData?.location && <div> Location: {profileData.location}</div>}
{profileData?.skills?.length > 0 && (
<div>
Skills:{" "}
{profileData.skills.slice(0, 4).map((s: any) => s.name).join(", ")}
</div>
)}
{activeResume ? (
<div className="text-blue-700 font-medium">
Active Resume: {activeResume.title}
</div>
) : (
<div className="text-zinc-600">
Profile details will be submitted directly to employer.
</div>
)}
</div>
<p className="text-[10px] text-zinc-400 italic pt-1 border-t border-zinc-200">
A frozen snapshot of this profile will be preserved for the employer upon submission.
</p>
</div>
<div>
<label className="block text-xs font-medium text-zinc-700 mb-1">
Cover Letter / Note to Hiring Manager <span className="text-zinc-400 font-normal">(Optional)</span>
</label>
<textarea
rows={4}
value={coverLetter}
onChange={(e) => setCoverLetter(e.target.value)}
placeholder="Briefly explain why you're a great fit for this role..."
className="w-full text-xs border border-zinc-300 rounded p-2 focus:ring-1 focus:ring-zinc-900 focus:outline-none"
/>
</div>
<div className="flex justify-end space-x-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 border border-zinc-300 text-zinc-700 rounded text-xs hover:bg-zinc-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-1.5 bg-zinc-900 text-white rounded text-xs font-semibold hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
{submitting ? "Submitting Application..." : "Submit One-Click Application"}
</button>
</div>
</form>
)} )}
</div> </div>
</div> </div>

View file

@ -1,7 +1,12 @@
/** /**
* Transactional email service using Resend with local dev fallback. * Transactional email service.
*
* Send priority: configured SMTP (e.g. Proton Mail Bridge) > Resend API > dev simulation.
* SMTP settings are read from the centralized config (web/src/lib/config.ts).
*/ */
import { envConfig } from "@/lib/config";
export async function sendEmail({ export async function sendEmail({
to, to,
subject, subject,
@ -11,44 +16,82 @@ export async function sendEmail({
subject: string; subject: string;
html: string; html: string;
}) { }) {
const smtp = envConfig.config.email;
// 1. Configured SMTP relay (preferred)
if (smtp.smtpHost && smtp.smtpUser && smtp.smtpPass) {
try {
const nodemailer = await import("nodemailer");
const port = smtp.smtpPort || 25;
const transporter = nodemailer.createTransport({
host: smtp.smtpHost,
port,
// Implicit TLS for 465/10465, STARTTLS otherwise.
secure: port === 465 || port === 10465,
requireTLS: true,
auth: {
user: smtp.smtpUser,
pass: smtp.smtpPass,
},
// Local relays such as Proton Mail Bridge present self-signed certs.
tls: { rejectUnauthorized: false },
});
const info = await transporter.sendMail({
from: smtp.fromEmail || "JobsBoard <notifications@jobsboard.internal>",
to,
subject,
html,
});
return { success: true, data: info };
} catch (err: any) {
console.error("SMTP email dispatch error:", err?.message || err);
return { success: false, error: err?.message || "SMTP send failed" };
}
}
// 2. Resend fallback
const apiKey = process.env.RESEND_API_KEY; const apiKey = process.env.RESEND_API_KEY;
const from = process.env.EMAIL_FROM || "DirectWire <notifications@resend.dev>"; const from = process.env.EMAIL_FROM || "DirectWire <notifications@resend.dev>";
if (!apiKey) { if (apiKey) {
console.log("========================================="); try {
console.log(`[EMAIL DISPATCH - DEV SIMULATION]`); const res = await fetch("https://api.resend.com/emails", {
console.log(`To: ${to}`); method: "POST",
console.log(`Subject: ${subject}`); headers: {
console.log(`Body (HTML length: ${html.length} chars)`); "Content-Type": "application/json",
console.log("========================================="); Authorization: `Bearer ${apiKey}`,
return { success: true, simulated: true }; },
} body: JSON.stringify({
from,
to: [to],
subject,
html,
}),
});
try { if (!res.ok) {
const res = await fetch("https://api.resend.com/emails", { const errorText = await res.text();
method: "POST", console.error("Resend email dispatch error:", errorText);
headers: { return { success: false, error: errorText };
"Content-Type": "application/json", }
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
from,
to: [to],
subject,
html,
}),
});
if (!res.ok) { const data = await res.json();
const errorText = await res.text(); return { success: true, data };
console.error("Resend email dispatch error:", errorText); } catch (err: any) {
return { success: false, error: errorText }; console.error("Email dispatch failed:", err);
return { success: false, error: err.message };
} }
const data = await res.json();
return { success: true, data };
} catch (err: any) {
console.error("Email dispatch failed:", err);
return { success: false, error: err.message };
} }
// 3. Dev simulation
console.log("=========================================");
console.log(`[EMAIL DISPATCH - DEV SIMULATION]`);
console.log(`To: ${to}`);
console.log(`Subject: ${subject}`);
console.log(`Body (HTML length: ${html.length} chars)`);
console.log("=========================================");
return { success: true, simulated: true };
} }