chore(release): v0.9.4

Messaging overhaul: expandable email timeline with lazy body loading,
sticky compose bar replacing always-visible action buttons, layout height
fixed to 100dvh. Accessibility fixes for contrast failures on orange/amber
backgrounds. Theme-aware replacements for hardcoded colors in Interviews,
References, and JobReview. Indeed alert parser, Oracle HCM scraper,
manage.sh compose engine detection.
This commit is contained in:
pyr0ball 2026-05-08 13:32:10 -07:00
parent 5d185650d9
commit a9fabcf521
14 changed files with 726 additions and 112 deletions

View file

@ -9,6 +9,53 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [0.9.4] — 2026-05-08
### Added
- **Messages view — expandable email timeline** — click any email item to lazy-load
and read the full body inline (HTML stripped to plain text via `DOMParser`).
Bodies are fetched on-demand via the new `GET /api/contacts/{id}` endpoint to avoid
loading 50KB+ email bodies on every page view.
- **Messages view — compose bar** — action buttons (Log call, Log note, Use template,
Draft reply with LLM, Call via Osprey) moved from the always-visible header into a
sticky bottom compose bar triggered by a New toggle. Reduces visual clutter when
just reading the thread.
- **Home view — "Skip review" checkbox** — when adding jobs by URL, a checkbox (default
on) sends them directly to the Apply queue, bypassing Job Review.
- **ContactsView — sync status** — shows last completed sync time and a spinner when
an email sync is running.
- **imap_sync: Indeed alert parser**`parse_indeed_alert()` extracts job title,
company, location, salary, and canonical URL from Indeed Job Alert digest emails.
- **scrape_url: Oracle HCM support** — Playwright-based scraper for Oracle HCM
CandidateExperience portals (React SPAs requiring JS execution).
- **manage.sh** — compose engine auto-detection (docker compose / podman compose /
podman-compose), `build` command, and cloud/demo stack shortcuts.
- **theme.css**`--color-overlay` token for modal/dialog backdrops.
### Fixed
- **Messages view layout** — changed `height: 100%` to `height: 100dvh` with a mobile
override for the 56px tab bar. `height: 100%` was resolving to "shrink-wrap" because
`.app-main` has no explicit height; compose bar is now correctly pinned to the bottom.
- **Accessibility: danger button contrast**`btn--danger` used `color: white` on
`--app-accent` (Talon Orange), yielding 2.8:1 contrast (fails WCAG AA 4.5:1 for
normal text). Fixed to `color: var(--app-accent-text)` (dark navy, 5.5:1).
- **Accessibility: warning badge contrast**`tab-badge` in Job Review used `color: white`
on `--color-warning` (amber). Same fix applied.
- **Theme: Interviews signal banners** — hardcoded `rgba(245,158,11,…)` / `rgba(39,174,…)`
/ `rgba(192,57,…)` replaced with `color-mix()` against `--color-warning/success/error`.
- **Theme: Interviews signal count**`color: #e67e22` hardcode replaced with
`var(--app-accent)`.
- **Theme: References academic tag chip**`color: #7c3aed` hardcode replaced with
`var(--status-synced)`; background uses `color-mix()` with the same token.
- **Theme: Interviews signal-move button**`color: #fff` on `--color-primary` fails
in dark mode (light green bg); fixed to `var(--color-text-inverse)`.
- **Modal backdrops**`rgba(0,0,0,0.5)` replaced with `var(--color-overlay)` for
theme consistency.
---
## [0.9.3] — 2026-05-05
### Added

View file

@ -114,6 +114,38 @@ app.include_router(_feedback_router, prefix="/api/feedback")
_log = logging.getLogger("peregrine.session")
# ── Structured auth logging ───────────────────────────────────────────────────
# Writes one JSON line per request to /devl/peregrine-logs/auth.log when in
# cloud mode. Rotates at 10 MB, keeps 5 files. Also logs to stdout in dev.
_AUTH_LOG_DIR = Path(os.environ.get("PEREGRINE_LOG_DIR", "/devl/peregrine-logs"))
class _JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if hasattr(record, "auth_event"):
payload.update(record.auth_event)
return json.dumps(payload)
def _setup_auth_logging() -> None:
from logging.handlers import RotatingFileHandler
_AUTH_LOG_DIR.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
_AUTH_LOG_DIR / "auth.log", maxBytes=10 * 1024 * 1024, backupCount=5
)
handler.setFormatter(_JsonFormatter())
handler.setLevel(logging.INFO)
_log.addHandler(handler)
_log.setLevel(logging.DEBUG)
_setup_auth_logging()
_seen_users: set[str] = set() # track first-access events within this process lifetime
def _demo_guard() -> None:
"""Raise 403 if running in demo mode. Call at the top of any write endpoint."""
@ -158,6 +190,16 @@ def _resolve_cf_user_id(cookie_str: str) -> str | None:
return None
def _auth_log(event: str, **kwargs) -> None:
"""Emit a structured INFO log line to the auth logger."""
record = logging.LogRecord(
name="peregrine.session", level=logging.INFO,
pathname="", lineno=0, msg=event, args=(), exc_info=None,
)
record.auth_event = {"event": event, **kwargs}
_log.handle(record)
@app.middleware("http")
async def cloud_session_middleware(request: Request, call_next):
"""In cloud mode, resolve per-user staging.db from the X-CF-Session header."""
@ -165,16 +207,36 @@ async def cloud_session_middleware(request: Request, call_next):
cookie_header = request.headers.get("X-CF-Session", "")
user_id = _resolve_cf_user_id(cookie_header)
if user_id:
first_access = user_id not in _seen_users
if first_access:
_seen_users.add(user_id)
user_db = str(_CLOUD_DATA_ROOT / user_id / "peregrine" / "staging.db")
if user_db not in _migrated_db_paths:
from scripts.db_migrate import migrate_db
migrate_db(Path(user_db))
_migrated_db_paths.add(user_db)
_auth_log(
"session_resolved",
user_id=user_id,
method=request.method,
path=request.url.path,
first_access=first_access,
)
token = _request_db.set(user_db)
try:
return await call_next(request)
finally:
_request_db.reset(token)
else:
# Only log failures on non-trivial paths (skip health checks / static assets)
if request.url.path.startswith("/api/"):
_auth_log(
"session_failed",
method=request.method,
path=request.url.path,
reason="no_user_id",
has_cookie=bool(cookie_header),
)
return await call_next(request)
@ -1677,6 +1739,16 @@ def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
return {"total": total, "contacts": [dict(r) for r in rows]}
@app.get("/api/contacts/{contact_id}")
def get_contact(contact_id: int):
db = _get_db()
row = db.execute("SELECT * FROM job_contacts WHERE id = ?", (contact_id,)).fetchone()
db.close()
if not row:
raise HTTPException(status_code=404, detail="Contact not found")
return dict(row)
# ── References ─────────────────────────────────────────────────────────────────
class ReferencePayload(BaseModel):
@ -2112,6 +2184,7 @@ def bulk_purge_jobs(body: BulkPurgeBody):
class AddJobsBody(BaseModel):
urls: List[str]
skip_review: bool = True
@app.post("/api/jobs/add", status_code=202)
@ -2123,6 +2196,7 @@ def add_jobs_by_url(body: AddJobsBody):
from scripts.task_runner import submit_task
db_path = _db_path()
existing = get_existing_urls(db_path)
status = "approved" if body.skip_review else "pending"
queued = 0
for raw_url in body.urls:
url = canonicalize_url(raw_url.strip())
@ -2132,6 +2206,7 @@ def add_jobs_by_url(body: AddJobsBody):
"title": "Importing...", "company": "", "url": url,
"source": "manual", "location": "", "description": "",
"date_found": _dt.now().isoformat()[:10],
"status": status,
})
if job_id:
submit_task(db_path, "scrape_url", job_id)

View file

@ -15,6 +15,11 @@ cd "$SCRIPT_DIR"
PROFILE="${PROFILE:-remote}"
# ── Compose engine detection ──────────────────────────────────────────────────
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
# ── Usage ────────────────────────────────────────────────────────────────────
usage() {
echo ""
@ -28,9 +33,10 @@ usage() {
echo -e " ${GREEN}start${NC} Start Peregrine (preflight → up)"
echo -e " ${GREEN}stop${NC} Stop all services"
echo -e " ${GREEN}restart${NC} Restart all services"
echo -e " ${GREEN}build [service]${NC} Rebuild image(s) without restarting (default: api web)"
echo -e " ${GREEN}status${NC} Show running containers"
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: app)"
echo -e " ${GREEN}update${NC} Pull latest images + rebuild app"
echo -e " ${GREEN}logs [service]${NC} Tail logs (default: api)"
echo -e " ${GREEN}update${NC} Pull latest images + rebuild"
echo -e " ${GREEN}preflight${NC} Check ports + resources; write .env"
echo -e " ${GREEN}models${NC} Check ollama models in config; pull any missing"
echo -e " ${GREEN}test${NC} Run test suite"
@ -41,6 +47,12 @@ usage() {
echo -e " ${GREEN}clean${NC} Remove containers, images, volumes (DESTRUCTIVE)"
echo -e " ${GREEN}open${NC} Open the web UI in your browser"
echo ""
echo -e " Cloud / demo commands:"
echo -e " ${GREEN}cloud-start${NC} Start the cloud stack (peregrine-cloud)"
echo -e " ${GREEN}cloud-restart${NC} Rebuild + restart the cloud stack"
echo -e " ${GREEN}demo-start${NC} Start the demo stack (peregrine-demo)"
echo -e " ${GREEN}demo-restart${NC} Rebuild + restart the demo stack"
echo ""
echo " Profiles (set via --profile or PROFILE env var):"
echo " remote API-only, no local inference (default)"
echo " cpu Local Ollama inference on CPU"
@ -70,7 +82,7 @@ while [[ $# -gt 0 ]]; do
esac
done
SERVICE="${1:-app}" # used by `logs` command
SERVICE="${1:-api}" # used by `logs` command
# ── Dependency guard ──────────────────────────────────────────────────────────
# Commands that delegate to make; others (status, logs, update, open, setup) run fine without it.
@ -101,7 +113,7 @@ case "$CMD" in
start)
info "Starting Peregrine (PROFILE=${PROFILE})..."
make start PROFILE="$PROFILE"
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
success "Peregrine is up → http://localhost:${PORT}"
;;
@ -114,33 +126,30 @@ case "$CMD" in
restart)
info "Restarting (PROFILE=${PROFILE})..."
make restart PROFILE="$PROFILE"
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
success "Peregrine restarted → http://localhost:${PORT}"
;;
status)
# Auto-detect compose engine same way Makefile does
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
$COMPOSE ps
;;
logs)
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
info "Tailing logs for: ${SERVICE}"
$COMPOSE logs -f "$SERVICE"
;;
build)
BUILD_SVC="$([[ "${SERVICE}" == "api" ]] && echo "api web" || echo "${SERVICE}")"
info "Building ${BUILD_SVC}..."
$COMPOSE build $BUILD_SVC
success "Build complete. Run './manage.sh restart' to apply."
;;
update)
info "Pulling latest images and rebuilding app..."
COMPOSE="$(command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 \
&& echo "docker compose" \
|| (command -v podman >/dev/null 2>&1 && echo "podman compose" || echo "podman-compose"))"
info "Pulling latest images and rebuilding..."
$COMPOSE pull searxng ollama 2>/dev/null || true
$COMPOSE build app web
$COMPOSE build api web
success "Update complete. Run './manage.sh restart' to apply."
;;
@ -167,7 +176,7 @@ case "$CMD" in
;;
open)
PORT="$(grep -m1 '^STREAMLIT_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8501)"
PORT="$(grep -m1 '^VUE_PORT=' .env 2>/dev/null | cut -d= -f2 || echo 8506)"
URL="http://localhost:${PORT}"
info "Opening ${URL}"
if command -v xdg-open &>/dev/null; then
@ -197,6 +206,32 @@ case "$CMD" in
-v "${@:3}"
;;
cloud-start)
info "Starting cloud stack (peregrine-cloud)..."
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d
success "Cloud stack up → http://localhost:8508"
;;
cloud-restart)
info "Rebuilding + restarting cloud stack (peregrine-cloud)..."
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud build api web
$COMPOSE -f compose.cloud.yml --project-name peregrine-cloud up -d
success "Cloud stack restarted → http://localhost:8508"
;;
demo-start)
info "Starting demo stack (peregrine-demo)..."
$COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d
success "Demo stack up → http://localhost:8504"
;;
demo-restart)
info "Rebuilding + restarting demo stack (peregrine-demo)..."
$COMPOSE -f compose.demo.yml --project-name peregrine-demo build api web
$COMPOSE -f compose.demo.yml --project-name peregrine-demo up -d
success "Demo stack restarted → http://localhost:8504"
;;
help|--help|-h)
usage
;;

View file

@ -234,10 +234,11 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
return None
conn = sqlite3.connect(db_path)
try:
status = job.get("status", "pending")
cursor = conn.execute(
"""INSERT INTO jobs
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
job.get("title", ""),
job.get("company", ""),
@ -249,6 +250,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
job.get("description", ""),
job.get("date_found", ""),
job.get("date_posted", "") or "",
status,
),
)
conn.commit()

View file

@ -392,6 +392,7 @@ def _has_todo_keyword(subject: str) -> bool:
_LINKEDIN_ALERT_SENDER = "jobalerts-noreply@linkedin.com"
_INDEED_ALERT_SENDER = "jobalerts@indeed.com"
# Social-proof / nav lines to skip when parsing alert blocks
_ALERT_SKIP_PHRASES = {
@ -447,6 +448,75 @@ def parse_linkedin_alert(body: str) -> list[dict]:
return jobs
def parse_indeed_alert(body: str) -> list[dict]:
"""
Parse the HTML body of an Indeed Job Alert email.
Returns a list of dicts: {title, company, location, salary, url}.
URL is canonicalised to https://www.indeed.com/viewjob?jk=<id>
(tracking parameters stripped).
"""
try:
from bs4 import BeautifulSoup as _BS
except ImportError:
return []
jobs: list[dict] = []
soup = _BS(body, "html.parser")
# Each job card is an <a> wrapping a job title — Indeed uses several layouts
# across their email templates. We try two strategies:
#
# Strategy A (2023+ layout): <td> blocks containing an <a> with /viewjob?jk=
# Strategy B (older layout): <tr class="job"> blocks
#
# Both extract the canonical jk= key from the href.
seen_jks: set[str] = set()
for anchor in soup.find_all("a", href=True):
href: str = anchor["href"]
jk_m = re.search(r"[?&]jk=([a-z0-9]+)", href, re.IGNORECASE)
if not jk_m:
continue
jk = jk_m.group(1)
if jk in seen_jks:
continue
seen_jks.add(jk)
title = anchor.get_text(separator=" ", strip=True)
if not title or len(title) < 3:
continue
# Walk up to find the container cell/row and extract company + location
container = anchor.find_parent(["td", "tr", "div"])
company = location = salary = ""
if container:
text_lines = [
t.strip() for t in container.get_text(separator="\n").splitlines()
if t.strip() and t.strip().lower() != title.lower()
]
if text_lines:
company = text_lines[0]
if len(text_lines) > 1:
location = text_lines[1]
# salary line often contains "$" or "/yr"
for line in text_lines[2:]:
if "$" in line or "/yr" in line.lower() or "/hour" in line.lower():
salary = line
break
jobs.append({
"title": title,
"company": company,
"location": location,
"salary": salary,
"url": f"https://www.indeed.com/viewjob?jk={jk}",
})
return jobs
def _scan_todo_label(conn: imaplib.IMAP4, cfg: dict, db_path: Path,
active_jobs: list[dict],
known_message_ids: set) -> int:
@ -558,20 +628,29 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
if mid in known_message_ids:
continue
# ── LinkedIn Job Alert digest — parse each card individually ──────
if _LINKEDIN_ALERT_SENDER in parsed["from_addr"].lower():
cards = parse_linkedin_alert(parsed["body"])
for card in cards:
# ── Job alert digests — parse each card deterministically ───────
from_lower = parsed["from_addr"].lower()
alert_cards: list[dict] = []
alert_source = ""
if _LINKEDIN_ALERT_SENDER in from_lower:
alert_cards = parse_linkedin_alert(parsed["body"])
alert_source = "linkedin"
elif _INDEED_ALERT_SENDER in from_lower:
alert_cards = parse_indeed_alert(parsed["body"])
alert_source = "indeed"
if alert_cards:
for card in alert_cards:
if card["url"] in existing_urls:
continue
job_id = insert_job(db_path, {
"title": card["title"],
"company": card["company"],
"url": card["url"],
"source": "linkedin",
"location": card["location"],
"is_remote": 0,
"salary": "",
"title": card["title"],
"company": card["company"],
"url": card["url"],
"source": alert_source,
"location": card.get("location", ""),
"is_remote": 0,
"salary": card.get("salary", ""),
"description": "",
"date_found": datetime.now().isoformat()[:10],
})
@ -580,7 +659,7 @@ def _scan_unmatched_leads(conn: imaplib.IMAP4, cfg: dict,
submit_task(db_path, "scrape_url", job_id)
existing_urls.add(card["url"])
new_leads += 1
print(f"[imap] LinkedIn alert → {card['company']}{card['title']}")
print(f"[imap] {alert_source} alert → {card['company']}{card['title']}")
known_message_ids.add(mid)
continue # skip normal LLM extraction path

View file

@ -57,7 +57,7 @@ _TIMEOUT = 12
def _detect_board(url: str) -> str:
"""Return 'linkedin', 'indeed', 'glassdoor', or 'generic'."""
"""Return 'linkedin', 'indeed', 'glassdoor', 'jobgether', 'oracle_hcm', or 'generic'."""
url_lower = url.lower()
if "linkedin.com" in url_lower:
return "linkedin"
@ -67,6 +67,8 @@ def _detect_board(url: str) -> str:
return "glassdoor"
if "jobgether.com" in url_lower:
return "jobgether"
if "oraclecloud.com" in url_lower and "hcmui" in url_lower:
return "oracle_hcm"
return "generic"
@ -201,6 +203,70 @@ def _scrape_jobgether(url: str) -> dict:
return {"company": company, "source": "jobgether"} if company else {}
def _scrape_oracle_hcm(url: str) -> dict:
"""Scrape an Oracle HCM CandidateExperience job page via Playwright.
Oracle HCM portals are React SPAs that require JS execution. The prospect
token in the URL path grants public access no auth needed.
"""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("[scrape_url] Oracle HCM: Playwright not installed, falling back to generic")
return _scrape_generic(url)
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
ctx = browser.new_context(user_agent=_HEADERS["User-Agent"])
page = ctx.new_page()
page.goto(url, timeout=30_000)
page.wait_for_load_state("networkidle", timeout=20_000)
result = page.evaluate("""() => {
const sel = (s) => document.querySelector(s)?.textContent?.trim() || '';
const selInner = (s) => document.querySelector(s)?.innerText?.trim() || '';
// Title: try known HCM selectors then fall back to first h1
const title = sel('[class*="requisition-title"]')
|| sel('[class*="JobTitle"]')
|| sel('.job-title')
|| sel('h1');
// Company: page header logo alt text, meta, or site-name span
const companyMeta = document.querySelector('meta[property="og:site_name"]')
?.getAttribute('content') || '';
const company = sel('[class*="company-name"]')
|| sel('[class*="siteName"]')
|| sel('[class*="site-name"]')
|| companyMeta;
// Location: job detail list items
const location = sel('[class*="job-location"]')
|| sel('[data-testid*="location"]')
|| sel('[class*="location"]');
// Description: main content div
const description = selInner('[class*="job-description"]')
|| selInner('[class*="requisition-description"]')
|| selInner('[class*="JobDescription"]')
|| selInner('main article')
|| selInner('main');
return { title, company, location, description };
}""")
finally:
browser.close()
result["source"] = "oracle_hcm"
return {k: v for k, v in result.items() if v}
except Exception as exc:
print(f"[scrape_url] Oracle HCM Playwright error for {url}: {exc}")
return {}
def _parse_json_ld_or_og(html: str) -> dict:
"""Extract job fields from JSON-LD structured data, then og: meta tags."""
soup = BeautifulSoup(html, "html.parser")
@ -278,6 +344,8 @@ def scrape_job_url(db_path: Path = DEFAULT_DB, job_id: int = None) -> dict:
fields = _scrape_glassdoor(url)
elif board == "jobgether":
fields = _scrape_jobgether(url)
elif board == "oracle_hcm":
fields = _scrape_oracle_hcm(url)
else:
fields = _scrape_generic(url)
except requests.RequestException as exc:

View file

@ -203,6 +203,73 @@ def test_parse_linkedin_alert_empty_body():
assert parse_linkedin_alert("No jobs here.") == []
# ── Indeed alert parser ───────────────────────────────────────────────────────
_INDEED_ALERT_HTML = """
<html><body>
<table>
<tr>
<td>
<a href="https://www.indeed.com/viewjob?jk=abc123def456&utm_source=jobseeker_email">
Senior Python Engineer
</a>
<br/>Acme Corp<br/>San Francisco, CA<br/>$130,000 - $160,000 a year
</td>
</tr>
<tr>
<td>
<a href="https://www.indeed.com/viewjob?jk=999zzzqqq111&trk=email_alert">
Staff Backend Engineer
</a>
<br/>Widgets Inc<br/>Remote
</td>
</tr>
<tr>
<td>
<a href="https://www.indeed.com/rc/clk?jk=abc123def456&pos=0">Duplicate link</a>
</td>
</tr>
</table>
</body></html>
"""
def test_parse_indeed_alert_extracts_jobs():
from scripts.imap_sync import parse_indeed_alert
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
assert len(jobs) == 2
assert jobs[0]["title"] == "Senior Python Engineer"
assert jobs[0]["url"] == "https://www.indeed.com/viewjob?jk=abc123def456"
assert jobs[1]["title"] == "Staff Backend Engineer"
assert jobs[1]["url"] == "https://www.indeed.com/viewjob?jk=999zzzqqq111"
def test_parse_indeed_alert_strips_tracking_params():
from scripts.imap_sync import parse_indeed_alert
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
for job in jobs:
assert "utm_source" not in job["url"]
assert "trk=" not in job["url"]
def test_parse_indeed_alert_deduplicates_jk():
from scripts.imap_sync import parse_indeed_alert
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
urls = [j["url"] for j in jobs]
assert len(urls) == len(set(urls))
def test_parse_indeed_alert_empty_body():
from scripts.imap_sync import parse_indeed_alert
assert parse_indeed_alert("") == []
assert parse_indeed_alert("<html><body>No jobs here</body></html>") == []
def test_parse_indeed_alert_extracts_salary():
from scripts.imap_sync import parse_indeed_alert
jobs = parse_indeed_alert(_INDEED_ALERT_HTML)
assert "$130,000" in jobs[0]["salary"]
# ── _scan_unmatched_leads integration ─────────────────────────────────────────
_ALERT_BODY = """\

View file

@ -63,6 +63,9 @@
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
/* Overlay — modal/dialog scrim */
--color-overlay: rgba(0, 0, 0, 0.5);
/* Transitions */
--transition: 200ms ease;
--transition-slow: 400ms ease;

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
@ -19,13 +19,15 @@ interface Contact {
job_company: string | null
}
const contacts = ref<Contact[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
const contacts = ref<Contact[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
const searchInput = ref('')
const syncing = ref(false)
const syncStatus = ref<{ status: string; last_completed_at: string | null } | null>(null)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function fetchContacts() {
@ -76,9 +78,45 @@ const signalLabel: Record<string, string> = {
rejected: '✖ Rejected',
positive_response: '✅ Positive',
survey_received: '📋 Survey',
event_rescheduled: '🔄 Rescheduled',
neutral: '— Neutral',
}
onMounted(fetchContacts)
async function fetchSyncStatus() {
const { data } = await useApiFetch<{ status: string; last_completed_at: string | null }>(
'/api/email/sync/status'
)
if (data) syncStatus.value = data
}
async function triggerSync() {
syncing.value = true
await useApiFetch('/api/tasks/email-sync', { method: 'POST' })
// Poll until the task finishes or we give up after 60 s
const deadline = Date.now() + 60_000
const poll = setInterval(async () => {
await fetchSyncStatus()
if (syncStatus.value?.status === 'completed' || Date.now() > deadline) {
clearInterval(poll)
syncing.value = false
fetchContacts()
}
}, 2000)
}
function formatSyncTime(iso: string | null): string {
if (!iso) return 'never'
const d = new Date(iso)
const diff = Date.now() - d.getTime()
if (diff < 60_000) return 'just now'
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
onMounted(async () => {
await Promise.all([fetchContacts(), fetchSyncStatus()])
})
</script>
<template>
@ -91,6 +129,20 @@ onMounted(fetchContacts)
<header class="contacts-header">
<h1 class="contacts-title">Contacts</h1>
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
<div class="contacts-sync">
<span v-if="syncStatus" class="sync-last">
Last sync: {{ formatSyncTime(syncStatus.last_completed_at) }}
</span>
<button
class="btn-sync"
:disabled="syncing"
@click="triggerSync"
:aria-label="syncing ? 'Email sync running' : 'Sync email now'"
>
<span :class="['sync-icon', { 'sync-icon--spinning': syncing }]"></span>
{{ syncing ? 'Syncing…' : 'Sync email' }}
</button>
</div>
</header>
<div class="contacts-toolbar">
@ -115,8 +167,16 @@ onMounted(fetchContacts)
<div v-if="loading" class="contacts-empty">Loading</div>
<div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div>
<div v-else-if="contacts.length === 0 && !search" class="contacts-empty contacts-empty--setup">
<p>No contacts yet.</p>
<p class="contacts-empty-hint">
Connect your inbox in
<a href="/settings?tab=connections" class="setup-link">Settings Connections</a>
then hit <strong>Sync email</strong> to import recruiter emails automatically.
</p>
</div>
<div v-else-if="contacts.length === 0" class="contacts-empty">
No contacts found{{ search ? ' for that search' : '' }}.
No contacts found for that search.
</div>
<div v-else class="contacts-table-wrap">
@ -339,4 +399,69 @@ onMounted(fetchContacts)
.text-muted {
color: var(--color-text-muted);
}
.contacts-sync {
display: flex;
align-items: center;
gap: var(--space-3);
margin-left: auto;
}
.sync-last {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.btn-sync {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 7px;
color: var(--color-text-muted);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
}
.btn-sync:hover:not(:disabled) {
border-color: var(--app-primary);
color: var(--app-primary);
}
.btn-sync:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sync-icon {
font-size: 1rem;
line-height: 1;
display: inline-block;
}
.sync-icon--spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.contacts-empty--setup {
padding: var(--space-10) var(--space-4);
}
.contacts-empty-hint {
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.setup-link {
color: var(--app-primary);
text-decoration: underline;
}
</style>

View file

@ -159,6 +159,10 @@
rows="4"
aria-label="Job URLs to add"
/>
<label class="add-jobs__skip-review">
<input type="checkbox" v-model="skipReview" />
Skip review add directly to Apply queue
</label>
<button
class="action-btn action-btn--primary"
:disabled="!urlInput.trim()"
@ -437,15 +441,16 @@ const runEnrich = () => runTask('enrich', '/api/tasks/enrich')
// Add jobs
const addTab = ref<'url' | 'csv'>('url')
const urlInput = ref('')
const addTab = ref<'url' | 'csv'>('url')
const urlInput = ref('')
const skipReview = ref(true)
async function addByUrl() {
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
await useApiFetch('/api/jobs/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls }),
body: JSON.stringify({ urls, skip_review: skipReview.value }),
})
urlInput.value = ''
store.refresh()
@ -791,6 +796,16 @@ onUnmounted(() => {
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
.add-jobs__skip-review {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
/* ── Danger Zone ──────────────────────────────────────── */
.danger-zone {

View file

@ -682,7 +682,7 @@ function formatRejectionDate(job: PipelineJob): string {
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
color: var(--color-text-muted);
}
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; }
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: var(--app-accent); }
/* Collapsible pre-list body */
.pre-list-body {
@ -713,15 +713,15 @@ function formatRejectionDate(job: PipelineJob): string {
border-top: 1px solid transparent;
display: flex; flex-direction: column; gap: 4px;
}
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); }
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08); border-top-color: rgba(39,174,96,0.4); }
.pre-signal-banner[data-color="red"] { background: rgba(192,57,43,0.08); border-top-color: rgba(192,57,43,0.4); }
.pre-signal-banner[data-color="amber"] { background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-warning) 40%, transparent); }
.pre-signal-banner[data-color="green"] { background: color-mix(in srgb, var(--color-success) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-success) 40%, transparent); }
.pre-signal-banner[data-color="red"] { background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface)); border-top-color: color-mix(in srgb, var(--color-error) 40%, transparent); }
.signal-label { font-size: 0.82em; }
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
.signal-actions { display: flex; gap: 6px; align-items: center; }
.btn-signal-move {
background: var(--color-primary); color: #fff;
background: var(--color-primary); color: var(--color-text-inverse);
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
}
.btn-signal-dismiss {

View file

@ -496,7 +496,7 @@ onUnmounted(() => {
.tab-badge {
background: var(--color-warning);
color: white;
color: var(--app-accent-text);
font-size: 0.65rem;
font-weight: 700;
border-radius: 999px;

View file

@ -34,35 +34,8 @@
</div>
<template v-else>
<!-- Action bar -->
<div class="action-bar" role="toolbar" aria-label="Message actions">
<button class="btn btn--ghost" @click="openLogModal('call_note')">Log call</button>
<button class="btn btn--ghost" @click="openLogModal('in_person')">Log note</button>
<button class="btn btn--ghost" @click="openTemplateModal('apply')">Use template</button>
<button
class="btn btn--primary"
:disabled="store.loading"
@click="requestDraft"
>
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
</button>
<!-- Osprey (Phase 2 stub) aria-disabled, never hidden -->
<button
class="btn btn--osprey"
aria-disabled="true"
:title="ospreyTitle"
@mouseenter="handleOspreyHover"
@focus="handleOspreyHover"
>
📞 Call via Osprey
</button>
</div>
<!-- Draft pending announcement (screen reader) -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
{{ draftAnnouncement }}
</div>
<div aria-live="polite" aria-atomic="true" class="sr-only">{{ draftAnnouncement }}</div>
<!-- Error banner -->
<p v-if="store.error" class="thread-error" role="alert">{{ store.error }}</p>
@ -76,9 +49,15 @@
v-for="item in timeline"
:key="item._key"
class="timeline__item"
:class="[`timeline__item--${item.type}`, item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '']"
:class="[
`timeline__item--${item.type}`,
item.approved_at === null && item.type === 'draft' ? 'timeline__item--draft-pending' : '',
item.type !== 'draft' ? 'timeline__item--expandable' : '',
expandedKeys.has(item._key) ? 'timeline__item--open' : '',
]"
role="listitem"
:aria-label="`${typeLabel(item.type)}, ${item.direction || ''}, ${item.logged_at}`"
@click="item.type !== 'draft' && toggleExpand(item)"
>
<span class="timeline__icon" aria-hidden="true">{{ typeIcon(item.type) }}</span>
<div class="timeline__content">
@ -89,19 +68,29 @@
<span
v-if="item.type === 'draft' && item.approved_at === null"
class="timeline__badge timeline__badge--pending"
>
Pending approval
</span>
>Pending approval</span>
<span
v-if="item.type === 'draft' && item.approved_at !== null"
class="timeline__badge timeline__badge--approved"
>
Approved
>Approved</span>
<span v-if="item.type !== 'draft'" class="timeline__expand-hint" aria-hidden="true">
{{ expandedKeys.has(item._key) ? '▲' : '▼' }}
</span>
</div>
<p v-if="item.subject" class="timeline__subject">{{ item.subject }}</p>
<!-- Draft body is editable before approval -->
<!-- Expandable body for non-draft items -->
<template v-if="item.type !== 'draft' && expandedKeys.has(item._key)">
<div class="timeline__body-wrap" @click.stop>
<div v-if="bodyCache[item.id] === null" class="timeline__body-loading">
Loading
</div>
<pre v-else-if="bodyCache[item.id]" class="timeline__body">{{ bodyCache[item.id] }}</pre>
<p v-else class="timeline__body-empty">No body content.</p>
</div>
</template>
<!-- Draft: editable textarea + actions -->
<template v-if="item.type === 'draft' && item.approved_at === null">
<textarea
:ref="el => setDraftRef(item.id, el)"
@ -119,25 +108,50 @@
v-if="item.to_addr"
:href="`mailto:${item.to_addr}?subject=${encodeURIComponent(item.subject ?? '')}&body=${encodeURIComponent(item.body ?? '')}`"
class="btn btn--ghost btn--sm"
target="_blank"
rel="noopener"
>
Open in email client
</a>
target="_blank" rel="noopener"
>Open in email client</a>
<button class="btn btn--ghost btn--sm btn--danger" @click="confirmDelete(item.id)">
Discard
</button>
</div>
</template>
<template v-else>
<p class="timeline__body">{{ item.body }}</p>
</template>
</div>
</li>
<li v-if="timeline.length === 0" class="timeline__empty">
No messages logged yet for this job.
</li>
</ul>
<!-- Compose bar (sticky footer) -->
<div class="compose-bar" role="toolbar" aria-label="Compose actions">
<div v-if="composing" class="compose-bar__actions">
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('call_note'))">Log call</button>
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openLogModal('in_person'))">Log note</button>
<button class="btn btn--ghost btn--sm" @click="triggerAction(() => openTemplateModal('apply'))">Use template</button>
<button
class="btn btn--primary btn--sm"
:disabled="store.loading"
@click="triggerAction(requestDraft)"
>
<span v-if="store.loading" class="btn__spinner" aria-hidden="true"></span>
{{ store.loading ? 'Drafting…' : 'Draft reply with LLM' }}
</button>
<button
class="btn btn--osprey btn--sm"
aria-disabled="true"
:title="ospreyTitle"
@mouseenter="handleOspreyHover"
@focus="handleOspreyHover"
>📞 Call via Osprey</button>
</div>
<button
class="btn compose-bar__toggle"
:class="composing ? 'btn--ghost' : 'btn--primary'"
@click="composing = !composing"
:aria-expanded="composing"
aria-controls="compose-actions"
>{{ composing ? '✕ Close' : ' New' }}</button>
</div>
</template>
</main>
@ -230,8 +244,8 @@ const jobContacts = ref<JobContact[]>([])
watch(selectedJobId, async (id) => {
if (id === null) { jobContacts.value = []; return }
const { data } = await useApiFetch<JobContact[]>(`/api/contacts?job_id=${id}`)
jobContacts.value = data ?? []
const { data } = await useApiFetch<{ total: number; contacts: JobContact[] }>(`/api/contacts?job_id=${id}`)
jobContacts.value = data?.contacts ?? []
})
const timeline = computed<TimelineItem[]>(() => {
@ -262,6 +276,31 @@ const timeline = computed<TimelineItem[]>(() => {
)
})
// Body expansion
const expandedKeys = ref(new Set<string>())
const bodyCache = ref<Record<number, string | null>>({}) // null = still loading
async function toggleExpand(item: TimelineItem) {
const key = item._key
const next = new Set(expandedKeys.value)
if (next.has(key)) { next.delete(key); expandedKeys.value = next; return }
next.add(key)
expandedKeys.value = next
if (key.startsWith('jc-') && !(item.id in bodyCache.value)) {
bodyCache.value = { ...bodyCache.value, [item.id]: null }
const { data } = await useApiFetch<{ body: string | null }>(`/api/contacts/${item.id}`)
const raw = data?.body ?? ''
const text = raw.trimStart().startsWith('<')
? (new DOMParser().parseFromString(raw, 'text/html').body.textContent ?? '').trim()
: raw.trim()
bodyCache.value = { ...bodyCache.value, [item.id]: text }
}
}
// Compose bar
const composing = ref(false)
function triggerAction(fn: () => void) { composing.value = false; fn() }
// Draft body edits (local, before approve)
const draftBodyEdits = ref<Record<number, string>>({})
@ -415,8 +454,15 @@ onUnmounted(() => {
<style scoped>
.messaging-layout {
display: flex;
height: 100%;
height: 100dvh;
min-height: 0;
overflow: hidden;
}
@media (max-width: 1023px) {
.messaging-layout {
height: calc(100dvh - 56px - env(safe-area-inset-bottom, 0px));
}
}
/* ── Left panel ─────────────────────── */
@ -465,11 +511,6 @@ onUnmounted(() => {
flex: 1; display: flex; align-items: center; justify-content: center;
color: var(--color-text-muted);
}
.action-bar {
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.btn--osprey {
opacity: 0.5; cursor: not-allowed;
background: none; border: 1px dashed var(--color-border);
@ -477,6 +518,21 @@ onUnmounted(() => {
color: var(--color-text-muted); font-size: var(--text-sm);
padding: var(--space-2) var(--space-3); min-height: 36px;
}
/* Compose bar */
.compose-bar {
flex-shrink: 0;
display: flex; flex-direction: column; align-items: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border-light);
background: var(--color-surface);
}
.compose-bar__actions {
display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center;
width: 100%; justify-content: flex-start;
}
.compose-bar__toggle { align-self: flex-end; min-width: 90px; justify-content: center; }
.thread-error {
margin: var(--space-2) var(--space-4);
color: var(--app-accent); font-size: var(--text-sm);
@ -507,10 +563,27 @@ onUnmounted(() => {
font-size: var(--text-xs); font-weight: 700;
padding: 1px 6px; border-radius: var(--radius-full);
}
.timeline__badge--pending { background: #fef3c7; color: #d97706; }
.timeline__badge--approved { background: #d1fae5; color: #065f46; }
.timeline__badge--pending { background: var(--color-accent-light); color: var(--color-accent); }
.timeline__badge--approved { background: var(--color-primary-light); color: var(--color-primary); }
.timeline__subject { font-size: var(--text-sm); font-weight: 500; margin: 0; }
.timeline__body { font-size: var(--text-sm); white-space: pre-wrap; margin: 0; color: var(--color-text); }
.timeline__expand-hint {
font-size: var(--text-xs); color: var(--color-text-muted); margin-left: auto;
transition: transform 150ms ease;
}
.timeline__item--expandable { cursor: pointer; }
.timeline__item--expandable:hover { border-color: var(--app-primary); }
.timeline__body-wrap {
margin-top: var(--space-2);
border-top: 1px solid var(--color-border-light);
padding-top: var(--space-2);
}
.timeline__body {
font-size: var(--text-sm); white-space: pre-wrap; margin: 0;
color: var(--color-text); max-height: 280px; overflow-y: auto;
font-family: var(--font-body);
}
.timeline__body-loading { font-size: var(--text-xs); color: var(--color-text-muted); }
.timeline__body-empty { font-size: var(--text-xs); color: var(--color-text-muted); margin: 0; }
.timeline__draft-body {
width: 100%; font-size: var(--text-sm); font-family: var(--font-body);
padding: var(--space-2); border: 1px solid var(--color-border);
@ -522,20 +595,45 @@ onUnmounted(() => {
.timeline__empty { color: var(--color-text-muted); font-size: var(--text-sm); padding: var(--space-2); }
/* Buttons */
.btn { padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; min-height: 36px; }
.btn {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 6px;
transition: background 120ms ease, border-color 120ms ease, opacity 120ms ease, transform 80ms ease;
}
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--app-primary); outline-offset: 2px; }
.btn--sm { padding: var(--space-1) var(--space-3); min-height: 30px; font-size: var(--text-xs); }
.btn--primary { background: var(--app-primary); color: var(--color-surface); border: none; }
.btn--primary:hover:not(:disabled) { opacity: 0.9; }
.btn--primary:hover:not(:disabled) { opacity: 0.88; }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--ghost { background: none; border: 1px solid var(--color-border); color: var(--color-text); }
.btn--ghost:hover { background: var(--color-surface-alt); }
.btn--danger { background: var(--app-accent); color: white; border: none; }
.btn--danger:hover { opacity: 0.9; }
.btn--ghost:hover:not(:disabled) { background: var(--color-surface-alt); border-color: var(--app-primary); color: var(--app-primary); }
.btn--danger { background: var(--app-accent); color: var(--app-accent-text); border: none; }
.btn--danger:hover:not(:disabled) { opacity: 0.88; }
/* Spinner inside buttons */
.btn__spinner {
width: 13px;
height: 13px;
border: 2px solid rgba(255,255,255,0.35);
border-top-color: white;
border-radius: 50%;
animation: btn-spin 0.65s linear infinite;
flex-shrink: 0;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
/* Modals (delete confirm) */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
background: var(--color-overlay);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}

View file

@ -442,7 +442,7 @@ onMounted(fetchRefs)
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
.tag-chip--academic { background: rgba(103, 58, 183, 0.12); color: #7c3aed; }
.tag-chip--academic { background: color-mix(in srgb, var(--status-synced) 12%, var(--color-surface)); color: var(--status-synced); }
.ref-card__actions {
display: flex;