From 50b852477508b4097e32d62759fc66779878fc31 Mon Sep 17 00:00:00 2001 From: JobsBoard Deployer Date: Mon, 7 Sep 2026 14:46:57 -0400 Subject: [PATCH] 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. --- .env.example | 21 ++ docker-compose.yml | 21 ++ web/.env.example | 13 +- web/package-lock.json | 21 ++ web/package.json | 4 +- web/scripts/test-smtp.mjs | 38 +++ web/src/app/api/applications/route.ts | 47 +++- web/src/app/companies/[id]/page.tsx | 3 +- web/src/app/jobs/[id]/page.tsx | 2 +- web/src/components/JobCard.tsx | 2 +- web/src/components/OneClickApplyModal.tsx | 278 +++++++++++++--------- web/src/lib/email.ts | 111 ++++++--- 12 files changed, 407 insertions(+), 154 deletions(-) create mode 100644 web/scripts/test-smtp.mjs diff --git a/.env.example b/.env.example index c8e8bec..833750a 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,24 @@ NODE_ENV=production SCRAPE_INTERVAL_MINUTES=30 # SOCKS5_PROXY=socks5://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 " +# 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 diff --git a/docker-compose.yml b/docker-compose.yml index 86cad3a..261a632 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,11 @@ services: NODE_ENV: "production" OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}" 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 }" depends_on: db: condition: service_healthy @@ -68,8 +73,24 @@ services: - 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: postgres_data: + protonmail_bridge_data: networks: default: diff --git a/web/.env.example b/web/.env.example index 0e89f4d..ed87d2a 100644 --- a/web/.env.example +++ b/web/.env.example @@ -9,11 +9,14 @@ NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="generate-a-secure-random-secret-key-32-chars-minimum" # SMTP Email Configuration (Alerts, Verification & Password Resets) -SMTP_HOST="smtp.example.com" -SMTP_PORT="587" -SMTP_USER="notifications@example.com" -SMTP_PASS="your-smtp-password-here" -SMTP_FROM="JobsBoard Alerts " +# In Docker this is supplied by docker-compose from the root .env. For local +# dev without the Bridge, point SMTP_HOST at any SMTP server you control. +# With Proton Mail Bridge the relay is `protonmail-bridge` on port 25 (STARTTLS). +SMTP_HOST="protonmail-bridge" +SMTP_PORT="25" +SMTP_USER="you@proton.me" +SMTP_PASS="your-proton-bridge-app-password" +SMTP_FROM="JobsBoard " # Environment Mode NODE_ENV="development" diff --git a/web/package-lock.json b/web/package-lock.json index 1fcc233..381e057 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,6 +16,7 @@ "lucide-react": "^0.439.0", "next": "^14.2.8", "next-auth": "^4.24.7", + "nodemailer": "^7.0.7", "pdf-parse": "^1.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -26,6 +27,7 @@ "@playwright/test": "^1.62.1", "@types/bcryptjs": "^2.4.6", "@types/node": "^20.16.5", + "@types/nodemailer": "^6.4.16", "@types/pdfkit": "^0.17.6", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", @@ -675,6 +677,16 @@ "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": { "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", @@ -1748,6 +1760,15 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/web/package.json b/web/package.json index eeb065a..2189a9f 100644 --- a/web/package.json +++ b/web/package.json @@ -26,12 +26,14 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^2.5.2", - "unpdf": "^1.8.1" + "unpdf": "^1.8.1", + "nodemailer": "^7.0.7" }, "devDependencies": { "@playwright/test": "^1.62.1", "@types/bcryptjs": "^2.4.6", "@types/node": "^20.16.5", + "@types/nodemailer": "^6.4.16", "@types/pdfkit": "^0.17.6", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", diff --git a/web/scripts/test-smtp.mjs b/web/scripts/test-smtp.mjs new file mode 100644 index 0000000..45c3219 --- /dev/null +++ b/web/scripts/test-smtp.mjs @@ -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 "; +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: "

If you received this, the Proton Mail Bridge SMTP relay is working.

", + }); + console.log("SMTP test email sent:", info.messageId); + process.exit(0); +} catch (err) { + console.error("SMTP test failed:", err.message); + process.exit(1); +} diff --git a/web/src/app/api/applications/route.ts b/web/src/app/api/applications/route.ts index 8ec5297..f7ae0cc 100644 --- a/web/src/app/api/applications/route.ts +++ b/web/src/app/api/applications/route.ts @@ -132,7 +132,7 @@ export async function POST(req: Request) { try { const body = await req.json(); - const { jobId, coverLetter, answersJson } = body; + const { jobId, coverLetter, answersJson, external, appliedUrl } = body; if (!jobId) { 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 const user = await prisma.user.findUnique({ where: { id: userId }, diff --git a/web/src/app/companies/[id]/page.tsx b/web/src/app/companies/[id]/page.tsx index b4eda81..1c833e4 100644 --- a/web/src/app/companies/[id]/page.tsx +++ b/web/src/app/companies/[id]/page.tsx @@ -16,6 +16,7 @@ interface JobItem { salaryMin?: number; salaryMax?: number; datePosted?: string; + jobUrl?: string | null; } interface ReviewItem { @@ -573,7 +574,7 @@ export default function CompanyDetailPage() { {/* Apply Modal */} {applyingJob && ( setApplyingJob(null)} onSuccess={() => setApplyingJob(null)} /> diff --git a/web/src/app/jobs/[id]/page.tsx b/web/src/app/jobs/[id]/page.tsx index 5777f07..5f9ee1a 100644 --- a/web/src/app/jobs/[id]/page.tsx +++ b/web/src/app/jobs/[id]/page.tsx @@ -573,7 +573,7 @@ export default function JobDetailPage() { {showApplyModal && ( setShowApplyModal(false)} onSuccess={() => { setIsApplied(true); diff --git a/web/src/components/JobCard.tsx b/web/src/components/JobCard.tsx index bcdc6d6..7d84f24 100644 --- a/web/src/components/JobCard.tsx +++ b/web/src/components/JobCard.tsx @@ -274,7 +274,7 @@ export function JobCard({ job, onStatusChange, isAuthenticated }: JobCardProps) {showApplyModal && ( setShowApplyModal(false)} onSuccess={() => { setCurrentStatus("APPLIED"); diff --git a/web/src/components/OneClickApplyModal.tsx b/web/src/components/OneClickApplyModal.tsx index 0c7f675..8b55ca1 100644 --- a/web/src/components/OneClickApplyModal.tsx +++ b/web/src/components/OneClickApplyModal.tsx @@ -8,6 +8,7 @@ interface OneClickApplyModalProps { id: string; title: string; company: string; + jobUrl?: string | null; }; onClose: () => void; onSuccess: () => void; @@ -23,6 +24,18 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(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(() => { 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) => { e.preventDefault(); setSubmitting(true); @@ -94,121 +122,151 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod )} - {loadingCompleteness ? ( -
- Checking candidate profile completeness... -
- ) : isComplete === false ? ( - /* Incomplete Profile State */ -
-
- - - - Profile Incomplete -
- -

- Employers require a minimum complete candidate profile or resume before receiving One-Click applications. + {hasExternal && ( +

+

+ This role is listed on the employer's own site. We'll send you there to + apply directly — your application goes straight to {job.company}.

+ + Apply on {job.company}'s site → + +

Opens: {externalHost}

+
+ )} - {missingFields.length > 0 && ( -
- Required missing details: -
    - {missingFields.map((field, idx) => ( -
  • {field}
  • - ))} -
-
+ {hasInternalPath && ( +
+ {hasExternal && ( +

+ — or apply through JobsBoard — +

)} -
- - Complete Profile → - - - Upload/Edit Resume → - -
+ {loadingCompleteness ? ( +
+ Checking candidate profile completeness... +
+ ) : isComplete === false ? ( + /* Incomplete Profile State */ +
+
+ + + + Profile Incomplete +
+ +

+ Employers require a minimum complete candidate profile or resume before receiving + One-Click applications. +

+ + {missingFields.length > 0 && ( +
+ Required missing details: +
    + {missingFields.map((field, idx) => ( +
  • {field}
  • + ))} +
+
+ )} + +
+ + Complete Profile → + + + Upload/Edit Resume → + +
+
+ ) : ( + /* Ready to Apply State */ +
+
+
+ Application Snapshot Summary + + Profile Verified + +
+ + {profileData?.headline && ( +

{profileData.headline}

+ )} + +
+ {profileData?.location &&
Location: {profileData.location}
} + {profileData?.skills?.length > 0 && ( +
+ Skills:{" "} + {profileData.skills.slice(0, 4).map((s: any) => s.name).join(", ")} +
+ )} + {activeResume ? ( +
+ Active Resume: {activeResume.title} +
+ ) : ( +
+ Profile details will be submitted directly to employer. +
+ )} +
+ +

+ A frozen snapshot of this profile will be preserved for the employer upon submission. +

+
+ +
+ +