diff --git a/backend/app/services/human_handoff_service.py b/backend/app/services/human_handoff_service.py index 795516e..8ec1496 100644 --- a/backend/app/services/human_handoff_service.py +++ b/backend/app/services/human_handoff_service.py @@ -58,6 +58,7 @@ from app.services.reply_extractor import ( ) from app.services.thread_service import ( attach_message_to_thread, + create_thread, message_event_at, threading_fields_from_parent, ) @@ -102,8 +103,8 @@ def build_handoff_subject( del rule_name subject = " · ".join( [ - _safe_subject_part(sender_name, "unknown sender"), "handoff", + _safe_subject_part(sender_name, "unknown sender"), _safe_subject_part(external_sender, "unknown sender"), _safe_subject_part(external_subject, "(No subject)"), ] @@ -111,6 +112,48 @@ def build_handoff_subject( return subject[:998].rstrip() +_EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +_SUBJECT_PREFIX = re.compile(r"^(?:re|fw|fwd)\s*:\s*", re.IGNORECASE) +_HANDOFF_OPENER = re.compile( + r"^handoff(?:\s*·\s*|\s*\|\s*|\s+-\s+)", + re.IGNORECASE, +) +_HANDOFF_SEPARATOR = re.compile(r"\s*·\s*|\s*\|\s*|\s+-\s+") + + +def parse_human_initiated_subject(subject: str | None) -> dict | None: + """Read `handoff · Name · email · subject` from a human-started email. + + The word handoff must come first. The three parts after it are the + customer name, the customer email, and the subject the customer should + see. Separate them with ·, |, or a spaced hyphen. + """ + cleaned = re.sub(r"[\r\n]+", " ", subject or "").strip() + while True: + without_prefix = _SUBJECT_PREFIX.sub("", cleaned, count=1).strip() + if without_prefix == cleaned: + break + cleaned = without_prefix + opener = _HANDOFF_OPENER.match(cleaned) + if opener is None: + return None + parts = _HANDOFF_SEPARATOR.split(cleaned[opener.end() :], maxsplit=2) + if len(parts) < 3: + return None + customer_name = parts[0].strip() + customer_email = parts[1].strip().lower() + customer_subject = parts[2].strip() + if not customer_name or not customer_subject: + return None + if _EMAIL_PATTERN.fullmatch(customer_email) is None: + return None + return { + "customer_name": customer_name, + "customer_email": customer_email, + "subject": customer_subject, + } + + def redact_human_address( value: str | None, *, @@ -304,6 +347,223 @@ async def resolve_handoff_from_inbound( return None +async def start_human_initiated_handoff( + database, + *, + account_id: str, + agent_email: str, + parsed: dict, + body_text: str, + body_html: str | None, + raw_mime: dict, + delivery_key: str, +) -> bool: + """Send the first customer email when a Human Owner writes to Gentic. + + Subject shape: handoff · Customer name · customer@email · Subject + The customer receives a normal new email. Later replies from that + customer are mirrored back on the same handoff thread. + """ + initiated = parse_human_initiated_subject(parsed.get("subject")) + if initiated is None: + return False + sender = parsed.get("sender") or {} + human_email = (sender.get("address") or "").strip().lower() + if not human_email: + return False + rules = ( + await database.human_handoff_rules.find( + { + "account_id": account_id, + "owner_email": human_email, + "active": True, + } + ) + .sort([("priority", 1), ("created_at", 1), ("_id", 1)]) + .to_list(length=1) + ) + if not rules: + return False + rule = rules[0] + + delivery_marker = f"handoff-init:{account_id}:{delivery_key}" + if await database.messages.find_one({"handoff_delivery_key": delivery_marker}): + return True + + extracted = extract_new_reply_content(body_text=body_text, body_html=body_html) + rich = None + try: + raw = await get_raw_mime_storage().get(raw_mime) + rich = extract_new_reply_mime_content(raw) + except Exception: + logger.exception( + "handoff_initiate_rich_extract_failed delivery=%s", + delivery_key, + ) + new_text = ( + rich.body_text if rich and rich.body_text else extracted["new_reply_content"] + ).strip() + new_html = rich.body_html if rich and rich.body_html else None + new_text = ( + redact_human_address( + new_text, + human_email=human_email, + agent_email=agent_email, + ) + or "" + ).strip() + new_html = redact_human_address( + new_html, + human_email=human_email, + agent_email=agent_email, + ) + if not new_text or not validate_external_reply_body(new_text): + logger.info( + "handoff_initiate_not_sent account_id=%s reason=empty_or_private_body", + account_id, + ) + return False + + _, domain, sender_name = await _account_and_domain(account_id, agent_email) + customer_name = initiated["customer_name"] + customer_email = initiated["customer_email"] + customer_subject = initiated["subject"] + internet_message_id = await deliver_email( + sender=agent_email, + sender_name=sender_name, + recipients=[customer_email], + cc=[], + bcc=[], + subject=customer_subject, + body_text=new_text, + body_html=new_html, + domain=domain, + reply_to=agent_email, + envelope_sender=agent_email, + inline_parts=getattr(rich, "inline_parts", None), + file_attachments=getattr(rich, "file_attachments", None), + ) + now = _now() + normalized_id = canonical_message_id(internet_message_id) + external = { + "direction": "outbound", + "message_scope": MESSAGE_SCOPE_EXTERNAL, + "message_origin": MESSAGE_ORIGIN_HUMAN_OWNER, + "generated_by": GENERATED_BY_HANDOFF_BRIDGE, + "account_id": account_id, + "sender": {"name": sender_name, "address": agent_email.lower()}, + "to": [{"name": customer_name, "address": customer_email}], + "cc": [], + "bcc": [], + "subject": customer_subject, + "snippet": _snippet(new_text), + "new_reply_content": new_text, + "body_text": new_text, + "body_html": new_html, + "attachments": [], + "created_at": now, + "sent_at": now, + "status": "accepted", + "thread_relation": "new_thread", + "thread_resolution_method": "human_handoff_initiate", + **message_id_fields(normalized_id), + } + inserted = await database.messages.insert_one(external) + external["_id"] = inserted.inserted_id + thread_id = await create_thread( + database, + account_id=account_id, + subject=customer_subject, + message_id=external["_id"], + event_at=now, + ) + await database.mailbox_entries.insert_one( + { + "account_id": account_id, + "message_id": external["_id"], + "thread_id": thread_id, + "mailbox": "sent", + "message_scope": MESSAGE_SCOPE_EXTERNAL, + "direction": "outbound", + "sender": external["sender"], + "recipients": external["to"], + "subject": customer_subject, + "snippet": external["snippet"], + "attachments": [], + "status": "accepted", + "is_read": True, + "is_starred": False, + "event_at": now, + "created_at": now, + "trashed_at": None, + "deletion_state": None, + } + ) + handoff = await _create_handoff_mapping( + database, + account_id=account_id, + agent_email=agent_email, + external_message={ + "_id": external["_id"], + "sender": {"name": customer_name, "address": customer_email}, + "subject": customer_subject, + "message_id": normalized_id, + }, + external_thread_id=thread_id, + rule=rule, + ) + human_message = await store_handoff_message( + database, + account_id=account_id, + thread_id=handoff["handoff_thread_id"], + direction="inbound", + sender={"name": sender.get("name") or "", "address": human_email}, + recipients=[{"name": sender_name, "address": agent_email.lower()}], + subject=handoff["private_subject"], + body_text=body_text, + body_html=body_html, + internet_message_id=parsed.get("message_id"), + in_reply_to=parsed.get("in_reply_to"), + references=parsed.get("references") or [], + handoff_id=handoff["_id"], + event_at=parsed.get("sent_at") or now, + raw_mime=raw_mime, + handoff_delivery_key=delivery_marker, + new_reply_content=new_text, + new_reply_html=new_html, + quoted_content=extracted.get("quoted_content", ""), + message_origin=MESSAGE_ORIGIN_HUMAN_OWNER, + generated_by=GENERATED_BY_HUMAN, + relay_status=RELAY_STATUS_SENT, + ) + await database.messages.update_one( + {"_id": human_message["_id"]}, + {"$set": {"generated_external_message_id": external["_id"]}}, + ) + await database.messages.update_one( + {"_id": external["_id"]}, + {"$set": {"generated_from_handoff_message_id": human_message["_id"]}}, + ) + await database.human_handoffs.update_one( + {"_id": handoff["_id"]}, + { + "$set": { + "status": HANDOFF_STATUS_HUMAN_REPLIED, + "current_owner": OWNER_AGENT, + "last_human_message_at": now, + "updated_at": now, + } + }, + ) + logger.info( + "handoff_initiated account_id=%s handoff_id=%s customer=%s", + account_id, + handoff["_id"], + customer_email, + ) + return True + + async def _record_link( database, *, diff --git a/backend/app/services/inbound_smtp.py b/backend/app/services/inbound_smtp.py index 9a64854..31013b7 100644 --- a/backend/app/services/inbound_smtp.py +++ b/backend/app/services/inbound_smtp.py @@ -19,6 +19,7 @@ from app.services.human_handoff_service import ( maybe_trigger_handoff, process_human_reply, resolve_handoff_from_inbound, + start_human_initiated_handoff, ) from app.services.message_ids import canonical_message_id, message_id_fields from app.services.mime_parser import parse_mime @@ -154,6 +155,23 @@ class InboundHandler: references=parsed.get("references", []), ) if handoff is None: + started = await start_human_initiated_handoff( + mongo_db, + account_id=account_id, + agent_email=recipient, + parsed=parsed, + body_text=body_text, + body_html=body_html, + raw_mime=raw_mime, + delivery_key=delivery_key, + ) + if started: + logger.info( + "handoff_initiated_from_human account_id=%s message_id=%s", + account_id, + internet_message_id, + ) + continue external_recipients.append(recipient) continue await process_human_reply( diff --git a/backend/app/services/reply_extractor.py b/backend/app/services/reply_extractor.py index 8a57cd2..2948426 100644 --- a/backend/app/services/reply_extractor.py +++ b/backend/app/services/reply_extractor.py @@ -17,6 +17,9 @@ TEXT_QUOTE_MARKERS = [ PRIVATE_LEAK_MARKERS = [ "human handoff", " · handoff · ", + "handoff · ", + "handoff - ", + "handoff | ", "a conversation needs your attention", "why this was handed off", "why you're receiving this", diff --git a/backend/tests/test_handoff_runtime.py b/backend/tests/test_handoff_runtime.py index 46aa620..08f3dc1 100644 --- a/backend/tests/test_handoff_runtime.py +++ b/backend/tests/test_handoff_runtime.py @@ -282,7 +282,7 @@ def test_private_subject_stays_off_the_external_plane(): external_sender="customer@example.com", external_subject="Invoice 42", ) - assert private.startswith("Parth Dixit · handoff ·") + assert private.startswith("handoff · Parth Dixit ·") assert "Invoice 42" in private assert "Finance approvals" not in private @@ -332,7 +332,7 @@ async def test_first_matching_rule_creates_a_new_linked_handoff(): assert result["rule_name"] == "Finance approvals" assert result["external_thread_id"] == external_thread_id assert result["handoff_thread_id"] - assert result["private_subject"].startswith("Parth Dixit · handoff ·") + assert result["private_subject"].startswith("handoff · Parth Dixit ·") assert database.handoff_delivery_jobs.items[0]["kind"] == "MIRROR_TO_HUMAN" diff --git a/backend/tests/test_human_handoff_service.py b/backend/tests/test_human_handoff_service.py index 489af8f..0b53ab3 100644 --- a/backend/tests/test_human_handoff_service.py +++ b/backend/tests/test_human_handoff_service.py @@ -7,6 +7,7 @@ from app.services.human_handoff_service import ( _mirror_from_identity, build_handoff_subject, maybe_trigger_handoff, + parse_human_initiated_subject, process_human_reply, redact_human_address, resolve_handoff_from_inbound, @@ -97,6 +98,32 @@ def test_mirror_from_stays_on_gentic_address(): assert agent_address == "suyash@gentic.email" +def test_human_initiated_subject_accepts_dot_hyphen_and_bar(): + assert parse_human_initiated_subject( + "handoff · Suyash Dixit · suyashdixit091@gmail.com · hello" + ) == { + "customer_name": "Suyash Dixit", + "customer_email": "suyashdixit091@gmail.com", + "subject": "hello", + } + assert parse_human_initiated_subject( + "handoff - Suyash Dixit - suyashdixit091@gmail.com - Need a quote" + )["subject"] == "Need a quote" + parsed = parse_human_initiated_subject( + "Re: handoff | Suyash Dixit | SuyashDixit091@gmail.com | hello - there" + ) + assert parsed["customer_email"] == "suyashdixit091@gmail.com" + assert parsed["customer_name"] == "Suyash Dixit" + assert parsed["subject"] == "hello - there" + + +def test_human_initiated_subject_rejects_incomplete_patterns(): + assert parse_human_initiated_subject("Suyash Dixit · handoff · a@b.com · hello") is None + assert parse_human_initiated_subject("handoff · Suyash Dixit · not-an-email · hello") is None + assert parse_human_initiated_subject("handoff-Suyash Dixit-a@b.com-hello") is None + assert parse_human_initiated_subject("hello from the customer") is None + + def test_private_subject_contains_customer_name_and_external_subject(): subject = build_handoff_subject( sender_name="Parth Dixit", @@ -104,7 +131,7 @@ def test_private_subject_contains_customer_name_and_external_subject(): external_subject="Invoice 42", ) assert subject == ( - "Parth Dixit · handoff · parthdixit091@gmail.com · Invoice 42" + "handoff · Parth Dixit · parthdixit091@gmail.com · Invoice 42" ) diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx index 45b4d81..d40dd05 100644 --- a/frontend/src/components/DashboardPage.jsx +++ b/frontend/src/components/DashboardPage.jsx @@ -12,6 +12,7 @@ import { LogOut, Mail, Menu, + PanelLeftOpen, HandHelping, Plug, Send, @@ -173,10 +174,16 @@ export default function DashboardPage() { window.location.assign("/login"); } + function closeMobileMenu() { + if (window.matchMedia("(max-width: 767px)").matches) { + setMenuOpen(false); + } + } + function selectCompanyItem(label) { setCompanyActive(label); setAgentNav(null); - setMenuOpen(false); + closeMobileMenu(); setSheet(null); } @@ -193,7 +200,7 @@ export default function DashboardPage() { setActive(item); setLens("all"); } - setMenuOpen(false); + closeMobileMenu(); setSheet(null); } @@ -202,7 +209,7 @@ export default function DashboardPage() { setAgentNav(null); setActive("Inbox"); setLens(name); - setMenuOpen(false); + closeMobileMenu(); setSheet(null); } @@ -211,7 +218,7 @@ export default function DashboardPage() { setActive("Inbox"); setLens("all"); setAgentNav(actionKey); - setMenuOpen(false); + closeMobileMenu(); const compact = window.matchMedia("(max-width: 1199px)").matches; setSheet(actionKey === "handoff" || !compact ? null : "agent"); } @@ -221,7 +228,7 @@ export default function DashboardPage() { setActive("Inbox"); setLens("all"); setAgentNav(actionKey); - setMenuOpen(false); + closeMobileMenu(); setSheet("agent"); } @@ -229,7 +236,7 @@ export default function DashboardPage() { setCompanyActive(null); setActive("Inbox"); setLens("all"); - setMenuOpen(false); + closeMobileMenu(); } function openSheet(next) { @@ -326,7 +333,13 @@ export default function DashboardPage() {