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:
parent
bb4c93b11c
commit
50b8524775
12 changed files with 407 additions and 154 deletions
21
.env.example
21
.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 <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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
21
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
38
web/scripts/test-smtp.mjs
Normal 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);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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's own site. We'll send you there to
|
||||
apply directly — 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}'s site →
|
||||
</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">
|
||||
— or apply through JobsBoard —
|
||||
</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 →
|
||||
</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 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -211,6 +267,8 @@ export function OneClickApplyModal({ job, onClose, onSuccess }: OneClickApplyMod
|
|||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -51,4 +84,14 @@ export async function sendEmail({
|
|||
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 };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue