Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions frontend/src/__tests__/email.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, test } from 'vitest'
import {
MAX_EMAIL_LENGTH,
sanitizeRecruiterEmail,
isValidRecruiterEmail,
buildSafeMailtoUrl,
isSafeHttpUrl,
} from '../utils/emailCheck'

describe('sanitizeRecruiterEmail', () => {
test('returns a trimmed email', () => {
expect(sanitizeRecruiterEmail(' jobs@startup.com ')).toBe('jobs@startup.com')
})

test('strips injected query parameters', () => {
expect(
sanitizeRecruiterEmail('recruiter@co.com?cc=attacker@evil.com')
).toBe('recruiter@co.com')

expect(
sanitizeRecruiterEmail('recruiter@co.com#fragment')
).toBe('recruiter@co.com')
})

test('preserves question marks in the local part', () => {
expect(
sanitizeRecruiterEmail('dev?jobs@company.com')
).toBe('dev?jobs@company.com')
})

test('does not modify long email addresses', () => {
const longLocal = 'a'.repeat(MAX_EMAIL_LENGTH)
const email = `${longLocal}@example.com`

expect(sanitizeRecruiterEmail(email)).toBe(email)
})
})

describe('isValidRecruiterEmail', () => {
test('accepts valid addresses', () => {
expect(isValidRecruiterEmail('jobs@startup.com')).toBe(true)
})

test('rejects invalid addresses', () => {
expect(isValidRecruiterEmail('not-an-email')).toBe(false)
expect(isValidRecruiterEmail('')).toBe(false)
expect(isValidRecruiterEmail(null)).toBe(false)
})

test('rejects email longer than MAX_EMAIL_LENGTH', () => {
const longEmail = `${'a'.repeat(MAX_EMAIL_LENGTH)}@example.com`

expect(isValidRecruiterEmail(longEmail)).toBe(false)
})

test('accepts address after stripping injected query parameters', () => {
expect(
isValidRecruiterEmail('recruiter@co.com?cc=attacker@evil.com')
).toBe(true)

expect(
sanitizeRecruiterEmail('recruiter@co.com?cc=attacker@evil.com')
).toBe('recruiter@co.com')
})
})

describe('buildSafeMailtoUrl', () => {
test('builds a mailto link with encoded subject', () => {
expect(
buildSafeMailtoUrl('jobs@startup.com', {
subject: 'Application for Engineer',
})
).toBe('mailto:jobs@startup.com?subject=Application+for+Engineer')
})

test('returns null for invalid email', () => {
expect(
buildSafeMailtoUrl('bad-address', {
subject: 'Hi',
})
).toBeNull()
})

test('ignores injected cc parameters from raw input', () => {
const url = buildSafeMailtoUrl(
'recruiter@co.com?cc=attacker@evil.com',
{
subject: 'Hello',
}
)

expect(url).toBe('mailto:recruiter@co.com?subject=Hello')
expect(url).not.toContain('attacker@evil.com')
})
})

describe('isSafeHttpUrl', () => {
test('accepts http and https URLs', () => {
expect(isSafeHttpUrl('https://example.com')).toBe(true)
expect(isSafeHttpUrl('http://example.com')).toBe(true)
})

test('rejects unsafe schemes', () => {
expect(isSafeHttpUrl('javascript:alert(1)')).toBe(false)
expect(isSafeHttpUrl('data:text/html,test')).toBe(false)
expect(isSafeHttpUrl('ftp://example.com')).toBe(false)
})

test('rejects invalid URLs', () => {
expect(isSafeHttpUrl('not-a-url')).toBe(false)
expect(isSafeHttpUrl('')).toBe(false)
expect(isSafeHttpUrl(null)).toBe(false)
})
})
133 changes: 116 additions & 17 deletions frontend/src/pages/JobAlerts.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@ import {
Mail,
ExternalLink,
Loader2,
AlertCircle,
Sparkles,
Zap
Zap,
Send
} from 'lucide-react';
import toast from 'react-hot-toast';
import { jobAlertsApi, jobsApi } from '../services/api';
import { JobAlertModal, JobAlertsList } from '../components';
import { SkeletonStatCards, SkeletonJobList } from '../components/ui/Skeleton'
import OutreachPanel from '../components/OutreachPanel';
import Modal from '../components/Modal';
import { SkeletonStatCards, SkeletonJobList } from '../components/ui/Skeleton';
import {
buildSafeMailtoUrl,
isValidRecruiterEmail,
sanitizeRecruiterEmail,
isSafeHttpUrl,
} from '../utils/emailCheck';

export default function JobAlerts() {
const [activeTab, setActiveTab] = useState('alerts'); // 'alerts' | 'search'
Expand All @@ -26,6 +34,8 @@ export default function JobAlerts() {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [searchLoading, setSearchLoading] = useState(false);
const [outreachJob, setOutreachJob] = useState(null);
const [mailtoConfirm, setMailtoConfirm] = useState(null);

const hoverBorderClassMap = {
indigo: 'hover:border-primary/30',
Expand Down Expand Up @@ -72,6 +82,38 @@ export default function JobAlerts() {
setIsModalOpen(true);
};

const handleApplyOutreach = (job) => {
setOutreachJob(job);
};

const handleRequestMailto = (job) => {
const email = sanitizeRecruiterEmail(job.recruiterEmail);

if (!isValidRecruiterEmail(email)) {
toast.error('Invalid recruiter email address');
return;
}

setMailtoConfirm({ email, title: job.title });
};

const handleConfirmMailto = () => {
if (!mailtoConfirm) return;

const url = buildSafeMailtoUrl(mailtoConfirm.email, {
subject: `Application for ${mailtoConfirm.title}`,
});

if (!url) {
toast.error('Invalid recruiter email address');
setMailtoConfirm(null);
return;
}

window.location.href = url;
setMailtoConfirm(null);
};

return (
<div className="min-h-screen bg-background">
<div className="fixed inset-0 overflow-hidden pointer-events-none">
Expand Down Expand Up @@ -238,7 +280,13 @@ export default function JobAlerts() {
animate="animate"
>
{searchResults.map((job, index) => (
<JobCard key={job.id || index} job={job} index={index} />
<JobCard
key={job.id || index}
job={job}
index={index}
onApplyOutreach={handleApplyOutreach}
onRequestMailto={handleRequestMailto}
/>
))}
</motion.div>
</div>
Expand Down Expand Up @@ -268,22 +316,64 @@ export default function JobAlerts() {
onClose={() => setIsModalOpen(false)}
onSuccess={fetchStats}
/>

{outreachJob && (
<OutreachPanel
companyName={outreachJob.company}
companyUrl={
isSafeHttpUrl(outreachJob.applyLink)
? outreachJob.applyLink
: ''
}
onClose={() => setOutreachJob(null)}
/>
)}

<Modal
isOpen={!!mailtoConfirm}
onClose={() => setMailtoConfirm(null)}
title="Open your mail client to apply?"
size="sm"
>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
You&apos;ll leave CareerPilot and open your email app to send an application
{mailtoConfirm?.email ? ` to ${mailtoConfirm.email}` : ''}.
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setMailtoConfirm(null)}
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={handleConfirmMailto}
className="px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary/90 rounded-lg transition-colors"
>
Open mail client
</button>
</div>
</Modal>
</div>
);
}

// Job Card Component
function JobCard({ job, index }) {
const handleApply = () => {
if (job.applyLink) {
window.open(job.applyLink, '_blank');
}
};
function JobCard({ job, index, onApplyOutreach, onRequestMailto }) {
const recruiterEmail = sanitizeRecruiterEmail(job.recruiterEmail);
const hasValidRecruiterEmail = isValidRecruiterEmail(recruiterEmail);

const handleEmail = () => {
if (job.recruiterEmail) {
window.location.href = `mailto:${job.recruiterEmail}?subject=Application for ${job.title}`;
const handleApplyOnSite = () => {
if (!job.applyLink) return;

if (!isSafeHttpUrl(job.applyLink)) {
toast.error('Invalid application link');
return;
}

window.open(job.applyLink, '_blank', 'noopener,noreferrer');
};

return (
Expand All @@ -310,18 +400,27 @@ function JobCard({ job, index }) {

{/* Action Buttons */}
<div className="flex gap-2 shrink-0">
{job.recruiterEmail && (
<button
onClick={() => onApplyOutreach(job)}
className="flex items-center gap-1.5 px-4 py-2 bg-card text-foreground rounded-lg border border-border font-medium hover:bg-muted/20 transition-colors text-sm cursor-pointer"
>
<Send className="w-4 h-4" />
Apply
</button>
)}
{job.applyLink && (
<button
onClick={handleApply}
onClick={handleApplyOnSite}
className="flex items-center gap-1.5 px-4 py-2 bg-card text-foreground rounded-lg border border-border font-medium hover:bg-muted/20 transition-colors text-sm cursor-pointer"
>
<ExternalLink className="w-4 h-4" />
Apply
{job.recruiterEmail ? 'Apply on site' : 'Apply'}
</button>
)}
{job.recruiterEmail && (
{hasValidRecruiterEmail && (
<button
onClick={handleEmail}
onClick={() => onRequestMailto(job)}
className="flex items-center gap-1.5 px-4 py-2 bg-card text-foreground rounded-lg border border-border font-medium hover:bg-muted/60 transition-colors text-sm cursor-pointer"
>
<Mail className="w-4 h-4" />
Expand Down
82 changes: 82 additions & 0 deletions frontend/src/utils/emailCheck.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/** RFC 5321 maximum email address length */
export const MAX_EMAIL_LENGTH = 254;

const RECRUITER_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

/**
* Removes query/fragment injection while preserving
* the original email address.
*/
export function sanitizeRecruiterEmail(raw) {
if (typeof raw !== 'string') return '';

const trimmed = raw.trim();
const atIndex = trimmed.lastIndexOf('@');

if (atIndex === -1) return trimmed;

const queryIndex = trimmed.indexOf('?', atIndex);
const fragmentIndex = trimmed.indexOf('#', atIndex);

const suffixStart = [queryIndex, fragmentIndex]
.filter(index => index >= 0)
.sort((a, b) => a - b)[0];

return (suffixStart === undefined
? trimmed
: trimmed.slice(0, suffixStart)
).trim();
}

/**
* Validates a recruiter email after sanitization.
*
* @param {string} email
* @returns {boolean}
*/
export function isValidRecruiterEmail(email) {
const sanitized = sanitizeRecruiterEmail(email);

if (!sanitized) return false;

if (sanitized.length > MAX_EMAIL_LENGTH) {
return false;
}

return RECRUITER_EMAIL_REGEX.test(sanitized);
}

/**
* Build a safe mailto URL with validated recipient and encoded query params.
* Returns null when the email is invalid.
*/
export function buildSafeMailtoUrl(email, { subject = '', body = '' } = {}) {
if (!isValidRecruiterEmail(email)) return null;

const sanitized = sanitizeRecruiterEmail(email);

const params = new URLSearchParams();
if (subject) params.set('subject', subject);
if (body) params.set('body', body);

const query = params.toString();

return query ? `mailto:${sanitized}?${query}` : `mailto:${sanitized}`;
}

/**
* Returns true only for valid HTTP/HTTPS URLs.
*
* @param {string} url
* @returns {boolean}
*/
export function isSafeHttpUrl(url) {
if (!url || typeof url !== "string") return false;

try {
const parsed = new URL(url.trim());
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}