handoff fixes
This commit is contained in:
parent
89ddebdb60
commit
8d5e9712a6
20 changed files with 1056 additions and 141 deletions
|
|
@ -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)]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
62
backend/app/services/handoff_mirror_policy.py
Normal file
62
backend/app/services/handoff_mirror_policy.py
Normal 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")
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
57
backend/tests/test_handoff_mirror_policy.py
Normal file
57
backend/tests/test_handoff_mirror_policy.py
Normal 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
|
||||||
82
backend/tests/test_handoff_threading.py
Normal file
82
backend/tests/test_handoff_threading.py
Normal 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
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue