Merge pull request 'Add mailbox sync functionality and related API endpoints' (#21) from Add_History into main
Deploy to S3 / deploy (push) Successful in 31s Details

Reviewed-on: #21
Add_History^2
ahmed.mujtaba 2026-08-20 12:56:04 +00:00
commit 64b7c9dde7
10 changed files with 450 additions and 418 deletions

View File

@ -47,6 +47,7 @@ class ReadAllBody(BaseModel):
isread: bool = True isread: bool = True
application_status: Candidate_application_Status = Candidate_application_Status.CLOSED application_status: Candidate_application_Status = Candidate_application_Status.CLOSED
assigned: bool | None = None assigned: bool | None = None
is_duplicate: bool | None = None
class TriageOverrideBody(BaseModel): class TriageOverrideBody(BaseModel):
@ -73,36 +74,67 @@ async def fetch_email(
token: str | None = Query(None), token: str | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Synchronous one-shot sync — kept for scripts/compat. UI uses POST /email/sync."""
try: try:
service=Email(session=session,token=token) service=Email(session=session,token=token)
if not service.token: if not service.token:
raise HTTPException(status_code=401,detail="Unauthorized") raise HTTPException(status_code=401,detail="Unauthorized")
data=await service.service_email(top,skip) summary=await service.run_mailbox_sync_page(top=top,skip=skip,test_on=test_on)
value=data.get("value")
items_lst=[]
# Classify the whole page first, bounded-parallel, then replay it in upstream
# order: the inserts stay serial on the one request session and pending_match_ids
# keeps the sequence it has today.
decisions=await service.triage_round([item.get("id") for item in value])
for item in value:
message_id=item.get("id")
service_per_email=await service.get_email_by_id(message_id,test_on,decision=decisions.get(str(message_id)))
items_lst.append({"message_id":message_id,"email_contents":service_per_email})
if service.pending_match_ids:
await service.enqueue_matching(list(service.pending_match_ids),force=False)
skipped=len(service.skipped_message_ids)
triage={"ingested":len(items_lst)-skipped,"skipped":skipped,"errors":len(service.triage_errors)}
account_setup=[]
if test_on: if test_on:
return JSONResponse(content={"data":items_lst,"triage":triage,"status_code":200}) return JSONResponse(content={
if service.pending_confirmation_emails: "data":summary["entries"],
account_setup=await service.send_account_setup(list(service.pending_confirmation_emails)) "triage":summary["triage"],
"status_code":200,
})
return JSONResponse(content={
"data":summary["entries"],
"account_setup":summary["account_setup"],
"triage":summary["triage"],
"status_code":200,
})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
return JSONResponse(content={"data":items_lst,"account_setup":account_setup,"triage":triage,"status_code":200})
@router.post("/email/sync")
async def start_email_sync(
top:int=Query(100),
skip:int=Query(0,ge=0),
test_on: bool = Query(True),
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
session: AsyncSession = Depends(get_session),
):
"""Enqueue mailbox sync on the dedicated mailbox_sync Taskiq queue.
Returns immediately with a run id. Poll GET /email/sync/fetch until completed.
Closing the browser does not cancel the worker.
"""
try:
service=Email(session=session)
if not service.token:
raise HTTPException(status_code=401,detail="Unauthorized")
data=await service.start_mailbox_sync(
current_user=current_user,top=top,skip=skip,test_on=test_on,
)
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("/email/sync/fetch")
async def fetch_email_sync(
run_id: str | None = 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_mailbox_sync(run_id=run_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -218,6 +250,7 @@ async def mark_all_inbox_read(
isread=payload.isread, isread=payload.isread,
application_status=payload.application_status, application_status=payload.application_status,
assigned=payload.assigned, assigned=payload.assigned,
is_duplicate=payload.is_duplicate,
) )
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
except HTTPException: except HTTPException:
@ -248,6 +281,7 @@ async def get_all_applications(
application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED), application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED),
isread: bool = Query(default=True), isread: bool = Query(default=True),
assigned: bool | None = Query(default=None), assigned: bool | None = Query(default=None),
is_duplicate: bool | None = Query(default=None),
search: str | None = Query(None), search: str | None = Query(None),
top: int | None = Query(None), top: int | None = Query(None),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
@ -258,19 +292,19 @@ async def get_all_applications(
service=Email(session=session) service=Email(session=session)
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED): if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned) items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate)
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned) total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
if isread==False: if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned) items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned) total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
if record_id: if record_id:
item=await service.get_application_by_id(record_id) item=await service.get_application_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200}) return JSONResponse(content={"data":item,"total":1,"status_code":200})
items=await service.get_all_applications(top,skip,search,assigned=assigned) items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate)
total=await service.count_inbox_messages(search,assigned=assigned) total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate)
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -353,6 +353,13 @@ class Inbox_Messages(SQLModel, table=True):
is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"}) is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"})
source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id") source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id")
processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"}) processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"})
# Ingestion time — list screens order by this (newest first). server_default
# backfills existing rows on the ALTER so NOT NULL is safe on a populated table.
created_at: datetime = Field(
default_factory=_now,
sa_type=DateTime(timezone=True),
sa_column_kwargs={"server_default": "now()"},
)
inbox: list[Inbox] = Relationship(back_populates="messages") inbox: list[Inbox] = Relationship(back_populates="messages")
@staticmethod @staticmethod
@ -577,6 +584,7 @@ class Inbox_Messages(SQLModel, table=True):
cls, statement, search: str | None=None, isread: bool=True, cls, statement, search: str | None=None, isread: bool=True,
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned: bool | None=None, assigned: bool | None=None,
is_duplicate: bool | None=None,
): ):
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE. """The one WHERE chain shared by the list, the count and the bulk read UPDATE.
@ -595,15 +603,21 @@ class Inbox_Messages(SQLModel, table=True):
statement = statement.where(cls.assigned_job_post_id.is_(None)) statement = statement.where(cls.assigned_job_post_id.is_(None))
if isread==False: if isread==False:
statement = statement.where(cls.message_read==False) statement = statement.where(cls.message_read==False)
if is_duplicate is True:
statement = statement.where(cls.is_duplicate==True) # noqa: E712
elif is_duplicate is False:
statement = statement.where(cls.is_duplicate==False) # noqa: E712
return statement return statement
@classmethod @classmethod
async def get_inbox_messages( async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None
): ):
# Page size is the caller's `top` (Inbox sends 10); `skip` is (page-1)*top
# so page 1 -> 0..9, page 2 -> 10..19. Newest first via created_at.
statement = cls._apply_filters( statement = cls._apply_filters(
select(cls).order_by(cls.message_received_time.desc()), select(cls).order_by(cls.created_at.desc()),
search, isread, application_status, assigned, search, isread, application_status, assigned, is_duplicate,
) )
if skip: if skip:
statement = statement.offset(skip) statement = statement.offset(skip)
@ -654,10 +668,10 @@ class Inbox_Messages(SQLModel, table=True):
return row return row
@classmethod @classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None): async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None):
statement = cls._apply_filters( statement = cls._apply_filters(
select(func.count()).select_from(cls), select(func.count()).select_from(cls),
search, isread, application_status, assigned, search, isread, application_status, assigned, is_duplicate,
) )
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar_one() return result.scalar_one()
@ -760,6 +774,7 @@ class Inbox_Messages(SQLModel, table=True):
cls, session: AsyncSession, read: bool, search: str | None=None, isread: bool=True, cls, session: AsyncSession, read: bool, search: str | None=None, isread: bool=True,
application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned: bool | None=None, assigned: bool | None=None,
is_duplicate: bool | None=None,
) -> int: ) -> int:
"""Mark every row matching a list filter. Returns rows actually CHANGED. """Mark every row matching a list filter. Returns rows actually CHANGED.
@ -768,7 +783,7 @@ class Inbox_Messages(SQLModel, table=True):
was already read. It also keeps read_overridden_at off rows nobody decided was already read. It also keeps read_overridden_at off rows nobody decided
anything about, so the Outlook sweep keeps its reach over untouched mail. anything about, so the Outlook sweep keeps its reach over untouched mail.
""" """
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned) statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate)
statement=statement.where(cls.message_read!=bool(read)) statement=statement.where(cls.message_read!=bool(read))
result=await session.execute( result=await session.execute(
statement.values(message_read=bool(read),read_overridden_at=_now()) statement.values(message_read=bool(read),read_overridden_at=_now())
@ -782,8 +797,8 @@ class Inbox_Messages(SQLModel, table=True):
func.count().label("all_count"), func.count().label("all_count"),
func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712 func.coalesce(func.sum(case((cls.message_read == False, 1), else_=0)), 0).label("unread"), # noqa: E712
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"), func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"), func.coalesce(func.sum(case((cls.application_status == Candidate_application_Status.PROCESS, 1), else_=0)), 0).label("processed"),
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"), func.coalesce(func.sum(case((cls.application_status == Candidate_application_Status.REJECTED, 1), else_=0)), 0).label("rejected"),
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"),
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"),
@ -1148,3 +1163,76 @@ class AtsResults(SQLModel, table=True):
session.add(link) session.add(link)
await session.commit() await session.commit()
return row return row
class MailboxSyncRun(SQLModel, table=True):
"""One Outlook mailbox sync job — survives tab close because work runs in Taskiq.
The Sync button enqueues a run and returns immediately. The UI polls this row for
status / per-message entries / triage totals. Closing the browser does not cancel
the worker.
"""
__tablename__ = "mailbox_sync_runs"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
status: str = Field(default="queued", index=True) # queued|running|completed|failed
task_id: str | None = Field(default=None)
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
top: int = Field(default=100)
skip: int = Field(default=0)
test_on: bool = Field(default=True)
triage: dict | None = Field(default=None, sa_column=Column(JSONB))
entries: list | None = Field(default=None, sa_column=Column(JSONB))
error: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
started_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
finished_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
return None
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def get_active(cls, session: AsyncSession):
result = await session.execute(
select(cls)
.where(cls.status.in_(("queued", "running")))
.order_by(cls.created_at.desc())
)
return result.scalars().first()
@classmethod
async def insert_run(cls, session: AsyncSession, fields: dict, *, commit: bool = True):
row = cls(**fields)
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row
@classmethod
async def update_run(cls, session: AsyncSession, record_id, fields: dict, *, commit: bool = True):
row = await cls.get_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
session.add(row)
if commit:
await session.commit()
await session.refresh(row)
return row

View File

@ -157,3 +157,21 @@ def serialize_triage(row: Inbox_Message_Triage) -> dict:
"overridden_at": row.overridden_at.isoformat() if row.overridden_at else None, "overridden_at": row.overridden_at.isoformat() if row.overridden_at else None,
"classified_at": row.classified_at.isoformat() if row.classified_at else None, "classified_at": row.classified_at.isoformat() if row.classified_at else None,
} }
def serialize_mailbox_sync_run(row) -> dict:
return {
"id": str(row.id),
"status": row.status,
"task_id": row.task_id,
"created_by": str(row.created_by) if row.created_by else None,
"top": row.top,
"skip": row.skip,
"test_on": row.test_on,
"triage": row.triage,
"entries": row.entries or [],
"error": row.error,
"created_at": row.created_at.isoformat() if row.created_at else None,
"started_at": row.started_at.isoformat() if row.started_at else None,
"finished_at": row.finished_at.isoformat() if row.finished_at else None,
}

View File

@ -4,9 +4,9 @@ import uuid
import httpx,os import httpx,os
from fastapi import HTTPException from fastapi import HTTPException
from inbox.enums import Candidate_application_Status from inbox.enums import Candidate_application_Status
from inbox.models import Inbox_Messages,Inbox_Message_Triage from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun
from inbox.file_decoder import decode_attachment from inbox.file_decoder import decode_attachment
from inbox.serializers import serialize_application, serialize_message, serialize_triage from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run
from inbox.plugins import ( from inbox.plugins import (
EMAIL_API_TOKEN, EMAIL_API_TOKEN,
fetch_message_read_status, fetch_message_read_status,
@ -270,13 +270,13 @@ class Email:
item["assigned_job_post"]=None item["assigned_job_post"]=None
return item return item
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None): async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None):
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED): if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate)
elif isread==False: elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate)
else: else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate)
return [serialize_application(m) for m in messages] return [serialize_application(m) for m in messages]
async def get_application_by_id(self,record_id): async def get_application_by_id(self,record_id):
@ -306,6 +306,115 @@ class Email:
task_ids.append(task.task_id) task_ids.append(task.task_id)
return task_ids return task_ids
async def start_mailbox_sync(self,current_user=None,top=100,skip=0,test_on=True):
"""Enqueue Outlook pull on the mailbox_sync queue; return the run row.
If a queued/running sync already exists, return it instead of stacking another.
"""
active=await MailboxSyncRun.get_active(self.session)
if active:
return serialize_mailbox_sync_run(active)
created_by=None
if isinstance(current_user,dict) and current_user.get("id"):
created_by=MailboxSyncRun._as_uuid(current_user.get("id"))
row=await MailboxSyncRun.insert_run(self.session,{
"status":"queued",
"created_by":created_by,
"top":int(top or 100),
"skip":int(skip or 0),
"test_on":bool(test_on) if test_on is not None else True,
})
from inbox.mailbox_sync_tasks import sync_mailbox
task=await sync_mailbox.kicker().with_labels(
created_at=datetime.now(timezone.utc).isoformat(),
correlation_id=str(row.id),
queue="mailbox_sync",
).kiq(str(row.id))
row=await MailboxSyncRun.update_run(self.session,row.id,{"task_id":task.task_id})
return serialize_mailbox_sync_run(row)
async def get_mailbox_sync(self,run_id=None):
if run_id:
row=await MailboxSyncRun.get_by_id(self.session,run_id)
if not row:
raise HTTPException(status_code=404,detail="Sync run not found")
return serialize_mailbox_sync_run(row)
row=await MailboxSyncRun.get_active(self.session)
if row:
return serialize_mailbox_sync_run(row)
# Latest finished run so the UI can still show the last result after refresh.
from sqlmodel import select
result=await self.session.execute(
select(MailboxSyncRun).order_by(MailboxSyncRun.created_at.desc()).limit(1)
)
row=result.scalars().first()
if not row:
raise HTTPException(status_code=404,detail="No sync runs yet")
return serialize_mailbox_sync_run(row)
async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True):
"""Pull one Outlook page, triage, ingest, enqueue matching. Returns summary.
Shared by the legacy synchronous /email/fetch and the background Taskiq worker.
"""
data=await self.service_email(top,skip)
value=data.get("value") or []
decisions=await self.triage_round([item.get("id") for item in value])
entries=[]
for item in value:
message_id=item.get("id")
decision=decisions.get(str(message_id)) or {}
try:
result=await self.get_email_by_id(message_id,test_on,decision=decision)
if isinstance(result,dict) and result.get("skipped"):
entries.append({
"message_id":str(message_id),
"status":"skipped",
"reason":result.get("reason") or "",
"triage_status":result.get("status") or "",
})
else:
# Prefer the decision status (known/recorded/…) when present.
status=decision.get("status") or "ingested"
if status in ("known","recorded","disabled"):
entry_status="known" if status=="known" else "ingested"
else:
entry_status="ingested"
entries.append({
"message_id":str(message_id),
"status":entry_status,
"reason":decision.get("reason") or "",
"triage_status":status,
})
except Exception as e:
entries.append({
"message_id":str(message_id),
"status":"error",
"reason":str(e),
"triage_status":"error",
})
self.triage_errors.append(str(message_id))
if self.pending_match_ids:
await self.enqueue_matching(list(self.pending_match_ids),force=False)
account_setup=[]
if not test_on and self.pending_confirmation_emails:
account_setup=await self.send_account_setup(list(self.pending_confirmation_emails))
skipped=len(self.skipped_message_ids)
ingested=sum(1 for e in entries if e.get("status") in ("ingested","known"))
triage={
"ingested":ingested,
"skipped":skipped,
"errors":len(self.triage_errors),
"total":len(entries),
}
return {"entries":entries,"triage":triage,"account_setup":account_setup}
async def send_account_setup(self,emails): async def send_account_setup(self,emails):
results=[] results=[]
for email in emails or []: for email in emails or []:
@ -317,13 +426,13 @@ class Email:
results.append({"email":email,"sent":False}) results.append({"email":email,"sent":False})
return results return results
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None): async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED: 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,assigned=assigned) return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate)
elif isread==False: elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned) return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate)
else: else:
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned) return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate)
async def assign_job_post(self,record_id,job_post_id): async def assign_job_post(self,record_id,job_post_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
@ -381,7 +490,7 @@ class Email:
async def set_read_all(self,read,search=None,isread:bool=True, async def set_read_all(self,read,search=None,isread:bool=True,
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED, application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned=None): assigned=None,is_duplicate=None):
"""Mark every row the SAME filter set would have listed. """Mark every row the SAME filter set would have listed.
The filter arguments are the caller's current view, not a free-form query: the The filter arguments are the caller's current view, not a free-form query: the
@ -390,10 +499,10 @@ class Email:
""" """
updated=await Inbox_Messages.set_read_scope( updated=await Inbox_Messages.set_read_scope(
self.session,read,search=search,isread=isread, self.session,read,search=search,isread=isread,
application_status=application_status,assigned=assigned, application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
) )
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s", logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status)) updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)
return {"updated":updated,"read":bool(read)} return {"updated":updated,"read":bool(read)}
async def refresh_read_status(self,record_id): async def refresh_read_status(self,record_id):

View File

@ -58,3 +58,8 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- ./app:/app/app - ./app:/app/app
taskiq-mailbox-sync-worker:
volumes:
- ./backend:/app
- ./app:/app/app

View File

@ -231,6 +231,25 @@ services:
<<: *backend-env <<: *backend-env
TASKIQ_CV_QUEUE_NAME: cv_upload TASKIQ_CV_QUEUE_NAME: cv_upload
# Dedicated stream: Outlook pull/triage must not block match/ATS or CV uploads.
taskiq-mailbox-sync-worker:
<<: *backend-service
container_name: hrms-taskiq-mailbox-sync-worker
command:
[
"taskiq",
"worker",
"taskiq_management.mailbox_sync_broker_setup:mailbox_sync_broker",
"inbox.mailbox_sync_tasks",
"--workers",
"1",
]
environment:
<<: *backend-env
TASKIQ_MAILBOX_SYNC_QUEUE_NAME: mailbox_sync
TASKIQ_WORKER_NAME: mailbox-sync-worker-01
volumes: *attachments
# --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT ------------------------------- # --- Postgres: DEFINED HERE, NOT STARTED BY DEFAULT -------------------------------
# The profile is what keeps it out of `docker compose build` and `docker compose up`. # The profile is what keeps it out of `docker compose build` and `docker compose up`.
# Nothing about the default stack changes by its presence in this file. # Nothing about the default stack changes by its presence in this file.

View File

@ -20,13 +20,14 @@ export function listMessages() {
* `assigned` is tri-valued: omit for no filter, true for rows with an * `assigned` is tri-valued: omit for no filter, true for rows with an
* assigned_job_post_id, false for the Job Matching queue. * assigned_job_post_id, false for the Job Matching queue.
*/ */
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned } = {}) { export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate } = {}) {
return request('/inbox/all-applications', { return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults // `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 // 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. // undefined but keeps false, so `isread: undefined` sends no param at all.
// Same for `application_status`: omit for every tab (server defaults to // Same for `application_status`: omit for every tab (server defaults to
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only. // CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
// Same for `is_duplicate`: omit unless the Duplicates tab.
params: { params: {
search, search,
top, top,
@ -35,6 +36,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
isread, isread,
application_status: applicationStatus, application_status: applicationStatus,
assigned, assigned,
is_duplicate: isDuplicate,
}, },
}) })
} }
@ -51,7 +53,23 @@ export function getMessage(recordId) {
return request('/inbox/fetch', { params: { record_id: recordId } }) return request('/inbox/fetch', { params: { record_id: recordId } })
} }
/** Triggers the Graph proxy to pull new mail and persist it. */ /** Enqueue Outlook pull on the mailbox_sync worker. Returns a run immediately. */
export function startMailboxSync({ top, skip, testOn } = {}) {
return request('/email/sync', {
method: 'POST',
params: { top, skip, test_on: testOn },
})
}
/** Poll one sync run (or the active/latest run when runId is omitted). */
export function getMailboxSync(runId) {
return request('/email/sync/fetch', { params: { run_id: runId } })
}
/**
* Legacy synchronous sync blocks until the page is ingested. Prefer
* startMailboxSync + getMailboxSync so closing the tab cannot kill the job.
*/
export function syncMailbox({ token, top, skip } = {}) { export function syncMailbox({ token, top, skip } = {}) {
return request('/email/fetch', { params: { token, top, skip } }) return request('/email/fetch', { params: { token, top, skip } })
} }
@ -96,7 +114,7 @@ export function bulkSetRead(recordIds, read) {
* Resolves to `{updated, read}`, where `updated` counts rows that actually * Resolves to `{updated, read}`, where `updated` counts rows that actually
* CHANGED state, so it is safe to show in a toast. * CHANGED state, so it is safe to show in a toast.
*/ */
export function setReadAll({ read, search, isread, applicationStatus, assigned } = {}) { export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate } = {}) {
return request('/inbox/read-all', { return request('/inbox/read-all', {
method: 'PATCH', method: 'PATCH',
body: { body: {
@ -105,6 +123,7 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned }
isread, isread,
application_status: applicationStatus, application_status: applicationStatus,
assigned, assigned,
is_duplicate: isDuplicate,
}, },
}) })
} }

View File

@ -27,6 +27,7 @@ export const qk = {
// qk.mailbox.all() already refreshes the intake gate's ledger, so an // qk.mailbox.all() already refreshes the intake gate's ledger, so an
// override or a sync needs no extra invalidation. // override or a sync needs no extra invalidation.
triage: (p = {}) => ['mailbox', 'triage', p], triage: (p = {}) => ['mailbox', 'triage', p],
sync: (id) => ['mailbox', 'sync', id],
}, },
assessments: { assessments: {
all: () => ['assessments'], all: () => ['assessments'],

View File

@ -1,12 +1,6 @@
/* ============================================================ /* ============================================================
Recruitment Inbox application tabs plus the Email tab, which is the Recruitment Inbox application tabs over GET /inbox/all-applications.
app's oldest real network call (GET /inbox/fetch, previously the only fetch Page size is 10, newest first (order by created_at on the server).
in the entire prototype).
The email body used to be interpolated raw into markup at js/inbox.js:292
the single widest XSS sink in the repository, and the one that mattered most
because inbound mail is attacker-supplied by definition. It renders as text
now, which is the structural fix.
============================================================ */ ============================================================ */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -16,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { Pagination, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -29,33 +24,22 @@ import {
inboxSources, sourceMeta, inboxSources, sourceMeta,
} from '../data/seed' } from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates', 'Email'] const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates']
const PAGE_SIZE = 10
/** /**
* Tabs that do not read /inbox/all-applications at all. Email reads * Server-side filters for each tab. Processed / Rejected use
* /inbox/fetch; every other tab is a view over the applications list. * Candidate_application_Status (PROCESS / REJECTED), not processing_state.
*/
const SPECIAL_TABS = new Set(['Email'])
/**
* Server-side filters for tabs /inbox/all-applications can narrow.
* Processed / Rejected / Duplicates filter client-side on
* processing_state + is_duplicate (see GET /inbox/counts).
*/ */
const TAB_FILTERS = { const TAB_FILTERS = {
Unread: { isread: false }, Unread: { isread: false },
Processed: { applicationStatus: 'PROCESS' },
Rejected: { applicationStatus: 'REJECTED' },
Duplicates: { isDuplicate: true },
} }
/** /** Every tab above is a true server-side scope — safe for "mark all". */
* Tabs whose visible set is EXACTLY what the server returns for TAB_FILTERS[tab]. const SERVER_SCOPED_TABS = new Set(TABS)
*
* Only these may use the scope endpoint for "mark all". Processed / Rejected /
* Duplicates narrow client-side over rows the server already handed back in full,
* so a scope call from one of those carries no such predicate and would mark the
* entire mailbox rows the user never saw, with no undo. Those tabs send the
* visible ids instead.
*/
const SERVER_SCOPED_TABS = new Set(['All Applications', 'Unread'])
/** /**
* message_received_time / message_sent_time are plain string columns * message_received_time / message_sent_time are plain string columns
@ -205,11 +189,14 @@ async function fetchMessageDetail(recordId) {
* *
* `processing` prefers processing_state (imported/processed/rejected); otherwise * `processing` prefers processing_state (imported/processed/rejected); otherwise
* Read/Unread from message_read. Duplicate comes from is_duplicate. * Read/Unread from message_read. Duplicate comes from is_duplicate.
* Returns `{ rows, total }` so the pager can show page 1 / 2 / 3 without
* loading the whole mailbox.
*/ */
async function fetchApplications(params) { async function fetchApplications(params) {
const res = await inboxApi.listApplications(params) const res = await inboxApi.listApplications(params)
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => { return {
rows: rows.map((row) => {
const name = row.name || row.email || 'Unknown' const name = row.name || row.email || 'Unknown'
return { return {
id: String(row.id), id: String(row.id),
@ -236,7 +223,9 @@ async function fetchApplications(params) {
suggestedIds: (row.suggested_job_post_ids || []).map(String), suggestedIds: (row.suggested_job_post_ids || []).map(String),
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
} }
}) }),
total: Number(res?.total ?? rows.length) || 0,
}
} }
// Shared with the sidebar badge through qk.mailbox.counts see inboxApi.fetchCounts. // Shared with the sidebar badge through qk.mailbox.counts see inboxApi.fetchCounts.
@ -258,9 +247,10 @@ async function setReadChunked(ids, read) {
} }
/** /**
* Rewrites ONE cache entry for a read-state flip: a row list, or the * Rewrites ONE cache entry for a read-state flip: a row list, a paginated
* single-message detail object. Anything else (the counts object) passes through * `{rows, total}` page, or the single-message detail object. Anything else
* untouched it has no `id`, and its delta is applied separately. * (the counts object) passes through untouched it has no `id`, and its
* delta is applied separately.
*/ */
function patchReadState(data, idSet, read) { function patchReadState(data, idSet, read) {
const patchRow = (r) => (idSet.has(r.id) const patchRow = (r) => (idSet.has(r.id)
@ -275,6 +265,7 @@ function patchReadState(data, idSet, read) {
} }
: r) : r)
if (Array.isArray(data)) return data.map(patchRow) if (Array.isArray(data)) return data.map(patchRow)
if (data && Array.isArray(data.rows)) return { ...data, rows: data.rows.map(patchRow) }
if (data && typeof data === 'object' && data.id && idSet.has(data.id)) return patchRow(data) if (data && typeof data === 'object' && data.id && idSet.has(data.id)) return patchRow(data)
return data return data
} }
@ -304,8 +295,9 @@ async function optimisticRead(qc, ids, read) {
// A Set, because the same id appears in several cached lists at once. // A Set, because the same id appears in several cached lists at once.
const flipping = new Set() const flipping = new Set()
for (const [, data] of previous) { for (const [, data] of previous) {
if (!Array.isArray(data)) continue const rows = Array.isArray(data) ? data : data?.rows
for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id) if (!Array.isArray(rows)) continue
for (const r of rows) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id)
} }
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read)) qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read))
@ -559,60 +551,37 @@ export default function Inbox() {
const updateInbox = useSeedMutation('inbox') const updateInbox = useSeedMutation('inbox')
const [tab, setTab] = useState('All Applications') const [tab, setTab] = useState('All Applications')
const [page, setPage] = useState(1)
const [selectedId, setSelectedId] = useState(null) const [selectedId, setSelectedId] = useState(null)
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [previewing, setPreviewing] = useState(null) const [previewing, setPreviewing] = useState(null)
const [assigning, setAssigning] = useState(null) const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null) const [noting, setNoting] = useState(null)
// Tabs with a server-side filter pass their params; everything else (and
// countsQuery) passes `{}` so the backend defaults mean "no filter".
const tabFilter = TAB_FILTERS[tab] ?? {} const tabFilter = TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
...tabFilter,
top: PAGE_SIZE,
skip: (page - 1) * PAGE_SIZE,
...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, page, q])
const applicationsQuery = useQuery({ const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(tabFilter), queryKey: qk.mailbox.applications(listParams),
queryFn: () => fetchApplications(tabFilter), queryFn: () => fetchApplications(listParams),
enabled: !SPECIAL_TABS.has(tab),
}) })
const countsQuery = useQuery({ const countsQuery = useQuery({
queryKey: qk.mailbox.counts(), queryKey: qk.mailbox.counts(),
queryFn: fetchInboxCounts, queryFn: fetchInboxCounts,
enabled: tab !== 'Email',
}) })
const inbox = applicationsQuery.data ?? [] const inbox = applicationsQuery.data?.rows ?? []
const total = applicationsQuery.data?.total ?? 0
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const currentPage = Math.min(page, pages)
const serverCounts = countsQuery.data ?? {} const serverCounts = countsQuery.data ?? {}
const emailsQuery = useQuery({
queryKey: qk.mailbox.messages(),
queryFn: async () => {
const res = await inboxApi.listMessages()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({
id: String(row.id),
from: row.sender_name || row.fromEmail || 'Unknown',
fromEmail: row.fromEmail || '',
subject: row.subject || '',
body: row.body || '',
when: parseDate(row.when) ?? parseDate(row.message_sent_time),
unread: Boolean(row.unread),
attachment: row.attachment_name || 'Resume.pdf',
attachmentSize: '—',
// 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,
}))
},
enabled: tab === 'Email',
})
const counts = useMemo( const counts = useMemo(
() => ({ () => ({
'All Applications': serverCounts.all ?? 0, 'All Applications': serverCounts.all ?? 0,
@ -620,28 +589,16 @@ export default function Inbox() {
Processed: serverCounts.processed ?? 0, Processed: serverCounts.processed ?? 0,
Rejected: serverCounts.rejected ?? 0, Rejected: serverCounts.rejected ?? 0,
Duplicates: serverCounts.duplicates ?? 0, Duplicates: serverCounts.duplicates ?? 0,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}), }),
[serverCounts, emailsQuery.data], [serverCounts],
) )
const list = useMemo(() => { const list = inbox
let l = inbox
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
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 === 'Duplicates') l = l.filter((i) => i.duplicate)
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
return l
}, [inbox, tab, q])
// 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({ const detailQuery = useQuery({
queryKey: qk.mailbox.message(selectedId), queryKey: qk.mailbox.message(selectedId),
queryFn: () => fetchMessageDetail(selectedId), queryFn: () => fetchMessageDetail(selectedId),
enabled: !SPECIAL_TABS.has(tab) && Boolean(selectedId), enabled: Boolean(selectedId),
}) })
const selectedRow = inbox.find((i) => i.id === selectedId) const selectedRow = inbox.find((i) => i.id === selectedId)
@ -677,15 +634,13 @@ export default function Inbox() {
} }
function setReadEverything(read) { function setReadEverything(read) {
// The scope endpoint may only be used where the server-side filter IS the // Search goes on the wire now, so every tab is a true server scope.
// view. The search box narrows client-side on name/position/source while the if (SERVER_SCOPED_TABS.has(tab)) {
// server's `search` matches subject/from/body, and three of the tabs narrow setReadAll.mutate({
// client-side entirely handing either to a WHERE clause would mark rows read,
// that were never on screen. Those cases send the visible ids instead, which filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) },
// is exact and, since the list endpoint is unpaginated, complete. ids: list.map((i) => i.id),
if (SERVER_SCOPED_TABS.has(tab) && !q.trim()) { })
// `ids` drives the optimistic patch only; the write itself is the filter.
setReadAll.mutate({ read, filter: tabFilter, ids: list.map((i) => i.id) })
return return
} }
const ids = list.map((i) => i.id) const ids = list.map((i) => i.id)
@ -791,14 +746,16 @@ export default function Inbox() {
<div style={{ margin: '0 16px', paddingTop: 8 }}> <div style={{ margin: '0 16px', paddingTop: 8 }}>
<Tabs <Tabs
value={tab} value={tab}
onChange={(t) => { setTab(t); setSelectedId(null); selection.clear() }} onChange={(t) => {
setTab(t)
setPage(1)
setSelectedId(null)
selection.clear()
}}
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))} tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
/> />
</div> </div>
{tab === 'Email' ? (
<EmailTab query={emailsQuery} toast={toast} />
) : (
<div className="split inbox-split"> <div className="split inbox-split">
<div className="split-list inbox-queue"> <div className="split-list inbox-queue">
{applicationsQuery.isSuccess && ( {applicationsQuery.isSuccess && (
@ -815,7 +772,11 @@ export default function Inbox() {
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}> <div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div className="toolbar-search" style={{ maxWidth: 'none' }}> <div className="toolbar-search" style={{ maxWidth: 'none' }}>
<Icon name="search" /> <Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search applications…" /> <input
value={q}
onChange={(e) => { setQ(e.target.value); setPage(1) }}
placeholder="Search applications…"
/>
</div> </div>
</div> </div>
<div> <div>
@ -823,7 +784,7 @@ export default function Inbox() {
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState> <EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
)} )}
{applicationsQuery.isError && ( {applicationsQuery.isError && (
<EmptyState icon="inbox" title="Couldnt load applications"> <EmptyState icon="inbox" title="Couldn't load applications">
{friendlyAuthError(applicationsQuery.error, 'Request failed')} {friendlyAuthError(applicationsQuery.error, 'Request failed')}
</EmptyState> </EmptyState>
)} )}
@ -861,9 +822,6 @@ export default function Inbox() {
<div className="ii-time"> <div className="ii-time">
{outlookListTime(i.received)} {outlookListTime(i.received)}
</div> </div>
{/* No ATS score exists server-side the agent returns a
verdict, not a number. The chip stays off rather than
rendering a placeholder that reads as a real score. */}
{i.atsScore != null && ( {i.atsScore != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div> <div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)} )}
@ -872,6 +830,17 @@ export default function Inbox() {
)) ))
)} )}
</div> </div>
{applicationsQuery.isSuccess && total > 0 && (
<Pagination
from={total ? (currentPage - 1) * PAGE_SIZE + 1 : 0}
to={Math.min(currentPage * PAGE_SIZE, total)}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => { setPage(p); setSelectedId(null); selection.clear() }}
pageButtons={pageWindow(currentPage, pages)}
/>
)}
</div> </div>
<div className="split-detail"> <div className="split-detail">
@ -883,7 +852,7 @@ export default function Inbox() {
</div> </div>
) : detailQuery.isError ? ( ) : detailQuery.isError ? (
<div style={{ padding: '100px 20px' }}> <div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Couldnt load this application"> <EmptyState icon="inbox" title="Couldn't load this application">
{friendlyAuthError(detailQuery.error, 'Request failed')} {friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState> </EmptyState>
</div> </div>
@ -904,7 +873,6 @@ export default function Inbox() {
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
{previewing && ( {previewing && (
@ -1368,232 +1336,3 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) {
</Modal> </Modal>
) )
} }
/** The live tab: real fetch, real loading state, real error state. */
function EmailTab({ query, toast }) {
const qc = useQueryClient()
const { can } = useAuth()
const canEdit = can('inbox.edit')
const [selectedId, setSelectedId] = useState(null)
const [replying, setReplying] = useState(null)
const [replyBody, setReplyBody] = useState('')
const [importedIds, setImportedIds] = useState(() => new Set())
const emails = query.data ?? []
const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length
const selection = useRowSelection(emails)
const setRead = useSetRead(toast)
const setReadAll = useSetReadAll(toast)
function setReadSelected(read) {
const ids = [...selection.selectedIds]
if (!ids.length) return
setRead.mutate({ ids, read }, {
onSuccess: () => {
toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success')
selection.clear()
},
})
}
/**
* This list is /inbox/fetch with no params every persisted message, no
* filter, no pagination so the empty scope really is the whole mailbox and
* the WHERE clause matches what is on screen exactly.
*/
function setReadEverything(read) {
setReadAll.mutate({ read, filter: {}, ids: emails.map((e) => e.id) })
}
const importMsg = useMutation({
mutationFn: (id) => inboxApi.setProcessingState(id, 'imported'),
onSuccess: (_d, id) => {
setImportedIds((s) => new Set(s).add(id))
toast('Marked as imported', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not import.'), 'error'),
onSettled: () => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
},
})
const reply = useMutation({
mutationFn: ({ recordId, body }) => inboxApi.replyEmail({ recordId, body }),
onSuccess: () => {
toast('Reply sent', 'success')
setReplying(null)
setReplyBody('')
},
onError: (err) => toast(friendlyAuthError(err, 'Could not send reply.'), 'error'),
})
function selectEmail(e) {
setSelectedId(e.id)
if (e.unread) setRead.mutate({ ids: [e.id], read: true })
}
const isImported = (e) => importedIds.has(e.id)
return (
<>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="integration-status"><span className="pulse" />Outlook · Microsoft Graph API</span>
<span className="text-muted text-sm">
{query.isPending ? 'Loading…' : query.isError ? 'Failed to load' : `${emails.length} messages · ${unread} unread`}
</span>
</div>
<div className="split inbox-split">
<div className="split-list inbox-queue">
{query.isSuccess && emails.length > 0 && (
<BulkReadBar
rows={emails}
selection={selection}
onSetRead={setReadSelected}
onSetAllRead={setReadEverything}
busy={setRead.isPending || setReadAll.isPending}
canEdit={canEdit}
scopeLabel="every message in the mailbox"
/>
)}
{query.isPending && <EmptyState icon="mail" title="Loading…">Fetching mailbox from the server.</EmptyState>}
{query.isError && (
<EmptyState icon="mail" title="Couldnt load mailbox">
{friendlyAuthError(query.error, 'Request failed')}
</EmptyState>
)}
{query.isSuccess && emails.length === 0 && (
<EmptyState icon="mail" title="Nothing here">No emails in the mailbox.</EmptyState>
)}
{query.isSuccess && emails.map((e) => (
<div
key={e.id}
className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => selectEmail(e)}
>
<RowCheck
checked={selection.selectedIds.has(e.id)}
onToggle={() => selection.toggle(e.id)}
label={`Select mail from ${e.from}`}
/>
<Avatar name={e.from} />
<div className="ii-main">
<div className="ii-name">{e.from}</div>
<div className="ii-pos">{e.subject}</div>
<div className="ii-meta">
<span className="source-chip" style={{ '--chip': '#0078d4' }}>
<Icon name="mail" />Outlook
</span>
{isImported(e) && <Badge className="b-green">Imported</Badge>}
</div>
</div>
<div className="ii-time">{outlookListTime(e.when)}</div>
</div>
))}
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="mail" title="Select an email">
Preview email body and resume attachments here.
</EmptyState>
</div>
) : (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-12" style={{ marginBottom: 20 }}>
<Avatar name={selected.from} />
<div>
<div className="fw-600">{selected.from}</div>
<div className="cell-sub">
{selected.fromEmail} · {selected.when ? fmtDate(selected.when) : 'Date unavailable'}
</div>
</div>
</div>
{/* Same Subject-strip + framed-body template as /matching. It replaces
the old <h2> subject rather than sitting under it two subject
lines on one panel is worse than none. The Imported/New badge
moves into the strip, which is where a mail client puts status. */}
<div style={{ marginBottom: 18 }}>
<div className="email-head flex items-center gap-12" style={{ justifyContent: 'space-between' }}>
<span>Subject: {selected.subject || '(no subject)'}</span>
{isImported(selected) ? <Badge className="b-green">Imported</Badge> : <Badge className="b-blue">New</Badge>}
</div>
{looksLikeHtml(selected.body) ? (
<EmailBody html={selected.body} />
) : (
<pre className="resume-thumb is-full email-plain">
{htmlToText(selected.body) || 'This email has no message body.'}
</pre>
)}
</div>
<div className="attach-card" style={{ marginBottom: 18 }}>
<span className="attach-icn"><Icon name="file" /></span>
<div style={{ flex: 1 }}>
<div className="fw-600">{selected.attachment}</div>
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
</div>
</div>
<div className="flex gap-8">
{isImported(selected) ? (
<button className="btn btn-secondary" disabled><Icon name="check" /> Already Imported</button>
) : (
<button
className="btn btn-primary"
disabled={importMsg.isPending}
onClick={() => importMsg.mutate(selected.id)}
>
<Icon name="user-plus" /> Import Candidate
</button>
)}
<button className="btn btn-secondary" onClick={() => { setReplying(selected); setReplyBody('') }}>
<Icon name="mail" /> Reply
</button>
</div>
</div>
)}
</div>
</div>
{replying && (
<Modal
title="Reply"
subtitle={`Re: ${replying.subject || '(no subject)'}`}
onClose={() => setReplying(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setReplying(null)} disabled={reply.isPending}>Cancel</button>
<button
className="btn btn-primary"
disabled={reply.isPending || !replyBody.trim()}
onClick={() => reply.mutate({ recordId: replying.id, body: replyBody.trim() })}
>
<Icon name="send" /> {reply.isPending ? 'Sending…' : 'Send Reply'}
</button>
</>
}
>
<div className="form-field">
<label>To</label>
<input value={replying.fromEmail || ''} disabled />
</div>
<div className="form-field">
<label>Message</label>
<textarea
rows={6}
value={replyBody}
onChange={(e) => setReplyBody(e.target.value)}
placeholder="Write your reply…"
/>
</div>
</Modal>
)}
</>
)
}

View File

@ -62,7 +62,7 @@ export function useDataTable({ columns, rows, pageSize = 10 }) {
} }
/** 1 … cur-1 cur cur+1 … n — the prototype's windowing, unchanged. */ /** 1 … cur-1 cur cur+1 … n — the prototype's windowing, unchanged. */
function pageWindow(cur, pages) { export function pageWindow(cur, pages) {
const list = [] const list = []
for (let i = 1; i <= pages; i++) { for (let i = 1; i <= pages; i++) {
if (i === 1 || i === pages || Math.abs(i - cur) <= 1) list.push(i) if (i === 1 || i === pages || Math.abs(i - cur) <= 1) list.push(i)