Is_READ_UPDATE #5

Merged
ahmed.mujtaba merged 8 commits from Is_READ_UPDATE into main 2026-08-10 09:08:02 +00:00
17 changed files with 1260 additions and 107 deletions

228
Sync_read.md Normal file
View File

@ -0,0 +1,228 @@
# Read-status sync (`/sync/*`)
Tracks **which messages got read or unread** — and which were deleted — without
re-downloading the mailbox. It sits on Microsoft Graph's **delta query**: Graph
hands you a cursor, and every later call with that cursor returns *only* what
changed since it was issued.
Five of the six endpoints share one piece of state: a delta cursor per
**(signed-in user + folder)**, persisted to disk so a restart doesn't re-backfill
the whole folder. The sixth — the per-message lookup — is deliberately outside
that machinery: it reads one id live and touches no cursor.
| Method | Path | Purpose |
| ------ | ---- | ------- |
| GET | `/sync/read-status` | Run one sync round **now** (synchronous) |
| GET | `/sync/read-status/changes` | Replay the last round's **full** result |
| GET | `/sync/read-status/message/{id}` | One message's status, by id — cursor-free |
| GET | `/sync/read-status/status` | Watcher health + cursor state |
| POST | `/sync/read-status/watch` | Start the background poller |
| DELETE | `/sync/read-status/watch` | Stop the background poller |
All require the API bearer token, and act on the **signed-in user's** mailbox —
they answer `401` until device-code sign-in completes.
---
## `GET /sync/read-status`
The workhorse. Asks Graph "what changed in this folder since my cursor?", emits
the changes, and advances the cursor.
The **first** call has no cursor, so it backfills the entire folder — an Inbox
with 4,700 messages is 47 pages of 100. Every call after that is incremental and
usually near-empty.
| Param | Default | Meaning |
| ----- | ------- | ------- |
| `folder` | `inbox` | Well-known name (`inbox`, `sentitems`, …) or folder id. Graph delta is **folder-scoped** — there is no all-mail delta |
| `since` | | ISO8601 lower bound, **initial sync only** (`receivedDateTime ge …`). The way to keep a first backfill small |
| `reset` | `false` | Discard the saved cursor and start a fresh baseline |
| `max_pages` | `10` | Cap on Graph pages (100 msgs each) fetched **per call** |
| `limit` | `10` | Cap on messages returned **in this response** |
`max_pages` and `limit` are independent and easy to confuse:
- **`max_pages` bounds the work.** Hit the cap and the call returns
`complete: false`, having saved its position; the next call resumes exactly
where it stopped. No changes are skipped, and no cursor is written until the
backfill genuinely finishes.
- **`limit` only trims the JSON.** It has no effect on how much is fetched.
`count` stays the true total, and the untruncated set is on
`/sync/read-status/changes`.
```jsonc
{
"synced_at": "2026-08-07T10:15:00Z",
"folder": "inbox",
"count": 1000, // changed messages this call actually fetched
"removed_count": 0, // deleted / moved out of the folder
"initial_sync": true, // this round is part of the first backfill
"complete": false, // hit max_pages — call again to continue
"pages": 10, // Graph pages fetched by this call
"truncated": true, // limit cut the lists below
"value": [ { "id": "AAMk…", "isRead": true,
"lastModifiedDateTime": "2026-08-07T10:14:52Z",
"subject": "Invoice #421" } ],
"removed": [ { "id": "AAMk…", "reason": "deleted" } ]
}
```
`value` is sorted newest-modified first before `limit` is applied, so a
truncated response shows the most recent changes rather than an arbitrary slice.
Only the four `$select` fields above come back — this endpoint is about *status*,
not content; use `GET /emails/{id}` for bodies.
## `GET /sync/read-status/changes`
Read-only replay of whatever the **last** round produced. No Graph call, cursor
untouched, safe to hit repeatedly.
Two reasons it exists:
1. It holds the **untruncated** lists — this is how you get the other 990 items
when `limit` trimmed the response.
2. It's the only way to collect what the **background watcher** found, since the
watcher has no caller to return to.
`404` until some sync has run. One buffer, last-writer-wins: the next round
overwrites it, so with the watcher running you must read it faster than
`interval` or you will miss rounds.
## `GET /sync/read-status/message/{message_id}`
One message, one record — a point lookup rather than a batch:
```jsonc
{ "id": "AAMk…", "isRead": true,
"lastModifiedDateTime": "2026-08-07T10:14:52Z", "subject": "Invoice #421" }
```
Identical shape to an entry in a sync `value` list, so both parse with the same
code. What makes it different from the endpoints above:
- **Cursor-free.** Touches no delta cursor, no cached state, and advances
nothing. Call it as often as you like without affecting a sync in progress.
- **Live.** Reports the mailbox *now*, straight from Graph — not what the last
round happened to capture. That makes it the right tool for re-checking one
message ("has this been read yet?") and for confirming a status after the fact.
- **Any id.** Works whether or not the message appeared in a sync, and whatever
folder it lives in.
It costs one Graph call per message, so it's a lookup, not a substitute for
delta — walking a mailbox with it would be far slower than a single sync round.
```bash
curl -H "$A" "$B/sync/read-status/message/AAMkAGI2TG93..."
```
URL-encode the id. Ids containing `/`, `+`, or `=` are handled (the route uses a
`:path` converter), so an already-encoded `%2F` works too. Unknown or deleted
ids surface Graph's own `404 ErrorItemNotFound`.
## `GET /sync/read-status/status`
Health check for the whole subsystem.
| Field | Meaning |
| ----- | ------- |
| `watching` / `interval` | Is the poller thread alive, and at what period |
| `folder` | Folder the cursor belongs to |
| `last_sync_at` | Timestamp of the most recent round |
| `last_change_count` / `last_removed_count` | Size of that round |
| `has_delta_link` | A real cursor exists ⇒ running incrementally |
| `backfill_in_progress` | Paused mid-backfill at the page cap ⇒ more rounds to go |
| `last_error` | Last Graph failure from the background thread, else `null` |
`has_delta_link: false` + `backfill_in_progress: true` is the normal state
*during* a long first sync.
## `POST /sync/read-status/watch`
Starts a daemon thread that runs the same sync every `interval` seconds and
writes each change to stdout.
```jsonc
{ "interval": 60, "folder": "inbox" } // interval min 10, both optional
```
- Idempotent — a second POST while running just answers
`{"message": "Already watching read-status changes"}`.
- While a backfill is still incomplete the loop continues immediately instead of
sleeping out the interval, so a big first sync finishes in consecutive chunks.
- Delivery is `_emit_read_status_changes()`, which prints. **That's the hook
point** — replace it to push to Slack, a webhook, or a queue.
## `DELETE /sync/read-status/watch`
Signals the thread to stop; `404` if nothing is running. The cursor survives, so
restarting the watcher resumes from where it left off rather than re-backfilling.
---
## Typical first run
```bash
export EMAIL_API_TOKEN=...
A="Authorization: Bearer $EMAIL_API_TOKEN"
B=http://localhost:5000
curl -X POST -H "$A" $B/auth/start # sign in once (see README)
# Baseline. Keep calling while "complete": false.
curl -H "$A" "$B/sync/read-status?since=2026-08-01T00:00:00Z"
# From here on, each call returns only what changed.
curl -H "$A" "$B/sync/read-status"
# Or hand it to the background poller and read results out of /changes.
curl -X POST -H "$A" -H "Content-Type: application/json" \
-d '{"interval":60,"folder":"inbox"}' $B/sync/read-status/watch
curl -H "$A" $B/sync/read-status/status
curl -H "$A" $B/sync/read-status/changes
# Re-check one message any time — no cursor involved.
curl -H "$A" "$B/sync/read-status/message/AAMkAGI2TG93..."
```
## Which endpoint do I want?
| You want | Use |
| -------- | --- |
| Everything that changed since last time | `GET /sync/read-status` |
| The full list a round produced (or the watcher's) | `GET /sync/read-status/changes` |
| The status of **one** message you already have an id for | `GET /sync/read-status/message/{id}` |
| Continuous tracking without calling in a loop | `POST /sync/read-status/watch` |
| Whether any of the above is healthy | `GET /sync/read-status/status` |
Rule of thumb: **delta for "what changed", point lookup for "what about this
one".** Using the lookup in a loop over a mailbox works but costs one Graph call
per message — a single sync round does the same job in pages of 100.
## How the cursor works
- A finished round returns Graph's **deltaLink**, saved to
`.delta_cache.json` (override with `EMAIL_API_DELTA_CACHE`; in Docker it lives
on the `/data` volume beside the token cache). Keyed by user + folder — change
either and the cache is ignored rather than misapplied.
- A round stopped by `max_pages` has no deltaLink yet, so it saves Graph's
**nextLink** instead. That resume position takes priority over any older
deltaLink on the following call, which is what makes a capped backfill safe:
the cursor never advances past data you haven't received.
- Cursors expire. Graph answers `410 Gone`, and the sync automatically falls
back to a fresh baseline for that folder.
- `reset=true` throws the cursor away deliberately — expect a full backfill, and
pass `since` with it unless you want the whole history again.
## Limits worth knowing
- **Folder-scoped only.** `/me/messages/delta` is not supported by Graph. Watch
another folder by passing `folder=`, but each folder is its own cursor and the
disk cache holds one at a time — switching folders forces a re-backfill.
- **Polling, not push.** Latency floor is the poll `interval`. True push needs a
Graph change-notification subscription (public HTTPS endpoint, validation
handshake, ~3-day renewals) — and you'd keep delta anyway as the catch-up path
for dropped notifications.
- **Single worker.** Cursor, watcher thread, and the `last_changes` buffer are
in-memory per process, so this only behaves with one uvicorn worker (which is
what the Docker service runs, for the same reason auth needs it).

86
Sync_write_request.md Normal file
View File

@ -0,0 +1,86 @@
# Email service — write read-status (`PATCH /sync/read-status/...`)
Copy everything below the line into any LLM session (or hand it to whoever owns the
email microservice) before implementing the write endpoint.
---
You are extending the **email microservice** that already exposes the read-status
delta and point-lookup APIs documented in `Sync_read.md`. Implement a **write**
path that marks a message read (or unread) in the signed-in user's Outlook mailbox
via Microsoft Graph. Mirror the existing `/sync/read-status/*` style exactly —
same bearer auth, same `:path` id handling, same response shape.
## Why we need this
The HR-ATS inbox app learns that a user opened a message before Outlook does.
Today that signal dies in our database: we have no Graph write permission and the
email service exposes no write endpoint. Without this PATCH, local mark-read and
Outlook drift permanently (and a later delta can even revert our flag).
## Requested contract
Mirror the existing read endpoints so both parse with one code path:
```
PATCH /sync/read-status/message/{id}
Authorization: Bearer <api token>
Content-Type: application/json
{ "isRead": true }
```
**200 response** — identical shape to `GET /sync/read-status/message/{id}`:
```jsonc
{
"id": "AAMk…",
"isRead": true,
"lastModifiedDateTime": "2026-08-07T10:14:52Z",
"subject": "Invoice #421"
}
```
Same `:path` converter for Graph ids that contain `/`, `+`, or `=`. Same bearer
auth as every other `/sync/*` route. Answer `401` until device-code sign-in
completes.
## Required behaviour
- **Idempotent.** Re-PATCHing `isRead: true` when already true is a no-op `200`
with the current record.
- **Must not advance or disturb the delta cursor.** This is a point write, not a
sync round. Cursor, watcher, and `/changes` buffer stay untouched.
- **404 `ErrorItemNotFound`** for unknown or deleted ids (same as the GET).
- **403 surfaced distinctly** if the Graph scope is missing, so callers can tell
"not permitted" from "not found".
## Graph scope prerequisite
Needs `Mail.ReadWrite`. The service currently signs in read-only. Treat upgrading
the consent / device-code scopes as an explicit product decision before shipping
the route — not an implementation footnote.
## Optional batch form
For bulk reconcile without N round-trips:
```
PATCH /sync/read-status/messages
{ "ids": ["AAMk…", "AAMk…"], "isRead": true }
```
Return a list of the same per-message records (or per-id errors). Nice-to-have;
the single-id PATCH is the hard requirement.
## What the caller will do with it
HR-ATS will enqueue one Taskiq task per human mark-read, retried via existing
smart-retry middleware. Expected volume is low (opens, not sweeps). After this
lands we will stop treating local-only mark-read as a known divergence.
## Out of scope for this request
- Changing the delta `/sync/read-status` contract
- Push / Graph change-notification subscriptions
- Writing any field other than `isRead`

View File

@ -4,6 +4,10 @@ DB_HOST=
DB_PORT= DB_PORT=
DB_NAME= DB_NAME=
EMAIL_URL= EMAIL_URL=
EMAIL_API_TOKEN=
EMAIL_SYNC_FOLDER=inbox
EMAIL_SYNC_SINCE=
EMAIL_SYNC_CRON=* * * * *
JWT_SECRET_KEY= JWT_SECRET_KEY=
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter,Depends, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi import HTTPException from fastapi import HTTPException
from db_setup import get_session from db_setup import get_session
from inbox.enums import Candidate_application_Status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from inbox.views import Email from inbox.views import Email
from users.permissions import PermissionTag, require_permission from users.permissions import PermissionTag, require_permission
@ -14,13 +15,13 @@ router = APIRouter()
async def fetch_email( async def fetch_email(
top:int=Query(100), top:int=Query(100),
skip:int=Query(0,ge=0), skip:int=Query(0,ge=0),
token=Query(...), token: str | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
if not token:
raise HTTPException(status_code=401,detail="Unauthorized")
service=Email(session=session,token=token) service=Email(session=session,token=token)
if not service.token:
raise HTTPException(status_code=401,detail="Unauthorized")
data=await service.service_email(top,skip) data=await service.service_email(top,skip)
value=data.get("value") value=data.get("value")
items_lst=[] items_lst=[]
@ -78,3 +79,70 @@ async def rematch_inbox(
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.post("/inbox/{record_id}/read")
async def mark_inbox_read(
record_id: str,
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Email(session=session)
data=await service.mark_read(record_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/inbox/{record_id}/read-status")
async def get_inbox_read_status(
record_id: str,
token: str | None = Query(None),
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Email(session=session,token=token)
data=await service.refresh_read_status(record_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/inbox/all-applications")
async def get_all_applications(
record_id: str | None = Query(None),
application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED),
isread: bool = Query(default=True),
search: str | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Email(session=session)
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
items=await service.get_all_applications(top, skip, search, application_status=application_status)
total=await service.count_inbox_messages(search, application_status=application_status)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False)
total=await service.count_inbox_messages(search, isread=False)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
if record_id:
item=await service.get_application_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200})
items=await service.get_all_applications(top,skip,search)
total=await service.count_inbox_messages(search)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))

11
backend/inbox/enums.py Normal file
View File

@ -0,0 +1,11 @@
from enum import Enum
# (str, Enum), like EnumRoles and PermissionTag: a bare Enum member is not JSON
# serializable, so JSONResponse raises the moment a serializer emits this field.
class Candidate_application_Status(str, Enum):
PROCESS="PROCESS"
PENDING="PENDING"
APPROVED="APPROVED"
REJECTED="REJECTED"
ONHOLD="ONHOLD"
CLOSED="CLOSED"

View File

@ -1,11 +1,12 @@
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from fastapi import HTTPException
from sqlalchemy import Column, DateTime, func, or_ from inbox.enums import Candidate_application_Status
from sqlalchemy import Column, DateTime, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select from sqlmodel import Field, Relationship, SQLModel, select, true
from users.models import Users from users.models import Users
@ -49,6 +50,7 @@ class Inbox_Messages(SQLModel, table=True):
full_email_response: dict[str, Any] | None = Field( full_email_response: dict[str, Any] | None = Field(
default=None, sa_column=Column(JSONB) default=None, sa_column=Column(JSONB)
) )
application_status: Candidate_application_Status = Field(default=Candidate_application_Status.CLOSED)
message_subject: str message_subject: str
message_body: str message_body: str
message_sent_time: str message_sent_time: str
@ -82,6 +84,17 @@ class Inbox_Messages(SQLModel, table=True):
return email_data.get("bodyPreview") or "" return email_data.get("bodyPreview") or ""
@classmethod @classmethod
async def get_all_applications(cls,session:AsyncSession,message_id:uuid.UUID|int|None=None):
try:
qry=select(cls.message_id,cls.full_email_response,cls.message_subject,cls.message_from,cls.message_to,cls.message_sent_time,cls.message_read,cls.attachment)
if message_id:
qry=qry.where(cls.message_id==message_id)
result=await session.execute(qry)
return result.scalars().all()
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def set_match_result( async def set_match_result(
cls, cls,
session: AsyncSession, session: AsyncSession,
@ -183,15 +196,23 @@ class Inbox_Messages(SQLModel, table=True):
@classmethod @classmethod
async def get_inbox_messages( async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED
): ):
statement = select(cls).order_by(cls.message_received_time.desc()) statement = select(cls).order_by(cls.message_received_time.desc())
if search: if search:
statement = statement.where(cls._search_filter(search)) statement = statement.where(cls._search_filter(search))
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
statement = statement.where(cls.application_status==application_status)
if skip: if skip:
statement = statement.offset(skip) statement = statement.offset(skip)
if top is not None: if top is not None:
statement = statement.limit(top) statement = statement.limit(top)
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement) result = await session.execute(statement)
return result.scalars().all() return result.scalars().all()
@ -205,9 +226,46 @@ class Inbox_Messages(SQLModel, table=True):
return result.scalars().first() return result.scalars().first()
@classmethod @classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None): async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED):
statement = select(func.count()).select_from(cls) statement = select(func.count()).select_from(cls)
if search: if search:
statement = statement.where(cls._search_filter(search)) statement = statement.where(cls._search_filter(search))
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
statement = statement.where(cls.application_status==application_status)
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar_one() return result.scalar_one()
@classmethod
async def apply_read_status(cls, session: AsyncSession, changes) -> int:
"""[{id, isRead, ...}] -> bulk UPDATE message_read. Returns rows touched.
read is a ONE-WAY LATCH: only false -> true is applied, never the reverse.
mark_message_read writes the local column only nothing pushes the state
back to Outlook so upstream keeps reporting isRead=false and the
every-minute sync_read_status sweep would otherwise revert a mail the user
just opened. Cost of the latch: un-reading a mail in Outlook no longer
propagates here.
"""
if not changes:
return 0
read_ids=[c.get("id") for c in changes if c.get("id") and c.get("isRead")]
if not read_ids:
return 0
result=await session.execute(
update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True)
)
await session.commit()
return result.rowcount or 0
@classmethod
async def mark_message_read(cls, session: AsyncSession, record_id):
row=await cls.get_inbox_message_by_id(session,record_id)
if not row:
return None
row.message_read=True
session.add(row)
await session.commit()
await session.refresh(row)
return row

View File

@ -1,22 +1,89 @@
"""Inbox helpers — attachment loading and resume text extraction.""" """Inbox helpers — attachment loading, resume text extraction, read-status sync."""
from __future__ import annotations from __future__ import annotations
import base64 import base64
import os
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import httpx
from dotenv import load_dotenv
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
from job.candidate.views import FileRead from job.candidate.views import FileRead
load_dotenv()
EMAIL_URL=os.getenv("EMAIL_URL")
EMAIL_API_TOKEN=os.getenv("EMAIL_API_TOKEN")
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments" _ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments"
async def fetch_read_status_delta(folder, since=None, limit=1000, max_pages=10, token=None):
"""GET /sync/read-status -> the raw round dict."""
if not EMAIL_URL:
raise RuntimeError("EMAIL_URL must be set")
auth_token=token or EMAIL_API_TOKEN
if not auth_token:
raise RuntimeError("EMAIL_API_TOKEN must be set")
params={"folder":folder,"limit":limit,"max_pages":max_pages}
if since:
params["since"]=since
async with httpx.AsyncClient(timeout=15.0) as client:
response=await client.get(
f"{EMAIL_URL.rstrip('/')}/sync/read-status",
params=params,
headers={"Authorization":f"Bearer {auth_token}"},
)
if response.status_code>=400:
raise httpx.HTTPStatusError(
response.text,
request=response.request,
response=response,
)
return response.json()
async def fetch_message_read_status(message_id, token=None):
"""GET /sync/read-status/message/{id} -> record dict, or None on 404."""
if not EMAIL_URL:
raise RuntimeError("EMAIL_URL must be set")
auth_token=token or EMAIL_API_TOKEN
if not auth_token:
raise RuntimeError("EMAIL_API_TOKEN must be set")
encoded_id=quote(str(message_id),safe="")
async with httpx.AsyncClient(timeout=15.0) as client:
response=await client.get(
f"{EMAIL_URL.rstrip('/')}/sync/read-status/message/{encoded_id}",
headers={"Authorization":f"Bearer {auth_token}"},
)
if response.status_code==404:
return None
if response.status_code>=400:
raise httpx.HTTPStatusError(
response.text,
request=response.request,
response=response,
)
return response.json()
def resolve_attachment_path(path_str:str) -> Path: def resolve_attachment_path(path_str:str) -> Path:
"""Prefer stored path; fall back to basename under decoded_attachments.""" """Prefer stored path; fall back to basename under decoded_attachments.
path=Path(path_str.strip())
Stored paths may be Windows absolutes written by the host API. The Taskiq
worker runs in Linux, where ``Path(r"D:\\...\\file.pdf").name`` is the
whole string (backslash is not a separator), so normalize separators before
taking the basename for the mounted attachments dir.
"""
raw=path_str.strip()
path=Path(raw)
if path.is_file(): if path.is_file():
return path return path
fallback=_ATTACHMENTS_DIR/path.name basename=Path(raw.replace("\\","/")).name
fallback=_ATTACHMENTS_DIR/basename
if fallback.is_file(): if fallback.is_file():
return fallback return fallback
return path return path

View File

@ -2,9 +2,19 @@ from pathlib import Path
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render.
_RESUME_STATUS = {
"processing": "Parsing",
"matched": "Parsed",
"no_text": "Failed",
"failed": "Failed",
"dlq": "Failed",
"skipped": "Pending",
}
def serialize_message(message: Inbox_Messages) -> dict:
"""inbox_messages row -> the shape the #inbox Email tab renders.""" def _sender_name(message: Inbox_Messages) -> str:
"""Graph's display name when the payload carries one, else the raw address."""
sender_name = message.message_from sender_name = message.message_from
full = message.full_email_response full = message.full_email_response
if isinstance(full, dict): if isinstance(full, dict):
@ -15,16 +25,26 @@ def serialize_message(message: Inbox_Messages) -> dict:
name = email_address.get("name") name = email_address.get("name")
if name: if name:
sender_name = name sender_name = name
return sender_name
attachment_name = None
def _attachment_name(message: Inbox_Messages) -> str | None:
if message.file_name: if message.file_name:
attachment_name = message.file_name.split(",")[0].strip() or None return message.file_name.split(",")[0].strip() or None
elif message.file_path: if message.file_path:
attachment_name = Path(message.file_path.split(",")[0].strip()).name or None return Path(message.file_path.split(",")[0].strip()).name or None
return None
def serialize_message(message: Inbox_Messages) -> dict:
"""inbox_messages row -> the shape the #inbox Email tab renders."""
sender_name = _sender_name(message)
attachment_name = _attachment_name(message)
return { return {
"id": str(message.id), "id": str(message.id),
"message_id": str(message.message_id) if message.message_id else None, "message_id": str(message.message_id) if message.message_id else None,
"full_email_response": message.full_email_response,
"sender_name": sender_name, "sender_name": sender_name,
"fromEmail": message.message_from, "fromEmail": message.message_from,
"subject": message.message_subject, "subject": message.message_subject,
@ -47,3 +67,37 @@ def serialize_message(message: Inbox_Messages) -> dict:
"match_error": message.match_error, "match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None, "matched_at": message.matched_at.isoformat() if message.matched_at else None,
} }
def serialize_application(message: Inbox_Messages) -> dict:
"""inbox_messages row -> the shape the #inbox All Applications tab renders.
`position` is the mail subject and `source` is the To address, which is where
the board tag (Rozee, Mustakbil, Employee Referral, ...) lands.
The tab also wants ats_score, phone, experience, recruiter, duplicate and a
processing state beyond read/unread. inbox_messages has no columns for any of
those, so they come back null instead of invented see the note in
inbox/file_decoder.py. `processing` is derived from message_read alone, so it
is only ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
"""
return {
"id": str(message.id),
"name": _sender_name(message),
"email": message.message_from,
"position": message.message_subject,
"source": message.message_to,
"received": message.message_received_time,
"unread": not message.message_read,
"processing": "Read" if message.message_read else "Unread",
"application_status": message.application_status,
"resume_status": _RESUME_STATUS.get(message.match_status, "Pending"),
"attachment": _attachment_name(message),
"has_attachment": message.attachment,
"resume_text": message.resume_text,
"ats_score": None,
"phone": None,
"experience": None,
"recruiter": None,
"duplicate": None,
}

View File

@ -0,0 +1,82 @@
"""Inbox Taskiq tasks — Outlook read-status delta sweep."""
from __future__ import annotations
import logging
import os
import httpx
import redis.asyncio as redis
from dotenv import load_dotenv
from db_setup import session_scope
from inbox.models import Inbox_Messages
from inbox.plugins import fetch_read_status_delta
from taskiq_management.broker_setup import broker
load_dotenv()
logger=logging.getLogger("inbox.sync")
EMAIL_SYNC_FOLDER=os.getenv("EMAIL_SYNC_FOLDER","inbox")
EMAIL_SYNC_SINCE=os.getenv("EMAIL_SYNC_SINCE") or None
EMAIL_SYNC_CRON=os.getenv("EMAIL_SYNC_CRON","* * * * *")
REDIS_URL=os.getenv("REDIS_URL","redis://localhost:6379/0")
_LOCK_KEY="inbox:sync_read_status:lock"
_LOCK_TTL=300
_MAX_ROUNDS=10
@broker.task(task_name="inbox.sync_read_status",schedule=[{"cron":EMAIL_SYNC_CRON}])
async def sync_read_status() -> dict:
client=redis.from_url(REDIS_URL,decode_responses=True)
try:
acquired=await client.set(_LOCK_KEY,"1",nx=True,ex=_LOCK_TTL)
if not acquired:
logger.info("sync_read_status skipped — lock held")
return {"skipped":"locked"}
try:
rounds=0
applied_total=0
removed_total=0
since=EMAIL_SYNC_SINCE
while rounds<_MAX_ROUNDS:
rounds+=1
try:
round_data=await fetch_read_status_delta(
EMAIL_SYNC_FOLDER,
since=since if rounds==1 else None,
limit=1000,
max_pages=10,
)
except httpx.HTTPStatusError as e:
if e.response.status_code==401:
logger.warning("sync_read_status 401 — device-code sign-in required")
return {"error":"unauthorized","status_code":401}
raise
changes=round_data.get("value") or []
removed=round_data.get("removed") or []
removed_total+=len(removed)
if removed:
logger.info("sync_read_status removed=%s",len(removed))
async with session_scope() as session:
applied=await Inbox_Messages.apply_read_status(session,changes)
applied_total+=applied
if round_data.get("complete",True):
break
return {
"rounds":rounds,
"applied":applied_total,
"removed":removed_total,
}
finally:
await client.delete(_LOCK_KEY)
finally:
await client.aclose()

View File

@ -1,10 +1,15 @@
import logging import logging
import httpx,os import httpx,os
from fastapi import HTTPException from fastapi import HTTPException
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
from inbox.file_decoder import decode_attachment from inbox.file_decoder import decode_attachment
from inbox.serializers import serialize_message from inbox.serializers import serialize_application, serialize_message
from inbox.plugins import load_message_files from inbox.plugins import (
EMAIL_API_TOKEN,
fetch_message_read_status,
load_message_files,
)
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -17,9 +22,19 @@ class Email:
def __init__(self,session:AsyncSession,token=None): def __init__(self,session:AsyncSession,token=None):
self.session=session self.session=session
self.get_url=os.getenv("EMAIL_URL") self.get_url=os.getenv("EMAIL_URL")
self.token=token self.token=token or EMAIL_API_TOKEN
self.pending_match_ids:list[str]=[] self.pending_match_ids:list[str]=[]
# async def get_all_applications(self,app_id=None):
# try:
# if app_id:
# application_lst=await Inbox_Messages.get_all_applications(self.session,message_id=app_id)
# else:
# application_lst=await Inbox_Messages.get_all_applications(self.session)
# return application_lst
# except Exception as e:
# raise HTTPException(status_code=500,detail=str(e))
async def service_email(self,top,skip): async def service_email(self,top,skip):
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
try: try:
@ -77,6 +92,21 @@ class Email:
item["files"]=files item["files"]=files
return item return item
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status)
elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread)
else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search)
return [serialize_application(m) for m in messages]
async def get_application_by_id(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:
raise HTTPException(status_code=404,detail="Application not found")
return serialize_application(message)
async def queue_rematch(self,record_id): async def queue_rematch(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message: if not message:
@ -98,5 +128,34 @@ class Email:
task_ids.append(task.task_id) task_ids.append(task.task_id)
return task_ids return task_ids
async def count_inbox_messages(self,search=None): async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
return await Inbox_Messages.count_inbox_messages(self.session,search) if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status)
elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False)
else:
return await Inbox_Messages.count_inbox_messages(self.session,search)
async def mark_read(self,record_id):
message=await Inbox_Messages.mark_message_read(self.session,record_id)
if not message:
raise HTTPException(status_code=404,detail="Message not found")
return serialize_message(message)
async def refresh_read_status(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:
raise HTTPException(status_code=404,detail="Message not found")
if not message.message_id:
raise HTTPException(status_code=400,detail="Message has no upstream id")
try:
status=await fetch_message_read_status(message.message_id,token=self.token)
except httpx.HTTPStatusError as e:
raise HTTPException(status_code=e.response.status_code,detail=e.response.text)
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
if status is None:
raise HTTPException(status_code=404,detail="Message not found upstream")
await Inbox_Messages.apply_read_status(self.session,[status])
refreshed=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
return serialize_message(refreshed)

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter,Depends from fastapi import APIRouter,Depends,Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi import HTTPException from fastapi import HTTPException
from db_setup import get_session from db_setup import get_session
@ -49,6 +49,22 @@ async def cv_upload(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/inbox-match")
async def candidate_inbox_match(
inbox_message_id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=FileRead(session=session)
data=await service.match_inbox_cv(inbox_message_id)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/post-job") @router.post("/job/post-job")
async def post_job( async def post_job(
payload: JobPostCreate, payload: JobPostCreate,

View File

@ -1,8 +1,10 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import os,logging,io import os,logging,io
from datetime import datetime,timezone
from fastapi import HTTPException from fastapi import HTTPException
from pypdf import PdfReader from pypdf import PdfReader
from sqlalchemy import select from sqlalchemy import select
from inbox.models import Inbox_Messages
from job.candidate.plugins import normalize_spaced_text from job.candidate.plugins import normalize_spaced_text
class FileRead: class FileRead:
@ -26,7 +28,41 @@ class FileRead:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(400, str(e)) raise HTTPException(400, str(e))
async def match_inbox_cv(self,inbox_message_id):
from inbox.plugins import resolve_attachment_path
from inbox.tasks import match_inbox_message
row=await Inbox_Messages.get_inbox_message_by_id(self.session,inbox_message_id)
if not row:
raise HTTPException(status_code=404,detail="Message not found")
if not row.attachment or not row.file_path:
raise HTTPException(status_code=400,detail="your file isnt in the system")
found=None
for path_str in (p.strip() for p in row.file_path.split(",") if p.strip()):
path=resolve_attachment_path(path_str)
if path.is_file():
found=path
break
if found is None:
raise HTTPException(status_code=400,detail="your file isnt in the system")
created_at=datetime.now(timezone.utc).isoformat()
task=await match_inbox_message.kicker().with_labels(
created_at=created_at,
correlation_id=str(row.id),
queue="inbox",
).kiq(str(row.id),force=True)
file_name=(row.file_name or "").split(",")[0].strip() or found.name
return {
"queued":True,
"inbox_message_id":str(row.id),
"file_name":file_name,
"task_id":task.task_id,
}
# async def get_intention(self,input): # async def get_intention(self,input):
# try: # try:
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id) # get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)
# get_file= # get_file=

View File

@ -1,6 +1,6 @@
"""Taskiq broker — Redis Streams + smart retry + DLQ. """Taskiq broker — Redis Streams + smart retry + DLQ.
Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks taskiq_management.tasks Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks inbox.sync_tasks taskiq_management.tasks
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler
""" """
@ -11,6 +11,7 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from taskiq import TaskiqScheduler from taskiq import TaskiqScheduler
from taskiq.middlewares import SmartRetryMiddleware from taskiq.middlewares import SmartRetryMiddleware
from taskiq.schedule_sources import LabelScheduleSource
from taskiq_redis import ( from taskiq_redis import (
ListRedisScheduleSource, ListRedisScheduleSource,
RedisAsyncResultBackend, RedisAsyncResultBackend,
@ -52,4 +53,7 @@ broker=(
) )
) )
scheduler=TaskiqScheduler(broker=broker,sources=[schedule_source]) scheduler=TaskiqScheduler(
broker=broker,
sources=[schedule_source,LabelScheduleSource(broker)],
)

View File

@ -24,6 +24,7 @@ services:
"worker", "worker",
"taskiq_management.broker_setup:broker", "taskiq_management.broker_setup:broker",
"inbox.tasks", "inbox.tasks",
"inbox.sync_tasks",
"taskiq_management.tasks", "taskiq_management.tasks",
"--workers", "--workers",
"1", "1",
@ -48,7 +49,7 @@ services:
build: build:
context: ./backend context: ./backend
container_name: hrms-taskiq-scheduler container_name: hrms-taskiq-scheduler
command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler"] command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler", "inbox.sync_tasks"]
env_file: env_file:
- ./backend/.env - ./backend/.env
environment: environment:

View File

@ -11,7 +11,41 @@ export function listMessages() {
return request('/inbox/fetch') return request('/inbox/fetch')
} }
/**
* Persisted applications the shape the All Applications tab renders.
*
* Unlike /inbox/fetch this one IS permissioned server-side
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403.
*/
export function listApplications({ search, top, skip, recordId, isread, applicationStatus } = {}) {
return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
// to true = no filter), send false for the Unread tab only. buildUrl drops
// undefined but keeps false, so `isread: undefined` sends no param at all.
// Same for `application_status`: omit for every tab (server defaults to
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
params: { search, top, skip, record_id: recordId, isread, application_status: applicationStatus },
})
}
/**
* One persisted message by id the detail behind an inbox row.
*
* `record_id` is the inbox_messages PRIMARY KEY, not the Graph message_id:
* get_inbox_message_by_id runs uuid.UUID(record_id) and matches on `id`, so the
* external string id would fail the parse and 404. The `id` field on both
* /inbox/fetch and /inbox/all-applications rows is already that primary key.
*/
export function getMessage(recordId) {
return request('/inbox/fetch', { params: { record_id: recordId } })
}
/** Triggers the Graph proxy to pull new mail and persist it. */ /** Triggers the Graph proxy to pull new mail and persist it. */
export function syncMailbox({ token, top, skip } = {}) { export function syncMailbox({ token, top, skip } = {}) {
return request('/email/fetch', { params: { token, top, skip } }) return request('/email/fetch', { params: { token, top, skip } })
} }
/** Marks one persisted inbox row read (local DB only). */
export function markRead(recordId) {
return request(`/inbox/${recordId}/read`, { method: 'POST' })
}

View File

@ -19,6 +19,8 @@ export const qk = {
mailbox: { mailbox: {
all: () => ['mailbox'], all: () => ['mailbox'],
messages: () => ['mailbox', 'messages'], messages: () => ['mailbox', 'messages'],
applications: (p = {}) => ['mailbox', 'applications', p],
message: (id) => ['mailbox', 'message', id],
}, },
// --- seed-backed buckets --- // --- seed-backed buckets ---

View File

@ -11,7 +11,7 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
@ -23,30 +23,60 @@ import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox' import * as inboxApi from '../api/inbox'
import { import {
atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob, atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob,
initials as initialsOf, int, locations, pick, relTime, skillsPool, TODAY, initials as initialsOf, inboxSources, int, locations, pick, relTime, sourceMeta,
TODAY,
} from '../data/seed' } from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email'] const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
/**
* Server-side filters for the tabs that /inbox/all-applications can narrow.
* Unfiltered tabs (and countsQuery) pass `{}` so the backend defaults apply
* isread=true and application_status=CLOSED both mean "no filter".
*/
const TAB_FILTERS = {
Unread: { isread: false },
Processed: { applicationStatus: 'PROCESS' },
Rejected: { applicationStatus: 'REJECTED' },
}
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */ /** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
const NOW = new Date('2026-07-09T20:00') const NOW = new Date('2026-07-09T20:00')
function resumeText(i) { /**
return `${i.name.toUpperCase()} * The seed candidate record importEmail() writes needs a number. The agent
${i.email} · ${i.phone} * returns a verdict, not a score, so there is nothing on the wire to use
${'—'.repeat(30)} * named here so the fabricated value is visible at its point of use instead of
PROFESSIONAL SUMMARY * arriving disguised as a server field on every message.
${i.experience} years of experience. Applied for ${i.position} via ${i.source}. */
const SEED_ATS_SCORE = 70
EXPERIENCE /**
${pick(companies)} Senior role (2021Present) * message_received_time / message_sent_time are plain string columns
${pick(companies)} Associate (20182021) * (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields
* an Invalid Date that every fmt* helper renders as the literal "Invalid Date",
* so return null instead and let the call sites decide what to show.
*/
function parseDate(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
EDUCATION /**
Bachelor's Degree, Computer Science * `source` arrives as the raw To address, because that is where the board tag
* lands careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
SKILLS * everything but letters from both sides so "Employee Referral" still matches
${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}` * "employee-referral@", and keep the brand colour SourceChip paints from.
* Nothing matches -> show the first recipient verbatim rather than guess.
*/
function sourceFrom(messageTo) {
const raw = (messageTo || '').trim()
if (!raw) return { source: 'Unknown', sourceMeta: null }
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
return { source: raw.split(',')[0].trim(), sourceMeta: null }
} }
function SourceChip({ item }) { function SourceChip({ item }) {
@ -60,10 +90,155 @@ function SourceChip({ item }) {
) )
} }
/**
* Graph delivers the body as text/html, so rendering it verbatim as text which
* is what keeps it XSS-safe prints the raw markup at the user.
*
* DOMParser builds a DETACHED document: it is never adopted into the live DOM, so
* scripts do not run and <img onerror> never fires. Reading textContent off it is
* therefore both safe and readable, and needs no dangerouslySetInnerHTML.
*/
function htmlToText(value) {
const raw = (value || '').trim()
if (!raw) return ''
if (!/<[a-z!/]/i.test(raw)) return raw // already plain text
// textContent ignores block boundaries, so <p>a</p><p>b</p> would collapse to
// "ab". Turn breaks and closing block tags into newlines BEFORE parsing.
const withBreaks = raw
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
/** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */
const RESUME_STATUS = {
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
}
/**
* GET /inbox/fetch?record_id=<pk> -> the detail behind one application row.
*
* Returns serialize_message, a different shape from serialize_application, so it
* is remapped onto the row shape here and OVERLAID on the list row rather than
* replacing it: serialize_message carries the body and the real decoded
* attachments, but omits resume_text, so the list row keeps supplying that.
* suggested_job_post_ids is dropped, same as everywhere else on this page.
*/
async function fetchMessageDetail(recordId) {
const res = await inboxApi.getMessage(recordId)
const row = res?.data
if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.fromEmail || '',
position: row.subject || '(no subject)',
...sourceFrom(row.message_to),
received: parseDate(row.when) ?? parseDate(row.message_sent_time),
unread: Boolean(row.unread),
processing: row.unread ? 'Unread' : 'Read',
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
attachment: row.attachment_name,
hasAttachment: Boolean(row.attachment),
body: htmlToText(row.body),
cc: row.message_cc || '',
bcc: row.message_bcc || '',
sentAt: parseDate(row.message_sent_time),
files: Array.isArray(row.files) ? row.files : [],
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
}
}
/**
* GET /inbox/all-applications -> the shape the application tabs render.
*
* READ-ONLY: inbox_messages has no columns for duplicates, recruiter, phone,
* experience or an ATS score, so those arrive null and every mutating action
* on these tabs is disabled until the endpoints exist. `processing` is derived
* from message_read alone (Read/Unread). Processed / Rejected tabs filter on
* `application_status` (PROCESS / REJECTED); Imported / Duplicates stay empty
* with no backing columns.
*/
async function fetchApplications(params) {
const res = await inboxApi.listApplications(params)
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => {
const name = row.name || row.email || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.email || '',
position: row.position || '(no subject)',
...sourceFrom(row.source),
received: parseDate(row.received),
unread: Boolean(row.unread),
processing: row.processing || 'Unread',
applicationStatus: row.application_status || null,
resumeStatus: row.resume_status || 'Pending',
attachment: row.attachment,
hasAttachment: Boolean(row.has_attachment),
resumeText: row.resume_text || '',
atsScore: row.ats_score,
phone: row.phone,
experience: row.experience,
recruiter: row.recruiter,
duplicate: Boolean(row.duplicate),
}
})
}
/**
* POST /inbox/{record_id}/read flips message_read false -> true for one row.
*
* Optimistic, so the row un-bolds on click instead of after the round trip, and
* rolls back if the server rejects. Both mailbox caches hold {id, unread} rows,
* so one setQueriesData over qk.mailbox.all() covers the Email tab and the
* application tabs at once; `processing` is derived from the same column, so it
* moves with it.
*
* NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW,
* so a view-only user gets a 403 here and the row snaps back to unread.
*/
function useMarkRead(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: (recordId) => inboxApi.markRead(recordId),
onMutate: async (recordId) => {
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (rows) => (
Array.isArray(rows)
? rows.map((r) => (r.id === recordId
? { ...r, unread: false, processing: r.processing === 'Unread' ? 'Read' : r.processing }
: r))
: rows
))
return { previous }
},
onError: (err, _recordId, ctx) => {
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
toast(friendlyAuthError(err, 'Could not mark as read.'), 'error')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
})
}
export default function Inbox() { export default function Inbox() {
const { toast } = useToast() const { toast } = useToast()
const navigate = useNavigate() const navigate = useNavigate()
const { data: inbox = [] } = useQuery(seedQuery('inbox')) const qc = useQueryClient()
const { data: jobs = [] } = useQuery(seedQuery('jobs')) const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox') const updateInbox = useSeedMutation('inbox')
@ -76,6 +251,32 @@ export default function Inbox() {
const [assigning, setAssigning] = useState(null) const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null) const [noting, setNoting] = useState(null)
// Tabs with a server-side filter pass their params; everything else (and
// countsQuery) passes `{}` so the backend defaults mean "no filter".
const tabFilter = TAB_FILTERS[tab] ?? {}
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(tabFilter),
queryFn: () => fetchApplications(tabFilter),
enabled: tab !== 'Email',
})
/**
* The tab badges need whole-table counts, which a server-filtered response
* cannot give and there is no counts endpoint. So the unfiltered set stays
* loaded for them. On every tab without a TAB_FILTERS entry this resolves to
* the SAME query key as the list above, so React Query serves both from one
* request.
*/
const countsQuery = useQuery({
queryKey: qk.mailbox.applications({}),
queryFn: () => fetchApplications({}),
enabled: tab !== 'Email',
})
const inbox = applicationsQuery.data ?? []
const allApplications = countsQuery.data ?? []
const emailsQuery = useQuery({ const emailsQuery = useQuery({
queryKey: qk.mailbox.messages(), queryKey: qk.mailbox.messages(),
queryFn: async () => { queryFn: async () => {
@ -87,11 +288,18 @@ export default function Inbox() {
fromEmail: row.fromEmail || '', fromEmail: row.fromEmail || '',
subject: row.subject || '', subject: row.subject || '',
body: row.body || '', body: row.body || '',
when: row.when ? new Date(row.when) : new Date(), when: parseDate(row.when) ?? parseDate(row.message_sent_time),
unread: Boolean(row.unread), unread: Boolean(row.unread),
attachment: row.attachment_name || 'Resume.pdf', attachment: row.attachment_name || 'Resume.pdf',
attachmentSize: '—', attachmentSize: '—',
atsScore: 70, // The agent's verdict, straight off backend/inbox/serializers.py:44-48.
// suggested_job_post_ids is deliberately NOT carried: job posts stay
// dark to the inbox.
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
imported: false, imported: false,
})) }))
}, },
@ -99,34 +307,57 @@ export default function Inbox() {
}) })
const counts = useMemo( const counts = useMemo(
// Counted off the UNFILTERED set `inbox` is server-filtered on Unread /
// Processed / Rejected, so counting it there would report that tab's total
// for every badge.
() => ({ () => ({
'All Applications': inbox.length, 'All Applications': allApplications.length,
Unread: inbox.filter((i) => i.processing === 'Unread').length, Unread: allApplications.filter((i) => i.processing === 'Unread').length,
Imported: inbox.filter((i) => i.processing === 'Imported').length, Imported: allApplications.filter((i) => i.processing === 'Imported').length,
Processed: inbox.filter((i) => i.processing === 'Processed').length, Processed: allApplications.filter((i) => i.applicationStatus === 'PROCESS').length,
Rejected: inbox.filter((i) => i.processing === 'Rejected').length, Rejected: allApplications.filter((i) => i.applicationStatus === 'REJECTED').length,
Duplicates: inbox.filter((i) => i.duplicate).length, Duplicates: allApplications.filter((i) => i.duplicate).length,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length, Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}), }),
[inbox, emailsQuery.data], [allApplications, emailsQuery.data],
) )
const list = useMemo(() => { const list = useMemo(() => {
let l = inbox let l = inbox
// Unread / Processed / Rejected are already filtered server-side; re-applying
// client-side keeps the optimistic mark-read drop-off for Unread, and keeps
// Processed/Rejected coherent if a stale cache briefly holds mixed rows.
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread') if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported') else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed') else if (tab === 'Processed') l = l.filter((i) => i.applicationStatus === 'PROCESS')
else if (tab === 'Rejected') l = l.filter((i) => i.processing === 'Rejected') else if (tab === 'Rejected') l = l.filter((i) => i.applicationStatus === 'REJECTED')
else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate) else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate)
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase())) if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
return l return l
}, [inbox, tab, q]) }, [inbox, tab, q])
const selected = inbox.find((i) => i.id === selectedId) // Clicking a row fetches that one record from /inbox/fetch. The list row is
// kept as the base and the detail is overlaid, so the pane paints instantly
// from cached list data and fills in body/attachments when the fetch lands.
const detailQuery = useQuery({
queryKey: qk.mailbox.message(selectedId),
queryFn: () => fetchMessageDetail(selectedId),
enabled: tab !== 'Email' && Boolean(selectedId),
})
const selectedRow = inbox.find((i) => i.id === selectedId)
const selected = selectedRow || detailQuery.data
? { ...selectedRow, ...(detailQuery.data ?? {}) }
: null
// The one mutation these tabs CAN persist everything else on them is
// disabled until the endpoints exist.
const markRead = useMarkRead(toast)
function select(id) { function select(id) {
setSelectedId(id) setSelectedId(id)
updateInbox((items) => items.map((i) => (i.id === id ? { ...i, unread: false } : i))) const item = inbox.find((i) => i.id === id)
if (item?.unread) markRead.mutate(id)
} }
function makeCandidate(item, job, cs) { function makeCandidate(item, job, cs) {
@ -223,7 +454,15 @@ export default function Inbox() {
</div> </div>
</div> </div>
<div> <div>
{list.length === 0 ? ( {applicationsQuery.isPending && (
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
)}
{applicationsQuery.isError && (
<EmptyState icon="inbox" title="Couldnt load applications">
{friendlyAuthError(applicationsQuery.error, 'Request failed')}
</EmptyState>
)}
{applicationsQuery.isSuccess && list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState> <EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
) : ( ) : (
list.map((i) => ( list.map((i) => (
@ -241,11 +480,23 @@ export default function Inbox() {
)} )}
</div> </div>
<div className="ii-pos">{i.position}</div> <div className="ii-pos">{i.position}</div>
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div> <div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge>
)}
</div>
</div> </div>
<div style={{ textAlign: 'right', flexShrink: 0 }}> <div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div> <div className="ii-time">
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div> {i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'}
</div>
{/* No ATS score exists server-side the agent returns a
verdict, not a number. The chip stays off rather than
rendering a placeholder that reads as a real score. */}
{i.atsScore != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
</div> </div>
</div> </div>
)) ))
@ -260,9 +511,16 @@ export default function Inbox() {
Choose an item from the list to view details and take action. Choose an item from the list to view details and take action.
</EmptyState> </EmptyState>
</div> </div>
) : detailQuery.isError ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Couldnt load this application">
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
) : ( ) : (
<ApplicationDetail <ApplicationDetail
item={selected} item={selected}
loading={detailQuery.isPending}
onPreview={() => setPreviewing(selected)} onPreview={() => setPreviewing(selected)}
onImport={() => importItem(selected)} onImport={() => importItem(selected)}
onParse={() => parseResume(selected)} onParse={() => parseResume(selected)}
@ -289,13 +547,17 @@ export default function Inbox() {
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }} onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
disabled
title="Needs a backend endpoint — not implemented yet"
> >
<Icon name="user-plus" /> Import Candidate <Icon name="user-plus" /> Import Candidate
</button> </button>
</> </>
} }
> >
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>{resumeText(previewing)}</pre> <pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>
{previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</Modal> </Modal>
)} )}
@ -336,9 +598,17 @@ export default function Inbox() {
) )
} }
function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) { /** Fields inbox_messages has no column for come back null; show a dash, not "null". */
function orDash(value, suffix = '') {
return value == null || value === '' ? '—' : `${value}${suffix}`
}
function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match' const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)' const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
// Every action below writes to a table column or an endpoint that does not
// exist yet, so they are disabled rather than silently dropping the click.
const noBackend = 'Needs a backend endpoint — not implemented yet'
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
@ -349,48 +619,103 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
<div className="ph-role">{i.position}</div> <div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}> <div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '} <SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<><Badge>{i.applicationStatus}</Badge>{' '}</>
)}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}> <Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus} {i.resumeStatus}
</Badge> </Badge>{' '}
{loading && <span className="cell-sub">Loading details</span>}
</div> </div>
</div> </div>
<div style={{ textAlign: 'center' }}> {i.atsScore != null && (
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}> <div style={{ textAlign: 'center' }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div> <div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div> </div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div> )}
</div>
</div> </div>
<div className="info-grid" style={{ marginBottom: 20 }}> <div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{i.email}</div></div> <div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{i.phone}</div></div> <div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{i.experience} years</div></div> <div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{i.recruiter}</div></div> <div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
<div className="info-item"><div className="il">Received</div><div className="iv">{fmtDate(i.received)}</div></div>
<div className="info-item"> <div className="info-item">
<div className="il">Match</div> <div className="il">Received</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div> <div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
</div> </div>
{/* Only present once GET /inbox/fetch?record_id= has resolved the list
endpoint carries none of these. */}
{i.sentAt && (
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
)}
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
{i.atsScore != null && (
<div className="info-item">
<div className="il">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
</div>
)}
</div> </div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}> {/* Body arrives only from GET /inbox/fetch?record_id= the list endpoint
<div className="card-body"> does not carry it. Already run through htmlToText, and still rendered as
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}> TEXT: inbound mail is attacker-supplied. A body that is only an empty
<div className="fw-600"><Icon name="paperclip" /> {i.attachment}</div> HTML skeleton flattens to '' and the block is skipped entirely. */}
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button> {!loading && (
</div> <div className="email-preview" style={{ marginBottom: 20 }}>
<pre className="resume-thumb">{resumeText(i)}</pre> {i.body || <span className="text-muted">This email has no message body.</span>}
</div> </div>
</div> )}
{i.hasAttachment && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div className="fw-600">
<Icon name="paperclip" /> {orDash(i.attachment)}
{i.files?.[0]?.size != null && (
<span className="cell-sub"> · {Math.round(i.files[0].size / 1024)} KB</span>
)}
</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
{/* The real extracted PDF text (inbox_messages.resume_text), written
by the matching task. Empty until that task has run. */}
<pre className="resume-thumb">
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</div>
</div>
)}
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}> <div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button> <button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button> <Icon name="user-plus" /> Import Candidate
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button> </button>
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button> <button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button> <Icon name="sparkles" /> Parse Resume
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}> </button>
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
<Icon name="users" /> Assign Recruiter
</button>
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
<Icon name="layers" /> Move to Pipeline
</button>
<button className="btn btn-secondary" onClick={onNote} disabled title={noBackend}>
<Icon name="edit" /> Add Note
</button>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={onReject}
disabled
title={noBackend}
>
<Icon name="x" /> Reject <Icon name="x" /> Reject
</button> </button>
</div> </div>
@ -436,13 +761,20 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
const selected = emails.find((e) => e.id === selectedId) const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length const unread = emails.filter((e) => e.unread).length
async function sync() { const markRead = useMarkRead(toast)
toast('Fetching from Outlook…', 'info')
const res = await qc.refetchQueries({ queryKey: qk.mailbox.messages() }) // Refetching the list alone only re-reads rows already in our DB. GET
if (query.isError) toast('Sync failed', 'error') // /email/fetch is the Graph proxy pull that inserts new mail and enqueues the
else toast('Mailbox synced', 'success') // matching agent, so it has to run FIRST then the list is invalidated to
return res // pick up whatever it wrote.
} const sync = useMutation({
mutationFn: () => inboxApi.syncMailbox(),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
toast('Mailbox synced', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
})
function importEmail(e) { function importEmail(e) {
const job = jobs[0] const job = jobs[0]
@ -455,12 +787,12 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
jobId: job.id, jobTitle: job.title, department: job.department, jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title, experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied', location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '', aiScore: SEED_ATS_SCORE, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree", applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000, skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: 'Potential Match', recommendation: 'Potential Match',
subScores: { skills: e.atsScore, experience: 80, education: 80, keywords: e.atsScore, location: 100, salary: 90 }, subScores: { skills: SEED_ATS_SCORE, experience: 80, education: 80, keywords: SEED_ATS_SCORE, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [], noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled', favorite: false, interviewStatus: 'Not Scheduled',
}, },
@ -472,6 +804,11 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
const isImported = (e) => imported.has(e.id) const isImported = (e) => imported.has(e.id)
function selectEmail(e) {
setSelectedId(e.id)
if (e.unread) markRead.mutate(e.id)
}
return ( return (
<> <>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
@ -479,8 +816,13 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
<span className="text-muted text-sm"> <span className="text-muted text-sm">
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`} {query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
</span> </span>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={sync}> <button
<Icon name="refresh" /> Sync Mailbox className="btn btn-secondary btn-sm"
style={{ marginLeft: 'auto' }}
disabled={sync.isPending}
onClick={() => { toast('Fetching from Outlook…', 'info'); sync.mutate() }}
>
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync Mailbox'}
</button> </button>
</div> </div>
@ -498,8 +840,8 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
{query.isSuccess && emails.map((e) => ( {query.isSuccess && emails.map((e) => (
<div <div
key={e.id} key={e.id}
className={`inbox-item${e.unread && selectedId !== e.id ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`} className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => setSelectedId(e.id)} onClick={() => selectEmail(e)}
> >
<Avatar name={e.from} /> <Avatar name={e.from} />
<div className="ii-main"> <div className="ii-main">
@ -512,7 +854,7 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
{isImported(e) && <Badge className="b-green">Imported</Badge>} {isImported(e) && <Badge className="b-green">Imported</Badge>}
</div> </div>
</div> </div>
<div className="ii-time">{fmtShort(e.when)}</div> <div className="ii-time">{e.when ? fmtShort(e.when) : '—'}</div>
</div> </div>
))} ))}
</div> </div>
@ -534,7 +876,9 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
<Avatar name={selected.from} /> <Avatar name={selected.from} />
<div> <div>
<div className="fw-600">{selected.from}</div> <div className="fw-600">{selected.from}</div>
<div className="cell-sub">{selected.fromEmail} · {fmtDate(selected.when)}</div> <div className="cell-sub">
{selected.fromEmail} · {selected.when ? fmtDate(selected.when) : 'Date unavailable'}
</div>
</div> </div>
</div> </div>
@ -551,7 +895,6 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
<div className="cell-sub">{selected.attachmentSize} · PDF</div> <div className="cell-sub">{selected.attachmentSize} · PDF</div>
</div> </div>
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<ScoreChip score={selected.atsScore} />
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}> <button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
<Icon name="eye" /> Preview <Icon name="eye" /> Preview
</button> </button>