from pathlib import Path from inbox.models import Inbox_Message_Triage, Inbox_Messages # match_status (inbox/tasks.py) -> the resume badge the inbox tabs render. _RESUME_STATUS = { "processing": "Parsing", "matched": "Parsed", "no_text": "Failed", "failed": "Failed", "dlq": "Failed", "skipped": "Pending", } def _sender_name(message: Inbox_Messages, *, light: bool = False) -> str: """Graph's display name when the payload carries one, else the raw address. `light` must not touch `full_email_response` — the list query defers that column, and reading it here would lazy-load the whole Graph payload per row. """ raw = message.message_from or "" if light: # "Jane Doe " → Jane Doe; otherwise the address as stored. if "<" in raw and raw.endswith(">"): return raw.split("<", 1)[0].strip() or raw return raw sender_name = raw full = message.full_email_response if isinstance(full, dict): from_block = full.get("from") if isinstance(from_block, dict): email_address = from_block.get("emailAddress") if isinstance(email_address, dict): name = email_address.get("name") if name: sender_name = name return sender_name def _attachment_name(message: Inbox_Messages) -> str | None: if message.file_name: return message.file_name.split(",")[0].strip() or None if message.file_path: return Path(message.file_path.split(",")[0].strip()).name or None return None def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict: """inbox_messages row -> the shape the #inbox Email tab renders.""" sender_name = _sender_name(message) attachment_name = _attachment_name(message) return { "id": str(message.id), "message_id": str(message.message_id) if message.message_id else None, "full_email_response": message.full_email_response, "sender_name": sender_name, "fromEmail": message.message_from, "subject": message.message_subject, "body": message.message_body, "when": message.message_received_time, "received": message.message_received_time, "created_at": message.created_at.isoformat() if message.created_at else None, "unread": not message.message_read, "attachment": message.attachment, "attachment_name": attachment_name, "file_name": message.file_name, "message_to": message.message_to, "message_cc": message.message_cc, "message_bcc": message.message_bcc, "message_sent_time": message.message_sent_time, "message_reply": message.message_reply, "file_path": message.file_path, "linkedin_slug": message.linkedin_slug or None, "linkedin_url": linkedin_url or None, "suggested_job_post_ids": list(message.suggested_job_post_ids or []), "assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None, "match_summary": message.match_summary, "match_reasoning": message.match_reasoning, "match_status": message.match_status, "match_error": message.match_error, "matched_at": message.matched_at.isoformat() if message.matched_at else None, "resume_text": message.resume_text, "ats_score": message.ats_score, "ats_band": message.ats_band or None, } def serialize_ats_result(row) -> dict: """One inbox ats_results row — same keys the Sheet Forms cards paint.""" return { "job_post_id": str(row.job_post_id) if row.job_post_id else None, "overall_score": row.overall_score, "band": row.band or None, "computed_at": row.computed_at.isoformat() if row.computed_at else None, } _PROCESSING_LABEL = { "unread": "Unread", "imported": "Imported", "processed": "Processed", "rejected": "Rejected", } def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light: bool = False) -> dict: """inbox_messages row -> the shape the #inbox All Applications tab renders. `position` is the mail subject and `source` is the To address, which is where the board tag (Rozee, Mustakbil, Employee Referral, ...) lands. The tab also wants ats_score, phone, experience, recruiter, duplicate and a processing state beyond read/unread. `processing` prefers imported / processed / rejected from the processing_state column so those writes are visible; the default `unread` state still follows message_read so existing rows keep Read/Unread until someone PATCHes a later state. `light=True` is the list path: skip resume_text / match_reasoning / Graph-payload name lookup so we never touch columns the list query defers. """ state = (message.processing_state or "").strip().lower() if state in ("imported", "processed", "rejected"): processing = _PROCESSING_LABEL[state] else: processing = "Read" if message.message_read else "Unread" payload = { "id": str(message.id), "message_id": str(message.message_id) if message.message_id else None, "name": _sender_name(message, light=light), "email": message.message_from, "position": message.message_subject, "source": message.message_to, "received": message.message_received_time, "created_at": message.created_at.isoformat() if message.created_at else None, "unread": not message.message_read, "processing": processing, "application_status": message.application_status, "resume_status": _RESUME_STATUS.get(message.match_status, "Pending"), "attachment": _attachment_name(message), "has_attachment": message.attachment, "file_path": message.file_path, "linkedin_slug": message.linkedin_slug or None, "linkedin_url": linkedin_url or None, "suggested_job_post_ids": list(message.suggested_job_post_ids or []), "assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None, "match_summary": message.match_summary, "match_status": message.match_status, "match_error": message.match_error, "matched_at": message.matched_at.isoformat() if message.matched_at else None, "ats_score": message.ats_score, "ats_band": message.ats_band or None, "phone": message.candidate_phone_number, "experience": message.experience or "", "current_employment": message.current_employment or "", "current_title": message.current_title or "", "city": message.city or None, "recruiter": str(message.recruiter_id) if message.recruiter_id else None, "duplicate": message.is_duplicate, "processing_state": message.processing_state, "source_channel_id": message.source_channel_id, } if not light: payload["resume_text"] = message.resume_text payload["match_reasoning"] = message.match_reasoning return payload def serialize_triage(row: Inbox_Message_Triage) -> dict: """inbox_message_triage row -> the intake gate's review shape. No body field exists to expose: the gate stores the verdict, never the mail. A reviewer opens the original from the mailbox, or overturns the verdict and lets the normal ingestion path re-fetch it. """ return { "id": str(row.id), "message_id": row.message_id, "is_application": row.is_application, "reason_code": row.reason_code, "confidence": row.confidence, "evidence": row.evidence, "status": row.status, "error": row.error, "model_name": row.model_name or None, "subject": row.message_subject, "fromEmail": row.message_from, "when": row.message_received_time, "attachment": row.file_name or None, "has_attachment": row.attachment, "ingested": row.ingested, "overridden_by": str(row.overridden_by_id) if row.overridden_by_id 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, } 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, }