优化回复和转发邮件的原始邮件排版格式
This commit is contained in:
parent
a145516a89
commit
ef5733ce8a
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,收件箱,
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user