Add mailbox sync functionality and related API endpoints #21
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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,38 +189,43 @@ 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 {
|
||||||
const name = row.name || row.email || 'Unknown'
|
rows: rows.map((row) => {
|
||||||
return {
|
const name = row.name || row.email || 'Unknown'
|
||||||
id: String(row.id),
|
return {
|
||||||
name,
|
id: String(row.id),
|
||||||
initials: initialsOf(name),
|
name,
|
||||||
color: avatarColor(name),
|
initials: initialsOf(name),
|
||||||
email: row.email || '',
|
color: avatarColor(name),
|
||||||
position: row.position || '(no subject)',
|
email: row.email || '',
|
||||||
...sourceFrom(row.source),
|
position: row.position || '(no subject)',
|
||||||
received: parseDate(row.received),
|
...sourceFrom(row.source),
|
||||||
unread: Boolean(row.unread),
|
received: parseDate(row.received),
|
||||||
processing: row.processing || 'Unread',
|
unread: Boolean(row.unread),
|
||||||
processingState: row.processing_state || null,
|
processing: row.processing || 'Unread',
|
||||||
applicationStatus: row.application_status || null,
|
processingState: row.processing_state || null,
|
||||||
resumeStatus: row.resume_status || 'Pending',
|
applicationStatus: row.application_status || null,
|
||||||
attachment: row.attachment,
|
resumeStatus: row.resume_status || 'Pending',
|
||||||
hasAttachment: Boolean(row.has_attachment),
|
attachment: row.attachment,
|
||||||
resumeText: row.resume_text || '',
|
hasAttachment: Boolean(row.has_attachment),
|
||||||
atsScore: row.ats_score,
|
resumeText: row.resume_text || '',
|
||||||
phone: row.phone,
|
atsScore: row.ats_score,
|
||||||
experience: row.experience,
|
phone: row.phone,
|
||||||
recruiter: row.recruiter,
|
experience: row.experience,
|
||||||
duplicate: Boolean(row.duplicate),
|
recruiter: row.recruiter,
|
||||||
suggestedIds: (row.suggested_job_post_ids || []).map(String),
|
duplicate: Boolean(row.duplicate),
|
||||||
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
|
suggestedIds: (row.suggested_job_post_ids || []).map(String),
|
||||||
}
|
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,15 +746,17 @@ 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' ? (
|
<div className="split inbox-split">
|
||||||
<EmailTab query={emailsQuery} toast={toast} />
|
|
||||||
) : (
|
|
||||||
<div className="split inbox-split">
|
|
||||||
<div className="split-list inbox-queue">
|
<div className="split-list inbox-queue">
|
||||||
{applicationsQuery.isSuccess && (
|
{applicationsQuery.isSuccess && (
|
||||||
<BulkReadBar
|
<BulkReadBar
|
||||||
|
|
@ -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="Couldn’t 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="Couldn’t 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="Couldn’t 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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue