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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
21
web/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
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 {
|
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 },
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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's own site. We'll send you there to
|
||||||
) : isComplete === false ? (
|
apply directly — 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}'s site →
|
||||||
|
</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) => (
|
— or apply through JobsBoard —
|
||||||
<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 →
|
||||||
|
</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 →
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue