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)],
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(
[("account_id", 1), ("thread_id", 1), ("event_at", -1), ("_id", -1)]
)

View file

@ -18,6 +18,7 @@ from app.schemas import (
HandoffTestRequest,
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.handoff_evaluator import EMAIL_TYPE_META, HANDOFF_LABELS, evaluate_handoff
@ -261,8 +262,15 @@ async def get_handoff_conversation(
messages: list[HandoffMessageRead] = []
for row in rows:
message = row["message"]
if is_relay_acknowledgment_message(message):
continue
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"]
raw_body = message.get("raw_body")
new_reply_content = message.get("new_reply_content")
@ -281,6 +289,7 @@ async def get_handoff_conversation(
and new_reply_content
and raw_body.strip() != new_reply_content.strip()
),
relay_status=message.get("relay_status") if role == "human" else None,
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.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.raw_mime_storage import get_raw_mime_storage
from app.services.reply_recipients import reply_recipients
@ -249,9 +249,8 @@ async def create_email(
references=threading_fields.get("references"),
)
document["status"] = "accepted"
normalized_id = normalize_message_id(internet_message_id)
document["message_id"] = normalized_id
document["internet_message_id"] = normalized_id
normalized_id = canonical_message_id(internet_message_id)
document.update(message_id_fields(normalized_id))
log_smtp_message_sending(
generated_message_id=normalized_id,
in_reply_to=threading_fields.get("in_reply_to"),

View file

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

View file

@ -7,17 +7,27 @@ from bson import ObjectId
from app.models import MailDomain
from app.services.handoff_email_templates import external_reply_subject
from app.services.mail_sender import deliver_email
from app.services.message_ids import normalize_message_id
from app.services.thread_service import (
attach_message_to_thread,
message_event_at,
threading_fields_from_parent,
from app.services.mail_sender import deliver_email, log_outbound_mime_headers
from app.services.message_ids import (
build_references_header,
canonical_message_id,
folded_message_id,
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__)
MESSAGE_SCOPE_EXTERNAL = "EXTERNAL"
MESSAGE_SCOPE_HANDOFF = "HANDOFF"
class ExternalReplyThreadingError(RuntimeError):
pass
@dataclass(frozen=True)
@ -38,9 +48,169 @@ class ExternalReplyPlan:
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")
)
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(
@ -50,9 +220,31 @@ def build_external_reply_plan(
body_html: str | None = None,
subject_override: str | None = None,
recipients: list[str],
parent_internet_id_override: str | None = None,
) -> 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)
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:
subject = subject_override
else:
@ -67,8 +259,8 @@ def build_external_reply_plan(
body_text=body_text,
body_html=body_html,
threading=ExternalReplyThreading(
in_reply_to=threading_fields.get("in_reply_to"),
references=threading_fields.get("references", []),
in_reply_to=in_reply_to,
references=references,
parent_internal_message_id=parent_external_message["_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(
*,
generated_message_id: str | None,
@ -114,7 +333,30 @@ async def send_external_reply(
plan: ExternalReplyPlan,
domain: MailDomain,
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:
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(
generated_message_id=None,
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,
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(
generated_message_id=normalized_id,
in_reply_to=plan.threading.in_reply_to,
@ -167,16 +410,23 @@ async def send_external_reply(
"created_at": now,
"sent_at": now,
"status": "accepted",
"message_id": normalized_id,
"internet_message_id": normalized_id,
**id_fields,
"in_reply_to": plan.threading.in_reply_to,
"references": plan.threading.references,
"parent_message_id": plan.threading.parent_internal_message_id,
"thread_relation": "reply",
"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)
document["_id"] = result.inserted_id
from app.services.thread_service import attach_message_to_thread
await attach_message_to_thread(
database,
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 app.services.external_reply_service import (
ExternalReplyThreadingError,
build_external_reply_plan,
internet_message_id_of,
log_original_external_message,
resolve_external_reply_parent,
resolve_internet_message_id,
send_external_reply,
)
from app.services.handoff_email_templates import (
build_relay_confirmation_text,
external_reply_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_evaluator import evaluate_handoff
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.thread_service import (
attach_message_to_thread,
@ -226,9 +245,13 @@ async def resolve_handoff_from_inbound(
return None
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_id": internet_id,
"$or": [
{"message_id_folded": folded},
{"message_id": internet_id},
],
"message_scope": MESSAGE_SCOPE_HANDOFF,
}
)
@ -256,6 +279,26 @@ async def resolve_handoff_from_inbound(
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(
database,
*,
@ -277,6 +320,11 @@ async def store_handoff_message(
raw_body: str | None = None,
new_reply_content: 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:
now = event_at or _now()
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,
"received_at": now if direction == "inbound" else None,
"status": "received" if direction == "inbound" else "accepted",
"message_id": normalize_message_id(internet_message_id),
"internet_message_id": normalize_message_id(internet_message_id),
"in_reply_to": normalize_message_id(in_reply_to),
**message_id_fields(internet_message_id),
"in_reply_to": canonical_message_id(in_reply_to),
"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)
document["_id"] = result.inserted_id
await attach_message_to_thread(
@ -357,6 +414,23 @@ async def send_handoff_to_human(
domain,
existing_handoff: dict | None = None,
) -> 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", "")
stable_subject = handoff_email_subject(external_subject)
mirror_source = {**external_message, "_agent_email": agent_email}
@ -394,6 +468,8 @@ async def send_handoff_to_human(
"updated_at": _now(),
"last_external_message_at": message_event_at(external_message),
"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)
handoff_id = insert.inserted_id
@ -431,6 +507,9 @@ async def send_handoff_to_human(
handoff_id=handoff_id,
link_relationship=relationship,
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(
@ -504,7 +583,8 @@ async def process_human_reply(
in_reply_to=inbound_parsed.get("in_reply_to"),
references=inbound_parsed.get("references", []),
handoff_id=handoff["_id"],
link_relationship=LINK_HUMAN_TRIGGERED_EXTERNAL,
message_origin=MESSAGE_ORIGIN_HUMAN_OWNER,
generated_by=GENERATED_BY_HUMAN,
)
now = _now()
@ -550,22 +630,68 @@ async def process_human_reply(
return
external_recipient = handoff.get("external_sender") or ""
external_parent = await _latest_external_customer_message(
external_parent = await resolve_external_reply_parent(
database,
account_id,
external_thread_id,
external_recipient,
account_id=account_id,
external_thread_id=external_thread_id,
customer_address=external_recipient,
handoff=handoff,
)
if external_parent is None:
external_parent = await _latest_external_message(database, account_id, external_thread_id)
if external_parent is None:
logger.warning("handoff_reply_missing_external_parent handoff_id=%s", handoff["_id"])
logger.warning(
"handoff_reply_missing_external_parent handoff_id=%s external_thread=%s "
"trigger_internet_message_id=%s",
handoff["_id"],
external_thread_id,
handoff.get("trigger_internet_message_id"),
)
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:
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
reply_plan = build_external_reply_plan(
parent_external_message=external_parent,
@ -574,7 +700,15 @@ async def process_human_reply(
handoff.get("external_subject") or external_parent.get("subject", "")
),
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(
database,
account_id=account_id,
@ -584,52 +718,69 @@ async def process_human_reply(
plan=reply_plan,
domain=domain,
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)
relay_subject = _handoff_reply_subject(handoff.get("external_subject", ""))
relay_threading = threading_fields_from_parent(human_message)
relay_internet_id = await deliver_email(
sender=agent_email,
recipients=[human_email],
cc=[],
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 database.messages.update_one(
{"_id": human_message["_id"]},
{
"$set": {
"relay_status": RELAY_STATUS_SENT,
"generated_external_message_id": external_doc["_id"],
"relay_delivered_at": now,
}
},
)
await store_handoff_message(
await _record_message_link(
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"],
link_relationship=LINK_EXTERNAL_RELAYED,
external_message_id=ext_result.inserted_id,
external_message_id=external_doc["_id"],
handoff_message_id=human_message["_id"],
relationship=LINK_HUMAN_TRIGGERED_EXTERNAL,
)
await database.human_handoffs.update_one(
{"_id": handoff["_id"]},
{
"$set": {
"status": HANDOFF_STATUS_AGENT_REPLIED,
"current_owner": OWNER_AGENT,
"status": HANDOFF_STATUS_WAITING,
"current_owner": OWNER_HUMAN,
"updated_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(
@ -641,6 +792,40 @@ async def maybe_trigger_handoff(
external_thread_id: ObjectId,
domain,
) -> 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(
{"account_id": account_id, "active": True}
).to_list(length=20)
@ -667,9 +852,6 @@ async def maybe_trigger_handoff(
if not rule.get("allow_replies", True):
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."
await send_handoff_to_human(
database,
@ -680,6 +862,6 @@ async def maybe_trigger_handoff(
external_thread_id=external_thread_id,
handoff_reason=reason,
domain=domain,
existing_handoff=existing,
existing_handoff=None,
)
return

View file

@ -10,6 +10,10 @@ from app.config import get_settings
from app.database import SessionFactory, mongo_db
from app.models import Account
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 (
MESSAGE_SCOPE_EXTERNAL,
MESSAGE_SCOPE_HANDOFF,
@ -17,7 +21,8 @@ from app.services.human_handoff_service import (
resolve_handoff_from_inbound,
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.raw_mime_storage import delivery_digest, get_raw_mime_storage
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)
now = datetime.now(timezone.utc)
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:
account_id = envelope.account_ids[recipient]
@ -160,8 +166,7 @@ class InboundHandler:
"delivery_key": delivery_key,
"received_at": now,
"created_at": now,
"message_id": internet_message_id,
"internet_message_id": internet_message_id,
**id_fields,
}
await process_human_reply(
mongo_db,
@ -194,8 +199,7 @@ class InboundHandler:
"envelope_sender": envelope.mail_from.lower(),
"envelope_recipients": recipients,
"delivery_key": delivery_key,
"message_id": internet_message_id,
"internet_message_id": internet_message_id,
**id_fields,
"raw_mime": {
"bucket": stored.bucket,
"name": stored.name,
@ -252,14 +256,15 @@ class InboundHandler:
{
"$set": {
"message_scope": MESSAGE_SCOPE_EXTERNAL,
"message_id": internet_message_id,
"internet_message_id": internet_message_id,
"in_reply_to": normalize_message_id(parsed.get("in_reply_to")),
**id_fields,
"in_reply_to": canonical_message_id(parsed.get("in_reply_to")),
"references": parsed.get("references", []),
"raw_headers": parsed.get("raw_headers", {}),
"parent_message_id": resolution.parent_message_id,
"thread_relation": resolution.thread_relation,
"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,
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:
await maybe_trigger_handoff(
mongo_db,
@ -307,6 +320,17 @@ class InboundHandler:
external_thread_id=thread_id,
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(
message_id=internet_message_id,
in_reply_to=parsed.get("in_reply_to"),

View file

@ -1,5 +1,7 @@
import email.policy
import email.utils
import logging
from email.parser import BytesParser
from email.message import EmailMessage, Message
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
@ -13,6 +15,20 @@ from app.models import MailDomain
from app.services.mime_body_extractor import ExtractedMimePart
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:
@ -60,7 +76,9 @@ def build_message(
root["Message-ID"] = generated_id
if 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)
alternative = MIMEMultipart("alternative", policy=SMTP_POLICY)
@ -94,7 +112,9 @@ def build_message(
message["Message-ID"] = generated_id
if 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.set_content(body_text)
if body_html:
@ -115,6 +135,10 @@ def sign_message(message: Message, domain: MailDomain) -> bytes:
]
if message.get("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(
raw_message,
domain.dkim_selector.encode(),
@ -180,7 +204,9 @@ async def deliver_email(
inline_parts=inline_parts,
file_attachments=file_attachments,
)
log_outbound_mime_headers(message)
signed = sign_message(message, domain)
log_signed_mime_headers(signed)
await send_via_smtp(
signed,
sender=sender,

View file

@ -3,7 +3,8 @@ import re
_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:
return None
cleaned = value.strip()
@ -11,22 +12,59 @@ def normalize_message_id(value: str | None) -> str | None:
return None
if not cleaned.startswith("<"):
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]:
if not header_value:
return []
folded = re.sub(r"\s+", " ", header_value.strip())
folded_header = re.sub(r"\s+", " ", header_value.strip())
seen: set[str] = set()
ordered: list[str] = []
for match in _MESSAGE_ID_PATTERN.findall(folded):
normalized = normalize_message_id(match)
if normalized and normalized not in seen:
seen.add(normalized)
ordered.append(normalized)
for match in _MESSAGE_ID_PATTERN.findall(folded_header):
canonical = canonical_message_id(match)
key = folded_message_id(canonical)
if canonical and key and key not in seen:
seen.add(key)
ordered.append(canonical)
if not ordered:
single = normalize_message_id(folded)
single = canonical_message_id(folded_header)
if single:
ordered.append(single)
return ordered
@ -41,11 +79,13 @@ def build_references_header(references: list[str], parent_message_id: str) -> st
chain: list[str] = []
seen: set[str] = set()
for value in references:
normalized = normalize_message_id(value)
if normalized and normalized not in seen:
seen.add(normalized)
chain.append(normalized)
parent = normalize_message_id(parent_message_id)
if parent and parent not in seen:
canonical = canonical_message_id(value)
key = folded_message_id(canonical)
if canonical and key and key not in seen:
seen.add(key)
chain.append(canonical)
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)
return " ".join(chain)

View file

@ -28,6 +28,13 @@ def _text(part) -> str:
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:
source = text
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"))
references = parse_message_id_list(message.get("References"))
raw_headers = {
"message_id": message.get("Message-ID"),
"in_reply_to": message.get("In-Reply-To"),
"references": message.get("References"),
"subject": message.get("Subject"),
"date": message.get("Date"),
"from": message.get("From"),
"to": message.get("To"),
"cc": message.get("Cc"),
"reply_to": message.get("Reply-To"),
"message_id": _header_str(message.get("Message-ID")),
"in_reply_to": _header_str(message.get("In-Reply-To")),
"references": _header_str(message.get("References")),
"subject": _header_str(message.get("Subject")),
"date": _header_str(message.get("Date")),
"from": _header_str(message.get("From")),
"to": _header_str(message.get("To")),
"cc": _header_str(message.get("Cc")),
"reply_to": _header_str(message.get("Reply-To")),
}
return {
"message_id": internet_message_id,

View file

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

View file

@ -2,7 +2,11 @@ from datetime import datetime, timezone
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.thread_resolver import ThreadResolution
@ -121,9 +125,7 @@ async def ensure_thread_for_message(
def threading_fields_from_parent(parent: dict) -> dict:
parent_internet_id = normalize_message_id(
parent.get("internet_message_id") or parent.get("message_id")
)
parent_internet_id = internet_message_id_of_message(parent)
parent_references = parent.get("references") or []
if isinstance(parent_references, str):
parent_references = [parent_references]

View file

@ -1,6 +1,10 @@
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
@ -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 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,
"message_id": "<handoff-1@gentic.email>",
"message_id_folded": "<handoff-1@gentic.email>",
"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"
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) => (
<article key={message.id} className={`handoff-message handoff-message-${message.role}`}>
<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>
</header>
<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>
))}
</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 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-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-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; }