handoff fixes

This commit is contained in:
debu-2 2026-10-06 03:14:53 +05:30
parent 89ddebdb60
commit 8d5e9712a6
20 changed files with 1056 additions and 141 deletions

View file

@ -194,6 +194,20 @@ async def lifespan(_: FastAPI):
[("message_id", 1)], [("message_id", 1)],
sparse=True, sparse=True,
) )
await mongo_db.messages.create_index(
[("message_id_folded", 1)],
sparse=True,
)
await mongo_db.messages.create_index(
[("generated_from_handoff_message_id", 1)],
sparse=True,
unique=True,
)
await mongo_db.messages.create_index(
[("mirrored_from_external_message_id", 1)],
sparse=True,
unique=True,
)
await mongo_db.mailbox_entries.create_index( await mongo_db.mailbox_entries.create_index(
[("account_id", 1), ("thread_id", 1), ("event_at", -1), ("_id", -1)] [("account_id", 1), ("thread_id", 1), ("event_at", -1), ("_id", -1)]
) )

View file

@ -18,6 +18,7 @@ from app.schemas import (
HandoffTestRequest, HandoffTestRequest,
HandoffTestResult, HandoffTestResult,
) )
from app.services.handoff_mirror_policy import is_relay_acknowledgment_message
from app.services.human_handoff_service import MESSAGE_SCOPE_HANDOFF from app.services.human_handoff_service import MESSAGE_SCOPE_HANDOFF
from app.services.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff from app.services.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff
@ -261,8 +262,15 @@ async def get_handoff_conversation(
messages: list[HandoffMessageRead] = [] messages: list[HandoffMessageRead] = []
for row in rows: for row in rows:
message = row["message"] message = row["message"]
if is_relay_acknowledgment_message(message):
continue
sender_address = (message.get("sender") or {}).get("address", "").lower() sender_address = (message.get("sender") or {}).get("address", "").lower()
role = "human" if sender_address == human_email else "agent" if message.get("mirrored_from_external_message_id"):
role = "customer"
elif sender_address == human_email:
role = "human"
else:
role = "agent"
event_at = row.get("event_at") or message.get("received_at") or message.get("sent_at") or message["created_at"] event_at = row.get("event_at") or message.get("received_at") or message.get("sent_at") or message["created_at"]
raw_body = message.get("raw_body") raw_body = message.get("raw_body")
new_reply_content = message.get("new_reply_content") new_reply_content = message.get("new_reply_content")
@ -281,6 +289,7 @@ async def get_handoff_conversation(
and new_reply_content and new_reply_content
and raw_body.strip() != new_reply_content.strip() and raw_body.strip() != new_reply_content.strip()
), ),
relay_status=message.get("relay_status") if role == "human" else None,
event_at=event_at, event_at=event_at,
) )
) )

View file

@ -24,7 +24,7 @@ from app.schemas import (
) )
from app.services.domain_provisioning import get_domain_by_name from app.services.domain_provisioning import get_domain_by_name
from app.services.mail_sender import deliver_email from app.services.mail_sender import deliver_email
from app.services.message_ids import normalize_message_id from app.services.message_ids import canonical_message_id, message_id_fields
from app.services.mime_parser import parse_mime from app.services.mime_parser import parse_mime
from app.services.raw_mime_storage import get_raw_mime_storage from app.services.raw_mime_storage import get_raw_mime_storage
from app.services.reply_recipients import reply_recipients from app.services.reply_recipients import reply_recipients
@ -249,9 +249,8 @@ async def create_email(
references=threading_fields.get("references"), references=threading_fields.get("references"),
) )
document["status"] = "accepted" document["status"] = "accepted"
normalized_id = normalize_message_id(internet_message_id) normalized_id = canonical_message_id(internet_message_id)
document["message_id"] = normalized_id document.update(message_id_fields(normalized_id))
document["internet_message_id"] = normalized_id
log_smtp_message_sending( log_smtp_message_sending(
generated_message_id=normalized_id, generated_message_id=normalized_id,
in_reply_to=threading_fields.get("in_reply_to"), in_reply_to=threading_fields.get("in_reply_to"),

View file

@ -310,13 +310,14 @@ class HandoffConversationSummary(BaseModel):
class HandoffMessageRead(BaseModel): class HandoffMessageRead(BaseModel):
id: str id: str
role: Literal["agent", "human"] role: Literal["agent", "human", "customer"]
subject: str subject: str
snippet: str snippet: str
body_text: str body_text: str
new_reply_content: str | None = None new_reply_content: str | None = None
raw_body: str | None = None raw_body: str | None = None
has_quoted_text: bool = False has_quoted_text: bool = False
relay_status: str | None = None
event_at: datetime event_at: datetime

View file

@ -7,17 +7,27 @@ from bson import ObjectId
from app.models import MailDomain from app.models import MailDomain
from app.services.handoff_email_templates import external_reply_subject from app.services.handoff_email_templates import external_reply_subject
from app.services.mail_sender import deliver_email from app.services.mail_sender import deliver_email, log_outbound_mime_headers
from app.services.message_ids import normalize_message_id from app.services.message_ids import (
from app.services.thread_service import ( build_references_header,
attach_message_to_thread, canonical_message_id,
message_event_at, folded_message_id,
threading_fields_from_parent, first_message_id,
internet_message_id_of_message,
message_id_fields,
) )
from app.services.mime_parser import parse_mime
from app.services.raw_mime_storage import get_raw_mime_storage
from app.services.thread_service import threading_fields_from_parent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL" MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
MESSAGE_SCOPE_HANDOFF = "HANDOFF"
class ExternalReplyThreadingError(RuntimeError):
pass
@dataclass(frozen=True) @dataclass(frozen=True)
@ -38,9 +48,169 @@ class ExternalReplyPlan:
def internet_message_id_of(message: dict) -> str | None: def internet_message_id_of(message: dict) -> str | None:
return normalize_message_id( stored = canonical_message_id(
message.get("internet_message_id") or message.get("message_id") message.get("internet_message_id") or message.get("message_id")
) )
raw_header = (message.get("raw_headers") or {}).get("message_id")
raw_canonical = canonical_message_id(raw_header)
if raw_canonical and stored:
if folded_message_id(raw_canonical) == folded_message_id(stored):
return raw_canonical
return stored or raw_canonical
async def resolve_external_reply_parent(
database,
*,
account_id: str,
external_thread_id: ObjectId,
customer_address: str,
handoff: dict | None = None,
) -> dict | None:
"""Pick the latest external inbound customer message with a real Message-ID."""
customer = customer_address.lower()
pipeline = [
{
"$match": {
"account_id": account_id,
"thread_id": external_thread_id,
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"deletion_state": {"$in": [None, ""]},
}
},
{"$sort": {"event_at": -1, "_id": -1}},
{
"$lookup": {
"from": "messages",
"localField": "message_id",
"foreignField": "_id",
"as": "message",
}
},
{"$unwind": "$message"},
{
"$match": {
"message.message_scope": {"$in": [MESSAGE_SCOPE_EXTERNAL, None]},
"message.direction": "inbound",
"$or": [
{"message.internet_message_id": {"$gt": ""}},
{"message.message_id": {"$gt": ""}},
],
}
},
{"$limit": 1},
]
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
if rows:
return rows[0]["message"]
if handoff and handoff.get("trigger_external_message_id"):
trigger = await database.messages.find_one(
{
"_id": handoff["trigger_external_message_id"],
"message_scope": MESSAGE_SCOPE_EXTERNAL,
}
)
if trigger and internet_message_id_of(trigger):
return trigger
trigger_internet = handoff.get("trigger_internet_message_id") if handoff else None
if trigger_internet:
folded = folded_message_id(trigger_internet)
trigger = await database.messages.find_one(
{
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"message_id_folded": folded,
}
)
if trigger:
return trigger
return None
def validate_external_reply_parent(parent_external_message: dict) -> None:
parent_id = internet_message_id_of(parent_external_message)
if not parent_id:
raise ExternalReplyThreadingError(
"External reply parent is missing internet_message_id"
)
scope = parent_external_message.get("message_scope")
if scope == MESSAGE_SCOPE_HANDOFF:
raise ExternalReplyThreadingError(
"External reply parent must not be a HANDOFF-scoped message"
)
async def assert_in_reply_to_resolves_to_external_parent(
database,
in_reply_to: str | None,
) -> dict:
"""Ensure In-Reply-To points at an EXTERNAL message, never HANDOFF."""
canonical = canonical_message_id(in_reply_to)
folded = folded_message_id(canonical)
if not canonical or not folded:
raise ExternalReplyThreadingError("External reply is missing In-Reply-To")
parent = await database.messages.find_one(
{
"$or": [
{"message_id_folded": folded},
{"message_id": canonical},
{"internet_message_id": canonical},
],
}
)
if parent is None:
raise ExternalReplyThreadingError(
f"In-Reply-To {canonical} does not resolve to a known message"
)
scope = parent.get("message_scope")
if scope == MESSAGE_SCOPE_HANDOFF:
raise ExternalReplyThreadingError(
f"In-Reply-To {canonical} resolves to HANDOFF-scoped message"
)
if scope not in (MESSAGE_SCOPE_EXTERNAL, None):
raise ExternalReplyThreadingError(
f"In-Reply-To {canonical} resolves to non-external message scope={scope}"
)
return parent
async def assert_external_threading_chain(
database,
*,
in_reply_to: str | None,
references: list[str] | None,
) -> None:
"""Ensure outbound headers only reference EXTERNAL messages."""
await assert_in_reply_to_resolves_to_external_parent(database, in_reply_to)
folded_targets = {
folded_message_id(value)
for value in [*(references or [])]
if folded_message_id(value)
}
in_reply_folded = folded_message_id(in_reply_to)
if in_reply_folded:
folded_targets.discard(in_reply_folded)
for folded in folded_targets:
handoff_hit = await database.messages.find_one(
{
"$or": [
{"message_id_folded": folded},
{"message_id": folded},
],
"message_scope": MESSAGE_SCOPE_HANDOFF,
},
{"_id": 1},
)
if handoff_hit:
raise ExternalReplyThreadingError(
"External reply threading references a HANDOFF message"
)
def build_external_reply_plan( def build_external_reply_plan(
@ -50,9 +220,31 @@ def build_external_reply_plan(
body_html: str | None = None, body_html: str | None = None,
subject_override: str | None = None, subject_override: str | None = None,
recipients: list[str], recipients: list[str],
parent_internet_id_override: str | None = None,
) -> ExternalReplyPlan: ) -> ExternalReplyPlan:
parent_internet_id = canonical_message_id(
parent_internet_id_override
) or internet_message_id_of(parent_external_message)
if not parent_internet_id:
raise ExternalReplyThreadingError(
"External reply parent is missing internet_message_id"
)
scope = parent_external_message.get("message_scope")
if scope == MESSAGE_SCOPE_HANDOFF:
raise ExternalReplyThreadingError(
"External reply parent must not be a HANDOFF-scoped message"
)
threading_fields = threading_fields_from_parent(parent_external_message) threading_fields = threading_fields_from_parent(parent_external_message)
parent_internet_id = internet_message_id_of(parent_external_message) in_reply_to = parent_internet_id or threading_fields.get("in_reply_to")
references = threading_fields.get("references", [])
if parent_internet_id:
references = build_references_header(
[ref for ref in references if folded_message_id(ref) != folded_message_id(parent_internet_id)],
parent_internet_id,
).split()
elif not references and in_reply_to:
references = [in_reply_to]
if subject_override: if subject_override:
subject = subject_override subject = subject_override
else: else:
@ -67,8 +259,8 @@ def build_external_reply_plan(
body_text=body_text, body_text=body_text,
body_html=body_html, body_html=body_html,
threading=ExternalReplyThreading( threading=ExternalReplyThreading(
in_reply_to=threading_fields.get("in_reply_to"), in_reply_to=in_reply_to,
references=threading_fields.get("references", []), references=references,
parent_internal_message_id=parent_external_message["_id"], parent_internal_message_id=parent_external_message["_id"],
replying_to_internet_message_id=parent_internet_id, replying_to_internet_message_id=parent_internet_id,
), ),
@ -76,6 +268,33 @@ def build_external_reply_plan(
) )
def log_original_external_message(
*,
internal_id: ObjectId | str,
thread_id: ObjectId | str | None,
internet_message_id: str | None,
sender: str | None,
recipient: str | None,
subject: str | None,
in_reply_to: str | None,
references: list[str] | None,
raw_message_available: bool,
) -> None:
logger.info(
"original_external_message internal_id=%s thread_id=%s internet_message_id=%s "
"from=%s to=%s subject=%s in_reply_to=%s references=%s raw_message_available=%s",
internal_id,
thread_id,
internet_message_id,
sender,
recipient,
subject,
in_reply_to,
references,
raw_message_available,
)
def log_smtp_message_sending( def log_smtp_message_sending(
*, *,
generated_message_id: str | None, generated_message_id: str | None,
@ -114,7 +333,30 @@ async def send_external_reply(
plan: ExternalReplyPlan, plan: ExternalReplyPlan,
domain: MailDomain, domain: MailDomain,
resolution_method: str = "in_reply_to", resolution_method: str = "in_reply_to",
generated_by: str | None = None,
generated_from_handoff_message_id: ObjectId | None = None,
message_origin: str | None = None,
) -> dict: ) -> dict:
if generated_from_handoff_message_id:
existing = await database.messages.find_one(
{
"generated_from_handoff_message_id": generated_from_handoff_message_id,
"message_scope": MESSAGE_SCOPE_EXTERNAL,
}
)
if existing:
logger.info(
"external_reply_idempotent_skip handoff_message=%s external=%s",
generated_from_handoff_message_id,
existing["_id"],
)
return existing
await assert_external_threading_chain(
database,
in_reply_to=plan.threading.in_reply_to,
references=plan.threading.references,
)
log_smtp_message_sending( log_smtp_message_sending(
generated_message_id=None, generated_message_id=None,
in_reply_to=plan.threading.in_reply_to, in_reply_to=plan.threading.in_reply_to,
@ -139,7 +381,8 @@ async def send_external_reply(
in_reply_to=plan.threading.in_reply_to, in_reply_to=plan.threading.in_reply_to,
references=plan.threading.references, references=plan.threading.references,
) )
normalized_id = normalize_message_id(internet_message_id) normalized_id = canonical_message_id(internet_message_id)
id_fields = message_id_fields(normalized_id)
log_smtp_message_sending( log_smtp_message_sending(
generated_message_id=normalized_id, generated_message_id=normalized_id,
in_reply_to=plan.threading.in_reply_to, in_reply_to=plan.threading.in_reply_to,
@ -167,16 +410,23 @@ async def send_external_reply(
"created_at": now, "created_at": now,
"sent_at": now, "sent_at": now,
"status": "accepted", "status": "accepted",
"message_id": normalized_id, **id_fields,
"internet_message_id": normalized_id,
"in_reply_to": plan.threading.in_reply_to, "in_reply_to": plan.threading.in_reply_to,
"references": plan.threading.references, "references": plan.threading.references,
"parent_message_id": plan.threading.parent_internal_message_id, "parent_message_id": plan.threading.parent_internal_message_id,
"thread_relation": "reply", "thread_relation": "reply",
"thread_resolution_method": resolution_method, "thread_resolution_method": resolution_method,
} }
if generated_by:
document["generated_by"] = generated_by
if generated_from_handoff_message_id:
document["generated_from_handoff_message_id"] = generated_from_handoff_message_id
if message_origin:
document["message_origin"] = message_origin
result = await database.messages.insert_one(document) result = await database.messages.insert_one(document)
document["_id"] = result.inserted_id document["_id"] = result.inserted_id
from app.services.thread_service import attach_message_to_thread
await attach_message_to_thread( await attach_message_to_thread(
database, database,
account_id=account_id, account_id=account_id,

View file

@ -0,0 +1,62 @@
"""Decide whether external messages should be mirrored into a Human Handoff thread."""
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
# Message provenance — who authored / generated the content
MESSAGE_ORIGIN_EXTERNAL_CUSTOMER = "EXTERNAL_CUSTOMER"
MESSAGE_ORIGIN_HUMAN_OWNER = "HUMAN_OWNER"
MESSAGE_ORIGIN_AGENT = "AGENT"
MESSAGE_ORIGIN_SYSTEM = "SYSTEM"
MESSAGE_ORIGIN_HANDOFF_MIRROR = "HANDOFF_MIRROR"
GENERATED_BY_CUSTOMER = "CUSTOMER"
GENERATED_BY_HUMAN = "HUMAN"
GENERATED_BY_AGENT = "AGENT"
GENERATED_BY_HANDOFF_BRIDGE = "HANDOFF_BRIDGE"
GENERATED_BY_SYSTEM = "SYSTEM"
GENERATED_BY_RELAY_ACK = "RELAY_ACK"
RELAY_STATUS_SENT = "SENT"
RELAY_STATUS_FAILED = "FAILED"
RELAY_STATUS_PENDING = "PENDING"
def should_mirror_to_human(message: dict) -> bool:
"""Return True only for new external customer content worth mirroring."""
scope = message.get("message_scope", MESSAGE_SCOPE_EXTERNAL)
if scope != MESSAGE_SCOPE_EXTERNAL:
return False
if message.get("direction") != "inbound":
return False
generated_by = message.get("generated_by")
if generated_by in (
GENERATED_BY_HANDOFF_BRIDGE,
GENERATED_BY_RELAY_ACK,
GENERATED_BY_SYSTEM,
):
return False
origin = message.get("message_origin")
if origin in (
MESSAGE_ORIGIN_HUMAN_OWNER,
MESSAGE_ORIGIN_SYSTEM,
MESSAGE_ORIGIN_HANDOFF_MIRROR,
):
return False
if message.get("generated_from_handoff_message_id"):
return False
return True
def is_relay_acknowledgment_message(message: dict) -> bool:
"""Detect legacy or internal delivery-receipt messages in handoff threads."""
if message.get("generated_by") == GENERATED_BY_RELAY_ACK:
return True
if message.get("message_origin") == MESSAGE_ORIGIN_SYSTEM:
return True
body = (message.get("body_text") or message.get("snippet") or "").strip()
return message.get("direction") == "outbound" and body.startswith("Sent to customer")

View file

@ -5,18 +5,37 @@ from datetime import datetime, timezone
from bson import ObjectId from bson import ObjectId
from app.services.external_reply_service import ( from app.services.external_reply_service import (
ExternalReplyThreadingError,
build_external_reply_plan, build_external_reply_plan,
internet_message_id_of,
log_original_external_message,
resolve_external_reply_parent,
resolve_internet_message_id,
send_external_reply, send_external_reply,
) )
from app.services.handoff_email_templates import ( from app.services.handoff_email_templates import (
build_relay_confirmation_text,
external_reply_subject, external_reply_subject,
handoff_email_subject, handoff_email_subject,
) )
from app.services.handoff_mirror_policy import (
GENERATED_BY_HANDOFF_BRIDGE,
GENERATED_BY_HUMAN,
GENERATED_BY_SYSTEM,
MESSAGE_ORIGIN_HANDOFF_MIRROR,
MESSAGE_ORIGIN_HUMAN_OWNER,
RELAY_STATUS_FAILED,
RELAY_STATUS_SENT,
should_mirror_to_human,
)
from app.services.handoff_mirror import load_mirror_payload, load_mirror_update_payload from app.services.handoff_mirror import load_mirror_payload, load_mirror_update_payload
from app.services.handoff_evaluator import evaluate_handoff from app.services.handoff_evaluator import evaluate_handoff
from app.services.mail_sender import deliver_email from app.services.mail_sender import deliver_email
from app.services.message_ids import normalize_message_id, parse_message_id_list from app.services.message_ids import (
canonical_message_id,
folded_message_id,
message_id_fields,
parse_message_id_list,
)
from app.services.reply_extractor import extract_new_reply_content, validate_external_reply_body from app.services.reply_extractor import extract_new_reply_content, validate_external_reply_body
from app.services.thread_service import ( from app.services.thread_service import (
attach_message_to_thread, attach_message_to_thread,
@ -226,9 +245,13 @@ async def resolve_handoff_from_inbound(
return None return None
for internet_id in reversed(parse_message_id_list(" ".join(search_ids))): for internet_id in reversed(parse_message_id_list(" ".join(search_ids))):
folded = folded_message_id(internet_id)
message = await database.messages.find_one( message = await database.messages.find_one(
{ {
"message_id": internet_id, "$or": [
{"message_id_folded": folded},
{"message_id": internet_id},
],
"message_scope": MESSAGE_SCOPE_HANDOFF, "message_scope": MESSAGE_SCOPE_HANDOFF,
} }
) )
@ -256,6 +279,26 @@ async def resolve_handoff_from_inbound(
return None return None
async def _handoff_mirror_exists(database, external_message_id: ObjectId) -> bool:
existing = await database.messages.find_one(
{
"mirrored_from_external_message_id": external_message_id,
"message_scope": MESSAGE_SCOPE_HANDOFF,
},
{"_id": 1},
)
return existing is not None
async def _bridged_external_exists(database, handoff_message_id: ObjectId) -> dict | None:
return await database.messages.find_one(
{
"generated_from_handoff_message_id": handoff_message_id,
"message_scope": MESSAGE_SCOPE_EXTERNAL,
}
)
async def store_handoff_message( async def store_handoff_message(
database, database,
*, *,
@ -277,6 +320,11 @@ async def store_handoff_message(
raw_body: str | None = None, raw_body: str | None = None,
new_reply_content: str | None = None, new_reply_content: str | None = None,
body_html: str | None = None, body_html: str | None = None,
message_origin: str | None = None,
generated_by: str | None = None,
mirrored_from_external_message_id: ObjectId | None = None,
relay_status: str | None = None,
generated_external_message_id: ObjectId | None = None,
) -> dict: ) -> dict:
now = event_at or _now() now = event_at or _now()
display_body = new_reply_content if new_reply_content is not None else body_text display_body = new_reply_content if new_reply_content is not None else body_text
@ -297,11 +345,20 @@ async def store_handoff_message(
"sent_at": now if direction == "outbound" else None, "sent_at": now if direction == "outbound" else None,
"received_at": now if direction == "inbound" else None, "received_at": now if direction == "inbound" else None,
"status": "received" if direction == "inbound" else "accepted", "status": "received" if direction == "inbound" else "accepted",
"message_id": normalize_message_id(internet_message_id), **message_id_fields(internet_message_id),
"internet_message_id": normalize_message_id(internet_message_id), "in_reply_to": canonical_message_id(in_reply_to),
"in_reply_to": normalize_message_id(in_reply_to),
"references": references or [], "references": references or [],
} }
if message_origin:
document["message_origin"] = message_origin
if generated_by:
document["generated_by"] = generated_by
if mirrored_from_external_message_id:
document["mirrored_from_external_message_id"] = mirrored_from_external_message_id
if relay_status:
document["relay_status"] = relay_status
if generated_external_message_id:
document["generated_external_message_id"] = generated_external_message_id
result = await database.messages.insert_one(document) result = await database.messages.insert_one(document)
document["_id"] = result.inserted_id document["_id"] = result.inserted_id
await attach_message_to_thread( await attach_message_to_thread(
@ -357,6 +414,23 @@ async def send_handoff_to_human(
domain, domain,
existing_handoff: dict | None = None, existing_handoff: dict | None = None,
) -> dict: ) -> dict:
if await _handoff_mirror_exists(database, external_message["_id"]):
logger.info(
"handoff_mirror_skipped_duplicate external_message=%s",
external_message["_id"],
)
if existing_handoff:
return existing_handoff
existing = await database.human_handoffs.find_one(
{
"account_id": account_id,
"external_thread_id": external_thread_id,
}
)
if existing:
return existing
raise RuntimeError("Handoff mirror duplicate without handoff record")
external_subject = external_message.get("subject", "") external_subject = external_message.get("subject", "")
stable_subject = handoff_email_subject(external_subject) stable_subject = handoff_email_subject(external_subject)
mirror_source = {**external_message, "_agent_email": agent_email} mirror_source = {**external_message, "_agent_email": agent_email}
@ -394,6 +468,8 @@ async def send_handoff_to_human(
"updated_at": _now(), "updated_at": _now(),
"last_external_message_at": message_event_at(external_message), "last_external_message_at": message_event_at(external_message),
"last_human_message_at": None, "last_human_message_at": None,
"trigger_external_message_id": external_message["_id"],
"trigger_internet_message_id": internet_message_id_of(external_message),
} }
insert = await database.human_handoffs.insert_one(handoff_doc) insert = await database.human_handoffs.insert_one(handoff_doc)
handoff_id = insert.inserted_id handoff_id = insert.inserted_id
@ -431,6 +507,9 @@ async def send_handoff_to_human(
handoff_id=handoff_id, handoff_id=handoff_id,
link_relationship=relationship, link_relationship=relationship,
external_message_id=external_message["_id"], external_message_id=external_message["_id"],
message_origin=MESSAGE_ORIGIN_HANDOFF_MIRROR,
generated_by=GENERATED_BY_SYSTEM,
mirrored_from_external_message_id=external_message["_id"],
) )
await database.human_handoffs.update_one( await database.human_handoffs.update_one(
@ -504,7 +583,8 @@ async def process_human_reply(
in_reply_to=inbound_parsed.get("in_reply_to"), in_reply_to=inbound_parsed.get("in_reply_to"),
references=inbound_parsed.get("references", []), references=inbound_parsed.get("references", []),
handoff_id=handoff["_id"], handoff_id=handoff["_id"],
link_relationship=LINK_HUMAN_TRIGGERED_EXTERNAL, message_origin=MESSAGE_ORIGIN_HUMAN_OWNER,
generated_by=GENERATED_BY_HUMAN,
) )
now = _now() now = _now()
@ -550,22 +630,68 @@ async def process_human_reply(
return return
external_recipient = handoff.get("external_sender") or "" external_recipient = handoff.get("external_sender") or ""
external_parent = await _latest_external_customer_message( external_parent = await resolve_external_reply_parent(
database, database,
account_id, account_id=account_id,
external_thread_id, external_thread_id=external_thread_id,
external_recipient, customer_address=external_recipient,
handoff=handoff,
) )
if external_parent is None: if external_parent is None:
external_parent = await _latest_external_message(database, account_id, external_thread_id) logger.warning(
if external_parent is None: "handoff_reply_missing_external_parent handoff_id=%s external_thread=%s "
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"]) "trigger_internet_message_id=%s",
handoff["_id"],
external_thread_id,
handoff.get("trigger_internet_message_id"),
)
return return
external_recipient = external_recipient or external_parent.get("sender", {}).get("address") parent_sender = (external_parent.get("sender") or {}).get("address")
log_original_external_message(
internal_id=external_parent["_id"],
thread_id=external_thread_id,
internet_message_id=internet_message_id_of(external_parent),
sender=parent_sender,
recipient=agent_email,
subject=external_parent.get("subject"),
in_reply_to=external_parent.get("in_reply_to"),
references=external_parent.get("references"),
raw_message_available=bool(external_parent.get("raw_mime")),
)
external_recipient = external_recipient or parent_sender
if not external_recipient: if not external_recipient:
return return
parent_internet_id = await resolve_internet_message_id(database, external_parent)
if not parent_internet_id:
logger.error(
"handoff_reply_missing_parent_message_id handoff_id=%s parent_internal_id=%s",
handoff["_id"],
external_parent["_id"],
)
return
logger.info(
"handoff_external_reply_threading handoff_id=%s parent_internal_id=%s "
"parent_internet_message_id=%s human_in_reply_to=%s human_references=%s",
handoff["_id"],
external_parent["_id"],
parent_internet_id,
human_message.get("in_reply_to"),
human_message.get("references"),
)
existing_bridge = await _bridged_external_exists(database, human_message["_id"])
if existing_bridge:
logger.info(
"handoff_bridge_already_processed handoff_message=%s external=%s",
human_message["_id"],
existing_bridge["_id"],
)
return
customer_body = new_reply_content customer_body = new_reply_content
reply_plan = build_external_reply_plan( reply_plan = build_external_reply_plan(
parent_external_message=external_parent, parent_external_message=external_parent,
@ -574,7 +700,15 @@ async def process_human_reply(
handoff.get("external_subject") or external_parent.get("subject", "") handoff.get("external_subject") or external_parent.get("subject", "")
), ),
recipients=[external_recipient], recipients=[external_recipient],
parent_internet_id_override=parent_internet_id,
) )
logger.info(
"handoff_external_reply_headers handoff_id=%s b_in_reply_to=%s b_references=%s",
handoff["_id"],
reply_plan.threading.in_reply_to,
reply_plan.threading.references,
)
try:
external_doc = await send_external_reply( external_doc = await send_external_reply(
database, database,
account_id=account_id, account_id=account_id,
@ -584,52 +718,69 @@ async def process_human_reply(
plan=reply_plan, plan=reply_plan,
domain=domain, domain=domain,
resolution_method="human_handoff_bridge", resolution_method="human_handoff_bridge",
generated_by=GENERATED_BY_HANDOFF_BRIDGE,
generated_from_handoff_message_id=human_message["_id"],
) )
except ExternalReplyThreadingError:
await database.messages.update_one(
{"_id": human_message["_id"]},
{"$set": {"relay_status": RELAY_STATUS_FAILED, "relay_failed_at": now}},
)
logger.error(
"handoff_bridge_threading_rejected handoff_id=%s handoff_message=%s "
"parent_internet_message_id=%s",
handoff["_id"],
human_message["_id"],
parent_internet_id,
)
return
except Exception:
await database.messages.update_one(
{"_id": human_message["_id"]},
{"$set": {"relay_status": RELAY_STATUS_FAILED, "relay_failed_at": now}},
)
logger.exception(
"handoff_bridge_send_failed handoff_id=%s handoff_message=%s",
handoff["_id"],
human_message["_id"],
)
raise
relay_body = build_relay_confirmation_text(customer_body) await database.messages.update_one(
relay_subject = _handoff_reply_subject(handoff.get("external_subject", "")) {"_id": human_message["_id"]},
relay_threading = threading_fields_from_parent(human_message) {
relay_internet_id = await deliver_email( "$set": {
sender=agent_email, "relay_status": RELAY_STATUS_SENT,
recipients=[human_email], "generated_external_message_id": external_doc["_id"],
cc=[], "relay_delivered_at": now,
bcc=[], }
subject=relay_subject, },
body_text=relay_body,
body_html=None,
domain=domain,
in_reply_to=relay_threading.get("in_reply_to"),
references=relay_threading.get("references"),
) )
await store_handoff_message( await _record_message_link(
database, database,
account_id=account_id,
agent_email=agent_email,
handoff_thread_id=handoff_thread_id,
direction="outbound",
sender={"name": "Gentic Agent", "address": agent_email},
recipients=[{"name": human_email.split("@")[0], "address": human_email}],
subject=relay_subject,
body_text=relay_body,
internet_message_id=relay_internet_id,
in_reply_to=relay_threading.get("in_reply_to"),
references=relay_threading.get("references"),
handoff_id=handoff["_id"], handoff_id=handoff["_id"],
link_relationship=LINK_EXTERNAL_RELAYED, external_message_id=external_doc["_id"],
external_message_id=ext_result.inserted_id, handoff_message_id=human_message["_id"],
relationship=LINK_HUMAN_TRIGGERED_EXTERNAL,
) )
await database.human_handoffs.update_one( await database.human_handoffs.update_one(
{"_id": handoff["_id"]}, {"_id": handoff["_id"]},
{ {
"$set": { "$set": {
"status": HANDOFF_STATUS_AGENT_REPLIED, "status": HANDOFF_STATUS_WAITING,
"current_owner": OWNER_AGENT, "current_owner": OWNER_HUMAN,
"updated_at": now, "updated_at": now,
"last_human_message_at": now, "last_human_message_at": now,
} }
}, },
) )
logger.info(
"handoff_bridge_complete handoff_id=%s handoff_message=%s external=%s",
handoff["_id"],
human_message["_id"],
external_doc["_id"],
)
async def maybe_trigger_handoff( async def maybe_trigger_handoff(
@ -641,6 +792,40 @@ async def maybe_trigger_handoff(
external_thread_id: ObjectId, external_thread_id: ObjectId,
domain, domain,
) -> None: ) -> None:
if not should_mirror_to_human(external_message):
logger.debug(
"handoff_mirror_skipped message_id=%s generated_by=%s origin=%s direction=%s",
external_message.get("_id"),
external_message.get("generated_by"),
external_message.get("message_origin"),
external_message.get("direction"),
)
return
existing = await find_active_handoff_for_external_thread(
database, account_id, external_thread_id
)
if existing:
if await _handoff_mirror_exists(database, external_message["_id"]):
return
rule = await _handoff_rule_for_human(
database, account_id, existing.get("human_email", "")
)
if rule and not rule.get("allow_replies", True):
return
await send_handoff_to_human(
database,
account_id=account_id,
agent_email=agent_email,
human_email=existing["human_email"],
external_message=external_message,
external_thread_id=external_thread_id,
handoff_reason=existing.get("handoff_reason", ""),
domain=domain,
existing_handoff=existing,
)
return
rules = await database.human_handoff_rules.find( rules = await database.human_handoff_rules.find(
{"account_id": account_id, "active": True} {"account_id": account_id, "active": True}
).to_list(length=20) ).to_list(length=20)
@ -667,9 +852,6 @@ async def maybe_trigger_handoff(
if not rule.get("allow_replies", True): if not rule.get("allow_replies", True):
continue continue
existing = await find_active_handoff_for_external_thread(
database, account_id, external_thread_id
)
reason = (rule.get("instructions") or "").strip() or "This conversation requires human attention." reason = (rule.get("instructions") or "").strip() or "This conversation requires human attention."
await send_handoff_to_human( await send_handoff_to_human(
database, database,
@ -680,6 +862,6 @@ async def maybe_trigger_handoff(
external_thread_id=external_thread_id, external_thread_id=external_thread_id,
handoff_reason=reason, handoff_reason=reason,
domain=domain, domain=domain,
existing_handoff=existing, existing_handoff=None,
) )
return return

View file

@ -10,6 +10,10 @@ from app.config import get_settings
from app.database import SessionFactory, mongo_db from app.database import SessionFactory, mongo_db
from app.models import Account from app.models import Account
from app.services.domain_provisioning import get_domain_by_name from app.services.domain_provisioning import get_domain_by_name
from app.services.handoff_mirror_policy import (
GENERATED_BY_CUSTOMER,
MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
)
from app.services.human_handoff_service import ( from app.services.human_handoff_service import (
MESSAGE_SCOPE_EXTERNAL, MESSAGE_SCOPE_EXTERNAL,
MESSAGE_SCOPE_HANDOFF, MESSAGE_SCOPE_HANDOFF,
@ -17,7 +21,8 @@ from app.services.human_handoff_service import (
resolve_handoff_from_inbound, resolve_handoff_from_inbound,
maybe_trigger_handoff, maybe_trigger_handoff,
) )
from app.services.message_ids import normalize_message_id from app.services.external_reply_service import log_original_external_message
from app.services.message_ids import canonical_message_id, message_id_fields
from app.services.mime_parser import parse_mime from app.services.mime_parser import parse_mime
from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage
from app.services.thread_resolver import participants_from_parsed, resolve_thread from app.services.thread_resolver import participants_from_parsed, resolve_thread
@ -128,7 +133,8 @@ class InboundHandler:
stored = await get_raw_mime_storage().put(raw, delivery_key) stored = await get_raw_mime_storage().put(raw, delivery_key)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
sender_address = (parsed.get("sender") or {}).get("address", "").lower() sender_address = (parsed.get("sender") or {}).get("address", "").lower()
internet_message_id = normalize_message_id(parsed.get("message_id")) internet_message_id = canonical_message_id(parsed.get("message_id"))
id_fields = message_id_fields(internet_message_id)
for recipient in recipients: for recipient in recipients:
account_id = envelope.account_ids[recipient] account_id = envelope.account_ids[recipient]
@ -160,8 +166,7 @@ class InboundHandler:
"delivery_key": delivery_key, "delivery_key": delivery_key,
"received_at": now, "received_at": now,
"created_at": now, "created_at": now,
"message_id": internet_message_id, **id_fields,
"internet_message_id": internet_message_id,
} }
await process_human_reply( await process_human_reply(
mongo_db, mongo_db,
@ -194,8 +199,7 @@ class InboundHandler:
"envelope_sender": envelope.mail_from.lower(), "envelope_sender": envelope.mail_from.lower(),
"envelope_recipients": recipients, "envelope_recipients": recipients,
"delivery_key": delivery_key, "delivery_key": delivery_key,
"message_id": internet_message_id, **id_fields,
"internet_message_id": internet_message_id,
"raw_mime": { "raw_mime": {
"bucket": stored.bucket, "bucket": stored.bucket,
"name": stored.name, "name": stored.name,
@ -252,14 +256,15 @@ class InboundHandler:
{ {
"$set": { "$set": {
"message_scope": MESSAGE_SCOPE_EXTERNAL, "message_scope": MESSAGE_SCOPE_EXTERNAL,
"message_id": internet_message_id, **id_fields,
"internet_message_id": internet_message_id, "in_reply_to": canonical_message_id(parsed.get("in_reply_to")),
"in_reply_to": normalize_message_id(parsed.get("in_reply_to")),
"references": parsed.get("references", []), "references": parsed.get("references", []),
"raw_headers": parsed.get("raw_headers", {}), "raw_headers": parsed.get("raw_headers", {}),
"parent_message_id": resolution.parent_message_id, "parent_message_id": resolution.parent_message_id,
"thread_relation": resolution.thread_relation, "thread_relation": resolution.thread_relation,
"thread_resolution_method": resolution.thread_resolution_method, "thread_resolution_method": resolution.thread_resolution_method,
"message_origin": MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
"generated_by": GENERATED_BY_CUSTOMER,
} }
}, },
) )
@ -298,6 +303,14 @@ class InboundHandler:
database_session, database_session,
agent_email.split("@", 1)[1], agent_email.split("@", 1)[1],
) )
persisted.update(
{
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"direction": "inbound",
"message_origin": MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
"generated_by": GENERATED_BY_CUSTOMER,
}
)
if domain is not None: if domain is not None:
await maybe_trigger_handoff( await maybe_trigger_handoff(
mongo_db, mongo_db,
@ -307,6 +320,17 @@ class InboundHandler:
external_thread_id=thread_id, external_thread_id=thread_id,
domain=domain, domain=domain,
) )
log_original_external_message(
internal_id=persisted["_id"],
thread_id=thread_id,
internet_message_id=internet_message_id,
sender=sender_address,
recipient=recipient,
subject=parsed.get("subject"),
in_reply_to=canonical_message_id(parsed.get("in_reply_to")),
references=parsed.get("references", []),
raw_message_available=bool(persisted.get("raw_mime")),
)
log_smtp_message_received( log_smtp_message_received(
message_id=internet_message_id, message_id=internet_message_id,
in_reply_to=parsed.get("in_reply_to"), in_reply_to=parsed.get("in_reply_to"),

View file

@ -1,5 +1,7 @@
import email.policy import email.policy
import email.utils import email.utils
import logging
from email.parser import BytesParser
from email.message import EmailMessage, Message from email.message import EmailMessage, Message
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
@ -13,6 +15,20 @@ from app.models import MailDomain
from app.services.mime_body_extractor import ExtractedMimePart from app.services.mime_body_extractor import ExtractedMimePart
SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998) SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998)
logger = logging.getLogger(__name__)
def log_outbound_mime_headers(message: Message) -> None:
logger.info(
"smtp_outbound_mime_headers message_id=%s in_reply_to=%s references=%s "
"from=%s to=%s subject=%s",
message.get("Message-ID"),
message.get("In-Reply-To"),
message.get("References"),
message.get("From"),
message.get("To"),
message.get("Subject"),
)
def _mime_attachment(part: ExtractedMimePart) -> MIMEBase: def _mime_attachment(part: ExtractedMimePart) -> MIMEBase:
@ -60,7 +76,9 @@ def build_message(
root["Message-ID"] = generated_id root["Message-ID"] = generated_id
if in_reply_to: if in_reply_to:
root["In-Reply-To"] = in_reply_to root["In-Reply-To"] = in_reply_to
if references: refs = references or [in_reply_to]
root["References"] = " ".join(refs)
elif references:
root["References"] = " ".join(references) root["References"] = " ".join(references)
alternative = MIMEMultipart("alternative", policy=SMTP_POLICY) alternative = MIMEMultipart("alternative", policy=SMTP_POLICY)
@ -94,7 +112,9 @@ def build_message(
message["Message-ID"] = generated_id message["Message-ID"] = generated_id
if in_reply_to: if in_reply_to:
message["In-Reply-To"] = in_reply_to message["In-Reply-To"] = in_reply_to
if references: refs = references or [in_reply_to]
message["References"] = " ".join(refs)
elif references:
message["References"] = " ".join(references) message["References"] = " ".join(references)
message.set_content(body_text) message.set_content(body_text)
if body_html: if body_html:
@ -115,6 +135,10 @@ def sign_message(message: Message, domain: MailDomain) -> bytes:
] ]
if message.get("Cc"): if message.get("Cc"):
signed_headers.insert(3, b"cc") signed_headers.insert(3, b"cc")
if message.get("In-Reply-To"):
signed_headers.append(b"in-reply-to")
if message.get("References"):
signed_headers.append(b"references")
signature = dkim.sign( signature = dkim.sign(
raw_message, raw_message,
domain.dkim_selector.encode(), domain.dkim_selector.encode(),
@ -180,7 +204,9 @@ async def deliver_email(
inline_parts=inline_parts, inline_parts=inline_parts,
file_attachments=file_attachments, file_attachments=file_attachments,
) )
log_outbound_mime_headers(message)
signed = sign_message(message, domain) signed = sign_message(message, domain)
log_signed_mime_headers(signed)
await send_via_smtp( await send_via_smtp(
signed, signed,
sender=sender, sender=sender,

View file

@ -3,7 +3,8 @@ import re
_MESSAGE_ID_PATTERN = re.compile(r"<[^>]+>") _MESSAGE_ID_PATTERN = re.compile(r"<[^>]+>")
def normalize_message_id(value: str | None) -> str | None: def canonical_message_id(value: str | None) -> str | None:
"""Normalize to angle-bracket form while preserving original case."""
if value is None: if value is None:
return None return None
cleaned = value.strip() cleaned = value.strip()
@ -11,22 +12,59 @@ def normalize_message_id(value: str | None) -> str | None:
return None return None
if not cleaned.startswith("<"): if not cleaned.startswith("<"):
cleaned = f"<{cleaned.strip('<>')}>" cleaned = f"<{cleaned.strip('<>')}>"
return cleaned.lower() return cleaned
def folded_message_id(value: str | None) -> str | None:
"""Case-insensitive lookup key for Message-ID matching."""
canonical = canonical_message_id(value)
return canonical.casefold() if canonical else None
def normalize_message_id(value: str | None) -> str | None:
"""Preserve case — Gmail threading requires exact Message-ID tokens."""
return canonical_message_id(value)
def internet_message_id_of_message(message: dict) -> str | None:
"""Resolve Message-ID, preferring raw MIME header case when stored value was folded."""
stored = canonical_message_id(
message.get("internet_message_id") or message.get("message_id")
)
raw_header = (message.get("raw_headers") or {}).get("message_id")
raw_canonical = canonical_message_id(raw_header)
if raw_canonical and stored:
if folded_message_id(raw_canonical) == folded_message_id(stored):
return raw_canonical
return stored or raw_canonical
def message_id_fields(value: str | None) -> dict[str, str]:
canonical = canonical_message_id(value)
if not canonical:
return {}
folded = folded_message_id(canonical)
return {
"message_id": canonical,
"internet_message_id": canonical,
"message_id_folded": folded or canonical.casefold(),
}
def parse_message_id_list(header_value: str | None) -> list[str]: def parse_message_id_list(header_value: str | None) -> list[str]:
if not header_value: if not header_value:
return [] return []
folded = re.sub(r"\s+", " ", header_value.strip()) folded_header = re.sub(r"\s+", " ", header_value.strip())
seen: set[str] = set() seen: set[str] = set()
ordered: list[str] = [] ordered: list[str] = []
for match in _MESSAGE_ID_PATTERN.findall(folded): for match in _MESSAGE_ID_PATTERN.findall(folded_header):
normalized = normalize_message_id(match) canonical = canonical_message_id(match)
if normalized and normalized not in seen: key = folded_message_id(canonical)
seen.add(normalized) if canonical and key and key not in seen:
ordered.append(normalized) seen.add(key)
ordered.append(canonical)
if not ordered: if not ordered:
single = normalize_message_id(folded) single = canonical_message_id(folded_header)
if single: if single:
ordered.append(single) ordered.append(single)
return ordered return ordered
@ -41,11 +79,13 @@ def build_references_header(references: list[str], parent_message_id: str) -> st
chain: list[str] = [] chain: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for value in references: for value in references:
normalized = normalize_message_id(value) canonical = canonical_message_id(value)
if normalized and normalized not in seen: key = folded_message_id(canonical)
seen.add(normalized) if canonical and key and key not in seen:
chain.append(normalized) seen.add(key)
parent = normalize_message_id(parent_message_id) chain.append(canonical)
if parent and parent not in seen: parent = canonical_message_id(parent_message_id)
parent_key = folded_message_id(parent)
if parent and parent_key and parent_key not in seen:
chain.append(parent) chain.append(parent)
return " ".join(chain) return " ".join(chain)

View file

@ -28,6 +28,13 @@ def _text(part) -> str:
return value if isinstance(value, str) else "" return value if isinstance(value, str) else ""
def _header_str(value) -> str | None:
if value is None:
return None
cleaned = str(value).strip()
return cleaned or None
def _snippet(text: str, html: str | None, limit: int = 240) -> str: def _snippet(text: str, html: str | None, limit: int = 240) -> str:
source = text source = text
if not source and html: if not source and html:
@ -81,15 +88,15 @@ def parse_mime(raw: bytes) -> dict:
in_reply_to = first_message_id(message.get("In-Reply-To")) in_reply_to = first_message_id(message.get("In-Reply-To"))
references = parse_message_id_list(message.get("References")) references = parse_message_id_list(message.get("References"))
raw_headers = { raw_headers = {
"message_id": message.get("Message-ID"), "message_id": _header_str(message.get("Message-ID")),
"in_reply_to": message.get("In-Reply-To"), "in_reply_to": _header_str(message.get("In-Reply-To")),
"references": message.get("References"), "references": _header_str(message.get("References")),
"subject": message.get("Subject"), "subject": _header_str(message.get("Subject")),
"date": message.get("Date"), "date": _header_str(message.get("Date")),
"from": message.get("From"), "from": _header_str(message.get("From")),
"to": message.get("To"), "to": _header_str(message.get("To")),
"cc": message.get("Cc"), "cc": _header_str(message.get("Cc")),
"reply_to": message.get("Reply-To"), "reply_to": _header_str(message.get("Reply-To")),
} }
return { return {
"message_id": internet_message_id, "message_id": internet_message_id,

View file

@ -5,8 +5,8 @@ from datetime import datetime, timedelta, timezone
from bson import ObjectId from bson import ObjectId
from app.services.message_ids import ( from app.services.message_ids import (
first_message_id, canonical_message_id,
normalize_message_id, folded_message_id,
parse_message_id_list, parse_message_id_list,
) )
from app.services.subject_normalize import normalize_subject from app.services.subject_normalize import normalize_subject
@ -52,14 +52,17 @@ def resolve_thread_from_known(
known_threads: dict[ObjectId, list[KnownMessage]] | None = None, known_threads: dict[ObjectId, list[KnownMessage]] | None = None,
now: datetime | None = None, now: datetime | None = None,
) -> ThreadResolution: ) -> ThreadResolution:
scoped = { scoped: dict[str, KnownMessage] = {}
key: value for key, value in known_by_internet_id.items():
for key, value in known_by_internet_id.items() if value.account_id != account_id:
if value.account_id == account_id continue
} fold_key = folded_message_id(key) or folded_message_id(value.internet_message_id)
in_reply = normalize_message_id(in_reply_to) if fold_key:
if in_reply and in_reply in scoped: scoped[fold_key] = value
parent = scoped[in_reply] in_reply = canonical_message_id(in_reply_to)
in_reply_folded = folded_message_id(in_reply)
if in_reply_folded and in_reply_folded in scoped:
parent = scoped[in_reply_folded]
return ThreadResolution( return ThreadResolution(
thread_id=parent.thread_id, thread_id=parent.thread_id,
parent_message_id=parent.id, parent_message_id=parent.id,
@ -68,9 +71,9 @@ def resolve_thread_from_known(
) )
reference_ids = [ reference_ids = [
normalize_message_id(value) folded_message_id(value)
for value in references for value in references
if normalize_message_id(value) if folded_message_id(value)
] ]
for reference in reversed(reference_ids): for reference in reversed(reference_ids):
if reference in scoped: if reference in scoped:
@ -155,22 +158,31 @@ async def lookup_known_messages(
account_id: str, account_id: str,
internet_ids: list[str], internet_ids: list[str],
) -> dict[str, KnownMessage]: ) -> dict[str, KnownMessage]:
normalized_ids = [ folded_ids = [
value value
for value in {normalize_message_id(item) for item in internet_ids} for value in {folded_message_id(item) for item in internet_ids}
if value if value
] ]
if not normalized_ids: if not folded_ids:
return {} return {}
pipeline = [ pipeline = [
{ {
"$match": { "$match": {
"message_id": {"$in": normalized_ids}, "$and": [
{
"$or": [
{"message_id_folded": {"$in": folded_ids}},
{"message_id": {"$in": folded_ids}},
]
},
{
"$or": [ "$or": [
{"message_scope": "EXTERNAL"}, {"message_scope": "EXTERNAL"},
{"message_scope": {"$exists": False}}, {"message_scope": {"$exists": False}},
], ]
},
]
} }
}, },
{ {
@ -198,15 +210,18 @@ async def lookup_known_messages(
known: dict[str, KnownMessage] = {} known: dict[str, KnownMessage] = {}
cursor = await database.messages.aggregate(pipeline) cursor = await database.messages.aggregate(pipeline)
async for document in cursor: async for document in cursor:
internet_id = normalize_message_id(document.get("message_id")) internet_id = canonical_message_id(
if not internet_id: document.get("internet_message_id") or document.get("message_id")
)
internet_key = folded_message_id(internet_id)
if not internet_id or not internet_key:
continue continue
entry = document["entry"][0] entry = document["entry"][0]
thread_id = entry.get("thread_id") or document.get("thread_id") thread_id = entry.get("thread_id") or document.get("thread_id")
if thread_id is None: if thread_id is None:
continue continue
participants = participants_from_parsed(document) participants = participants_from_parsed(document)
known[internet_id] = KnownMessage( known[internet_key] = KnownMessage(
id=document["_id"], id=document["_id"],
thread_id=thread_id, thread_id=thread_id,
internet_message_id=internet_id, internet_message_id=internet_id,

View file

@ -2,7 +2,11 @@ from datetime import datetime, timezone
from bson import ObjectId from bson import ObjectId
from app.services.message_ids import build_references_header, normalize_message_id from app.services.message_ids import (
build_references_header,
canonical_message_id,
internet_message_id_of_message,
)
from app.services.subject_normalize import normalize_subject from app.services.subject_normalize import normalize_subject
from app.services.thread_resolver import ThreadResolution from app.services.thread_resolver import ThreadResolution
@ -121,9 +125,7 @@ async def ensure_thread_for_message(
def threading_fields_from_parent(parent: dict) -> dict: def threading_fields_from_parent(parent: dict) -> dict:
parent_internet_id = normalize_message_id( parent_internet_id = internet_message_id_of_message(parent)
parent.get("internet_message_id") or parent.get("message_id")
)
parent_references = parent.get("references") or [] parent_references = parent.get("references") or []
if isinstance(parent_references, str): if isinstance(parent_references, str):
parent_references = [parent_references] parent_references = [parent_references]

View file

@ -1,6 +1,10 @@
from bson import ObjectId from bson import ObjectId
from app.services.external_reply_service import build_external_reply_plan from app.services.external_reply_service import (
build_external_reply_plan,
internet_message_id_of,
)
from app.services.message_ids import canonical_message_id, folded_message_id
from app.services.thread_service import threading_fields_from_parent from app.services.thread_service import threading_fields_from_parent
@ -55,3 +59,76 @@ def test_external_reply_never_uses_internal_object_id_as_in_reply_to():
) )
assert str(parent["_id"]) not in (plan.threading.in_reply_to or "") assert str(parent["_id"]) not in (plan.threading.in_reply_to or "")
assert all(str(parent["_id"]) not in ref for ref in plan.threading.references) assert all(str(parent["_id"]) not in ref for ref in plan.threading.references)
def test_handoff_bridge_reply_uses_external_parent_not_human_reply():
"""Human handoff must thread B against customer A, never private H2."""
customer_a = {
"_id": ObjectId(),
"internet_message_id": "<CAHabc123@mail.gmail.com>",
"message_id": "<CAHabc123@mail.gmail.com>",
"message_scope": "EXTERNAL",
"direction": "inbound",
"references": [],
"subject": "fresh test",
}
human_h2 = {
"_id": ObjectId(),
"internet_message_id": "<H2@gmail.com>",
"message_id": "<H2@gmail.com>",
"message_scope": "HANDOFF",
"direction": "inbound",
"in_reply_to": "<H1@gentic.email>",
"references": ["<H1@gentic.email>"],
"subject": "Re: fresh test",
}
plan = build_external_reply_plan(
parent_external_message=customer_a,
body_text="human said B",
recipients=["091rabbytes@gmail.com"],
)
assert plan.threading.in_reply_to == "<CAHabc123@mail.gmail.com>"
assert plan.threading.references == ["<CAHabc123@mail.gmail.com>"]
assert human_h2["internet_message_id"] not in (plan.threading.in_reply_to or "")
assert all(
human_h2["internet_message_id"] not in ref for ref in plan.threading.references
)
assert "<H1@gentic.email>" not in plan.threading.references
def test_raw_header_restores_gmail_message_id_case():
parent = {
"_id": ObjectId(),
"internet_message_id": "<cahabc123@mail.gmail.com>",
"message_id": "<cahabc123@mail.gmail.com>",
"raw_headers": {"message_id": "<CAHabc123@mail.gmail.com>"},
"references": [],
"subject": "fresh test",
}
plan = build_external_reply_plan(
parent_external_message=parent,
body_text="human said B",
recipients=["customer@gmail.com"],
)
assert plan.threading.in_reply_to == "<CAHabc123@mail.gmail.com>"
assert plan.threading.references == ["<CAHabc123@mail.gmail.com>"]
def test_gmail_message_id_case_is_preserved_for_threading():
raw = "<CAHabc123@mail.gmail.com>"
assert canonical_message_id(raw) == raw
assert folded_message_id(raw) == "<cahabc123@mail.gmail.com>"
parent = {
"_id": ObjectId(),
"internet_message_id": raw,
"message_id": raw,
"references": [],
"subject": "fresh test",
}
plan = build_external_reply_plan(
parent_external_message=parent,
body_text="human said B",
recipients=["customer@gmail.com"],
)
assert plan.threading.in_reply_to == raw
assert internet_message_id_of(parent) == raw

View file

@ -0,0 +1,57 @@
from bson import ObjectId
from app.services.handoff_mirror_policy import (
GENERATED_BY_CUSTOMER,
GENERATED_BY_HANDOFF_BRIDGE,
GENERATED_BY_RELAY_ACK,
MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
MESSAGE_ORIGIN_HUMAN_OWNER,
is_relay_acknowledgment_message,
should_mirror_to_human,
)
def test_should_mirror_customer_inbound():
message = {
"message_scope": "EXTERNAL",
"direction": "inbound",
"generated_by": GENERATED_BY_CUSTOMER,
"message_origin": MESSAGE_ORIGIN_EXTERNAL_CUSTOMER,
}
assert should_mirror_to_human(message) is True
def test_should_not_mirror_handoff_bridge_outbound():
message = {
"message_scope": "EXTERNAL",
"direction": "outbound",
"generated_by": GENERATED_BY_HANDOFF_BRIDGE,
"generated_from_handoff_message_id": ObjectId(),
}
assert should_mirror_to_human(message) is False
def test_should_not_mirror_human_origin():
message = {
"message_scope": "EXTERNAL",
"direction": "inbound",
"message_origin": MESSAGE_ORIGIN_HUMAN_OWNER,
}
assert should_mirror_to_human(message) is False
def test_is_relay_acknowledgment_detects_legacy_body():
message = {
"direction": "outbound",
"body_text": "Sent to customer:\n\nhuman said B",
}
assert is_relay_acknowledgment_message(message) is True
def test_is_relay_acknowledgment_detects_generated_by():
message = {
"direction": "outbound",
"generated_by": GENERATED_BY_RELAY_ACK,
"body_text": "anything",
}
assert is_relay_acknowledgment_message(message) is True

View file

@ -0,0 +1,82 @@
"""Integration-style tests for Human Handoff → Customer RFC threading."""
from bson import ObjectId
from app.services.external_reply_service import (
build_external_reply_plan,
internet_message_id_of,
)
from app.services.mail_sender import build_message, sign_message
from types import SimpleNamespace
def test_handoff_bridge_first_reply_threads_against_customer_a():
customer_a_id = "<CAHabc123@mail.gmail.com>"
customer_a = {
"_id": ObjectId(),
"internet_message_id": customer_a_id,
"message_id": customer_a_id,
"message_scope": "EXTERNAL",
"direction": "inbound",
"references": [],
"subject": "fresh test",
}
plan = build_external_reply_plan(
parent_external_message=customer_a,
body_text="human said B",
recipients=["091rabbytes@gmail.com"],
)
assert plan.threading.in_reply_to == customer_a_id
assert plan.threading.references == [customer_a_id]
assert plan.threading.in_reply_to != "<H2@gmail.com>"
assert "<H1@gentic.email>" not in plan.threading.references
def test_lowercased_stored_id_recovered_via_raw_headers():
customer_a_id = "<CAHabc123@mail.gmail.com>"
parent = {
"_id": ObjectId(),
"internet_message_id": "<cahabc123@mail.gmail.com>",
"message_id": "<cahabc123@mail.gmail.com>",
"raw_headers": {"message_id": customer_a_id},
"references": [],
"subject": "fresh test",
}
plan = build_external_reply_plan(
parent_external_message=parent,
body_text="human said B",
recipients=["customer@gmail.com"],
parent_internet_id_override=internet_message_id_of(parent),
)
assert plan.threading.in_reply_to == customer_a_id
assert plan.threading.references == [customer_a_id]
def test_signed_mime_preserves_threading_headers():
customer_a_id = "<CAHabc123@mail.gmail.com>"
message = build_message(
sender="suyash@gentic.email",
recipients=["091rabbytes@gmail.com"],
cc=[],
subject="Re: fresh test",
body_text="human said B",
body_html=None,
in_reply_to=customer_a_id,
references=[customer_a_id],
message_id="<B@gentic.email>",
)
domain = SimpleNamespace(
domain_name="gentic.email",
dkim_selector="agmail",
dkim_private_key="private-key",
)
from unittest.mock import patch
with patch(
"app.services.mail_sender.dkim.sign",
return_value=b"DKIM-Signature: test\r\n",
):
signed = sign_message(message, domain)
assert f"In-Reply-To: {customer_a_id}".encode() in signed
assert f"References: {customer_a_id}".encode() in signed

View file

@ -73,6 +73,7 @@ async def test_resolve_handoff_requires_owner_and_thread_context():
{ {
"_id": message_id, "_id": message_id,
"message_id": "<handoff-1@gentic.email>", "message_id": "<handoff-1@gentic.email>",
"message_id_folded": "<handoff-1@gentic.email>",
"message_scope": MESSAGE_SCOPE_HANDOFF, "message_scope": MESSAGE_SCOPE_HANDOFF,
} }
] ]

View file

@ -41,3 +41,50 @@ async def test_send_via_smtp_uses_configured_client_hostname():
) )
assert send_mock.await_args.kwargs["local_hostname"] == "backend.gentic.email" assert send_mock.await_args.kwargs["local_hostname"] == "backend.gentic.email"
def test_build_message_sets_in_reply_to_and_references():
message = build_message(
sender="suyash@gentic.email",
recipients=["customer@gmail.com"],
cc=[],
subject="Re: fresh test",
body_text="human said B",
body_html=None,
in_reply_to="<CAHabc123@mail.gmail.com>",
references=["<CAHabc123@mail.gmail.com>"],
message_id="<B@gentic.email>",
)
assert message["Message-ID"] == "<B@gentic.email>"
assert message["In-Reply-To"] == "<CAHabc123@mail.gmail.com>"
assert message["References"] == "<CAHabc123@mail.gmail.com>"
raw = message.as_bytes()
assert b"In-Reply-To: <CAHabc123@mail.gmail.com>" in raw
assert b"References: <CAHabc123@mail.gmail.com>" in raw
def test_signed_mime_preserves_in_reply_to():
from unittest.mock import patch
message = build_message(
sender="suyash@gentic.email",
recipients=["customer@gmail.com"],
cc=[],
subject="Re: fresh test",
body_text="human said B",
body_html=None,
in_reply_to="<CAHabc123@mail.gmail.com>",
references=["<CAHabc123@mail.gmail.com>"],
message_id="<B@gentic.email>",
)
domain = SimpleNamespace(
domain_name="gentic.email",
dkim_selector="agmail",
dkim_private_key="private-key",
)
with patch(
"app.services.mail_sender.dkim.sign",
return_value=b"DKIM-Signature: test\r\n",
):
signed = sign_message(message, domain)
assert b"In-Reply-To: <CAHabc123@mail.gmail.com>" in signed

View file

@ -83,10 +83,26 @@ export default function HandoffConversation({ handoffId, onBack }) {
{conversation.messages.map((message) => ( {conversation.messages.map((message) => (
<article key={message.id} className={`handoff-message handoff-message-${message.role}`}> <article key={message.id} className={`handoff-message handoff-message-${message.role}`}>
<header> <header>
<strong>{message.role === "human" ? "HUMAN OWNER" : "AGENT"}</strong> <strong>
{message.role === "human"
? "HUMAN OWNER"
: message.role === "customer"
? "CUSTOMER"
: "AGENT"}
</strong>
<time>{formatTime(message.event_at)}</time> <time>{formatTime(message.event_at)}</time>
</header> </header>
<HandoffMessageBody message={message} /> <HandoffMessageBody message={message} />
{message.role === "human" && message.relay_status === "SENT" && (
<p className="handoff-relay-status handoff-relay-status-sent">
✓ Sent to customer
</p>
)}
{message.role === "human" && message.relay_status === "FAILED" && (
<p className="handoff-relay-status handoff-relay-status-failed">
⚠ Could not send to customer
</p>
)}
</article> </article>
))} ))}
</div> </div>

View file

@ -559,6 +559,10 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.handoff-message-human header strong { color:#11966a; } .handoff-message-human header strong { color:#11966a; }
.handoff-message header time { color:#9a96a5; font-size:11px; } .handoff-message header time { color:#9a96a5; font-size:11px; }
.handoff-message-body { color:#292536; font-size:13px; line-height:1.6; white-space:pre-wrap; } .handoff-message-body { color:#292536; font-size:13px; line-height:1.6; white-space:pre-wrap; }
.handoff-message-customer header strong { color:#2563eb; }
.handoff-relay-status { margin:10px 0 0; font-size:12px; }
.handoff-relay-status-sent { color:#11966a; }
.handoff-relay-status-failed { color:#c2410c; }
.handoff-show-quoted { margin-top:8px; padding:0; border:0; background:transparent; color:#6d5bd0; font-size:11px; font-weight:600; cursor:pointer; } .handoff-show-quoted { margin-top:8px; padding:0; border:0; background:transparent; color:#6d5bd0; font-size:11px; font-weight:600; cursor:pointer; }
.handoff-quoted-body { margin:8px 0 0; padding:10px 12px; border:1px solid #ece9f2; border-radius:8px; background:#faf9fc; color:#77758a; font-size:11px; line-height:1.45; white-space:pre-wrap; overflow:auto; } .handoff-quoted-body { margin:8px 0 0; padding:10px 12px; border:1px solid #ece9f2; border-radius:8px; background:#faf9fc; color:#77758a; font-size:11px; line-height:1.45; white-space:pre-wrap; overflow:auto; }
.handoff-settings-intro { margin-bottom:18px; padding:18px 20px; border:1px solid #ece7ff; border-radius:14px; background:#fbf9ff; } .handoff-settings-intro { margin-bottom:18px; padding:18px 20px; border:1px solid #ece7ff; border-radius:14px; background:#fbf9ff; }