From 8b4ebb1bb83d9faac5bd7c9e99fdb245fd66ac87 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 18:26:37 +0500 Subject: [PATCH 1/8] Background saving terminated --- Sync_read.md | 228 ++++++++++++++++++++++ Sync_write_request.md | 86 ++++++++ backend/.env.example | 4 + backend/inbox/app.py | 39 +++- backend/inbox/models.py | 34 +++- backend/inbox/plugins.py | 61 +++++- backend/inbox/sync_tasks.py | 82 ++++++++ backend/inbox/views.py | 32 ++- backend/taskiq_management/broker_setup.py | 8 +- docker-compose.yml | 3 +- frontend/src/api/inbox.js | 5 + frontend/src/screens/Inbox.jsx | 19 +- 12 files changed, 588 insertions(+), 13 deletions(-) create mode 100644 Sync_read.md create mode 100644 Sync_write_request.md create mode 100644 backend/inbox/sync_tasks.py diff --git a/Sync_read.md b/Sync_read.md new file mode 100644 index 0000000..6f24af0 --- /dev/null +++ b/Sync_read.md @@ -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). diff --git a/Sync_write_request.md b/Sync_write_request.md new file mode 100644 index 0000000..c906988 --- /dev/null +++ b/Sync_write_request.md @@ -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 +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` diff --git a/backend/.env.example b/backend/.env.example index 54f01dc..32f5440 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/inbox/app.py b/backend/inbox/app.py index ae0906f..a4d8d89 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -14,13 +14,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 +78,36 @@ 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)) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index a2e4a64..a23c4e0 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Optional -from sqlalchemy import Column, DateTime, func, or_ +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 @@ -211,3 +211,35 @@ class Inbox_Messages(SQLModel, table=True): statement = statement.where(cls._search_filter(search)) 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.""" + if not changes: + return 0 + read_ids=[c.get("id") for c in changes if c.get("id") and c.get("isRead")] + unread_ids=[c.get("id") for c in changes if c.get("id") and not c.get("isRead")] + touched=0 + if read_ids: + result=await session.execute( + update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True) + ) + touched+=result.rowcount or 0 + if unread_ids: + result=await session.execute( + update(cls).where(cls.message_id.in_(unread_ids)).values(message_read=False) + ) + touched+=result.rowcount or 0 + await session.commit() + return touched + + @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 diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 3c31eb4..5983537 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -1,16 +1,75 @@ -"""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()) diff --git a/backend/inbox/sync_tasks.py b/backend/inbox/sync_tasks.py new file mode 100644 index 0000000..42dc6ac --- /dev/null +++ b/backend/inbox/sync_tasks.py @@ -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() diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 9ae3313..e786a70 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -4,7 +4,11 @@ from fastapi import HTTPException 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.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,7 +21,7 @@ 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 service_email(self,top,skip): @@ -100,3 +104,27 @@ class Email: async def count_inbox_messages(self,search=None): 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) diff --git a/backend/taskiq_management/broker_setup.py b/backend/taskiq_management/broker_setup.py index 6313ab8..931634c 100644 --- a/backend/taskiq_management/broker_setup.py +++ b/backend/taskiq_management/broker_setup.py @@ -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)], +) diff --git a/docker-compose.yml b/docker-compose.yml index 40c623b..be849d0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index 8ad8e13..df813d3 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -15,3 +15,8 @@ export function listMessages() { 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' }) +} diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index fd29bde..dcf2003 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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' @@ -436,6 +436,14 @@ function EmailTab({ query, jobs, updateCandidates, toast }) { const selected = emails.find((e) => e.id === selectedId) const unread = emails.filter((e) => e.unread).length + const markRead = useMutation({ + mutationFn: (recordId) => inboxApi.markRead(recordId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + }, + onError: (err) => toast(friendlyAuthError(err, 'Could not mark message read.'), 'error'), + }) + async function sync() { toast('Fetching from Outlook…', 'info') const res = await qc.refetchQueries({ queryKey: qk.mailbox.messages() }) @@ -472,6 +480,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 ( <>
@@ -498,8 +511,8 @@ function EmailTab({ query, jobs, updateCandidates, toast }) { {query.isSuccess && emails.map((e) => (
setSelectedId(e.id)} + className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`} + onClick={() => selectEmail(e)} >
-- 2.40.1 From 52b76bb1cfa74ddc2b0ceb040d7f295a981c14b1 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 19:03:23 +0500 Subject: [PATCH 2/8] added all aplicants --- backend/inbox/app.py | 17 +++++++++ backend/inbox/models.py | 12 ++++++ backend/inbox/views.py | 10 +++++ frontend/src/screens/Inbox.jsx | 70 ++++++++++++++++++++++++++-------- 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/backend/inbox/app.py b/backend/inbox/app.py index a4d8d89..db68767 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -1,9 +1,11 @@ +from typing import Any from fastapi import APIRouter,Depends, Query from fastapi.responses import JSONResponse from fastapi import HTTPException from db_setup import get_session from sqlalchemy.ext.asyncio import AsyncSession from inbox.views import Email +import uuid from users.permissions import PermissionTag, require_permission from dotenv import load_dotenv load_dotenv() @@ -111,3 +113,18 @@ async def get_inbox_read_status( raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + +@router.get("/inbox/all-applications") +async def get_all_applications( + app_id:uuid.UUID|int=Query(None), + current_user:dict=Depends(require_permission(PermissionTag.INBOX_VIEW)), + session:AsyncSession=Depends(get_session), +): + try: + service=Email(session=session) + data=await service.get_all_applications(app_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)) \ No newline at end of file diff --git a/backend/inbox/models.py b/backend/inbox/models.py index a23c4e0..820e077 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1,6 +1,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Optional +from fastapi import HTTPException from sqlalchemy import Column, DateTime, func, or_, update from sqlalchemy.dialects.postgresql import JSONB @@ -82,6 +83,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, diff --git a/backend/inbox/views.py b/backend/inbox/views.py index e786a70..1fba17d 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -24,6 +24,16 @@ class Email: 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: diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index dcf2003..308d481 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -31,6 +31,26 @@ const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', /** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */ const NOW = new Date('2026-07-09T20:00') +/** + * 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 + +/** + * 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 +} + function resumeText(i) { return `${i.name.toUpperCase()} ${i.email} · ${i.phone} @@ -87,11 +107,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, })) }, @@ -444,13 +471,18 @@ function EmailTab({ query, jobs, updateCandidates, toast }) { onError: (err) => toast(friendlyAuthError(err, 'Could not mark message read.'), 'error'), }) - 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 - } + // 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] @@ -463,12 +495,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', }, @@ -492,8 +524,13 @@ function EmailTab({ query, jobs, updateCandidates, toast }) { {query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`} -
@@ -525,7 +562,7 @@ function EmailTab({ query, jobs, updateCandidates, toast }) { {isImported(e) && Imported}
-
{fmtShort(e.when)}
+
{e.when ? fmtShort(e.when) : '—'}
))} @@ -547,7 +584,9 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
{selected.from}
-
{selected.fromEmail} · {fmtDate(selected.when)}
+
+ {selected.fromEmail} · {selected.when ? fmtDate(selected.when) : 'Date unavailable'} +
@@ -564,7 +603,6 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
{selected.attachmentSize} · PDF
- -- 2.40.1 From 3ae716a8c4ebd1f799b945de64e35caf00b6bb36 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 19:17:16 +0500 Subject: [PATCH 3/8] frontend all aplications --- backend/inbox/app.py | 20 ++-- backend/inbox/serializers.py | 64 ++++++++++- backend/inbox/views.py | 12 +- frontend/src/api/inbox.js | 12 ++ frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/Inbox.jsx | 204 +++++++++++++++++++++++++-------- 6 files changed, 251 insertions(+), 62 deletions(-) diff --git a/backend/inbox/app.py b/backend/inbox/app.py index db68767..4162bcf 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -1,11 +1,9 @@ -from typing import Any from fastapi import APIRouter,Depends, Query from fastapi.responses import JSONResponse from fastapi import HTTPException from db_setup import get_session from sqlalchemy.ext.asyncio import AsyncSession from inbox.views import Email -import uuid from users.permissions import PermissionTag, require_permission from dotenv import load_dotenv load_dotenv() @@ -116,14 +114,22 @@ async def get_inbox_read_status( @router.get("/inbox/all-applications") async def get_all_applications( - app_id:uuid.UUID|int=Query(None), - current_user:dict=Depends(require_permission(PermissionTag.INBOX_VIEW)), - session:AsyncSession=Depends(get_session), + record_id: str | None = Query(None), + 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) - data=await service.get_all_applications(app_id) - return JSONResponse(content={"data":data,"total":1,"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: diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 1a1ec02..6893e10 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -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,12 +25,21 @@ 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), @@ -47,3 +66,36 @@ 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", + "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, + } diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 1fba17d..889f470 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -3,7 +3,7 @@ import httpx,os from fastapi import HTTPException from inbox.models import Inbox_Messages 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 ( EMAIL_API_TOKEN, fetch_message_read_status, @@ -91,6 +91,16 @@ class Email: item["files"]=files return item + async def get_all_applications(self,top,skip,search=None): + 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: diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index df813d3..d1c969a 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -11,6 +11,18 @@ 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 } = {}) { + return request('/inbox/all-applications', { + params: { search, top, skip, 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 } }) diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index eb9464b..1830e71 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -19,6 +19,7 @@ export const qk = { mailbox: { all: () => ['mailbox'], messages: () => ['mailbox', 'messages'], + applications: (p = {}) => ['mailbox', 'applications', p], }, // --- seed-backed buckets --- diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 308d481..bb697db 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -23,7 +23,8 @@ 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'] @@ -51,22 +52,20 @@ function parseDate(value) { return Number.isNaN(d.getTime()) ? null : d } -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}. - -EXPERIENCE -• ${pick(companies)} — Senior role (2021–Present) -• ${pick(companies)} — Associate (2018–2021) - -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 }) { @@ -83,7 +82,7 @@ function SourceChip({ item }) { 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') @@ -96,6 +95,48 @@ export default function Inbox() { const [assigning, setAssigning] = useState(null) const [noting, setNoting] = useState(null) + /** + * GET /inbox/all-applications. READ-ONLY: inbox_messages has no columns for + * processing state, duplicates, recruiter, phone, experience or an ATS score, + * so those arrive null and every mutating action on this tab is disabled until + * the endpoints exist. `processing` is derived from message_read alone, which + * is why the Imported / Processed / Rejected / Duplicates tabs read empty. + */ + const applicationsQuery = useQuery({ + queryKey: qk.mailbox.applications(), + queryFn: async () => { + const res = await inboxApi.listApplications() + 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', + 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), + } + }) + }, + enabled: tab !== 'Email', + }) + + const inbox = applicationsQuery.data ?? [] + const emailsQuery = useQuery({ queryKey: qk.mailbox.messages(), queryFn: async () => { @@ -151,9 +192,20 @@ export default function Inbox() { const selected = inbox.find((i) => i.id === selectedId) + // The one mutation this tab CAN persist. Note it needs INBOX_EDIT while the + // list only needs INBOX_VIEW, so a view-only user gets a 403 here. + const markApplicationRead = useMutation({ + mutationFn: (recordId) => inboxApi.markRead(recordId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + }, + onError: (err) => toast(friendlyAuthError(err, 'Could not mark application read.'), 'error'), + }) + 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) markApplicationRead.mutate(id) } function makeCandidate(item, job, cs) { @@ -250,7 +302,15 @@ export default function Inbox() {
- {list.length === 0 ? ( + {applicationsQuery.isPending && ( + Fetching applications from the server. + )} + {applicationsQuery.isError && ( + + {friendlyAuthError(applicationsQuery.error, 'Request failed')} + + )} + {applicationsQuery.isSuccess && list.length === 0 ? ( No applications in this view. ) : ( list.map((i) => ( @@ -271,8 +331,15 @@ export default function Inbox() {
{i.processing}
-
{relTime(Math.round((NOW - i.received) / 60000))}
-
+
+ {i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'} +
+ {/* 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 && ( +
+ )}
)) @@ -316,13 +383,17 @@ export default function Inbox() { } > -
{resumeText(previewing)}
+
+            {previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
+          
)} @@ -363,9 +434,17 @@ export default function Inbox() { ) } +/** 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, 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 (
@@ -381,43 +460,72 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
-
-
-
{i.atsScore}
+ {i.atsScore != null && ( +
+
+
{i.atsScore}
+
+
ATS Score
-
ATS Score
-
+ )}
-
Email
{i.email}
-
Phone
{i.phone}
-
Experience
{i.experience} years
-
Assigned Recruiter
{i.recruiter}
-
Received
{fmtDate(i.received)}
+
Email
{orDash(i.email)}
+
Phone
{orDash(i.phone)}
+
Experience
{orDash(i.experience, ' years')}
+
Assigned Recruiter
{orDash(i.recruiter)}
-
Match
-
{recLabel}
+
Received
+
{i.received ? fmtDate(i.received) : '—'}
+ {i.atsScore != null && ( +
+
Match
+
{recLabel}
+
+ )}
-
-
-
-
{i.attachment}
- + {i.hasAttachment && ( +
+
+
+
{orDash(i.attachment)}
+ +
+ {/* The real extracted PDF text (inbox_messages.resume_text), written + by the matching task. Empty until that task has run. */} +
+              {i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
+            
-
{resumeText(i)}
-
+ )}
- - - - - - + + + + +
-- 2.40.1 From 40e40a3864c72c7ae17b2cf7ea3873fb1962f9ea Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 19:58:53 +0500 Subject: [PATCH 4/8] isRead working --- backend/inbox/app.py | 5 + backend/inbox/enums.py | 11 +++ backend/inbox/models.py | 42 ++++---- backend/inbox/views.py | 32 ++++--- frontend/src/api/inbox.js | 7 +- frontend/src/screens/Inbox.jsx | 170 +++++++++++++++++++++------------ 6 files changed, 174 insertions(+), 93 deletions(-) create mode 100644 backend/inbox/enums.py diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 4162bcf..cdd4ed8 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -115,6 +115,7 @@ async def get_inbox_read_status( @router.get("/inbox/all-applications") async def get_all_applications( record_id: str | None = Query(None), + isread: bool = Query(default=True), search: str | None = Query(None), top: int | None = Query(None), skip: int = Query(0, ge=0), @@ -123,6 +124,10 @@ async def get_all_applications( ): try: service=Email(session=session) + 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}) diff --git a/backend/inbox/enums.py b/backend/inbox/enums.py new file mode 100644 index 0000000..992c23b --- /dev/null +++ b/backend/inbox/enums.py @@ -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" \ No newline at end of file diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 820e077..039a3ba 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -2,11 +2,11 @@ import uuid from datetime import datetime, timezone from typing import Any, Optional 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 @@ -50,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 @@ -195,7 +196,7 @@ 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 ): statement = select(cls).order_by(cls.message_received_time.desc()) if search: @@ -204,6 +205,8 @@ class Inbox_Messages(SQLModel, table=True): 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() @@ -217,33 +220,36 @@ 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): statement = select(func.count()).select_from(cls) if search: statement = statement.where(cls._search_filter(search)) + 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.""" + """[{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")] - unread_ids=[c.get("id") for c in changes if c.get("id") and not c.get("isRead")] - touched=0 - if read_ids: - result=await session.execute( - update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True) - ) - touched+=result.rowcount or 0 - if unread_ids: - result=await session.execute( - update(cls).where(cls.message_id.in_(unread_ids)).values(message_read=False) - ) - touched+=result.rowcount or 0 + 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 touched + return result.rowcount or 0 @classmethod async def mark_message_read(cls, session: AsyncSession, record_id): diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 889f470..62db458 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -24,15 +24,15 @@ class Email: 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 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: @@ -91,8 +91,11 @@ class Email: item["files"]=files return item - async def get_all_applications(self,top,skip,search=None): - messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search) + async def get_all_applications(self,top,skip,search=None,isread:bool=True): + if 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): @@ -122,8 +125,11 @@ 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): + if 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) diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index d1c969a..4f3acd9 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -17,9 +17,12 @@ export function listMessages() { * 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 } = {}) { +export function listApplications({ search, top, skip, recordId, isread } = {}) { return request('/inbox/all-applications', { - params: { search, top, skip, record_id: recordId }, + // `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. + params: { search, top, skip, record_id: recordId, isread }, }) } diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index bb697db..de76a1c 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -79,6 +79,80 @@ function SourceChip({ item }) { ) } +/** + * GET /inbox/all-applications -> the shape the application tabs render. + * + * READ-ONLY: inbox_messages has no columns for processing state, 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, which is why the Imported / + * Processed / Rejected / Duplicates tabs read empty. + */ +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', + 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() @@ -95,47 +169,30 @@ export default function Inbox() { const [assigning, setAssigning] = useState(null) const [noting, setNoting] = useState(null) - /** - * GET /inbox/all-applications. READ-ONLY: inbox_messages has no columns for - * processing state, duplicates, recruiter, phone, experience or an ATS score, - * so those arrive null and every mutating action on this tab is disabled until - * the endpoints exist. `processing` is derived from message_read alone, which - * is why the Imported / Processed / Rejected / Duplicates tabs read empty. - */ + // Only the Unread tab filters server-side; every other tab omits the param and + // the backend's default (true) means "no filter". + const isread = tab === 'Unread' ? false : undefined + const applicationsQuery = useQuery({ - queryKey: qk.mailbox.applications(), - queryFn: async () => { - const res = await inboxApi.listApplications() - 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', - 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), - } - }) - }, + queryKey: qk.mailbox.applications({ isread }), + queryFn: () => fetchApplications({ isread }), + 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 except Unread 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({ isread: undefined }), + queryFn: () => fetchApplications({}), enabled: tab !== 'Email', }) const inbox = applicationsQuery.data ?? [] + const allApplications = countsQuery.data ?? [] const emailsQuery = useQuery({ queryKey: qk.mailbox.messages(), @@ -167,20 +224,25 @@ export default function Inbox() { }) const counts = useMemo( + // Counted off the UNFILTERED set — `inbox` is server-filtered on the Unread + // tab, so counting it there would report the unread 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.processing === 'Processed').length, + Rejected: allApplications.filter((i) => i.processing === '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 is already filtered server-side; re-applying it client-side is what + // makes the optimistic mark-read drop the row from the list immediately + // instead of leaving it until the refetch lands. 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') @@ -192,20 +254,14 @@ export default function Inbox() { const selected = inbox.find((i) => i.id === selectedId) - // The one mutation this tab CAN persist. Note it needs INBOX_EDIT while the - // list only needs INBOX_VIEW, so a view-only user gets a 403 here. - const markApplicationRead = useMutation({ - mutationFn: (recordId) => inboxApi.markRead(recordId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.mailbox.all() }) - }, - onError: (err) => toast(friendlyAuthError(err, 'Could not mark application read.'), 'error'), - }) + // 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) const item = inbox.find((i) => i.id === id) - if (item?.unread) markApplicationRead.mutate(id) + if (item?.unread) markRead.mutate(id) } function makeCandidate(item, job, cs) { @@ -571,13 +627,7 @@ function EmailTab({ query, jobs, updateCandidates, toast }) { const selected = emails.find((e) => e.id === selectedId) const unread = emails.filter((e) => e.unread).length - const markRead = useMutation({ - mutationFn: (recordId) => inboxApi.markRead(recordId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.mailbox.all() }) - }, - onError: (err) => toast(friendlyAuthError(err, 'Could not mark message read.'), 'error'), - }) + 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 -- 2.40.1 From 4bd02cefb0336198b35f40f35cbee9d990dde4c8 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 20:16:54 +0500 Subject: [PATCH 5/8] email response handled --- backend/inbox/serializers.py | 1 + frontend/src/api/inbox.js | 12 ++++ frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/Inbox.jsx | 119 +++++++++++++++++++++++++++++++-- 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 6893e10..f1c5963 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -44,6 +44,7 @@ def serialize_message(message: Inbox_Messages) -> dict: 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, diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index 4f3acd9..5450dea 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -26,6 +26,18 @@ export function listApplications({ search, top, skip, recordId, isread } = {}) { }) } +/** + * 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 } }) diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 1830e71..2a4d330 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -20,6 +20,7 @@ export const qk = { all: () => ['mailbox'], messages: () => ['mailbox', 'messages'], applications: (p = {}) => ['mailbox', 'applications', p], + message: (id) => ['mailbox', 'message', id], }, // --- seed-backed buckets --- diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index de76a1c..b00f68e 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -79,6 +79,75 @@ 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 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

a

b

would collapse to + // "ab". Turn breaks and closing block tags into newlines BEFORE parsing. + const withBreaks = raw + .replace(//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= -> 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. * @@ -252,7 +321,19 @@ export default function Inbox() { 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. @@ -410,9 +491,16 @@ export default function Inbox() { Choose an item from the list to view details and take action.
+ ) : detailQuery.isError ? ( +
+ + {friendlyAuthError(detailQuery.error, 'Request failed')} + +
) : ( setPreviewing(selected)} onImport={() => importItem(selected)} onParse={() => parseResume(selected)} @@ -495,7 +583,7 @@ function orDash(value, suffix = '') { return value == null || value === '' ? '—' : `${value}${suffix}` } -function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) { +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 @@ -513,7 +601,8 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on {i.processing}{' '} {i.resumeStatus} - + {' '} + {loading && Loading details…}
{i.atsScore != null && ( @@ -535,6 +624,13 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
Received
{i.received ? fmtDate(i.received) : '—'}
+ {/* Only present once GET /inbox/fetch?record_id= has resolved — the list + endpoint carries none of these. */} + {i.sentAt && ( +
Sent
{fmtDate(i.sentAt)}
+ )} + {i.cc &&
CC
{i.cc}
} + {i.bcc &&
BCC
{i.bcc}
} {i.atsScore != null && (
Match
@@ -543,11 +639,26 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on )}
+ {/* 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 && ( +
+ {i.body || This email has no message body.} +
+ )} + {i.hasAttachment && (
-
{orDash(i.attachment)}
+
+ {orDash(i.attachment)} + {i.files?.[0]?.size != null && ( + · {Math.round(i.files[0].size / 1024)} KB + )} +
{/* The real extracted PDF text (inbox_messages.resume_text), written -- 2.40.1 From 9f6926b1e150eda675f314e6af6552717b42eba6 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 20:35:27 +0500 Subject: [PATCH 6/8] PROCESSED ADN REJECTED TAB TOO with their own specific conditions --- backend/inbox/app.py | 5 +++++ backend/inbox/models.py | 8 +++++++- backend/inbox/plugins.py | 14 +++++++++++--- backend/inbox/views.py | 6 +++++- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/backend/inbox/app.py b/backend/inbox/app.py index cdd4ed8..8653268 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -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 @@ -115,6 +116,7 @@ async def get_inbox_read_status( @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), @@ -124,6 +126,9 @@ async def get_all_applications( ): 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) if isread==False: items=await service.get_all_applications(top, skip, search, isread=False) total=await service.count_inbox_messages(search, isread=False) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 039a3ba..f0074a4 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -196,15 +196,21 @@ class Inbox_Messages(SQLModel, table=True): @classmethod async def get_inbox_messages( - cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True + 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) diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 5983537..45294fe 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -71,11 +71,19 @@ async def fetch_message_read_status(message_id, token=None): 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 diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 62db458..6b6925c 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -1,6 +1,7 @@ import logging import httpx,os from fastapi import HTTPException +from backend.inbox.enums import Candidate_application_Status from inbox.models import Inbox_Messages from inbox.file_decoder import decode_attachment from inbox.serializers import serialize_application, serialize_message @@ -91,7 +92,10 @@ class Email: item["files"]=files return item - async def get_all_applications(self,top,skip,search=None,isread:bool=True): + 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) if isread==False: messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread) else: -- 2.40.1 From 36be406f92c07c986d69517ffa5e96c7dd1de511 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 20:49:00 +0500 Subject: [PATCH 7/8] WIRED WITH FRONTEND --- backend/inbox/app.py | 2 + backend/inbox/models.py | 4 +- backend/inbox/serializers.py | 1 + backend/inbox/views.py | 11 +++--- frontend/src/api/inbox.js | 6 ++- frontend/src/screens/Inbox.jsx | 69 ++++++++++++++++++++++------------ 6 files changed, 62 insertions(+), 31 deletions(-) diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 8653268..dce4c6c 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -126,9 +126,11 @@ async def get_all_applications( ): 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) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index f0074a4..75b02a6 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -226,10 +226,12 @@ class Inbox_Messages(SQLModel, table=True): return result.scalars().first() @classmethod - async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True): + 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) diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index f1c5963..fcab277 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -90,6 +90,7 @@ def serialize_application(message: Inbox_Messages) -> dict: "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, diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 6b6925c..3855cc0 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -1,7 +1,7 @@ import logging import httpx,os from fastapi import HTTPException -from backend.inbox.enums import Candidate_application_Status +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_application, serialize_message @@ -93,10 +93,9 @@ class Email: 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) - if isread==False: + 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) @@ -129,8 +128,10 @@ class Email: task_ids.append(task.task_id) return task_ids - async def count_inbox_messages(self,search=None,isread:bool=True): - if isread==False: + 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) diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index 5450dea..beee5c6 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -17,12 +17,14 @@ export function listMessages() { * 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 } = {}) { +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. - params: { search, top, skip, record_id: recordId, isread }, + // 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 }, }) } diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index b00f68e..89772ea 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -29,6 +29,17 @@ import { 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') @@ -151,11 +162,12 @@ async function fetchMessageDetail(recordId) { /** * GET /inbox/all-applications -> the shape the application tabs render. * - * READ-ONLY: inbox_messages has no columns for processing state, 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, which is why the Imported / - * Processed / Rejected / Duplicates tabs read empty. + * 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) @@ -173,6 +185,7 @@ async function fetchApplications(params) { 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), @@ -238,24 +251,25 @@ export default function Inbox() { const [assigning, setAssigning] = useState(null) const [noting, setNoting] = useState(null) - // Only the Unread tab filters server-side; every other tab omits the param and - // the backend's default (true) means "no filter". - const isread = tab === 'Unread' ? false : undefined + // 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({ isread }), - queryFn: () => fetchApplications({ isread }), + 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 except Unread this resolves to the SAME query - * key as the list above, so React Query serves both from one request. + * 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({ isread: undefined }), + queryKey: qk.mailbox.applications({}), queryFn: () => fetchApplications({}), enabled: tab !== 'Email', }) @@ -293,14 +307,15 @@ export default function Inbox() { }) const counts = useMemo( - // Counted off the UNFILTERED set — `inbox` is server-filtered on the Unread - // tab, so counting it there would report the unread total for every badge. + // 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': allApplications.length, Unread: allApplications.filter((i) => i.processing === 'Unread').length, Imported: allApplications.filter((i) => i.processing === 'Imported').length, - Processed: allApplications.filter((i) => i.processing === 'Processed').length, - Rejected: allApplications.filter((i) => i.processing === 'Rejected').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, }), @@ -309,13 +324,13 @@ export default function Inbox() { const list = useMemo(() => { let l = inbox - // Unread is already filtered server-side; re-applying it client-side is what - // makes the optimistic mark-read drop the row from the list immediately - // instead of leaving it until the refetch lands. + // 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 @@ -465,7 +480,12 @@ export default function Inbox() { )}
{i.position}
-
{i.processing}
+
+ {i.processing} + {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( + {i.applicationStatus} + )} +
@@ -599,6 +619,9 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onA
{i.position}
{i.processing}{' '} + {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( + <>{i.applicationStatus}{' '} + )} {i.resumeStatus} {' '} -- 2.40.1 From 09fdb39109bfa5981b2778b29128b1181f1c3c2f Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 10 Aug 2026 14:06:54 +0500 Subject: [PATCH 8/8] IS READ UPDATE --- backend/job/app.py | 18 +++++++++++++++- backend/job/candidate/views.py | 38 +++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/backend/job/app.py b/backend/job/app.py index 203c679..ce910a2 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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, diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 47caba5..5c09149 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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,7 +28,41 @@ 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) - # get_file= \ No newline at end of file + # get_file= -- 2.40.1