优化回复和转发邮件的原始邮件排版格式

This commit is contained in:
jingrow 2026-05-13 18:51:43 +08:00
parent a145516a89
commit ef5733ce8a
3 changed files with 215 additions and 27 deletions

View File

@ -238,18 +238,18 @@ function open(opts?: Partial<typeof props>) {
}
visible.value = true
nextTick(async () => {
// Pre-fill content if provided
if (propsRef.defaultContent) {
editorContent.value = propsRef.defaultContent || ''
}
// Append email signature
await appendSignature()
// Append reply quoting
if (propsRef.isReply && propsRef.lastEmail) {
appendReplyQuote()
} else if (propsRef.isForward && propsRef.lastEmail) {
appendForwardQuote()
}
// Restore draft if available (only for new emails, not replies/forwards)
if (!propsRef.isReply && !propsRef.isForward) {
await restoreDraft()
}
// Restore draft if available
await restoreDraft()
// Load existing attachments from reference document
loadExistingAttachments()
})
@ -515,32 +515,144 @@ function appendReplyQuote() {
if (!lastEmail) return
let content = lastEmail.original_comment || lastEmail.content || ''
// Convert to text-ish for quoting
content = htmlToQuotedText(content)
// Clip to max 20k chars
if (content.length > 20 * 1024) {
content = content.slice(0, 20 * 1024)
}
if (!content || content.trim() === '') return
const date = lastEmail.communication_date || lastEmail.creation || ''
const date = formatRfc5322Date(lastEmail.communication_date || lastEmail.creation || '')
const senderName = lastEmail.sender || ''
const separator = '<div>---</div>'
const quoteHeader = `<p>${t('On {0}, {1} wrote:', [date, senderName])}</p>`
const quotedBlock = `<blockquote>${content}</blockquote>`
// Clean quoted content: strip scripts/styles, remove inline styles, strip head/meta/title
content = cleanupQuotedContent(content)
// Normalize image src to absolute URLs
content = fixImageUrls(content)
// Gmail/Outlook style: localized "On [date], [sender] wrote:"
const replyHeader = `<p class="reply-header">${t('On {0}, {1} wrote:', { 0: date, 1: senderName })}</p>`
const current = editorContent.value
const quoteHtml = separator + quoteHeader + quotedBlock
editorContent.value = (current || '') + '<div><br></div>' + quoteHtml
editorContent.value = (current || '') + '<hr>' + replyHeader + `<blockquote>${content}</blockquote>`
replyQuoteAdded.value = true
}
function htmlToQuotedText(html: string): string {
return html
.replace(/<\/div>/g, '<br></div>')
.replace(/<\/p>/g, '<br></p>')
.replace(/<br>/g, '\n')
.replace(/\n{3,}/g, '\n\n')
function appendForwardQuote() {
if (replyQuoteAdded.value) return
const lastEmail = propsRef.lastEmail
if (!lastEmail) return
let content = lastEmail.original_comment || lastEmail.content || ''
if (!content || content.trim() === '') return
const date = formatRfc5322Date(lastEmail.communication_date || lastEmail.creation || '')
const senderName = lastEmail.sender || ''
const subject = lastEmail.subject || ''
// Clean quoted content: strip scripts/styles, remove inline styles, strip head/meta/title
content = cleanupQuotedContent(content)
// Normalize image src to absolute URLs
content = fixImageUrls(content)
// Forward: subject as header
const forwardHeader = `<p class="reply-header">${escapeHtml(t('Subject:'))} ${escapeHtml(subject)}</p>`
const current = editorContent.value
editorContent.value = (current || '') + '<hr>' + forwardHeader + `<blockquote>${content}</blockquote>`
replyQuoteAdded.value = true
}
function formatRfc5322Date(dateStr: string): string {
if (!dateStr) return ''
try {
const d = new Date(dateStr)
const locale = getCurrentLocale()
return new Intl.DateTimeFormat(locale, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
}).format(d)
} catch {
return dateStr
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function fixImageUrls(html: string): string {
// Convert relative image src to absolute URLs
const base = window.location.origin
return html.replace(/<img([^>]*?)src=["']([^"']*?)["']([^>]*?)>/gi, (match, before, src, after) => {
if (src.startsWith('https://') || src.startsWith('http://') || src.startsWith('//') || src.startsWith('data:')) {
return match
}
const absolute = src.startsWith('/') ? base + src : base + '/' + src
return `<img${before}src="${absolute}"${after}>`
})
}
/**
* Clean up quoted email content:
* 1. Parse via DOMParser to extract body (strips <head>, <meta>, <title>, etc.)
* 2. Remove dangerous/irrelevant tags: script, style, noscript, title, link
* 3. Remove all inline style attributes (so original bold/font-size doesn't leak)
* 4. Remove tracking pixels (1x1 transparent images)
* 5. Keep: p, br, span, a, img, table, tr, td, th, thead, tbody, ul, ol, li,
* h1-h6, blockquote, pre, code, em, strong, u, del, hr, div (with CSS isolation)
*/
function cleanupQuotedContent(html: string): string {
try {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
// Remove unwanted top-level elements
const evilTags = ['script', 'style', 'noscript', 'title', 'meta', 'base', 'head', 'link']
for (const tag of evilTags) {
for (const el of Array.from(doc.body.getElementsByTagName(tag))) {
el.remove()
}
}
// Remove external stylesheet links
for (const el of Array.from(doc.body.querySelectorAll('link[rel="stylesheet"]'))) {
el.remove()
}
// Remove all inline style attributes (prevent original bold/font-size leaks)
for (const el of Array.from(doc.body.querySelectorAll('[style]'))) {
el.removeAttribute('style')
}
// Remove tracking pixels (1x1 transparent images)
for (const img of Array.from(doc.body.querySelectorAll('img'))) {
const w = img.getAttribute('width')
const h = img.getAttribute('height')
if ((w === '1' || w === '0' || !w) && (h === '1' || h === '0' || !h)) {
img.remove()
}
}
// Get cleaned body HTML
let cleaned = doc.body.innerHTML
// Remove any stray HTML comment nodes
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, '')
// Remove leftover MSO/Outlook conditional tags
cleaned = cleaned.replace(/<!--\[if[\s\S]*?\]>[\s\S]*?<!\[endif\]-->/gi, '')
return cleaned
} catch {
return html
}
}
// -------------------------------------------------------
@ -959,4 +1071,72 @@ function splitEmailList(str: string | undefined): string[] {
color: #166534 !important;
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15) !important;
}
/* Email quote styles - quoted original message content
Rendered as blockquote inside Tiptap editor */
.jeditor blockquote {
border-left: 3px solid #d0d7de;
background: #f8f9fa;
padding: 8px 16px;
margin: 8px 0;
color: #57606a;
font-size: 14px;
border-radius: 0 4px 4px 0;
}
.jeditor blockquote p {
margin: 0 0 6px 0;
color: #57606a;
}
.jeditor blockquote p:last-child {
margin-bottom: 0;
}
.jeditor blockquote img {
max-width: 100%;
height: auto;
display: block;
margin: 8px 0;
border-radius: 4px;
}
.jeditor blockquote table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
}
.jeditor blockquote table td,
.jeditor blockquote table th {
border: 1px solid #e5e6eb;
padding: 6px 10px;
}
.jeditor blockquote table th {
background-color: #f0f2f5;
}
.jeditor blockquote pre {
background: #f0f2f5;
border-radius: 4px;
padding: 8px 12px;
overflow-x: auto;
font-size: 13px;
}
/* Reply header: Gmail-style single-line "On [date], [sender] wrote:" */
.jeditor .reply-header {
color: #57606a;
font-size: 13px;
margin: 0 0 6px 0;
line-height: 1.5;
}
/* Quote separator - modern subtle hr */
.jeditor hr {
border: none;
border-top: 1px dashed #d0d7de;
margin: 12px 0 8px 0;
}
</style>

View File

@ -17548,7 +17548,7 @@ msgstr "在或之前"
#: jingrow/public/js/jingrow/views/communication.js:951
msgid "On {0}, {1} wrote:"
msgstr "于{0}{1}写道:"
msgstr "{1}于{0}写道:"
#. Label of a Check field in PageType 'Workspace Link'
#: jingrow/desk/pagetype/workspace_link/workspace_link.json

View File

@ -93,6 +93,7 @@ First Name,名,
Frequency,频率,
Friday,星期五,
From,,
From:,发件人:,
Full,充分,
Full Name,全名,
Further nodes can be only created under 'Group' type nodes,只能在“组”节点下新建节点,
@ -236,6 +237,7 @@ Start Import,开始导入,
State,,
Stopped,已停止,
Subject,主题,
Subject:,主题:,
Submit,提交,
Summary,概要,
Sunday,星期天,
@ -248,6 +250,7 @@ Thank you,谢谢,
The page you are looking for is missing. This could be because it is moved or there is a typo in the link.,您正在访问的网页不存在。可能是网页被移到别处了或您输入的网址不正确。,
Timespan,时间跨度,
To,,
To:,到:,
To Date,至今,
Traceback,回溯,
URL,网址,
@ -305,6 +308,9 @@ ASC,ASC码,
About Us Settings,关于我们设置,
About Us Team Member,关于我们 团队成员,
Accept Payment,接受支付,
---------- Forwarded message ----------,---------- 转发邮件 ----------,
Forwarded message,转发信息,
---,---,
Access Key ID,访问密钥ID,
Access Token URL,访问令牌链接地址,
Action Failed,操作失败,
@ -1720,7 +1726,7 @@ Office 365,Office 365,
Old Password,旧密码,
Old Password Required.,需要旧密码,
Older backups will be automatically deleted,旧的备份将被自动删除,
"On {0}, {1} wrote:",{0}{1}写道:,
"On {0}, {1} wrote:",{1}于{0}写道:,
"Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.",提交后,无法更改可提交的文档。它们只能被取消和修改。,
"Once you have set this, the users will only be able access documents (eg. Blog Post) where the link exists (eg. Blogger).",一旦你设置这个,用户将只能访问在其链路存在时(如博主的)文档(如博客文章) 。,
One Last Step,最后一步,
@ -1762,6 +1768,7 @@ Org History,组织历史,
Org History Heading,组织历史航向,
Orientation,方向,
Original Value,原始值,
Original message,原始邮件,
Outgoing email account not correct,发出电子邮件帐户不正确,
Outlook.com,Outlook.com,
Output,产量,
@ -3688,6 +3695,7 @@ Currency,货币,
Customize,定制,
Daily,每日,
Date,日期,
Date:,日期:,
Dear,亲爱,
Default,默认,
Delete,删除,
@ -3803,7 +3811,7 @@ EMail,邮件,
Edit in Full Page,全页编辑,
Email Inbox,电子邮件收件箱,
File,文件,
Forward,向前,
Forward,转发,
Icon,图标,
In,,
Inbox,收件箱,

1 A4 A4
93 Frequency 频率
94 Friday 星期五
95 From
96 From: 发件人:
97 Full 充分
98 Full Name 全名
99 Further nodes can be only created under 'Group' type nodes 只能在“组”节点下新建节点
237 State
238 Stopped 已停止
239 Subject 主题
240 Subject: 主题:
241 Submit 提交
242 Summary 概要
243 Sunday 星期天
250 The page you are looking for is missing. This could be because it is moved or there is a typo in the link. 您正在访问的网页不存在。可能是网页被移到别处了或您输入的网址不正确。
251 Timespan 时间跨度
252 To
253 To: 到:
254 To Date 至今
255 Traceback 回溯
256 URL 网址
308 About Us Settings 关于我们设置
309 About Us Team Member 关于我们 团队成员
310 Accept Payment 接受支付
311 ---------- Forwarded message ---------- ---------- 转发邮件 ----------
312 Forwarded message 转发信息
313 --- ---
314 Access Key ID 访问密钥ID
315 Access Token URL 访问令牌链接地址
316 Action Failed 操作失败
1726 Old Password 旧密码
1727 Old Password Required. 需要旧密码
1728 Older backups will be automatically deleted 旧的备份将被自动删除
1729 On {0}, {1} wrote: {0},{1}写道: {1}于{0}写道:
1730 Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended. 提交后,无法更改可提交的文档。它们只能被取消和修改。
1731 Once you have set this, the users will only be able access documents (eg. Blog Post) where the link exists (eg. Blogger). 一旦你设置这个,用户将只能访问在其链路存在时(如博主的)文档(如博客文章) 。
1732 One Last Step 最后一步
1768 Org History Heading 组织历史航向
1769 Orientation 方向
1770 Original Value 原始值
1771 Original message 原始邮件
1772 Outgoing email account not correct 发出电子邮件帐户不正确
1773 Outlook.com Outlook.com
1774 Output 产量
3695 Customize 定制
3696 Daily 每日
3697 Date 日期
3698 Date: 日期:
3699 Dear 亲爱
3700 Default 默认
3701 Delete 删除
3811 Edit in Full Page 全页编辑
3812 Email Inbox 电子邮件收件箱
3813 File 文件
3814 Forward 向前 转发
3815 Icon 图标
3816 In
3817 Inbox 收件箱