thread fix
This commit is contained in:
parent
d58fb8841b
commit
a58ba11566
11 changed files with 177 additions and 30 deletions
|
|
@ -27,6 +27,7 @@ from app.services.mail_sender import deliver_email
|
||||||
from app.services.message_ids import canonical_message_id, message_id_fields
|
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_extractor import build_reply_display, reply_fields_for_message
|
||||||
from app.services.reply_recipients import reply_recipients
|
from app.services.reply_recipients import reply_recipients
|
||||||
from app.services.thread_service import (
|
from app.services.thread_service import (
|
||||||
create_thread,
|
create_thread,
|
||||||
|
|
@ -98,7 +99,12 @@ def summarize(message: dict, entry: dict) -> MessageSummary:
|
||||||
sender=MailAddress(**sender),
|
sender=MailAddress(**sender),
|
||||||
recipients=[MailAddress(**item) for item in recipients],
|
recipients=[MailAddress(**item) for item in recipients],
|
||||||
subject=message.get("subject", ""),
|
subject=message.get("subject", ""),
|
||||||
snippet=message.get("snippet", message.get("body_text", "")[:240]),
|
snippet=message.get("snippet")
|
||||||
|
or re.sub(
|
||||||
|
r"\s+",
|
||||||
|
" ",
|
||||||
|
message.get("new_reply_content") or message.get("body_text", ""),
|
||||||
|
).strip()[:240],
|
||||||
status=message.get("status", "received"),
|
status=message.get("status", "received"),
|
||||||
created_at=message.get("received_at") or message["created_at"],
|
created_at=message.get("received_at") or message["created_at"],
|
||||||
is_read=entry.get("is_read", True),
|
is_read=entry.get("is_read", True),
|
||||||
|
|
@ -200,6 +206,10 @@ async def create_email(
|
||||||
}
|
}
|
||||||
if not subject.strip():
|
if not subject.strip():
|
||||||
subject = reply_plan.subject
|
subject = reply_plan.subject
|
||||||
|
reply_display = build_reply_display(
|
||||||
|
body_text=payload.body_text,
|
||||||
|
body_html=payload.body_html,
|
||||||
|
)
|
||||||
document = {
|
document = {
|
||||||
"direction": "outbound",
|
"direction": "outbound",
|
||||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
|
@ -212,7 +222,10 @@ async def create_email(
|
||||||
"cc": [address(value) for value in cc],
|
"cc": [address(value) for value in cc],
|
||||||
"bcc": [address(value) for value in bcc],
|
"bcc": [address(value) for value in bcc],
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"snippet": re.sub(r"\s+", " ", payload.body_text).strip()[:240],
|
"snippet": reply_display["snippet"],
|
||||||
|
"new_reply_content": reply_display["new_reply_content"],
|
||||||
|
"quoted_content": reply_display["quoted_content"],
|
||||||
|
"has_quoted_text": reply_display["has_quoted_text"],
|
||||||
"body_text": payload.body_text,
|
"body_text": payload.body_text,
|
||||||
"body_html": payload.body_html,
|
"body_html": payload.body_html,
|
||||||
"attachments": [],
|
"attachments": [],
|
||||||
|
|
@ -371,6 +384,11 @@ async def get_email(
|
||||||
body_text = parsed["body_text"]
|
body_text = parsed["body_text"]
|
||||||
body_html = parsed["body_html"]
|
body_html = parsed["body_html"]
|
||||||
summary = summarize(document, entry)
|
summary = summarize(document, entry)
|
||||||
|
reply_fields = reply_fields_for_message(
|
||||||
|
document,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
)
|
||||||
return MessageRead(
|
return MessageRead(
|
||||||
**summary.model_dump(),
|
**summary.model_dump(),
|
||||||
cc=document.get("cc", []),
|
cc=document.get("cc", []),
|
||||||
|
|
@ -378,6 +396,7 @@ async def get_email(
|
||||||
reply_to=document.get("reply_to", []),
|
reply_to=document.get("reply_to", []),
|
||||||
body_text=body_text,
|
body_text=body_text,
|
||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
|
**reply_fields,
|
||||||
message_id=document.get("message_id"),
|
message_id=document.get("message_id"),
|
||||||
in_reply_to=document.get("in_reply_to"),
|
in_reply_to=document.get("in_reply_to"),
|
||||||
references=document.get("references", []),
|
references=document.get("references", []),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from app.routers.emails import decode_cursor, encode_cursor, summarize
|
||||||
from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary
|
from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary
|
||||||
from app.services.mailbox_scope import apply_external_scope
|
from app.services.mailbox_scope import apply_external_scope
|
||||||
from app.services.mime_parser import parse_mime
|
from app.services.mime_parser import parse_mime
|
||||||
|
from app.services.reply_extractor import reply_fields_for_message
|
||||||
from app.services.raw_mime_storage import get_raw_mime_storage
|
from app.services.raw_mime_storage import get_raw_mime_storage
|
||||||
|
|
||||||
router = APIRouter(prefix="/threads", tags=["threads"])
|
router = APIRouter(prefix="/threads", tags=["threads"])
|
||||||
|
|
@ -241,6 +242,11 @@ async def message_read(
|
||||||
body_text = parsed["body_text"]
|
body_text = parsed["body_text"]
|
||||||
body_html = parsed["body_html"]
|
body_html = parsed["body_html"]
|
||||||
summary = summarize(message, entry)
|
summary = summarize(message, entry)
|
||||||
|
reply_fields = reply_fields_for_message(
|
||||||
|
message,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
)
|
||||||
return MessageRead(
|
return MessageRead(
|
||||||
**summary.model_dump(),
|
**summary.model_dump(),
|
||||||
cc=message.get("cc", []),
|
cc=message.get("cc", []),
|
||||||
|
|
@ -248,6 +254,7 @@ async def message_read(
|
||||||
reply_to=message.get("reply_to", []),
|
reply_to=message.get("reply_to", []),
|
||||||
body_text=body_text,
|
body_text=body_text,
|
||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
|
**reply_fields,
|
||||||
message_id=message.get("message_id"),
|
message_id=message.get("message_id"),
|
||||||
in_reply_to=message.get("in_reply_to"),
|
in_reply_to=message.get("in_reply_to"),
|
||||||
references=message.get("references", []),
|
references=message.get("references", []),
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,9 @@ class MessageRead(MessageSummary):
|
||||||
reply_to: list[MailAddress] = Field(default_factory=list)
|
reply_to: list[MailAddress] = Field(default_factory=list)
|
||||||
body_text: str = ""
|
body_text: str = ""
|
||||||
body_html: str | None = None
|
body_html: str | None = None
|
||||||
|
new_reply_content: str = ""
|
||||||
|
quoted_content: str = ""
|
||||||
|
has_quoted_text: bool = False
|
||||||
message_id: str | None = None
|
message_id: str | None = None
|
||||||
in_reply_to: str | None = None
|
in_reply_to: str | None = None
|
||||||
references: list[str] = Field(default_factory=list)
|
references: list[str] = Field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from app.services.message_ids import (
|
||||||
message_id_fields,
|
message_id_fields,
|
||||||
)
|
)
|
||||||
from app.services.mime_parser import parse_mime
|
from app.services.mime_parser import parse_mime
|
||||||
|
from app.services.reply_extractor import build_reply_display
|
||||||
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.thread_service import threading_fields_from_parent
|
from app.services.thread_service import threading_fields_from_parent
|
||||||
|
|
||||||
|
|
@ -410,6 +411,10 @@ async def send_external_reply(
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
reply_display = build_reply_display(
|
||||||
|
body_text=plan.body_text,
|
||||||
|
body_html=plan.body_html,
|
||||||
|
)
|
||||||
document = {
|
document = {
|
||||||
"direction": "outbound",
|
"direction": "outbound",
|
||||||
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
"message_scope": MESSAGE_SCOPE_EXTERNAL,
|
||||||
|
|
@ -417,7 +422,10 @@ async def send_external_reply(
|
||||||
"sender": {"name": sender_name, "address": sender},
|
"sender": {"name": sender_name, "address": sender},
|
||||||
"to": [{"name": value.split("@")[0], "address": value} for value in plan.recipients],
|
"to": [{"name": value.split("@")[0], "address": value} for value in plan.recipients],
|
||||||
"subject": plan.subject,
|
"subject": plan.subject,
|
||||||
"snippet": re.sub(r"\s+", " ", plan.body_text).strip()[:240],
|
"snippet": reply_display["snippet"],
|
||||||
|
"new_reply_content": reply_display["new_reply_content"],
|
||||||
|
"quoted_content": reply_display["quoted_content"],
|
||||||
|
"has_quoted_text": reply_display["has_quoted_text"],
|
||||||
"body_text": plan.body_text,
|
"body_text": plan.body_text,
|
||||||
"body_html": plan.body_html,
|
"body_html": plan.body_html,
|
||||||
"attachments": [],
|
"attachments": [],
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from app.services.external_reply_service import log_original_external_message
|
||||||
from app.services.human_handoff_service import MESSAGE_SCOPE_EXTERNAL
|
from app.services.human_handoff_service import MESSAGE_SCOPE_EXTERNAL
|
||||||
from app.services.message_ids import canonical_message_id, message_id_fields
|
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.reply_extractor import build_reply_display
|
||||||
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
|
||||||
from app.services.thread_service import ensure_thread_for_message, message_event_at
|
from app.services.thread_service import ensure_thread_for_message, message_event_at
|
||||||
|
|
@ -155,12 +156,26 @@ class InboundHandler:
|
||||||
raise RuntimeError("Inbound message was not persisted.")
|
raise RuntimeError("Inbound message was not persisted.")
|
||||||
|
|
||||||
if body_text or body_html:
|
if body_text or body_html:
|
||||||
|
reply_display = build_reply_display(body_text=body_text, body_html=body_html)
|
||||||
await mongo_db.messages.update_one(
|
await mongo_db.messages.update_one(
|
||||||
{"_id": persisted["_id"]},
|
{"_id": persisted["_id"]},
|
||||||
{"$set": {"body_text": body_text, "body_html": body_html}},
|
{
|
||||||
|
"$set": {
|
||||||
|
"body_text": body_text,
|
||||||
|
"body_html": body_html,
|
||||||
|
"new_reply_content": reply_display["new_reply_content"],
|
||||||
|
"quoted_content": reply_display["quoted_content"],
|
||||||
|
"has_quoted_text": reply_display["has_quoted_text"],
|
||||||
|
"snippet": reply_display["snippet"],
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
persisted["body_text"] = body_text
|
persisted["body_text"] = body_text
|
||||||
persisted["body_html"] = body_html
|
persisted["body_html"] = body_html
|
||||||
|
persisted["snippet"] = reply_display["snippet"]
|
||||||
|
persisted["new_reply_content"] = reply_display["new_reply_content"]
|
||||||
|
persisted["quoted_content"] = reply_display["quoted_content"]
|
||||||
|
persisted["has_quoted_text"] = reply_display["has_quoted_text"]
|
||||||
|
|
||||||
participants = participants_from_parsed(parsed)
|
participants = participants_from_parsed(parsed)
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
|
|
@ -214,7 +229,7 @@ class InboundHandler:
|
||||||
"sender": parsed["sender"],
|
"sender": parsed["sender"],
|
||||||
"recipients": parsed["to"],
|
"recipients": parsed["to"],
|
||||||
"subject": parsed["subject"],
|
"subject": parsed["subject"],
|
||||||
"snippet": parsed["snippet"],
|
"snippet": persisted.get("snippet", parsed["snippet"]),
|
||||||
"attachments": parsed["attachments"],
|
"attachments": parsed["attachments"],
|
||||||
"status": "received",
|
"status": "received",
|
||||||
"is_read": False,
|
"is_read": False,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from email.utils import getaddresses, parsedate_to_datetime
|
||||||
from html import unescape
|
from html import unescape
|
||||||
|
|
||||||
from app.services.message_ids import first_message_id, parse_message_id_list
|
from app.services.message_ids import first_message_id, parse_message_id_list
|
||||||
|
from app.services.reply_extractor import build_reply_display
|
||||||
|
|
||||||
|
|
||||||
def _addresses(values: list[str]) -> list[dict[str, str]]:
|
def _addresses(values: list[str]) -> list[dict[str, str]]:
|
||||||
|
|
@ -36,11 +37,7 @@ def _header_str(value) -> str | 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
|
return build_reply_display(body_text=text, body_html=html, limit=limit)["snippet"]
|
||||||
if not source and html:
|
|
||||||
source = re.sub(r"<[^>]+>", " ", html)
|
|
||||||
source = unescape(source)
|
|
||||||
return re.sub(r"\s+", " ", source).strip()[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_mime(raw: bytes) -> dict:
|
def parse_mime(raw: bytes) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,13 @@ from html import unescape
|
||||||
# Patterns that mark the start of quoted previous-message content.
|
# Patterns that mark the start of quoted previous-message content.
|
||||||
TEXT_QUOTE_MARKERS = [
|
TEXT_QUOTE_MARKERS = [
|
||||||
re.compile(r"\n-{2,}\s*Original Message\s*-{2,}", re.IGNORECASE),
|
re.compile(r"\n-{2,}\s*Original Message\s*-{2,}", re.IGNORECASE),
|
||||||
re.compile(
|
re.compile(r"(?:^|\n)\s*On .+? wrote:\s*", re.IGNORECASE),
|
||||||
r"\nOn .+? wrote:\s*\n",
|
|
||||||
re.IGNORECASE,
|
|
||||||
),
|
|
||||||
re.compile(
|
|
||||||
r"\nOn .+?, .+? at .+? .+? wrote:\s*\n",
|
|
||||||
re.IGNORECASE,
|
|
||||||
),
|
|
||||||
re.compile(
|
re.compile(
|
||||||
r"\nFrom:\s*.+\nSent:\s*.+\nTo:\s*.+\nSubject:\s*.+",
|
r"\nFrom:\s*.+\nSent:\s*.+\nTo:\s*.+\nSubject:\s*.+",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
),
|
),
|
||||||
re.compile(r"\n_{5,}\n"),
|
re.compile(r"\n_{5,}\n"),
|
||||||
|
re.compile(r"\n\s*> ?"),
|
||||||
]
|
]
|
||||||
|
|
||||||
HTML_QUOTE_PATTERNS = [
|
HTML_QUOTE_PATTERNS = [
|
||||||
|
|
@ -110,6 +104,46 @@ def extract_new_reply_content(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reply_fields_for_message(
|
||||||
|
message: dict,
|
||||||
|
*,
|
||||||
|
body_text: str | None,
|
||||||
|
body_html: str | None = None,
|
||||||
|
) -> dict[str, str | bool]:
|
||||||
|
if "new_reply_content" in message:
|
||||||
|
return {
|
||||||
|
"new_reply_content": message.get("new_reply_content", ""),
|
||||||
|
"quoted_content": message.get("quoted_content", ""),
|
||||||
|
"has_quoted_text": bool(message.get("has_quoted_text") or message.get("quoted_content")),
|
||||||
|
}
|
||||||
|
built = build_reply_display(body_text=body_text, body_html=body_html)
|
||||||
|
return {
|
||||||
|
"new_reply_content": built["new_reply_content"],
|
||||||
|
"quoted_content": built["quoted_content"],
|
||||||
|
"has_quoted_text": built["has_quoted_text"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_reply_display(
|
||||||
|
*,
|
||||||
|
body_text: str | None,
|
||||||
|
body_html: str | None = None,
|
||||||
|
limit: int = 240,
|
||||||
|
) -> dict[str, str | bool]:
|
||||||
|
extracted = extract_new_reply_content(body_text=body_text, body_html=body_html)
|
||||||
|
new_content = extracted["new_reply_content"]
|
||||||
|
quoted = extracted["quoted_content"]
|
||||||
|
fallback = re.sub(r"\s+", " ", (body_text or "")).strip()
|
||||||
|
snippet_source = new_content or fallback
|
||||||
|
return {
|
||||||
|
"new_reply_content": new_content,
|
||||||
|
"quoted_content": quoted,
|
||||||
|
"has_quoted_text": bool(quoted),
|
||||||
|
"raw_body": extracted["raw_body"],
|
||||||
|
"snippet": re.sub(r"\s+", " ", snippet_source).strip()[:limit],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_external_reply_body(body: str) -> bool:
|
def validate_external_reply_body(body: str) -> bool:
|
||||||
normalized = (body or "").strip()
|
normalized = (body or "").strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,35 @@ def test_external_reply_subject_uses_customer_subject():
|
||||||
assert external_reply_subject("Human Handoff · are you ready?") == "Re: are you ready?"
|
assert external_reply_subject("Human Handoff · are you ready?") == "Re: are you ready?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracts_reply_before_blockquote_lines():
|
||||||
|
body = """responder: B
|
||||||
|
> sender: A"""
|
||||||
|
result = extract_new_reply_content(body_text=body)
|
||||||
|
assert result["new_reply_content"] == "responder: B"
|
||||||
|
assert "sender: A" in result["quoted_content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extracts_reply_from_gmail_on_wrote_without_blank_line():
|
||||||
|
body = """sender: C
|
||||||
|
On Tue, Oct 6, 2026 at 4:29 AM <suyash@gentic.email> wrote:
|
||||||
|
> Responder: B
|
||||||
|
> sender: A"""
|
||||||
|
result = extract_new_reply_content(body_text=body)
|
||||||
|
assert result["new_reply_content"] == "sender: C"
|
||||||
|
assert "Responder: B" in result["quoted_content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_reply_display_uses_clean_snippet():
|
||||||
|
from app.services.reply_extractor import build_reply_display
|
||||||
|
|
||||||
|
body = """responder: D
|
||||||
|
On Tue, Oct 6, 2026 at 4:29 AM wrote:
|
||||||
|
> sender: C"""
|
||||||
|
result = build_reply_display(body_text=body)
|
||||||
|
assert result["snippet"] == "responder: D"
|
||||||
|
assert result["has_quoted_text"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_outlook_original_message_split():
|
def test_outlook_original_message_split():
|
||||||
body = """Thanks for the update.
|
body = """Thanks for the update.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,13 @@ function joinAddresses(value) {
|
||||||
.join(", ");
|
.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messagePreview(message) {
|
||||||
|
return (message?.new_reply_content || message?.snippet || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
function messageBody(email) {
|
function messageBody(email) {
|
||||||
|
const preview = messagePreview(email);
|
||||||
|
if (preview) return { mode: "text", value: preview };
|
||||||
const text = (email?.body_text || email?.text_body || email?.plain_text || "").trim();
|
const text = (email?.body_text || email?.text_body || email?.plain_text || "").trim();
|
||||||
if (text) return { mode: "text", value: text };
|
if (text) return { mode: "text", value: text };
|
||||||
const html = (email?.body_html || "").trim();
|
const html = (email?.body_html || "").trim();
|
||||||
|
|
@ -49,6 +55,39 @@ function messageBody(email) {
|
||||||
return { mode: "text", value: "This message has no readable content." };
|
return { mode: "text", value: "This message has no readable content." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MessageBodyContent({ message }) {
|
||||||
|
const [showQuoted, setShowQuoted] = useState(false);
|
||||||
|
const body = messageBody(message);
|
||||||
|
const quoted = (message?.quoted_content || "").trim();
|
||||||
|
const hasQuoted = Boolean(message?.has_quoted_text && quoted);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`mail-detail-body${body.mode === "html" ? " is-html" : ""}`}>
|
||||||
|
{body.mode === "html" ? (
|
||||||
|
<div className="mail-detail-body-html" dangerouslySetInnerHTML={{ __html: body.value }} />
|
||||||
|
) : (
|
||||||
|
<div className="mail-detail-body-text">{body.value}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasQuoted ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mail-quoted-toggle"
|
||||||
|
onClick={() => setShowQuoted((open) => !open)}
|
||||||
|
aria-expanded={showQuoted}
|
||||||
|
aria-label={showQuoted ? "Hide quoted text" : "Show quoted text"}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">···</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{showQuoted && quoted ? (
|
||||||
|
<pre className="mail-quoted-body">{quoted}</pre>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function detailTime(email) {
|
function detailTime(email) {
|
||||||
const value = email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
|
const value = email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
|
||||||
if (!value) return { absolute: "", relative: "" };
|
if (!value) return { absolute: "", relative: "" };
|
||||||
|
|
@ -149,7 +188,6 @@ function MessageBlock({
|
||||||
onMove,
|
onMove,
|
||||||
}) {
|
}) {
|
||||||
const [headersOpen, setHeadersOpen] = useState(false);
|
const [headersOpen, setHeadersOpen] = useState(false);
|
||||||
const body = messageBody(message);
|
|
||||||
const time = detailTime(message);
|
const time = detailTime(message);
|
||||||
const sender = senderIdentity(message);
|
const sender = senderIdentity(message);
|
||||||
const attachments = message?.attachments || [];
|
const attachments = message?.attachments || [];
|
||||||
|
|
@ -160,7 +198,7 @@ function MessageBlock({
|
||||||
<SenderAvatar email={message} />
|
<SenderAvatar email={message} />
|
||||||
<span className="mail-thread-message-collapsed-main">
|
<span className="mail-thread-message-collapsed-main">
|
||||||
<strong>{sender?.name}</strong>
|
<strong>{sender?.name}</strong>
|
||||||
<span>{message.snippet || message.body_text || ""}</span>
|
<span>{messagePreview(message) || message.body_text || ""}</span>
|
||||||
</span>
|
</span>
|
||||||
<time>{time.absolute}</time>
|
<time>{time.absolute}</time>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -218,14 +256,8 @@ function MessageBlock({
|
||||||
</MessageAction>
|
</MessageAction>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mail-detail-message-content${body?.mode === "html" ? " is-html" : ""}`}>
|
<div className="mail-detail-message-content">
|
||||||
<div className={`mail-detail-body${body?.mode === "html" ? " is-html" : " is-text"}`}>
|
<MessageBodyContent message={message} />
|
||||||
{body?.mode === "html" ? (
|
|
||||||
<div className="mail-detail-body-html" dangerouslySetInnerHTML={{ __html: body.value }} />
|
|
||||||
) : (
|
|
||||||
<div className="mail-detail-body-text">{body?.value}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="mail-detail-attachments" aria-label="Attachments">
|
<div className="mail-detail-attachments" aria-label="Attachments">
|
||||||
{attachments.map((file) => (
|
{attachments.map((file) => (
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ function buildReplyContext(message, replyAll = false) {
|
||||||
const sender = message?.sender || { name: "", address: "" };
|
const sender = message?.sender || { name: "", address: "" };
|
||||||
const subject = (message?.subject || "").trim();
|
const subject = (message?.subject || "").trim();
|
||||||
const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject || "(No subject)"}`;
|
const replySubject = /^re:/i.test(subject) ? subject : `Re: ${subject || "(No subject)"}`;
|
||||||
const quote = (message?.body_text || message?.snippet || "").trim();
|
const quote = (message?.new_reply_content || message?.body_text || message?.snippet || "").trim();
|
||||||
return {
|
return {
|
||||||
replyToMessageId: message.id,
|
replyToMessageId: message.id,
|
||||||
replyAll,
|
replyAll,
|
||||||
|
|
@ -781,7 +781,7 @@ export default function MailboxView({
|
||||||
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
|
const target = threadDetail?.messages?.[threadDetail.messages.length - 1] || detail;
|
||||||
onCompose?.({
|
onCompose?.({
|
||||||
subject: `Fwd: ${target?.subject || ""}`,
|
subject: `Fwd: ${target?.subject || ""}`,
|
||||||
bodyHtml: `<p><br></p><blockquote>${(target?.body_text || target?.snippet || "").replace(/\n/g, "<br>")}</blockquote>`,
|
bodyHtml: `<p><br></p><blockquote>${(target?.new_reply_content || target?.body_text || target?.snippet || "").replace(/\n/g, "<br>")}</blockquote>`,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onExtractActions={() => detail && extractActions(detail)}
|
onExtractActions={() => detail && extractActions(detail)}
|
||||||
|
|
|
||||||
|
|
@ -855,6 +855,9 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||||
.mail-thread-message-collapsed-main strong { font:600 12px/1.3 "DM Sans"; color:#171424; }
|
.mail-thread-message-collapsed-main strong { font:600 12px/1.3 "DM Sans"; color:#171424; }
|
||||||
.mail-thread-message-collapsed-main span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#77758a; font-size:11px; }
|
.mail-thread-message-collapsed-main span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#77758a; font-size:11px; }
|
||||||
.mail-thread-message-collapsed time { color:#77758a; font-size:11px; white-space:nowrap; }
|
.mail-thread-message-collapsed time { color:#77758a; font-size:11px; white-space:nowrap; }
|
||||||
|
.mail-quoted-toggle { margin:10px 0 0; padding:2px 10px; border:1px solid #e8e5ee; border-radius:999px; background:#f7f6f9; color:#77758a; font-size:16px; line-height:1; letter-spacing:1px; cursor:pointer; }
|
||||||
|
.mail-quoted-toggle:hover { background:#f1eff3; color:#4a4652; }
|
||||||
|
.mail-quoted-body { margin:10px 0 0; padding:12px 14px; border-left:2px solid #e8e5ee; border-radius:0 8px 8px 0; background:#faf9fc; color:#77758a; font-size:12px; line-height:1.5; white-space:pre-wrap; overflow:auto; }
|
||||||
.mailbox-thread-count { min-width:18px; height:18px; padding:0 6px; display:inline-grid; place-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
|
.mailbox-thread-count { min-width:18px; height:18px; padding:0 6px; display:inline-grid; place-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
|
||||||
.agent-panel.email-context .agent-stat-grid,
|
.agent-panel.email-context .agent-stat-grid,
|
||||||
.agent-panel.email-context .agent-context-block,
|
.agent-panel.email-context .agent-context-block,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue