Skip to content

feat: add email notifications + fix websearch scan_method guard#560

Open
Craesol wants to merge 6 commits intosantifer:mainfrom
Craesol:feat/email-notifications-websearch-fix
Open

feat: add email notifications + fix websearch scan_method guard#560
Craesol wants to merge 6 commits intosantifer:mainfrom
Craesol:feat/email-notifications-websearch-fix

Conversation

@Craesol
Copy link
Copy Markdown

@Craesol Craesol commented May 2, 2026

What does this PR do?

Adds a complete email notification pipeline for daily portal scans, fixes a silent failure in the websearch scan_method guard, and introduces Windows Task Scheduler automation.

Related issue

Type of change

  • Bug fix
  • New feature
  • Documentation / translation
  • Refactor (no behavior change)

Changes

scan.mjs — Bug fix

Add early-return guard in detectApi() for scan_method: 'websearch' companies. Previously, companies migrated to websearch fallback were still hitting the Ashby API detection path, causing silent 404s on every scan run.

scan-and-notify.mjs — New script

  • Wraps scan.mjs, reads data/scan-history.tsv, sends HTML email via nodemailer only when new roles are added today
  • Supports --dry-run flag
  • Reads credentials from .env (never hardcoded)

send-agent-results.mjs — New script

  • Companion emailer for the Level 3 CCR WebSearch agent
  • Splits results into two sections: 🌍 Remote (title or portal name contains "remote") and 📍 Other
  • Date is fully dynamic — no hardcoded dates

setup-scheduler.ps1 — New script

  • One-shot Windows Task Scheduler setup for daily scans at 11:00 and 20:00 local time
  • Idempotent: re-running removes and re-registers existing tasks

package.json

  • Add nodemailer ^8.0.7 dependency
  • Add scan:notify and scan:dry npm scripts

.env.example

  • Document GMAIL_USER, GMAIL_APP_PASSWORD, NOTIFY_EMAIL with step-by-step App Password setup instructions
  • Placeholder values only — no real email addresses

Checklist

  • I have read CONTRIBUTING.md
  • I linked a related issue above (required for features and architecture changes)
  • My PR does not include personal data (CV, email, real names)
  • I ran node test-all.mjs — 68/68 pass; dashboard Go build skipped (no Go compiler in env, unrelated to these changes)
  • My changes respect the Data Contract (no modifications to user-layer files)
  • My changes align with the project roadmap

Questions? Join the Discord for faster feedback.

Summary by CodeRabbit

  • New Features

    • Automated daily job scanning across multiple ATS platforms and public sources
    • Email notifications for newly discovered job postings, organized by remote/other categories
    • Job title classification based on keyword matching for relevance
    • Windows scheduled task automation for recurring scans at 11:00 AM and 8:00 PM
    • Dry-run mode for testing scan operations before execution
  • Dependencies

    • Added nodemailer for email notification support
  • Configuration

    • New environment variables for email notification setup
  • Chores

    • Updated .gitignore to exclude scan logs and temporary files

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 2, 2026

Welcome to career-ops, @Craesol! Thanks for your first PR.

A few things to know:

  • Tests will run automatically — check the status below
  • Make sure you've linked a related issue (required for features)
  • Read CONTRIBUTING.md if you haven't

We'll review your PR soon. Join our Discord if you have questions.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 2, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This pull request introduces a comprehensive automated job scanning and notification system. It adds daily ATS and public feed scanning with keyword-based title classification, maintains scan history, and delivers notifications via Gmail (scan-and-notify) and Resend API (send-agent-results, send-daily-email). Windows Task Scheduler automation runs scans twice daily with logging.

Changes

Job Scanning & Notification System

Layer / File(s) Summary
Configuration & Dependencies
.env.example, package.json
Gmail/Resend credentials and nodemailer dependency added; npm scripts scan:notify and scan:dry created.
Daily ATS & Feed Scanning
daily-ats-scan.mjs
New script implements two-phase scanning: Phase 1 queries Greenhouse/Ashby/Lever APIs; Phase 2 fetches from RemoteOK, Remotive, LinkedIn, Hacker News, and web3.career. Keyword-based title matching classifies entries as added or skipped_title; deduplicates by URL; sends Resend email with new roles.
Scan API Configuration
scan.mjs
detectApi() now returns null for companies with scan_method: 'websearch' to exclude them from API scanning.
Email Notification Delivery
scan-and-notify.mjs, send-agent-results.mjs, send-daily-email.mjs
Three complementary notification scripts: scan-and-notify.mjs sends Gmail notifications post-scan via nodemailer; send-agent-results.mjs filters today's added entries and sends Resend-based full scan report with "Remote" / "Other" categorization; send-daily-email.mjs groups roles by tier (API, T1–T3, Series A, General) and sends tiered HTML table via Resend.
Windows Task Scheduling
setup-scheduler.ps1, daily-ats-scan.bat
PowerShell script registers two daily Windows Scheduled Tasks (career-ops-scan-11h at 11:00, career-ops-scan-20h at 20:00) that invoke daily-ats-scan.mjs. Batch script wraps execution with timestamped logging to logs\scan.log and captures exit codes.
Repository Configuration
.gitignore
Ignores runtime artifacts: logs/, tmp/, *.bak, and batch/batch-results-*.md.

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested Labels

📄 docs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes both main changes: adding email notifications and fixing the websearch scan_method guard in detectApi().
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
setup-scheduler.ps1 (1)

63-88: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Non-ASCII characters in a UTF-8 file without BOM — garbled output on Windows PowerShell 5.x.

The PSScriptAnalyzer BOM warning is valid. The script contains non-ASCII characters ( on line 67, on line 63) and the usage comment on line 7 instructs powershell -ExecutionPolicy Bypass, which invokes powershell.exe (Windows PowerShell 5.x). Without a UTF-8 BOM, PowerShell 5.x reads the file using the system ANSI codepage and misinterprets the characters. Either save the file with UTF-8 BOM or replace the non-ASCII characters with ASCII equivalents.

🛠️ Proposed ASCII-safe substitution
-Write-Host "career-ops — Scheduler Setup"
+Write-Host "career-ops - Scheduler Setup"
 Write-Host "============================="
-    Write-Host "  Registered: $TaskName  →  runs daily at ${Hour}:${Minute}"
+    Write-Host "  Registered: $TaskName  ->  runs daily at ${Hour}:${Minute}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup-scheduler.ps1` around lines 63 - 88, The file contains non-ASCII
characters '→' and '—' in Write-Host strings (e.g., the "Registered: $TaskName 
→  runs daily ..." and "career-ops — Scheduler Setup") while the usage comment
invokes powershell -ExecutionPolicy Bypass (Windows PowerShell 5.x), which will
misread UTF-8 without BOM; fix by either saving the script with a UTF-8 BOM or
replacing those characters with ASCII equivalents (replace '→' with '->' or
'->'/'>' and '—' with '--' or '-') in the Write-Host lines and any usage/comment
text so the script displays correctly in PowerShell 5.x.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.env.example:
- Line 31: The GMAIL_APP_PASSWORD placeholder in .env.example should be quoted
to satisfy dotenv linting and signal to users that the 16-character App Password
(which may contain spaces) must be wrapped in quotes; update the
GMAIL_APP_PASSWORD entry so the placeholder value is enclosed in either single
or double quotes (e.g., GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx") in
.env.example and ensure the README or comment nearby briefly notes that the real
app password must be quoted.

In `@scan-and-notify.mjs`:
- Around line 21-28: The file uses createRequire and require to load nodemailer;
replace this CJS pattern with a native ESM import: remove createRequire and the
require('nodemailer') usage and instead import the transporter API directly
(e.g., import { createTransport } from 'nodemailer'), keeping the existing
__dirname calculation via fileURLToPath; update any code that referenced the
nodemailer variable to use createTransport (or the named imports) so the module
stays pure ESM and consistent with send-agent-results.mjs.
- Around line 103-110: The HTML generation for htmlRows is inserting unescaped
values (j.company, j.title, j.url) which can break markup; use the existing
escapeHtml helper (same as in send-agent-results.mjs) when building the string:
replace interpolations in the template produced by newEntries.map (htmlRows)
with escapeHtml(j.company), escapeHtml(j.title), and escapeHtml(j.url) and also
ensure the href attribute uses the escaped URL (escapeHtml(j.url)) so ampersands
and <>& are encoded for well-formed HTML.
- Around line 53-80: The dry-run path never shows the email preview because
beforeCount is computed before the scan and remains nonzero when execSync is
skipped; update the logic so that when DRY_RUN is true you set beforeCount = 0
(or compute beforeCount after you obtain afterEntries) instead of using the
pre-scan count. Specifically, change how beforeCount/getAddedToday() is used so
that in DRY_RUN the slice uses the full afterEntries (refer to beforeCount,
DRY_RUN, getAddedToday(), newEntries) ensuring newEntries contains today's
entries and the preview block is reachable.

In `@send-agent-results.mjs`:
- Around line 5-7: Validate GMAIL_USER and GMAIL_APP_PASSWORD before creating
the nodemailer transport: check that both process.env.GMAIL_USER and
process.env.GMAIL_APP_PASSWORD are non-empty and if not throw or exit with a
clear error (referencing GMAIL_USER and GMAIL_APP_PASSWORD); construct
NOTIFY_EMAIL only after confirming GMAIL_USER exists (avoid defaulting to an
undefined GMAIL_USER), and ensure the "from" address used when calling sendMail
never interpolates an undefined GMAIL_USER (use a safe fallback or abort
earlier).
- Around line 65-67: The href uses r.url raw which can break HTML when it
contains characters like &; update the template to pass r.url through the
existing escapeHtml function (the one defined in this file) when interpolating
into the anchor's href (and optionally for the anchor text if it currently
displays the URL), so change the interpolation from ${r.url} to
${escapeHtml(r.url)} ensuring the href attribute receives an HTML-escaped URL.
- Around line 25-31: The status field is not being trimmed when parsing TSV, so
on Windows CRLF lines e.status contains a trailing '\r' and the filter newRoles
(which checks e.status === 'added') silently drops all rows; update the TSV
parsing where entries are constructed (the variable entries and the property
assignment that sets status) to trim the field (e.g., set status =
cols[5]?.trim()) so the comparison in newRoles works reliably (follow the same
approach used in scan-and-notify.mjs).
- Around line 29-34: The newRoles filter currently excludes only greenhouse-api
and ashby-api so entries with portal 'lever-api' slip through; update the filter
in the newRoles computation to be a positive filter that only accepts the
websearch portal (e.g., check e.portal?.startsWith('websearch')) instead of
relying on exclusion, or alternatively extend the exclusion list to include all
API portals such as 'lever-api'; modify the predicate used in entries.filter
(referencing newRoles, entries and e.portal) so only websearch-origin entries
are selected for the WebSearch-only email.
- Line 12: The code unconditionally calls readFileSync to load
'./data/scan-history.tsv' which throws ENOENT on fresh setups; guard the read
with fs.existsSync('./data/scan-history.tsv') and only call readFileSync if the
file exists, otherwise initialize tsvContent to an empty string (or the same
graceful default behavior used in scan-and-notify.mjs); ensure you reference the
same fs import so existsSync is available and update the declaration that
creates tsvContent to use the conditional path.

---

Outside diff comments:
In `@setup-scheduler.ps1`:
- Around line 63-88: The file contains non-ASCII characters '→' and '—' in
Write-Host strings (e.g., the "Registered: $TaskName  →  runs daily ..." and
"career-ops — Scheduler Setup") while the usage comment invokes powershell
-ExecutionPolicy Bypass (Windows PowerShell 5.x), which will misread UTF-8
without BOM; fix by either saving the script with a UTF-8 BOM or replacing those
characters with ASCII equivalents (replace '→' with '->' or '->'/'>' and '—'
with '--' or '-') in the Write-Host lines and any usage/comment text so the
script displays correctly in PowerShell 5.x.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d7196278-a0b8-4858-8b92-84b6c5c042ca

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae201d and d6ece83b5be19eb81d358e7cab3c0fcf72a14c59.

📒 Files selected for processing (7)
  • .env.example
  • VERSION
  • package.json
  • scan-and-notify.mjs
  • scan.mjs
  • send-agent-results.mjs
  • setup-scheduler.ps1

Comment thread .env.example
# Step 3: Fill in below and save as .env (not .env.example)
#
GMAIL_USER=your@gmail.com
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Quote the GMAIL_APP_PASSWORD placeholder value.

The dotenv linter flags ValueWithoutQuotes. Although dotenv handles unquoted space-separated values, quoting the placeholder explicitly signals to users that the actual 16-char App Password (which contains spaces) must be quoted in their .env, preventing silent truncation by shells or other tools.

🛠️ Proposed fix
-GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
+GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
🧰 Tools
🪛 dotenv-linter (4.0.0)

[warning] 31-31: [UnorderedKey] The GMAIL_APP_PASSWORD key should go before the GMAIL_USER key

(UnorderedKey)


[warning] 31-31: [ValueWithoutQuotes] This value needs to be surrounded in quotes

(ValueWithoutQuotes)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example at line 31, The GMAIL_APP_PASSWORD placeholder in .env.example
should be quoted to satisfy dotenv linting and signal to users that the
16-character App Password (which may contain spaces) must be wrapped in quotes;
update the GMAIL_APP_PASSWORD entry so the placeholder value is enclosed in
either single or double quotes (e.g., GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx")
in .env.example and ensure the README or comment nearby briefly notes that the
real app password must be quoted.

Comment thread scan-and-notify.mjs Outdated
Comment thread scan-and-notify.mjs
Comment on lines +53 to +80
const beforeCount = getAddedToday().length;

console.log(`[scan-and-notify] Starting scan at ${new Date().toLocaleTimeString()} (${today})`);
console.log(`[scan-and-notify] Roles already added today before this scan: ${beforeCount}`);

if (!DRY_RUN) {
try {
execSync('node scan.mjs', {
stdio: 'inherit',
cwd: __dirname,
});
} catch (err) {
console.error('[scan-and-notify] Scan failed:', err.message);
process.exit(1);
}
} else {
console.log('[scan-and-notify] --dry-run: skipping actual scan');
}

// ── Check for new entries ─────────────────────────────────────────────

const afterEntries = getAddedToday();
const newEntries = afterEntries.slice(beforeCount);

if (newEntries.length === 0) {
console.log('[scan-and-notify] No new roles found — skipping email.');
process.exit(0);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

--dry-run never reaches the email preview — the block is dead code.

beforeCount is computed before the scan runs. In --dry-run mode the execSync is skipped, so no new rows are ever appended to scan-history.tsv. afterEntries.slice(beforeCount) therefore always returns an empty array, causing an unconditional process.exit(0) at line 79. The actual dry-run preview at lines 137–142 is unreachable.

🐛 Proposed fix
-const beforeCount = getAddedToday().length;
+const beforeCount = DRY_RUN ? 0 : getAddedToday().length;

In dry-run mode this sets beforeCount to 0, so newEntries will contain all of today's already-scanned entries and the email preview is shown as intended.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const beforeCount = getAddedToday().length;
console.log(`[scan-and-notify] Starting scan at ${new Date().toLocaleTimeString()} (${today})`);
console.log(`[scan-and-notify] Roles already added today before this scan: ${beforeCount}`);
if (!DRY_RUN) {
try {
execSync('node scan.mjs', {
stdio: 'inherit',
cwd: __dirname,
});
} catch (err) {
console.error('[scan-and-notify] Scan failed:', err.message);
process.exit(1);
}
} else {
console.log('[scan-and-notify] --dry-run: skipping actual scan');
}
// ── Check for new entries ─────────────────────────────────────────────
const afterEntries = getAddedToday();
const newEntries = afterEntries.slice(beforeCount);
if (newEntries.length === 0) {
console.log('[scan-and-notify] No new roles found — skipping email.');
process.exit(0);
}
const beforeCount = DRY_RUN ? 0 : getAddedToday().length;
console.log(`[scan-and-notify] Starting scan at ${new Date().toLocaleTimeString()} (${today})`);
console.log(`[scan-and-notify] Roles already added today before this scan: ${beforeCount}`);
if (!DRY_RUN) {
try {
execSync('node scan.mjs', {
stdio: 'inherit',
cwd: __dirname,
});
} catch (err) {
console.error('[scan-and-notify] Scan failed:', err.message);
process.exit(1);
}
} else {
console.log('[scan-and-notify] --dry-run: skipping actual scan');
}
// ── Check for new entries ─────────────────────────────────────────────
const afterEntries = getAddedToday();
const newEntries = afterEntries.slice(beforeCount);
if (newEntries.length === 0) {
console.log('[scan-and-notify] No new roles found — skipping email.');
process.exit(0);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scan-and-notify.mjs` around lines 53 - 80, The dry-run path never shows the
email preview because beforeCount is computed before the scan and remains
nonzero when execSync is skipped; update the logic so that when DRY_RUN is true
you set beforeCount = 0 (or compute beforeCount after you obtain afterEntries)
instead of using the pre-scan count. Specifically, change how
beforeCount/getAddedToday() is used so that in DRY_RUN the slice uses the full
afterEntries (refer to beforeCount, DRY_RUN, getAddedToday(), newEntries)
ensuring newEntries contains today's entries and the preview block is reachable.

Comment thread scan-and-notify.mjs
Comment on lines +103 to +110
const htmlRows = newEntries.map(j => `
<tr>
<td style="padding:10px 12px;border-bottom:1px solid #eee">
<strong>${j.company}</strong><br>
<span style="color:#555">${j.title}</span><br>
<a href="${j.url}" style="font-size:12px;color:#0066cc">${j.url}</a>
</td>
</tr>`).join('');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing HTML escaping in email body — company names and titles can contain &, <, >.

j.company, j.title, and j.url are interpolated directly into the HTML table without escaping. Job titles from API responses regularly contain & (e.g. "Data & Analytics Engineer") which would break the HTML structure. send-agent-results.mjs in this same PR implements an escapeHtml() helper for exactly this purpose — apply it here for consistency and correctness. The j.url placed inside href="${j.url}" also needs at minimum &&amp; encoding for well-formed HTML.

🐛 Proposed fix — add escapeHtml and apply it
+function escapeHtml(str) {
+  if (!str) return '';
+  return str
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;');
+}
+
 const htmlRows = newEntries.map(j => `
   <tr>
     <td style="padding:10px 12px;border-bottom:1px solid `#eee`">
-      <strong>${j.company}</strong><br>
-      <span style="color:`#555`">${j.title}</span><br>
-      <a href="${j.url}" style="font-size:12px;color:`#0066cc`">${j.url}</a>
+      <strong>${escapeHtml(j.company)}</strong><br>
+      <span style="color:`#555`">${escapeHtml(j.title)}</span><br>
+      <a href="${escapeHtml(j.url)}" style="font-size:12px;color:`#0066cc`">${escapeHtml(j.url)}</a>
     </td>
   </tr>`).join('');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scan-and-notify.mjs` around lines 103 - 110, The HTML generation for htmlRows
is inserting unescaped values (j.company, j.title, j.url) which can break
markup; use the existing escapeHtml helper (same as in send-agent-results.mjs)
when building the string: replace interpolations in the template produced by
newEntries.map (htmlRows) with escapeHtml(j.company), escapeHtml(j.title), and
escapeHtml(j.url) and also ensure the href attribute uses the escaped URL
(escapeHtml(j.url)) so ampersands and <>& are encoded for well-formed HTML.

Comment thread send-agent-results.mjs Outdated
Comment thread send-agent-results.mjs
Comment thread send-agent-results.mjs
Comment thread send-agent-results.mjs
Comment on lines +29 to +34
const newRoles = entries.filter(e =>
e.date === today &&
e.status === 'added' &&
!e.portal.startsWith('greenhouse-api') &&
!e.portal.startsWith('ashby-api')
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

lever-api portal entries incorrectly pass the WebSearch-only filter.

scan.mjs writes source: 'lever-api' for Lever API results (see scan.mjs line 321). The current exclusion list only covers greenhouse-api and ashby-api, so Lever API entries will silently appear in what is supposed to be a WebSearch-only email. Either extend the exclusion list to all API portal types, or switch to a positive filter that only accepts the websearch portal value.

🐛 Proposed fix (positive filter approach)
-// Filter: today's date AND status=added AND portal does NOT start with greenhouse-api or ashby-api
 const newRoles = entries.filter(e =>
   e.date === today &&
-  e.status === 'added' &&
-  !e.portal.startsWith('greenhouse-api') &&
-  !e.portal.startsWith('ashby-api')
+  e.status?.trim() === 'added' &&
+  !e.portal?.startsWith('greenhouse-api') &&
+  !e.portal?.startsWith('ashby-api') &&
+  !e.portal?.startsWith('lever-api')
 );

Or, if the websearch agent writes a known portal prefix, use a positive filter: e.portal?.startsWith('websearch').

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@send-agent-results.mjs` around lines 29 - 34, The newRoles filter currently
excludes only greenhouse-api and ashby-api so entries with portal 'lever-api'
slip through; update the filter in the newRoles computation to be a positive
filter that only accepts the websearch portal (e.g., check
e.portal?.startsWith('websearch')) instead of relying on exclusion, or
alternatively extend the exclusion list to include all API portals such as
'lever-api'; modify the predicate used in entries.filter (referencing newRoles,
entries and e.portal) so only websearch-origin entries are selected for the
WebSearch-only email.

Comment thread send-agent-results.mjs
Comment on lines +65 to +67
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">
<a href="${r.url}" style="color: #2563eb; text-decoration: none;">View posting</a>
</td>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

r.url not passed through escapeHtml in the href attribute.

escapeHtml is already defined in this file and applied to company/title, but not to the URL. URLs with & in query parameters (common in job board links) produce malformed HTML like href="...&ref=api" instead of href="...&amp;ref=api". Most mail clients tolerate this, but it's inconsistent and breaks HTML validity.

🐛 Proposed fix
-      <a href="${r.url}" style="color: `#2563eb`; text-decoration: none;">View posting</a>
+      <a href="${escapeHtml(r.url)}" style="color: `#2563eb`; text-decoration: none;">View posting</a>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">
<a href="${r.url}" style="color: #2563eb; text-decoration: none;">View posting</a>
</td>
<td style="padding: 8px 12px; border-bottom: 1px solid `#e5e7eb`;">
<a href="${escapeHtml(r.url)}" style="color: `#2563eb`; text-decoration: none;">View posting</a>
</td>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@send-agent-results.mjs` around lines 65 - 67, The href uses r.url raw which
can break HTML when it contains characters like &; update the template to pass
r.url through the existing escapeHtml function (the one defined in this file)
when interpolating into the anchor's href (and optionally for the anchor text if
it currently displays the URL), so change the interpolation from ${r.url} to
${escapeHtml(r.url)} ensuring the href attribute receives an HTML-escaped URL.

## scan.mjs
- Add early-return guard in `detectApi()` for `scan_method: 'websearch'`
  companies — prevents false Ashby API attempts and silent failures for
  the ~45 companies migrated to websearch fallback in portals.yml

## Email notification pipeline
- Add `scan-and-notify.mjs`: wraps `scan.mjs`, reads scan-history.tsv,
  and sends an HTML email via nodemailer only when new roles are added
  today. Supports `--dry-run` flag.
- Add `send-agent-results.mjs`: companion script for the CCR WebSearch
  agent (Level 3 scan). Splits results into two email sections —
  🌍 Remote and 📍 Other — based on title/portal name. Date is dynamic
  (no hardcoded dates).
- Add `setup-scheduler.ps1`: one-shot Windows Task Scheduler setup
  registering two daily tasks (11:00 and 20:00 local) to run
  scan-and-notify.mjs

## package.json
- Add `nodemailer` ^8.0.7 dependency
- Add `scan:notify` and `scan:dry` npm scripts

## .env.example
- Document email notification variables (GMAIL_USER, GMAIL_APP_PASSWORD,
  NOTIFY_EMAIL) with step-by-step App Password setup instructions
- Use placeholder values only — no real email addresses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Craesol Craesol force-pushed the feat/email-notifications-websearch-fix branch from d6ece83 to a1d22a6 Compare May 3, 2026 11:46
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (9)
send-agent-results.mjs (5)

16-25: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Trim the TSV status field before filtering.

On CRLF files, cols[5] keeps the trailing \r, so e.status === 'added' silently drops every row on Windows.

🛠️ Proposed fix
   return {
     url: cols[0],
     date: cols[1],
     portal: cols[2],
     title: cols[3],
     company: cols[4],
-    status: cols[5],
+    status: cols[5]?.trim(),
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@send-agent-results.mjs` around lines 16 - 25, The TSV parsing map that builds
entries leaves cols[5] with possible CRLF characters so status comparisons like
e.status === 'added' fail on Windows; update the mapping in the entries
construction to trim whitespace on the status field (e.g., assign status:
cols[5].trim()) or otherwise normalize status (trim/lowercase) so downstream
filters using e.status === 'added' will match correctly.

11-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard the TSV read on first run.

readFileSync('./data/scan-history.tsv') still throws ENOENT when the file is missing, so fresh setups can't run this script. As per coding guidelines, scripts handle missing data/ directories gracefully.

🛠️ Proposed fix
-import { readFileSync } from 'fs';
+import { readFileSync, existsSync } from 'fs';
...
-const tsvContent = readFileSync('./data/scan-history.tsv', 'utf-8');
+if (!existsSync('./data/scan-history.tsv')) {
+  console.log('No scan history found — run scan first.');
+  process.exit(0);
+}
+const tsvContent = readFileSync('./data/scan-history.tsv', 'utf-8');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@send-agent-results.mjs` around lines 11 - 13,
readFileSync('./data/scan-history.tsv') can throw ENOENT on first run; update
the startup logic around tsvContent/lines to guard the read by checking for the
file (e.g., fs.existsSync or stat) or wrapping readFileSync in a try/catch, and
if the file is missing initialize tsvContent to an empty string (so lines
becomes an empty array) and ensure the './data' folder is created if needed;
modify the block referencing readFileSync, tsvContent and lines to use this
guarded read so fresh setups don't crash.

5-7: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restore the upfront credential guard.

createTransport is still built from potentially undefined Gmail credentials, so the failure will surface later as an SMTP error and the from address can become <undefined>.

🐛 Proposed fix
 const GMAIL_USER = process.env.GMAIL_USER;
 const GMAIL_APP_PASSWORD = process.env.GMAIL_APP_PASSWORD;
 const NOTIFY_EMAIL = process.env.NOTIFY_EMAIL || GMAIL_USER;
+
+if (!GMAIL_USER || !GMAIL_APP_PASSWORD) {
+  console.error('Missing GMAIL_USER or GMAIL_APP_PASSWORD in .env');
+  process.exit(1);
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@send-agent-results.mjs` around lines 5 - 7, The code reads GMAIL_USER,
GMAIL_APP_PASSWORD, and NOTIFY_EMAIL but does not guard before building the
transporter, so createTransport may be called with undefined credentials and
produce an SMTP error and an undefined "from" address; add an upfront credential
guard that checks GMAIL_USER and GMAIL_APP_PASSWORD (and set NOTIFY_EMAIL
fallback to GMAIL_USER only after the guard) and if either is missing throw or
exit with a clear error via processLogger or similar before calling
createTransport/send functions (reference GMAIL_USER, GMAIL_APP_PASSWORD,
NOTIFY_EMAIL and createTransport to locate where to add the guard).

28-34: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

lever-api still slips through this exclusion list.

scan.mjs writes Lever rows with a lever-api source, so this WebSearch email will pick up rows that were meant to be excluded. Extend the exclusion list or switch to a positive websearch predicate.

🛠️ Proposed fix
 const newRoles = entries.filter(e =>
   e.date === today &&
   e.status === 'added' &&
   !e.portal.startsWith('greenhouse-api') &&
-  !e.portal.startsWith('ashby-api')
+  !e.portal.startsWith('ashby-api') &&
+  !e.portal.startsWith('lever-api')
 );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@send-agent-results.mjs` around lines 28 - 34, The filter that builds newRoles
(entries.filter(...)) currently excludes only greenhouse-api and ashby-api but
still lets lever-api through; update the predicate to either add
"!e.portal.startsWith('lever-api')" to the exclusion list or, better, change the
predicate to a positive check that only includes the intended websearch source
(e.g., replace the negative startsWith checks with a single positive condition
like e.portal === 'websearch' or the exact portal identifier you expect). Modify
the entries.filter call that assigns newRoles and update any related variable
names (newRoles / e.portal) accordingly so only the intended websearch rows are
included.

65-67: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Escape the posting URL in the link markup.

r.url is still inserted raw into the href, so job board links with query strings can produce malformed HTML.

🛠️ Proposed fix
-      <a href="${r.url}" style="color: `#2563eb`; text-decoration: none;">View posting</a>
+      <a href="${escapeHtml(r.url)}" style="color: `#2563eb`; text-decoration: none;">View posting</a>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@send-agent-results.mjs` around lines 65 - 67, The href currently injects
r.url raw into the anchor template (the <a href="${r.url}" ...>View posting</a>
snippet) which can produce malformed HTML; fix by HTML-escaping the URL before
inserting it into the attribute (e.g., add or import a small utility like
escapeHtml/escapeAttribute and replace href="${r.url}" with
href="${escapeHtml(r.url)}" or use encodeURI(r.url) if appropriate), ensuring
you call the escaping function where the template string is constructed so the
link markup uses the sanitized value.
scan-and-notify.mjs (3)

21-28: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Keep this script pure ESM.

createRequire is unnecessary here; switch to a native Nodemailer import and update the later transport creation accordingly. As per coding guidelines, .mjs scripts use .mjs extension for ES modules; maintain Node.js module format throughout.

♻️ Proposed fix
-import { createRequire } from 'module';
 import { fileURLToPath } from 'url';
 import path from 'path';
+import { createTransport } from 'nodemailer';
 import 'dotenv/config';

 const __dirname = path.dirname(fileURLToPath(import.meta.url));
-const require = createRequire(import.meta.url);
-const nodemailer = require('nodemailer');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scan-and-notify.mjs` around lines 21 - 28, The file currently uses
createRequire and a require call to load Nodemailer (createRequire, require,
nodemailer); remove createRequire and the require invocation and instead add a
native ESM import like `import nodemailer from "nodemailer"`, then update any
transport creation code that referenced nodemailer via the CommonJS require to
use the imported nodemailer (e.g., nodemailer.createTransport) and adjust any
default-interop usages if necessary so the module stays pure ESM.

53-80: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

--dry-run still skips the preview path.

beforeCount is captured before the scan, and the scan is skipped entirely in dry-run mode, so afterEntries.slice(beforeCount) stays empty and the email preview never appears.

🐛 Proposed fix
-const beforeCount = getAddedToday().length;
+const beforeCount = DRY_RUN ? 0 : getAddedToday().length;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scan-and-notify.mjs` around lines 53 - 80, The dry-run branch currently skips
running the scanner so beforeCount and afterEntries are identical and the email
preview never appears; change the DRY_RUN handling to still invoke the scan in a
preview/simulate mode (or run the same update logic) so getAddedToday() reflects
new items: e.g. in the DRY_RUN path call the scanner with a preview flag or set
an env var (referencing DRY_RUN, execSync('node scan.mjs', ...), scan.mjs,
beforeCount, getAddedToday, newEntries) so the scan logic executes but
side-effects like sending emails are suppressed, then continue to compute
afterEntries and slice newEntries as before.

103-110: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Escape the HTML fields before interpolating them.

j.url is still injected raw into both the href attribute and the visible link text, so any & or < in the posting URL will break the markup.

🐛 Proposed fix
-      <strong>${j.company}</strong><br>
-      <span style="color:`#555`">${j.title}</span><br>
-      <a href="${j.url}" style="font-size:12px;color:`#0066cc`">${j.url}</a>
+      <strong>${escapeHtml(j.company)}</strong><br>
+      <span style="color:`#555`">${escapeHtml(j.title)}</span><br>
+      <a href="${escapeHtml(j.url)}" style="font-size:12px;color:`#0066cc`">${escapeHtml(j.url)}</a>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scan-and-notify.mjs` around lines 103 - 110, The template in htmlRows
interpolates unescaped fields (newEntries -> j.company, j.title, j.url) which
can break HTML or enable injection; add an HTML-escaping helper (e.g.,
escapeHtml) and apply it to visible text (escapeHtml(j.company),
escapeHtml(j.title), escapeHtml(j.url)) and for the href use a safe URL encoding
(e.g., encodeURI(j.url) or a sanitizeUrl helper) before interpolation; update
the htmlRows mapping to use these escaped/sanitized values so both the href
attribute and visible link text are safe.
.env.example (1)

30-32: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Quote the App Password placeholder and keep the env keys lint-friendly.

The placeholder still contains spaces unquoted, which can be truncated by shell parsers and keeps dotenv-linter unhappy. Reordering GMAIL_APP_PASSWORD before GMAIL_USER will also clear the linter warning.

🛠️ Proposed fix
-GMAIL_USER=your@gmail.com
-GMAIL_APP_PASSWORD=xxxx xxxx xxxx xxxx
-NOTIFY_EMAIL=your@gmail.com
+GMAIL_APP_PASSWORD="xxxx xxxx xxxx xxxx"
+GMAIL_USER=your@gmail.com
+NOTIFY_EMAIL=your@gmail.com
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.env.example around lines 30 - 32, Reorder the env entries so
GMAIL_APP_PASSWORD appears before GMAIL_USER and make the password placeholder
lint-friendly by removing internal spaces and wrapping the value in quotes
(e.g., GMAIL_APP_PASSWORD="xxxx_xxxx_xxxx_xxxx"); ensure keys remain uppercase
exactly as GMAIL_APP_PASSWORD, GMAIL_USER and NOTIFY_EMAIL and that there are no
stray spaces around the equals signs so dotenv-linter passes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@setup-scheduler.ps1`:
- Around line 45-48: The scheduled task principal currently uses
New-ScheduledTaskPrincipal with -LogonType Interactive which requires an
interactive session; change the logon type to a non-interactive model (for
example -LogonType Password or -LogonType S4U) on the $principal creation so the
task runs when the user is signed out—update the New-ScheduledTaskPrincipal
invocation that sets $principal (replace -LogonType Interactive) and, if you
choose Password, ensure credentials are provided securely; if you choose S4U,
verify your environment supports S4U for unattended execution.

---

Duplicate comments:
In @.env.example:
- Around line 30-32: Reorder the env entries so GMAIL_APP_PASSWORD appears
before GMAIL_USER and make the password placeholder lint-friendly by removing
internal spaces and wrapping the value in quotes (e.g.,
GMAIL_APP_PASSWORD="xxxx_xxxx_xxxx_xxxx"); ensure keys remain uppercase exactly
as GMAIL_APP_PASSWORD, GMAIL_USER and NOTIFY_EMAIL and that there are no stray
spaces around the equals signs so dotenv-linter passes.

In `@scan-and-notify.mjs`:
- Around line 21-28: The file currently uses createRequire and a require call to
load Nodemailer (createRequire, require, nodemailer); remove createRequire and
the require invocation and instead add a native ESM import like `import
nodemailer from "nodemailer"`, then update any transport creation code that
referenced nodemailer via the CommonJS require to use the imported nodemailer
(e.g., nodemailer.createTransport) and adjust any default-interop usages if
necessary so the module stays pure ESM.
- Around line 53-80: The dry-run branch currently skips running the scanner so
beforeCount and afterEntries are identical and the email preview never appears;
change the DRY_RUN handling to still invoke the scan in a preview/simulate mode
(or run the same update logic) so getAddedToday() reflects new items: e.g. in
the DRY_RUN path call the scanner with a preview flag or set an env var
(referencing DRY_RUN, execSync('node scan.mjs', ...), scan.mjs, beforeCount,
getAddedToday, newEntries) so the scan logic executes but side-effects like
sending emails are suppressed, then continue to compute afterEntries and slice
newEntries as before.
- Around line 103-110: The template in htmlRows interpolates unescaped fields
(newEntries -> j.company, j.title, j.url) which can break HTML or enable
injection; add an HTML-escaping helper (e.g., escapeHtml) and apply it to
visible text (escapeHtml(j.company), escapeHtml(j.title), escapeHtml(j.url)) and
for the href use a safe URL encoding (e.g., encodeURI(j.url) or a sanitizeUrl
helper) before interpolation; update the htmlRows mapping to use these
escaped/sanitized values so both the href attribute and visible link text are
safe.

In `@send-agent-results.mjs`:
- Around line 16-25: The TSV parsing map that builds entries leaves cols[5] with
possible CRLF characters so status comparisons like e.status === 'added' fail on
Windows; update the mapping in the entries construction to trim whitespace on
the status field (e.g., assign status: cols[5].trim()) or otherwise normalize
status (trim/lowercase) so downstream filters using e.status === 'added' will
match correctly.
- Around line 11-13: readFileSync('./data/scan-history.tsv') can throw ENOENT on
first run; update the startup logic around tsvContent/lines to guard the read by
checking for the file (e.g., fs.existsSync or stat) or wrapping readFileSync in
a try/catch, and if the file is missing initialize tsvContent to an empty string
(so lines becomes an empty array) and ensure the './data' folder is created if
needed; modify the block referencing readFileSync, tsvContent and lines to use
this guarded read so fresh setups don't crash.
- Around line 5-7: The code reads GMAIL_USER, GMAIL_APP_PASSWORD, and
NOTIFY_EMAIL but does not guard before building the transporter, so
createTransport may be called with undefined credentials and produce an SMTP
error and an undefined "from" address; add an upfront credential guard that
checks GMAIL_USER and GMAIL_APP_PASSWORD (and set NOTIFY_EMAIL fallback to
GMAIL_USER only after the guard) and if either is missing throw or exit with a
clear error via processLogger or similar before calling createTransport/send
functions (reference GMAIL_USER, GMAIL_APP_PASSWORD, NOTIFY_EMAIL and
createTransport to locate where to add the guard).
- Around line 28-34: The filter that builds newRoles (entries.filter(...))
currently excludes only greenhouse-api and ashby-api but still lets lever-api
through; update the predicate to either add "!e.portal.startsWith('lever-api')"
to the exclusion list or, better, change the predicate to a positive check that
only includes the intended websearch source (e.g., replace the negative
startsWith checks with a single positive condition like e.portal === 'websearch'
or the exact portal identifier you expect). Modify the entries.filter call that
assigns newRoles and update any related variable names (newRoles / e.portal)
accordingly so only the intended websearch rows are included.
- Around line 65-67: The href currently injects r.url raw into the anchor
template (the <a href="${r.url}" ...>View posting</a> snippet) which can produce
malformed HTML; fix by HTML-escaping the URL before inserting it into the
attribute (e.g., add or import a small utility like escapeHtml/escapeAttribute
and replace href="${r.url}" with href="${escapeHtml(r.url)}" or use
encodeURI(r.url) if appropriate), ensuring you call the escaping function where
the template string is constructed so the link markup uses the sanitized value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 72c365b6-a2d1-4d69-84c7-f68c5088c38f

📥 Commits

Reviewing files that changed from the base of the PR and between d6ece83b5be19eb81d358e7cab3c0fcf72a14c59 and a1d22a6.

📒 Files selected for processing (6)
  • .env.example
  • package.json
  • scan-and-notify.mjs
  • scan.mjs
  • send-agent-results.mjs
  • setup-scheduler.ps1

Comment thread setup-scheduler.ps1
Comment on lines +45 to +48
$principal = New-ScheduledTaskPrincipal `
-UserId $env:USERNAME `
-LogonType Interactive `
-RunLevel Limited
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't require an interactive session for the scheduled task.

New-ScheduledTaskPrincipal -LogonType Interactive maps to an existing interactive session, so these daily jobs will be skipped whenever the user is signed out. For unattended notifications, this needs a non-interactive logon model. (learn.microsoft.com)

🧰 Tools
🪛 PSScriptAnalyzer (1.25.0)

[warning] Missing BOM encoding for non-ASCII encoded file 'setup-scheduler.ps1'

(PSUseBOMForUnicodeEncodedFile)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setup-scheduler.ps1` around lines 45 - 48, The scheduled task principal
currently uses New-ScheduledTaskPrincipal with -LogonType Interactive which
requires an interactive session; change the logon type to a non-interactive
model (for example -LogonType Password or -LogonType S4U) on the $principal
creation so the task runs when the user is signed out—update the
New-ScheduledTaskPrincipal invocation that sets $principal (replace -LogonType
Interactive) and, if you choose Password, ensure credentials are provided
securely; if you choose S4U, verify your environment supports S4U for unattended
execution.

@Qodo-Free-For-OSS
Copy link
Copy Markdown

Hi, package.json was reset to version 1.0.0 while VERSION and .release-please-manifest.json remain at 1.6.0, which will confuse tooling and may break release automation expectations.

Severity: action required | Category: correctness

How to fix: Align VERSION/manifest/package.json

Agent prompt to fix - you can give this to your LLM of choice:

Issue description

package.json version no longer matches VERSION and .release-please-manifest.json.

Issue Context

This repo already uses VERSION and release-please manifest for versioning; inconsistent values increase the chance of incorrect releases and confusing update messaging.

Fix Focus Areas

  • package.json[1-4]
  • VERSION[1-1]
  • .release-please-manifest.json[1-3]

Fix outline

  • Decide the canonical version (likely 1.6.0 given existing files).
  • Update package.json to match, or update VERSION + manifest to match the intended reset (if the reset is intentional).
  • Ensure release-please configuration is consistent with the chosen source of truth.

Found by Qodo code review. FYI, Qodo is free for open-source.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package.json`:
- Around line 18-19: scan-and-notify.mjs currently uses CommonJS require (const
nodemailer = require('nodemailer')) which crashes in an ES module; replace that
require with an ES module import (e.g., import { createTransport } from
'nodemailer') and update any usages of nodemailer.createTransport(...) to call
createTransport(...) directly (or import default if the file expects default
export), ensuring the import is placed at top-level and any destructured names
match how send-agent-results.mjs imports nodemailer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 53ddfcee-28a1-4560-a090-e00a786c7449

📥 Commits

Reviewing files that changed from the base of the PR and between a1d22a6 and 6503962.

📒 Files selected for processing (1)
  • package.json

Comment thread package.json
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
scan-and-notify.mjs (2)

101-108: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Escape job data before embedding it in HTML.

This remains unresolved: j.company, j.title, and j.url are still interpolated raw into the HTML email, which can break markup and expose recipients to injected content.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scan-and-notify.mjs` around lines 101 - 108, The HTML builder for htmlRows is
injecting unescaped user data (newEntries -> j.company, j.title, j.url) directly
into the template; create and use a small escaping helper (e.g., escapeHtml) to
replace &,<,>,",',/ before interpolation for j.company and j.title, and
sanitize/validate or percent-encode j.url (or use a safeHref helper that allows
only http(s) and encodes unsafe characters) before inserting into the href and
visible link text; update the htmlRows mapping to call these helpers for each
field so no raw values are embedded.

51-78: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

--dry-run still skips the preview path.

This remains unresolved: beforeCount is captured before the scan, so when --dry-run skips scan.mjs, afterEntries.slice(beforeCount) is always empty and the preview block at Lines 135-140 never runs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scan-and-notify.mjs` around lines 51 - 78, The dry-run path currently skips
running the scanner so beforeCount is unchanged and newEntries is always empty;
fix by still invoking the scan in preview mode when DRY_RUN is true (or setting
an env/flag that causes scan.mjs to only simulate additions) so getAddedToday()
after the run reflects the preview results; update the DRY_RUN branch to call
execSync('node scan.mjs --preview') or set a PREVIEW env var before execSync,
keeping the existing execSync block and error handling (referenced symbols:
DRY_RUN, execSync('node scan.mjs'), scan.mjs, getAddedToday, beforeCount,
newEntries, preview block).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@scan-and-notify.mjs`:
- Around line 101-108: The HTML builder for htmlRows is injecting unescaped user
data (newEntries -> j.company, j.title, j.url) directly into the template;
create and use a small escaping helper (e.g., escapeHtml) to replace &,<,>,",',/
before interpolation for j.company and j.title, and sanitize/validate or
percent-encode j.url (or use a safeHref helper that allows only http(s) and
encodes unsafe characters) before inserting into the href and visible link text;
update the htmlRows mapping to call these helpers for each field so no raw
values are embedded.
- Around line 51-78: The dry-run path currently skips running the scanner so
beforeCount is unchanged and newEntries is always empty; fix by still invoking
the scan in preview mode when DRY_RUN is true (or setting an env/flag that
causes scan.mjs to only simulate additions) so getAddedToday() after the run
reflects the preview results; update the DRY_RUN branch to call execSync('node
scan.mjs --preview') or set a PREVIEW env var before execSync, keeping the
existing execSync block and error handling (referenced symbols: DRY_RUN,
execSync('node scan.mjs'), scan.mjs, getAddedToday, beforeCount, newEntries,
preview block).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0da81b01-9731-4b9b-bc8a-dd1fca7252b0

📥 Commits

Reviewing files that changed from the base of the PR and between 6503962 and 923be21.

📒 Files selected for processing (1)
  • scan-and-notify.mjs

deepak-glitch pushed a commit to deepak-glitch/career-ops that referenced this pull request May 5, 2026
…Fs, archived 3 below-threshold)

- scan.mjs: ElevenLabs Spain FDE (1 new offer added by Level 1/2)
- Level 3 WebSearch: Greenhouse new grad + Workable HF/Jalasoft/Vettura
  + Breezy Urrly + Ashby P-1 AI + Himalayas Robots & Pencils + Remotive
  Littlebird (8 URLs added; 3 archived as fetch errors)
- 6 new evaluations (santifer#556-santifer#561 — ElevenLabs Spain dedup'd against santifer#468):
  * santifer#557 Underdog 2026 New Grad (1.8/5 — no visa sponsorship; SKIP)
  * santifer#558/562 P-1 AI FDE (3.0/5 — engineering AGI seed; PDF generated)
  * santifer#559 Vettura AI/ML (3.0/5 — mid breadth NLP+CV+GenAI; PDF generated)
  * santifer#560 Robots & Pencils (1.8/5 — Colombia + senior; SKIP)
  * santifer#561 Littlebird Applied AI (3.5/5 — RAG hybrid search; PDF generated)
- merge-tracker + verify-pipeline: 0 errors / 0 warnings
- cleanup-low-scores: 3 archived to reports/below-threshold/

https://claude.ai/code/session_overnight_2026-05-05
claude and others added 3 commits May 6, 2026 21:29
Generated by the daily scan pipeline. Reads data/scan-history.tsv,
groups today's roles by tier, and sends a tier-grouped HTML email
via Resend API. Used as a fallback when the sandbox blocks outbound
requests to api.resend.com.

https://claude.ai/code/session_01DmmLET8pauRKW3jHrYgTv3
Removes the createRequire workaround and replaces it with a clean ES module
import matching the pattern already used in send-agent-results.mjs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- daily-ats-scan.mjs: standalone scanner that hits 16 ATS APIs
  (Greenhouse/Ashby/Lever) plus 5 public feeds (RemoteOK, Remotive,
  LinkedIn jobs-guest, HN Who's Hiring, Web3.career) and emails
  results via Resend HTTP API. Runs locally via Windows Task
  Scheduler — no cloud routine auth dependency.

- daily-ats-scan.bat: Windows runner for Task Scheduler integration
  (cd's to repo, runs the scanner, appends to logs/scan.log)

- send-agent-results.mjs: replace nodemailer/Gmail SMTP with Resend
  HTTP API. Cloud-friendly (port 443, no SMTP), simpler env vars,
  reads RESEND_API_KEY/RESEND_FROM/NOTIFY_EMAIL.

- .gitignore: add logs/, tmp/, *.bak, and batch/batch-results-*.md
  for local scanner runtime artifacts.

Filter improvements baked into daily-ats-scan.mjs:
- Word-boundary matching for short keywords (≤4 chars) prevents
  false positives like "ngo" matching "Django"
- Expanded NEGATIVE list with 30+ engineering-specific titles
- HN parsing filters to top-level "Who is hiring" comments only
  (parent_id === thread.objectID), HTML entity decoding
- LinkedIn regex updated to allow Tailwind class suffixes after
  base-card__full-link

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Craesol Craesol force-pushed the feat/email-notifications-websearch-fix branch from 923be21 to 6faf726 Compare May 7, 2026 10:44
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

♻️ Duplicate comments (6)
send-agent-results.mjs (4)

32-32: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Regression: status field is no longer trimmed — Windows CRLF silently drops every row.

Same regression. With CRLF line endings on scan-history.tsv (the default on Windows where the new Task Scheduler runs), cols[5] becomes 'added\r' and the equality check at line 39 always fails, so no email is ever sent. Trim the field at parse time.

🐛 Proposed fix
   return {
     url: cols[0],
     date: cols[1],
     portal: cols[2],
     title: cols[3],
     company: cols[4],
-    status: cols[5],
+    status: cols[5]?.trim(),
   };

Also applies to: 39-39

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-agent-results.mjs` at line 32, Trim the parsed status field so CRLF or
trailing whitespace doesn't break comparisons: when constructing the object that
currently sets "status: cols[5]," replace that assignment with a trimmed value
(e.g., use cols[5].trim()) and ensure any equality checks that reference the
same raw cols[5] (the comparison that looks for "added") use the trimmed value
or the object's status property so the comparison succeeds even with
CRLF/whitespace.

73-75: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

r.url still not escaped in the href attribute.

escapeHtml is applied to company/title but not to r.url. ATS URLs often contain & (e.g., …?gh_jid=…&utm_source=…), producing malformed HTML.

🐛 Proposed fix
-      <a href="${r.url}" style="color: `#2563eb`; text-decoration: none;">View posting</a>
+      <a href="${escapeHtml(r.url)}" style="color: `#2563eb`; text-decoration: none;">View posting</a>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-agent-results.mjs` around lines 73 - 75, The anchor's href uses
unescaped r.url which can break the HTML when the URL contains characters like
'&'; update the template to pass r.url through the existing escapeHtml helper
(the same function used for company/title) before interpolating into href so the
href becomes href="${escapeHtml(r.url)}", ensuring attribute-safe escaping for
ampersands, quotes, and other special chars.

19-21: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Regression: existsSync guard for data/scan-history.tsv is missing again.

A previous review flagged this and was marked addressed, but the current state of the file calls readFileSync('./data/scan-history.tsv', 'utf-8') unconditionally. On a fresh checkout where no scan has run yet, this throws an unhelpful ENOENT. As per coding guidelines, scripts must handle missing data/ directories gracefully.

🐛 Proposed fix
-import { readFileSync } from 'fs';
+import { readFileSync, existsSync } from 'fs';
@@
-// Read scan-history.tsv
-const tsvContent = readFileSync('./data/scan-history.tsv', 'utf-8');
+// Read scan-history.tsv
+if (!existsSync('./data/scan-history.tsv')) {
+  console.log('No scan history found — run scan first.');
+  process.exit(0);
+}
+const tsvContent = readFileSync('./data/scan-history.tsv', 'utf-8');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-agent-results.mjs` around lines 19 - 21, The code unconditionally calls
readFileSync('./data/scan-history.tsv') (tsvContent / lines) and will throw
ENOENT on fresh checkouts; add an fs.existsSync('./data/scan-history.tsv') guard
before reading and if the file is missing set tsvContent to '' (or directly set
lines = []), so the rest of the script can continue gracefully; update the block
around readFileSync/tsvContent/lines to check existsSync and only call
readFileSync when present.

36-42: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

lever-api portal entries still slip into the WebSearch-only email.

Per scan.mjs, Lever results are written with source: 'lever-api', but the exclusion list here only covers greenhouse-api and ashby-api. A positive filter on the websearch portal is more robust; otherwise add Lever to the deny list.

🐛 Proposed fix
 const newRoles = entries.filter(e =>
   e.date === today &&
-  e.status === 'added' &&
-  !e.portal.startsWith('greenhouse-api') &&
-  !e.portal.startsWith('ashby-api')
+  e.status?.trim() === 'added' &&
+  !e.portal?.startsWith('greenhouse-api') &&
+  !e.portal?.startsWith('ashby-api') &&
+  !e.portal?.startsWith('lever-api')
 );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-agent-results.mjs` around lines 36 - 42, The filter that builds newRoles
allows unwanted 'lever-api' entries because it only excludes 'greenhouse-api'
and 'ashby-api'; update the entries.filter used to produce newRoles to either
(A) explicitly deny 'lever-api' as well (add !e.portal.startsWith('lever-api'))
or (B) switch to a positive match for the WebSearch-only portal (e.g., require
e.portal === 'websearch' or e.source === 'websearch') so only websearch results
are included; modify the filter around newRoles/entries.filter and the portal
checks accordingly.
scan-and-notify.mjs (2)

101-108: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

HTML escaping still missing in email body — company/title/url interpolated raw.

Same issue as flagged previously and not yet addressed: titles like "Data & Analytics Engineer" or any URL with & query parameters break the HTML structure (and a malicious upstream feed could inject markup). Reuse the escapeHtml helper from send-agent-results.mjs (or inline an equivalent).

🐛 Proposed fix
+function escapeHtml(str) {
+  if (!str) return '';
+  return String(str)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;');
+}
+
 const htmlRows = newEntries.map(j => `
   <tr>
     <td style="padding:10px 12px;border-bottom:1px solid `#eee`">
-      <strong>${j.company}</strong><br>
-      <span style="color:`#555`">${j.title}</span><br>
-      <a href="${j.url}" style="font-size:12px;color:`#0066cc`">${j.url}</a>
+      <strong>${escapeHtml(j.company)}</strong><br>
+      <span style="color:`#555`">${escapeHtml(j.title)}</span><br>
+      <a href="${escapeHtml(j.url)}" style="font-size:12px;color:`#0066cc`">${escapeHtml(j.url)}</a>
     </td>
   </tr>`).join('');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scan-and-notify.mjs` around lines 101 - 108, The HTML rows are interpolating
newEntries fields raw (in htmlRows), allowing ampersands and markup to break the
email and enabling injection; update scan-and-notify.mjs to escape company,
title and url when building htmlRows by reusing or importing the escapeHtml
helper from send-agent-results.mjs (or inline an equivalent function) and wrap
each interpolation as escapeHtml(j.company), escapeHtml(j.title), and
escapeHtml(j.url) before joining into the template string so the email body is
safe and well-formed.

51-78: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Dry-run preview is still unreachable — beforeCount not zeroed when --dry-run.

Same logic gap as flagged previously and not yet addressed: beforeCount is captured before the conditional execSync, so in dry-run mode where the scan is skipped, afterEntries.length === beforeCount and newEntries is always empty. Line 75 then exits before the preview block at lines 135–141 is reached.

🐛 Proposed fix
-const beforeCount = getAddedToday().length;
+const beforeCount = DRY_RUN ? 0 : getAddedToday().length;

In dry-run mode this includes every "added" row from today, so the email preview actually renders.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scan-and-notify.mjs` around lines 51 - 78, The dry-run path captures
beforeCount before deciding to skip the actual scan, so newEntries is always
empty in DRY_RUN; change the logic so beforeCount reflects the dry-run behavior
(e.g., compute beforeCount only when not DRY_RUN, otherwise set beforeCount = 0)
— locate the getAddedToday() call that assigns beforeCount and move/branch it
around the DRY_RUN check (symbols: beforeCount, DRY_RUN, getAddedToday(),
execSync, newEntries) so that in --dry-run the preview sees all today's entries
instead of none.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@daily-ats-scan.bat`:
- Around line 6-12: Before redirecting output to logs, ensure the logs directory
exists by adding a check/creation step at the top of daily-ats-scan.bat: after
changing to the repo directory (the cd /d "C:\Users\Claude\career-ops" line)
create the logs folder if it doesn't exist so the subsequent echo and the node
daily-ats-scan.mjs >> logs\scan.log 2>&1 redirects succeed; make the creation
idempotent (no-op if already present) and keep the existing echo and node lines
unchanged.
- Around line 1-15: Convert this batch script to use CRLF line endings and
append an explicit exit code so Task Scheduler sees Node's exit status: re-save
daily-ats-scan.bat with CRLF line endings (e.g., git config core.autocrlf true
or unix2dos) and add a final line "exit /b %ERRORLEVEL%" after the existing
logging lines (keep the existing echo [%date% %time%] ... finished line and then
add exit /b %ERRORLEVEL%) so cmd.exe returns Node's exit code instead of an
implicit success.
- Line 6: The batch file currently uses a hardcoded, user-specific path in the
cd command ("C:\Users\Claude\career-ops"), which breaks for other contributors;
update the cd invocation in daily-ats-scan.bat to use the batch file's own
directory resolver (%~dp0) so the script changes to the repository location
relative to the .bat file (adjust to parent folder if needed) instead of the
fixed user path.

In `@daily-ats-scan.mjs`:
- Line 252: Sanitize any TSV fields before writing: the appendFileSync call that
joins [j.url,today,portal,j.title,t.company,status] must clean j.title and
company values (j.company or t.company) by replacing tabs and any newline/CR
characters with a safe separator (e.g., a single space) and trimming surrounding
whitespace so embedded '\t', '\n', '\r' cannot break columns; apply the same
sanitization helper to the other occurrence around line 270 (where company/title
are written) and use that helper in place of the raw j.title/t.company/j.company
values.
- Line 288: The HTML anchor in function row currently interpolates r.url raw
causing malformed HTML or attribute breakout; update row to use the same
escaping helper (esc) for the href value (i.e., use esc(r.url)) so the URL is
HTML-escaped before being inserted into the href attribute and ensure any
displayed link text is escaped as well if you ever render r.url there; modify
the row() implementation to replace the raw r.url with the escaped value while
keeping esc(r.company), esc(r.title), and esc(r.portal) unchanged.
- Around line 235-237: Ensure the ./data directory exists before writing files:
before the block that checks existsSync('./data/scan-history.tsv') and before
any appendFileSync('./data/pipeline.md') calls, call mkdirSync('./data', {
recursive: true }) (or equivalent) to create the directory if missing; then
proceed with the existing existsSync and writeFileSync logic so write/append
operations no longer throw ENOENT.
- Around line 280-286: Move the Resend credential validation to the very start
of the script so the scan never performs network/IO when required env is
missing: ensure the existing checks for RESEND_API_KEY and NOTIFY_EMAIL (and
fallback RESEND_FROM) run before any scraping or calls that append to
scan-history.tsv or pipeline.md (i.e., execute the current if (!RESEND_API_KEY
|| !NOTIFY_EMAIL) block at script initialization/top-level before invoking the
feed/endpoint scraping functions); log the missing-variable error with
process.exit(1) as currently done so the run aborts immediately.
- Around line 50-72: Add a 15s AbortSignal timeout to every fetch call so hung
requests don't stall the scheduled task: update the three fetches inside
fetchAts (Greenhouse, Ashby, Lever) to pass { signal: AbortSignal.timeout(15000)
} and do the same for fetchRemoteOK, fetchRemotive, fetchLinkedIn,
fetchHackerNewsWhoshiring, fetchWeb3Career, and the Resend API call (the HTTP
call around the resend/send function) so each fetch includes the same signal
option.

In `@scan-and-notify.mjs`:
- Around line 56-68: The current use of execSync('node scan.mjs') in
scan-and-notify.mjs blocks the event loop and lacks a timeout; replace it with a
non-blocking child process invocation (use child_process.spawn or spawnSync with
a timeout) and ensure a timeout is passed (e.g., 10 * 60 * 1000 ms) so a hung
scan fails fast; update the try/catch around the execSync call (refer to
execSync and the DRY_RUN branch) to use spawn with stdio:'inherit', cwd:
__dirname, and handle the child process exit/error/timeout to call
process.exit(1) on failure.

In `@send-daily-email.mjs`:
- Around line 22-25: The status field from cols[5] can include a trailing CR on
Windows, so the filter r.status === 'added' always fails; update the parsing
step that builds r (where status: cols[5]) to trim the value (e.g., use
cols[5].trim()) or trim all cols before mapping, so r.status contains the
normalized string and the subsequent filter comparing to 'added' and TODAY works
correctly.
- Line 76: The href currently injects raw r.url into the template which can
break HTML when it contains characters like &, so update the template that
renders the link (the string using ${r.url}) to pass the URL through the
existing escHtml helper (use escHtml(r.url)) just like the title is escaped with
escHtml(r.title) to ensure the href attribute is properly escaped.
- Line 7: The TODAY constant is hardcoded; replace the static assignment of
TODAY with a runtime-computed date string so the script filters correctly every
day. In send-daily-email.mjs, change the TOP-level symbol TODAY to be computed
at runtime (e.g., derive from new Date() and format as YYYY-MM-DD or using the
same formatter/util the project uses) so any DB/query that relies on TODAY (the
TODAY constant) receives the current date string; ensure the formatted string
matches the expected timezone/format used by the existing filter logic.
- Line 8: TSV_PATH in send-daily-email.mjs is a hardcoded absolute Linux path;
change it to resolve the data file relative to the running script instead.
Replace the literal '/home/user/…/scan-history.tsv' assigned to TSV_PATH with
logic that computes the path from the module location (use import.meta.url and
fileURLToPath/path.dirname or new URL with import.meta.url) so TSV_PATH points
to ./data/scan-history.tsv relative to the script regardless of OS or working
directory.
- Around line 4-11: Validate required env vars RESEND_API_KEY and NOTIFY_EMAIL
(used as TO) at startup and emit a clear processLogger.error message and
process.exit(1) if missing; do not rely on downstream Resend errors for this.
Before calling readFileSync(TSV_PATH, ...), use fs.existsSync on TSV_PATH (or
its containing data directory) to handle first-run missing files gracefully: if
the file is absent, treat the TSV as empty (e.g., set lines=[] or skip
processing) rather than letting readFileSync throw ENOENT. Update any error logs
to include which var or file is missing and mirror the validation/existsSync
pattern used in send-agent-results.mjs, referencing RESEND_API_KEY, TO,
TSV_PATH, existsSync, and readFileSync to locate the changes.

---

Duplicate comments:
In `@scan-and-notify.mjs`:
- Around line 101-108: The HTML rows are interpolating newEntries fields raw (in
htmlRows), allowing ampersands and markup to break the email and enabling
injection; update scan-and-notify.mjs to escape company, title and url when
building htmlRows by reusing or importing the escapeHtml helper from
send-agent-results.mjs (or inline an equivalent function) and wrap each
interpolation as escapeHtml(j.company), escapeHtml(j.title), and
escapeHtml(j.url) before joining into the template string so the email body is
safe and well-formed.
- Around line 51-78: The dry-run path captures beforeCount before deciding to
skip the actual scan, so newEntries is always empty in DRY_RUN; change the logic
so beforeCount reflects the dry-run behavior (e.g., compute beforeCount only
when not DRY_RUN, otherwise set beforeCount = 0) — locate the getAddedToday()
call that assigns beforeCount and move/branch it around the DRY_RUN check
(symbols: beforeCount, DRY_RUN, getAddedToday(), execSync, newEntries) so that
in --dry-run the preview sees all today's entries instead of none.

In `@send-agent-results.mjs`:
- Line 32: Trim the parsed status field so CRLF or trailing whitespace doesn't
break comparisons: when constructing the object that currently sets "status:
cols[5]," replace that assignment with a trimmed value (e.g., use
cols[5].trim()) and ensure any equality checks that reference the same raw
cols[5] (the comparison that looks for "added") use the trimmed value or the
object's status property so the comparison succeeds even with CRLF/whitespace.
- Around line 73-75: The anchor's href uses unescaped r.url which can break the
HTML when the URL contains characters like '&'; update the template to pass
r.url through the existing escapeHtml helper (the same function used for
company/title) before interpolating into href so the href becomes
href="${escapeHtml(r.url)}", ensuring attribute-safe escaping for ampersands,
quotes, and other special chars.
- Around line 19-21: The code unconditionally calls
readFileSync('./data/scan-history.tsv') (tsvContent / lines) and will throw
ENOENT on fresh checkouts; add an fs.existsSync('./data/scan-history.tsv') guard
before reading and if the file is missing set tsvContent to '' (or directly set
lines = []), so the rest of the script can continue gracefully; update the block
around readFileSync/tsvContent/lines to check existsSync and only call
readFileSync when present.
- Around line 36-42: The filter that builds newRoles allows unwanted 'lever-api'
entries because it only excludes 'greenhouse-api' and 'ashby-api'; update the
entries.filter used to produce newRoles to either (A) explicitly deny
'lever-api' as well (add !e.portal.startsWith('lever-api')) or (B) switch to a
positive match for the WebSearch-only portal (e.g., require e.portal ===
'websearch' or e.source === 'websearch') so only websearch results are included;
modify the filter around newRoles/entries.filter and the portal checks
accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f9d33315-4310-491c-89aa-e7a224407a5c

📥 Commits

Reviewing files that changed from the base of the PR and between 923be21 and 6faf726.

📒 Files selected for processing (7)
  • .gitignore
  • daily-ats-scan.bat
  • daily-ats-scan.mjs
  • package.json
  • scan-and-notify.mjs
  • send-agent-results.mjs
  • send-daily-email.mjs

Comment thread daily-ats-scan.bat
Comment on lines +1 to +15
@echo off
REM career-ops daily ATS scan runner
REM Hits 16 Greenhouse/Ashby/Lever endpoints, filters, dedups, emails via Resend
REM Triggered by Windows Task Scheduler at 12:00 CEST (10:00 UTC) daily

cd /d "C:\Users\Claude\career-ops"

REM Log start
echo [%date% %time%] career-ops daily scan starting >> logs\scan.log

REM Run scan and append output to log
node daily-ats-scan.mjs >> logs\scan.log 2>&1

echo [%date% %time%] career-ops daily scan finished (exit code %ERRORLEVEL%) >> logs\scan.log
echo. >> logs\scan.log
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Convert to CRLF line endings + add explicit exit code.

Static analysis (Blinter E018/W001) flags this file as LF-only, which can trigger Windows cmd.exe's 512-byte boundary parsing bug for GOTO/CALL and is a recurring source of "works on my machine" failures. Re-save with CRLF and add an explicit exit /b so the Task Scheduler "Last Run Result" reflects Node's exit code rather than cmd.exe's implicit success.

🛠️ Proposed addition
 echo [%date% %time%] career-ops daily scan finished (exit code %ERRORLEVEL%) >> logs\scan.log
 echo. >> logs\scan.log
+
+exit /b %ERRORLEVEL%

Also re-save the file with CRLF line endings (e.g., git config core.autocrlf true then re-add, or unix2dos daily-ats-scan.bat).

🧰 Tools
🪛 Blinter (1.0.112)

[error] 1-1: Unix line endings detected. Explanation: Batch file uses Unix line endings (LF-only) which can cause GOTO/CALL label parsing failures and script malfunction due to Windows batch parser 512-byte boundary bugs. Recommendation: Convert file to Windows line endings (CRLF). Use tools like dos2unix, notepad++, or configure git with 'git config core.autocrlf true'. Context: File uses Unix line endings (LF-only) - 15 LF sequences found

(E018)


[warning] 15-15: Missing exit code. Explanation: Script doesn't set appropriate exit codes to indicate success or failure. Recommendation: Add EXIT /b 0 for success or EXIT /b 1 for errors at script end. Context: Script can reach end of file without explicit EXIT statement

(W001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daily-ats-scan.bat` around lines 1 - 15, Convert this batch script to use
CRLF line endings and append an explicit exit code so Task Scheduler sees Node's
exit status: re-save daily-ats-scan.bat with CRLF line endings (e.g., git config
core.autocrlf true or unix2dos) and add a final line "exit /b %ERRORLEVEL%"
after the existing logging lines (keep the existing echo [%date% %time%] ...
finished line and then add exit /b %ERRORLEVEL%) so cmd.exe returns Node's exit
code instead of an implicit success.

Comment thread daily-ats-scan.bat
Comment on lines +6 to +12
cd /d "C:\Users\Claude\career-ops"

REM Log start
echo [%date% %time%] career-ops daily scan starting >> logs\scan.log

REM Run scan and append output to log
node daily-ats-scan.mjs >> logs\scan.log 2>&1
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

logs\ directory is not created — first-run redirect fails silently.

.gitignore excludes logs/ and there's no .gitkeep, so on a fresh clone the directory does not exist. Append-redirects to a missing directory in cmd.exe cause The system cannot find the path specified. and the Node process never runs. Create the directory up front.

🛠️ Proposed fix
 cd /d "%~dp0"
+
+if not exist "logs" mkdir "logs"
+
 REM Log start
 echo [%date% %time%] career-ops daily scan starting >> logs\scan.log
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cd /d "C:\Users\Claude\career-ops"
REM Log start
echo [%date% %time%] career-ops daily scan starting >> logs\scan.log
REM Run scan and append output to log
node daily-ats-scan.mjs >> logs\scan.log 2>&1
cd /d "C:\Users\Claude\career-ops"
if not exist "logs" mkdir "logs"
REM Log start
echo [%date% %time%] career-ops daily scan starting >> logs\scan.log
REM Run scan and append output to log
node daily-ats-scan.mjs >> logs\scan.log 2>&1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daily-ats-scan.bat` around lines 6 - 12, Before redirecting output to logs,
ensure the logs directory exists by adding a check/creation step at the top of
daily-ats-scan.bat: after changing to the repo directory (the cd /d
"C:\Users\Claude\career-ops" line) create the logs folder if it doesn't exist so
the subsequent echo and the node daily-ats-scan.mjs >> logs\scan.log 2>&1
redirects succeed; make the creation idempotent (no-op if already present) and
keep the existing echo and node lines unchanged.

Comment thread daily-ats-scan.bat
REM Hits 16 Greenhouse/Ashby/Lever endpoints, filters, dedups, emails via Resend
REM Triggered by Windows Task Scheduler at 12:00 CEST (10:00 UTC) daily

cd /d "C:\Users\Claude\career-ops"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Hardcoded user-specific path will break for anyone but you.

C:\Users\Claude\career-ops is not portable; any contributor running this scheduled task will have to edit the file (and risk committing the edit back). Use the script's own location instead so it works wherever the repo is checked out.

🛠️ Proposed fix
-cd /d "C:\Users\Claude\career-ops"
+cd /d "%~dp0"

%~dp0 resolves to the directory containing the .bat file.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cd /d "C:\Users\Claude\career-ops"
cd /d "%~dp0"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daily-ats-scan.bat` at line 6, The batch file currently uses a hardcoded,
user-specific path in the cd command ("C:\Users\Claude\career-ops"), which
breaks for other contributors; update the cd invocation in daily-ats-scan.bat to
use the batch file's own directory resolver (%~dp0) so the script changes to the
repository location relative to the .bat file (adjust to parent folder if
needed) instead of the fixed user path.

Comment thread daily-ats-scan.mjs
Comment on lines +50 to +72
async function fetchAts(t){
try {
if(t.ats==='greenhouse'){
const r=await fetch('https://boards-api.greenhouse.io/v1/boards/'+t.slug+'/jobs');
if(!r.ok)return [];
const j=await r.json();
return (j.jobs||[]).map(x=>({url:x.absolute_url,title:x.title,company:t.company}));
}
if(t.ats==='ashby'){
const r=await fetch('https://api.ashbyhq.com/posting-api/job-board/'+encodeURIComponent(t.slug));
if(!r.ok)return [];
const j=await r.json();
return (j.jobs||[]).map(x=>({url:x.jobUrl||x.applyUrl,title:x.title,company:t.company}));
}
if(t.ats==='lever'){
const r=await fetch('https://api.lever.co/v0/postings/'+t.slug+'?mode=json');
if(!r.ok)return [];
const arr=await r.json();
return (Array.isArray(arr)?arr:[]).map(x=>({url:x.hostedUrl,title:x.text,company:t.company}));
}
} catch(e) { console.error('['+t.company+']',e.message); }
return [];
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Node.js fetch default timeout AbortSignal.timeout

💡 Result:

Node.js fetch has no default timeout; it will wait indefinitely unless a signal is provided or Undici's internal timeouts (like headersTimeout: 300s, bodyTimeout: 300s, connectTimeout: 10s) trigger first [1][2][3]. The AbortSignal.timeout static method creates an AbortSignal that automatically aborts after the specified milliseconds (added in Node.js v17.3.0/v16.14.0), throwing a TimeoutError DOMException (or AbortError in some cases) [4][1][5]. By default, fetch options.signal is null/undefined, so no AbortSignal.timeout is applied automatically [6][7]. To use it: fetch(url, { signal: AbortSignal.timeout(5000) }) [8][9].

Citations:


🏁 Script executed:

#!/bin/bash
# Find the daily-ats-scan.mjs file
find . -name "daily-ats-scan.mjs" -type f

Repository: santifer/career-ops

Length of output: 84


🏁 Script executed:

#!/bin/bash
# Read the daily-ats-scan.mjs file to check its current state
wc -l ./daily-ats-scan.mjs

Repository: santifer/career-ops

Length of output: 88


🏁 Script executed:

#!/bin/bash
# Read the entire daily-ats-scan.mjs file
cat -n ./daily-ats-scan.mjs

Repository: santifer/career-ops

Length of output: 20768


Add 15-second request timeouts to all fetch calls.

None of the ATS, RemoteOK, Remotive, LinkedIn, HN, or Web3.career fetch calls set a timeout. A single hung connection will block the scheduled task indefinitely until Task Scheduler kills it. Apply signal: AbortSignal.timeout(15000) to all 9 fetch calls: three in fetchAts, and one each in fetchRemoteOK, fetchRemotive, fetchLinkedIn, fetchHackerNewsWhoshiring, fetchWeb3Career, and the Resend API call at line 324.

🛡️ Proposed pattern
-      const r=await fetch('https://boards-api.greenhouse.io/v1/boards/'+t.slug+'/jobs');
+      const r=await fetch('https://boards-api.greenhouse.io/v1/boards/'+t.slug+'/jobs',{signal:AbortSignal.timeout(15000)});

Apply the same signal: AbortSignal.timeout(15000) to every fetch in the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daily-ats-scan.mjs` around lines 50 - 72, Add a 15s AbortSignal timeout to
every fetch call so hung requests don't stall the scheduled task: update the
three fetches inside fetchAts (Greenhouse, Ashby, Lever) to pass { signal:
AbortSignal.timeout(15000) } and do the same for fetchRemoteOK, fetchRemotive,
fetchLinkedIn, fetchHackerNewsWhoshiring, fetchWeb3Career, and the Resend API
call (the HTTP call around the resend/send function) so each fetch includes the
same signal option.

Comment thread daily-ats-scan.mjs
Comment on lines +235 to +237
if (!existsSync('./data/scan-history.tsv')) {
writeFileSync('./data/scan-history.tsv', 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\n');
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing data/ directory creation — writeFileSync throws ENOENT on fresh setup.

If the data/ directory does not yet exist (fresh clone, first run on a new machine), writeFileSync('./data/scan-history.tsv', …) and the subsequent appendFileSync('./data/pipeline.md', …) calls will throw ENOENT: no such file or directory. As per coding guidelines, scripts must "handle missing data/ directories gracefully."

🛡️ Proposed fix
-import { readFileSync, appendFileSync, existsSync, writeFileSync } from 'fs';
+import { readFileSync, appendFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
@@
-if (!existsSync('./data/scan-history.tsv')) {
+mkdirSync('./data', { recursive: true });
+if (!existsSync('./data/scan-history.tsv')) {
   writeFileSync('./data/scan-history.tsv', 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\n');
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!existsSync('./data/scan-history.tsv')) {
writeFileSync('./data/scan-history.tsv', 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\n');
}
import { readFileSync, appendFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
// ... (other code)
mkdirSync('./data', { recursive: true });
if (!existsSync('./data/scan-history.tsv')) {
writeFileSync('./data/scan-history.tsv', 'url\tfirst_seen\tportal\ttitle\tcompany\tstatus\n');
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@daily-ats-scan.mjs` around lines 235 - 237, Ensure the ./data directory
exists before writing files: before the block that checks
existsSync('./data/scan-history.tsv') and before any
appendFileSync('./data/pipeline.md') calls, call mkdirSync('./data', {
recursive: true }) (or equivalent) to create the directory if missing; then
proceed with the existing existsSync and writeFileSync logic so write/append
operations no longer throw ENOENT.

Comment thread send-daily-email.mjs
Comment on lines +4 to +11
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const FROM = process.env.RESEND_FROM || 'career-ops <onboarding@resend.dev>';
const TO = process.env.NOTIFY_EMAIL;
const TODAY = '2026-05-06';
const TSV_PATH = '/home/user/career-ops/data/scan-history.tsv';

// Read TSV and filter for today's added roles
const lines = readFileSync(TSV_PATH, 'utf8').trim().split('\n');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing env-var validation and existsSync guard.

Three reachable startup failures, all silent:

  • RESEND_API_KEY and NOTIFY_EMAIL (used as TO) are never validated. Sending without a key produces an opaque Resend 401 response that is then logged as JSON and exits 1, rather than a clean, actionable message.
  • readFileSync(TSV_PATH, …) throws ENOENT on first run before any scan has populated history. As per coding guidelines, scripts must handle missing data/ directories gracefully.

send-agent-results.mjs already implements both checks; mirror them here.

🛡️ Proposed fix
 const RESEND_API_KEY = process.env.RESEND_API_KEY;
 const FROM = process.env.RESEND_FROM || 'career-ops <onboarding@resend.dev>';
 const TO = process.env.NOTIFY_EMAIL;
+
+if (!RESEND_API_KEY) { console.error('Missing RESEND_API_KEY env var.'); process.exit(1); }
+if (!TO) { console.error('Missing NOTIFY_EMAIL env var.'); process.exit(1); }
+
+if (!existsSync(TSV_PATH)) {
+  console.log('No scan history found — run scan first.');
+  process.exit(0);
+}
 …
 const lines = readFileSync(TSV_PATH, 'utf8').trim().split('\n');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-daily-email.mjs` around lines 4 - 11, Validate required env vars
RESEND_API_KEY and NOTIFY_EMAIL (used as TO) at startup and emit a clear
processLogger.error message and process.exit(1) if missing; do not rely on
downstream Resend errors for this. Before calling readFileSync(TSV_PATH, ...),
use fs.existsSync on TSV_PATH (or its containing data directory) to handle
first-run missing files gracefully: if the file is absent, treat the TSV as
empty (e.g., set lines=[] or skip processing) rather than letting readFileSync
throw ENOENT. Update any error logs to include which var or file is missing and
mirror the validation/existsSync pattern used in send-agent-results.mjs,
referencing RESEND_API_KEY, TO, TSV_PATH, existsSync, and readFileSync to locate
the changes.

Comment thread send-daily-email.mjs
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const FROM = process.env.RESEND_FROM || 'career-ops <onboarding@resend.dev>';
const TO = process.env.NOTIFY_EMAIL;
const TODAY = '2026-05-06';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Hardcoded TODAY date — script becomes a no-op after 2026-05-06.

const TODAY = '2026-05-06'; will silently filter to zero rows on every subsequent day, sending the "no new roles" Monty Python email forever. Compute the date at runtime.

🐛 Proposed fix
-const TODAY = '2026-05-06';
+const TODAY = new Date().toISOString().slice(0, 10);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const TODAY = '2026-05-06';
const TODAY = new Date().toISOString().slice(0, 10);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-daily-email.mjs` at line 7, The TODAY constant is hardcoded; replace the
static assignment of TODAY with a runtime-computed date string so the script
filters correctly every day. In send-daily-email.mjs, change the TOP-level
symbol TODAY to be computed at runtime (e.g., derive from new Date() and format
as YYYY-MM-DD or using the same formatter/util the project uses) so any DB/query
that relies on TODAY (the TODAY constant) receives the current date string;
ensure the formatted string matches the expected timezone/format used by the
existing filter logic.

Comment thread send-daily-email.mjs
const FROM = process.env.RESEND_FROM || 'career-ops <onboarding@resend.dev>';
const TO = process.env.NOTIFY_EMAIL;
const TODAY = '2026-05-06';
const TSV_PATH = '/home/user/career-ops/data/scan-history.tsv';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Hardcoded absolute Linux path is broken on every machine but the author's.

/home/user/career-ops/data/scan-history.tsv will ENOENT on macOS, on Windows (which is the target platform per setup-scheduler.ps1 and daily-ats-scan.bat), and on any Linux box where the repo lives elsewhere. Resolve relative to the script.

🐛 Proposed fix
-import { readFileSync } from 'fs';
-import { join } from 'path';
+import { readFileSync, existsSync } from 'fs';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
@@
-const TSV_PATH = '/home/user/career-ops/data/scan-history.tsv';
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const TSV_PATH = join(__dirname, 'data', 'scan-history.tsv');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-daily-email.mjs` at line 8, TSV_PATH in send-daily-email.mjs is a
hardcoded absolute Linux path; change it to resolve the data file relative to
the running script instead. Replace the literal '/home/user/…/scan-history.tsv'
assigned to TSV_PATH with logic that computes the path from the module location
(use import.meta.url and fileURLToPath/path.dirname or new URL with
import.meta.url) so TSV_PATH points to ./data/scan-history.tsv relative to the
script regardless of OS or working directory.

Comment thread send-daily-email.mjs
Comment on lines +22 to +25
status: cols[5]
};
})
.filter(r => r.first_seen === TODAY && r.status === 'added');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

status field not trimmed — Windows CRLF makes the equality check fail for every row.

When scan-history.tsv is written/read on Windows (as it will be under Task Scheduler), each line keeps its trailing \r, so cols[5] is 'added\r' and r.status === 'added' is always false. Trim at parse time.

🐛 Proposed fix
-      status: cols[5]
+      status: cols[5]?.trim()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-daily-email.mjs` around lines 22 - 25, The status field from cols[5] can
include a trailing CR on Windows, so the filter r.status === 'added' always
fails; update the parsing step that builds r (where status: cols[5]) to trim the
value (e.g., use cols[5].trim()) or trim all cols before mapping, so r.status
contains the normalized string and the subsequent filter comparing to 'added'
and TODAY works correctly.

Comment thread send-daily-email.mjs
if (!roles.length) return '';
const rows = roles.map(r => `
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #30363d;"><a href="${r.url}" style="color: #58a6ff; text-decoration: none;">${escHtml(r.title)}</a></td>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Unescaped r.url in the href attribute.

ATS URLs commonly include & query separators, producing invalid HTML. Pass it through escHtml.

🐛 Proposed fix
-      <td …><a href="${r.url}" …>${escHtml(r.title)}</a></td>
+      <td …><a href="${escHtml(r.url)}" …>${escHtml(r.title)}</a></td>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<td style="padding: 8px 12px; border-bottom: 1px solid #30363d;"><a href="${r.url}" style="color: #58a6ff; text-decoration: none;">${escHtml(r.title)}</a></td>
<td style="padding: 8px 12px; border-bottom: 1px solid `#30363d`;"><a href="${escHtml(r.url)}" style="color: `#58a6ff`; text-decoration: none;">${escHtml(r.title)}</a></td>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@send-daily-email.mjs` at line 76, The href currently injects raw r.url into
the template which can break HTML when it contains characters like &, so update
the template that renders the link (the string using ${r.url}) to pass the URL
through the existing escHtml helper (use escHtml(r.url)) just like the title is
escaped with escHtml(r.title) to ensure the href attribute is properly escaped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants