Merge pull request 'Is_READ_UPDATE' (#5) from Is_READ_UPDATE into main

Reviewed-on: #5
pull/6/head^2
ahmed.mujtaba 2026-08-10 09:08:02 +00:00
commit 6c3156a43e
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_NAME=
EMAIL_URL=
EMAIL_API_TOKEN=
EMAIL_SYNC_FOLDER=inbox
EMAIL_SYNC_SINCE=
EMAIL_SYNC_CRON=* * * * *
JWT_SECRET_KEY=
JWT_ALGORITHM=HS256

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter,Depends, Query
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from inbox.enums import Candidate_application_Status
from sqlalchemy.ext.asyncio import AsyncSession
from inbox.views import Email
from users.permissions import PermissionTag, require_permission
@ -14,13 +15,13 @@ router = APIRouter()
async def fetch_email(
top:int=Query(100),
skip:int=Query(0,ge=0),
token=Query(...),
token: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
try:
if not token:
raise HTTPException(status_code=401,detail="Unauthorized")
service=Email(session=session,token=token)
if not service.token:
raise HTTPException(status_code=401,detail="Unauthorized")
data=await service.service_email(top,skip)
value=data.get("value")
items_lst=[]
@ -78,3 +79,70 @@ async def rematch_inbox(
raise
except Exception as 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
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import Column, DateTime, func, or_
from fastapi import HTTPException
from inbox.enums import Candidate_application_Status
from sqlalchemy import Column, DateTime, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB
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
@ -49,6 +50,7 @@ class Inbox_Messages(SQLModel, table=True):
full_email_response: dict[str, Any] | None = Field(
default=None, sa_column=Column(JSONB)
)
application_status: Candidate_application_Status = Field(default=Candidate_application_Status.CLOSED)
message_subject: str
message_body: str
message_sent_time: str
@ -82,6 +84,17 @@ class Inbox_Messages(SQLModel, table=True):
return email_data.get("bodyPreview") or ""
@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(
cls,
session: AsyncSession,
@ -183,15 +196,23 @@ class Inbox_Messages(SQLModel, table=True):
@classmethod
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())
if 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:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement)
return result.scalars().all()
@ -205,9 +226,46 @@ class Inbox_Messages(SQLModel, table=True):
return result.scalars().first()
@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)
if 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)
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
import base64
import os
from pathlib import Path
from urllib.parse import quote
import httpx
from dotenv import load_dotenv
from inbox.models import Inbox_Messages
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"
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:
"""Prefer stored path; fall back to basename under decoded_attachments."""
path=Path(path_str.strip())
"""Prefer stored path; fall back to basename under decoded_attachments.
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():
return path
fallback=_ATTACHMENTS_DIR/path.name
basename=Path(raw.replace("\\","/")).name
fallback=_ATTACHMENTS_DIR/basename
if fallback.is_file():
return fallback
return path

View File

@ -2,9 +2,19 @@ from pathlib import Path
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
full = message.full_email_response
if isinstance(full, dict):
@ -15,16 +25,26 @@ def serialize_message(message: Inbox_Messages) -> dict:
name = email_address.get("name")
if name:
sender_name = name
return sender_name
attachment_name = None
def _attachment_name(message: Inbox_Messages) -> str | None:
if message.file_name:
attachment_name = message.file_name.split(",")[0].strip() or None
elif message.file_path:
attachment_name = Path(message.file_path.split(",")[0].strip()).name or None
return message.file_name.split(",")[0].strip() or None
if message.file_path:
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 {
"id": str(message.id),
"message_id": str(message.message_id) if message.message_id else None,
"full_email_response": message.full_email_response,
"sender_name": sender_name,
"fromEmail": message.message_from,
"subject": message.message_subject,
@ -47,3 +67,37 @@ def serialize_message(message: Inbox_Messages) -> dict:
"match_error": message.match_error,
"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 httpx,os
from fastapi import HTTPException
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox_Messages
from inbox.file_decoder import decode_attachment
from inbox.serializers import serialize_message
from inbox.plugins import load_message_files
from inbox.serializers import serialize_application, serialize_message
from inbox.plugins import (
EMAIL_API_TOKEN,
fetch_message_read_status,
load_message_files,
)
from dotenv import load_dotenv
load_dotenv()
from sqlalchemy.ext.asyncio import AsyncSession
@ -17,9 +22,19 @@ class Email:
def __init__(self,session:AsyncSession,token=None):
self.session=session
self.get_url=os.getenv("EMAIL_URL")
self.token=token
self.token=token or EMAIL_API_TOKEN
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 with httpx.AsyncClient() as client:
try:
@ -77,6 +92,21 @@ class Email:
item["files"]=files
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):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:
@ -98,5 +128,34 @@ class Email:
task_ids.append(task.task_id)
return task_ids
async def count_inbox_messages(self,search=None):
return await Inbox_Messages.count_inbox_messages(self.session,search)
async def count_inbox_messages(self,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:
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 import HTTPException
from db_setup import get_session
@ -49,6 +49,22 @@ async def cv_upload(
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")
async def post_job(
payload: JobPostCreate,

View File

@ -1,8 +1,10 @@
from sqlalchemy.ext.asyncio import AsyncSession
import os,logging,io
from datetime import datetime,timezone
from fastapi import HTTPException
from pypdf import PdfReader
from sqlalchemy import select
from inbox.models import Inbox_Messages
from job.candidate.plugins import normalize_spaced_text
class FileRead:
@ -26,6 +28,40 @@ class FileRead:
raise
except Exception as 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):
# try:
# get_subject=Inbox_Messages.candidate_x_inbox(self.session,self.candidate_id)

View File

@ -1,6 +1,6 @@
"""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
"""
@ -11,6 +11,7 @@ import os
from dotenv import load_dotenv
from taskiq import TaskiqScheduler
from taskiq.middlewares import SmartRetryMiddleware
from taskiq.schedule_sources import LabelScheduleSource
from taskiq_redis import (
ListRedisScheduleSource,
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",
"taskiq_management.broker_setup:broker",
"inbox.tasks",
"inbox.sync_tasks",
"taskiq_management.tasks",
"--workers",
"1",
@ -48,7 +49,7 @@ services:
build:
context: ./backend
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:
- ./backend/.env
environment:

View File

@ -11,7 +11,41 @@ export function listMessages() {
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. */
export function syncMailbox({ 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: {
all: () => ['mailbox'],
messages: () => ['mailbox', 'messages'],
applications: (p = {}) => ['mailbox', 'applications', p],
message: (id) => ['mailbox', 'message', id],
},
// --- seed-backed buckets ---

View File

@ -11,7 +11,7 @@
import { useMemo, useState } from 'react'
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 { Tabs } from '../ui/Tabs'
@ -23,30 +23,60 @@ import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox'
import {
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'
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. */
const NOW = new Date('2026-07-09T20:00')
function resumeText(i) {
return `${i.name.toUpperCase()}
${i.email} · ${i.phone}
${'—'.repeat(30)}
PROFESSIONAL SUMMARY
${i.experience} years of experience. Applied for ${i.position} via ${i.source}.
/**
* The seed candidate record importEmail() writes needs a number. The agent
* returns a verdict, not a score, so there is nothing on the wire to use
* named here so the fabricated value is visible at its point of use instead of
* arriving disguised as a server field on every message.
*/
const SEED_ATS_SCORE = 70
EXPERIENCE
${pick(companies)} Senior role (2021Present)
${pick(companies)} Associate (20182021)
/**
* message_received_time / message_sent_time are plain string columns
* (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
SKILLS
${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
/**
* `source` arrives as the raw To address, because that is where the board tag
* lands careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
* everything but letters from both sides so "Employee Referral" still matches
* "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 }) {
@ -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() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
const qc = useQueryClient()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
@ -76,6 +251,32 @@ export default function Inbox() {
const [assigning, setAssigning] = 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({
queryKey: qk.mailbox.messages(),
queryFn: async () => {
@ -87,11 +288,18 @@ export default function Inbox() {
fromEmail: row.fromEmail || '',
subject: row.subject || '',
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),
attachment: row.attachment_name || 'Resume.pdf',
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,
}))
},
@ -99,34 +307,57 @@ export default function Inbox() {
})
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,
Unread: inbox.filter((i) => i.processing === 'Unread').length,
Imported: inbox.filter((i) => i.processing === 'Imported').length,
Processed: inbox.filter((i) => i.processing === 'Processed').length,
Rejected: inbox.filter((i) => i.processing === 'Rejected').length,
Duplicates: inbox.filter((i) => i.duplicate).length,
'All Applications': allApplications.length,
Unread: allApplications.filter((i) => i.processing === 'Unread').length,
Imported: allApplications.filter((i) => i.processing === 'Imported').length,
Processed: allApplications.filter((i) => i.applicationStatus === 'PROCESS').length,
Rejected: allApplications.filter((i) => i.applicationStatus === 'REJECTED').length,
Duplicates: allApplications.filter((i) => i.duplicate).length,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}),
[inbox, emailsQuery.data],
[allApplications, emailsQuery.data],
)
const list = useMemo(() => {
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')
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 === 'Rejected') l = l.filter((i) => i.processing === 'Rejected')
else if (tab === 'Processed') l = l.filter((i) => i.applicationStatus === 'PROCESS')
else if (tab === 'Rejected') l = l.filter((i) => i.applicationStatus === 'REJECTED')
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()))
return l
}, [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) {
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) {
@ -223,7 +454,15 @@ export default function Inbox() {
</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>
) : (
list.map((i) => (
@ -241,11 +480,23 @@ export default function Inbox() {
)}
</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 style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div>
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
<div className="ii-time">
{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>
))
@ -260,9 +511,16 @@ export default function Inbox() {
Choose an item from the list to view details and take action.
</EmptyState>
</div>
) : detailQuery.isError ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Couldnt load this application">
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
) : (
<ApplicationDetail
item={selected}
loading={detailQuery.isPending}
onPreview={() => setPreviewing(selected)}
onImport={() => importItem(selected)}
onParse={() => parseResume(selected)}
@ -289,13 +547,17 @@ export default function Inbox() {
<button
className="btn btn-primary"
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
disabled
title="Needs a backend endpoint — not implemented yet"
>
<Icon name="user-plus" /> Import Candidate
</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>
)}
@ -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 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 (
<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-tags" style={{ marginTop: 8 }}>
<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'}>
{i.resumeStatus}
</Badge>
</Badge>{' '}
{loading && <span className="cell-sub">Loading details</span>}
</div>
</div>
<div style={{ textAlign: 'center' }}>
<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>
{i.atsScore != null && (
<div style={{ textAlign: 'center' }}>
<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 className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>
)}
</div>
<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">Phone</div><div className="iv">{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">Assigned Recruiter</div><div className="iv">{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="il">Email</div><div className="iv">{orDash(i.email)}</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">{orDash(i.experience, ' years')}</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">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
<div className="il">Received</div>
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</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 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" /> {i.attachment}</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
<pre className="resume-thumb">{resumeText(i)}</pre>
{/* Body arrives only from GET /inbox/fetch?record_id= the list endpoint
does not carry it. Already run through htmlToText, and still rendered as
TEXT: inbound mail is attacker-supplied. A body that is only an empty
HTML skeleton flattens to '' and the block is skipped entirely. */}
{!loading && (
<div className="email-preview" style={{ marginBottom: 20 }}>
{i.body || <span className="text-muted">This email has no message body.</span>}
</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' }}>
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button>
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button>
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button>
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button>
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button>
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}>
<button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
<Icon name="user-plus" /> Import Candidate
</button>
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
<Icon name="sparkles" /> Parse Resume
</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
</button>
</div>
@ -436,13 +761,20 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length
async function sync() {
toast('Fetching from Outlook…', 'info')
const res = await qc.refetchQueries({ queryKey: qk.mailbox.messages() })
if (query.isError) toast('Sync failed', 'error')
else toast('Mailbox synced', 'success')
return res
}
const markRead = useMarkRead(toast)
// Refetching the list alone only re-reads rows already in our DB. GET
// /email/fetch is the Graph proxy pull that inserts new mail and enqueues the
// matching agent, so it has to run FIRST then the list is invalidated to
// 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) {
const job = jobs[0]
@ -455,12 +787,12 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
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",
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
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: [],
favorite: false, interviewStatus: 'Not Scheduled',
},
@ -472,6 +804,11 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
const isImported = (e) => imported.has(e.id)
function selectEmail(e) {
setSelectedId(e.id)
if (e.unread) markRead.mutate(e.id)
}
return (
<>
<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">
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
</span>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 'auto' }} onClick={sync}>
<Icon name="refresh" /> Sync Mailbox
<button
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>
</div>
@ -498,8 +840,8 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
{query.isSuccess && emails.map((e) => (
<div
key={e.id}
className={`inbox-item${e.unread && selectedId !== e.id ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => setSelectedId(e.id)}
className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => selectEmail(e)}
>
<Avatar name={e.from} />
<div className="ii-main">
@ -512,7 +854,7 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
{isImported(e) && <Badge className="b-green">Imported</Badge>}
</div>
</div>
<div className="ii-time">{fmtShort(e.when)}</div>
<div className="ii-time">{e.when ? fmtShort(e.when) : '—'}</div>
</div>
))}
</div>
@ -534,7 +876,9 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
<Avatar name={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>
@ -551,7 +895,6 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
</div>
<div className="flex items-center gap-8">
<ScoreChip score={selected.atsScore} />
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
<Icon name="eye" /> Preview
</button>