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
# 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 <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"
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 <notifications@jobsboard.internal>}"
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:

View file

@ -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 <notifications@example.com>"
# 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 <notifications@yourdomain.com>"
# Environment Mode
NODE_ENV="development"

21
web/package-lock.json generated
View file

@ -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",

View file

@ -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",

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 {
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 },

View file

@ -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 && (
<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)}
onSuccess={() => setApplyingJob(null)}
/>

View file

@ -573,7 +573,7 @@ export default function JobDetailPage() {
{showApplyModal && (
<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)}
onSuccess={() => {
setIsApplied(true);

View file

@ -274,7 +274,7 @@ export function JobCard({ job, onStatusChange, isAuthenticated }: JobCardProps)
{showApplyModal && (
<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)}
onSuccess={() => {
setCurrentStatus("APPLIED");

View file

@ -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<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(() => {
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,6 +122,33 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod
</div>
)}
{hasExternal && (
<div className="bg-blue-50 border border-blue-200 p-4 rounded-md text-xs space-y-3">
<p className="text-blue-900 font-medium leading-relaxed">
This role is listed on the employer&apos;s own site. We&apos;ll send you there to
apply directly &mdash; your application goes straight to {job.company}.
</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>
)}
{hasInternalPath && (
<div className={hasExternal ? "pt-1 border-t border-zinc-200" : ""}>
{hasExternal && (
<p className="text-[11px] font-semibold text-zinc-500 mb-2">
&mdash; or apply through JobsBoard &mdash;
</p>
)}
{loadingCompleteness ? (
<div className="py-8 text-center text-xs text-zinc-500 animate-pulse">
Checking candidate profile completeness...
@ -109,7 +164,8 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod
</div>
<p className="text-amber-800 leading-relaxed">
Employers require a minimum complete candidate profile or resume before receiving One-Click applications.
Employers require a minimum complete candidate profile or resume before receiving
One-Click applications.
</p>
{missingFields.length > 0 && (
@ -129,14 +185,14 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod
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 →
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 →
Upload/Edit Resume &rarr;
</Link>
</div>
</div>
@ -211,6 +267,8 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod
</form>
)}
</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({
to,
subject,
@ -11,19 +16,47 @@ export async function sendEmail({
subject: 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 from = process.env.EMAIL_FROM || "DirectWire <notifications@resend.dev>";
if (!apiKey) {
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 };
}
if (apiKey) {
try {
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
@ -52,3 +85,13 @@ export async function sendEmail({
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 };
}