Skip to content

Commit 1b4ebdd

Browse files
committed
优化邮件转发正文链接处理
1 parent e409932 commit 1b4ebdd

8 files changed

Lines changed: 154 additions & 18 deletions

File tree

config.example.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"wh_17700000000"
9898
],
9999
"filter_rule_ids": [],
100+
"include_links": false,
100101
"enabled": true
101102
}
102103
],

globals.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,7 @@ var (
2626
)
2727

2828
const (
29-
ConfigFile = "data/config.json"
29+
ConfigFile = "data/config.json"
30+
maxForwardURLLength = 600
31+
maxForwardLinkTextRunes = 80
3032
)

mail.go

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"net"
77
"net/url"
8+
"regexp"
89
"strings"
910
"time"
1011

@@ -16,6 +17,8 @@ import (
1617

1718
// ===================== 邮件处理 =====================
1819

20+
var markdownLinkRegex = regexp.MustCompile(`\[([^\]\n]{0,240})\]\((https?://[^)\s]+|长链接由于超长已被过滤)\)`)
21+
1922
func cleanHTML(htmlStr string) string {
2023
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlStr))
2124
if err != nil {
@@ -35,14 +38,14 @@ func cleanHTML(htmlStr string) string {
3538
href = sanitizeURL(href)
3639
if text == "" {
3740
text = "链接"
38-
} else if len(text) > 80 {
39-
text = text[:77] + "..."
41+
} else {
42+
text = truncateRunes(text, maxForwardLinkTextRunes)
4043
}
4144
text = escapeMarkdownText(text)
4245
if href != "" {
4346
href = escapeMarkdownLinkURL(href)
44-
if len(href) > 600 {
45-
s.SetText(fmt.Sprintf("[%s](长链接由于超长已被过滤)", text))
47+
if len(href) > maxForwardURLLength {
48+
s.SetText(text)
4649
} else {
4750
s.SetText(fmt.Sprintf("[%s](%s)", text, href))
4851
}
@@ -62,8 +65,8 @@ func cleanHTML(htmlStr string) string {
6265

6366
text := normalizeFormattedText(doc.Text())
6467
text = urlRegex.ReplaceAllStringFunc(text, func(u string) string {
65-
if len(u) > 600 {
66-
return u[:80] + "...(该段长链接由于超长已被过滤)"
68+
if len(u) > maxForwardURLLength {
69+
return ""
6770
}
6871
return u
6972
})
@@ -146,19 +149,112 @@ func normalizeFormattedText(text string) string {
146149

147150
func formatPlainTextBody(body string) string {
148151
body = urlRegex.ReplaceAllStringFunc(body, func(u string) string {
149-
disp := u
150-
if len(disp) > 80 {
151-
disp = disp[:77] + "..."
152+
if len(u) > maxForwardURLLength {
153+
return ""
152154
}
155+
disp := truncateRunes(u, maxForwardLinkTextRunes)
153156
u = escapeMarkdownLinkURL(u)
154-
if len(u) > 600 {
155-
return fmt.Sprintf("[%s](长链接由于超长已被过滤)", disp)
156-
}
157157
return fmt.Sprintf("[%s](%s)", disp, u)
158158
})
159159
return normalizeFormattedText(body)
160160
}
161161

162+
func formatForwardBody(body string, includeLinks bool) string {
163+
body = normalizeFormattedText(body)
164+
if includeLinks {
165+
return normalizeFormattedText(formatURLsAsMarkdown(body))
166+
}
167+
return normalizeFormattedText(removeLinksFromText(body))
168+
}
169+
170+
func formatURLsAsMarkdown(text string) string {
171+
var b strings.Builder
172+
last := 0
173+
matches := urlRegex.FindAllStringIndex(text, -1)
174+
for _, loc := range matches {
175+
start, end := loc[0], loc[1]
176+
if isMarkdownURLDestination(text, start, end) {
177+
continue
178+
}
179+
u := text[start:end]
180+
b.WriteString(text[last:start])
181+
if len(u) <= maxForwardURLLength {
182+
disp := truncateRunes(u, maxForwardLinkTextRunes)
183+
b.WriteString(fmt.Sprintf("[%s](%s)", disp, escapeMarkdownLinkURL(u)))
184+
}
185+
last = end
186+
}
187+
if last == 0 {
188+
return text
189+
}
190+
b.WriteString(text[last:])
191+
return b.String()
192+
}
193+
194+
func removeLinksFromText(text string) string {
195+
text = markdownLinkRegex.ReplaceAllStringFunc(text, func(match string) string {
196+
parts := markdownLinkRegex.FindStringSubmatch(match)
197+
if len(parts) < 2 {
198+
return ""
199+
}
200+
return unescapeMarkdownText(parts[1])
201+
})
202+
text = urlRegex.ReplaceAllString(text, "")
203+
204+
lines := strings.Split(text, "\n")
205+
kept := make([]string, 0, len(lines))
206+
for _, line := range lines {
207+
if isDanglingLinkLabel(line) {
208+
continue
209+
}
210+
kept = append(kept, line)
211+
}
212+
return strings.Join(kept, "\n")
213+
}
214+
215+
func isMarkdownURLDestination(text string, start int, end int) bool {
216+
if start <= 0 || text[start-1] != '(' {
217+
return false
218+
}
219+
return (end < len(text) && text[end] == ')') || (end > start && text[end-1] == ')')
220+
}
221+
222+
func isDanglingLinkLabel(line string) bool {
223+
line = strings.TrimSpace(line)
224+
line = strings.TrimRight(line, "::")
225+
switch strings.ToLower(line) {
226+
case "链接", "url", "link":
227+
return true
228+
default:
229+
return false
230+
}
231+
}
232+
233+
func truncateRunes(text string, max int) string {
234+
runes := []rune(text)
235+
if len(runes) <= max {
236+
return text
237+
}
238+
if max <= 3 {
239+
return string(runes[:max])
240+
}
241+
return string(runes[:max-3]) + "..."
242+
}
243+
244+
func unescapeMarkdownText(text string) string {
245+
replacer := strings.NewReplacer(
246+
`\\[`, `[`,
247+
`\\]`, `]`,
248+
`\\(`, `(`,
249+
`\\)`, `)`,
250+
"\\`", "`",
251+
`\\*`, `*`,
252+
`\\_`, `_`,
253+
`\\\\`, `\`,
254+
)
255+
return replacer.Replace(text)
256+
}
257+
162258
func normalizeFolderKey(folder string) string {
163259
folder = strings.TrimSpace(folder)
164260
if folder == "" {

main_test.go

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ func TestCleanHTML_PreservesReadableStructure(t *testing.T) {
2525
}
2626
}
2727

28-
func TestFormatPlainTextBody_ConvertsURLToMarkdownAndTruncatesLongLinks(t *testing.T) {
28+
func TestFormatPlainTextBody_ConvertsURLToMarkdownAndRemovesLongLinks(t *testing.T) {
2929
body := "请查看 https://example.com/docs?id=1 和这个超长链接 https://example.com/" + strings.Repeat("a", 650)
3030

3131
got := formatPlainTextBody(body)
3232

3333
if !strings.Contains(got, "[https://example.com/docs?id=1](https://example.com/docs?id=1)") {
3434
t.Fatalf("expected normal URL converted to markdown link, got: %q", got)
3535
}
36-
if !strings.Contains(got, "长链接由于超长已被过滤") {
37-
t.Fatalf("expected overlong URL filtered message, got: %q", got)
36+
if strings.Contains(got, strings.Repeat("a", 80)) || strings.Contains(got, "长链接由于超长已被过滤") {
37+
t.Fatalf("expected overlong URL removed without placeholder, got: %q", got)
3838
}
3939
}
4040

@@ -74,6 +74,32 @@ func TestFormatPlainTextBody_FirstLineUsesTwoSpaceIndent(t *testing.T) {
7474
}
7575
}
7676

77+
func TestFormatForwardBody_RemovesLinksByDefault(t *testing.T) {
78+
body := "查看 [详情](https://example.com/docs?id=1)\n链接:https://example.com/raw\n第二段"
79+
80+
got := formatForwardBody(body, false)
81+
82+
if strings.Contains(got, "https://") || strings.Contains(got, "[详情]") {
83+
t.Fatalf("expected links removed by default, got: %q", got)
84+
}
85+
if !strings.Contains(got, "查看 详情") || !strings.Contains(got, "第二段") {
86+
t.Fatalf("expected readable text preserved, got: %q", got)
87+
}
88+
}
89+
90+
func TestFormatForwardBody_KeepsShortLinksAndRemovesLongLinksWhenEnabled(t *testing.T) {
91+
body := "查看 https://example.com/docs?id=1\n长链接:https://example.com/" + strings.Repeat("b", 650)
92+
93+
got := formatForwardBody(body, true)
94+
95+
if !strings.Contains(got, "[https://example.com/docs?id=1](https://example.com/docs?id=1)") {
96+
t.Fatalf("expected short URL converted to markdown link, got: %q", got)
97+
}
98+
if strings.Contains(got, strings.Repeat("b", 80)) {
99+
t.Fatalf("expected overlong URL removed, got: %q", got)
100+
}
101+
}
102+
77103
func TestApplyFilterRules_BlacklistBlocksSender(t *testing.T) {
78104
msg := &Message{From: "newsletter@example.com", Subject: "周报", Body: "正文"}
79105
ctx := filterContext{DisplaySender: msg.From}

models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type ForwardRule struct {
5454
TargetWebhook string `json:"target_webhook"` // 兼容旧配置的单 Webhook 目标ID
5555
TargetWebhooks []string `json:"target_webhooks"` // Webhook 目标ID列表
5656
FilterRuleIDs []string `json:"filter_rule_ids"` // 独立过滤规则ID
57+
IncludeLinks bool `json:"include_links"` // 是否在转发正文中保留邮件链接,默认不保留
5758
Enabled bool `json:"enabled"`
5859
}
5960

processor.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func processPendingMessages() {
8484
senderForSend := filterCtx.DisplaySender
8585

8686
// 智能提取验证码并高亮前置
87-
displayBody := msg.Body
87+
displayBody := formatForwardBody(msg.Body, rule.IncludeLinks)
8888
var verificationCode string
8989
if matches := codeRegex.FindStringSubmatch(msg.Subject); len(matches) > 1 {
9090
verificationCode = matches[1]
@@ -93,7 +93,7 @@ func processPendingMessages() {
9393
}
9494

9595
if verificationCode != "" {
96-
displayBody = fmt.Sprintf("**[智能提取验证码] %s**\n\n%s", verificationCode, msg.Body)
96+
displayBody = fmt.Sprintf("**[智能提取验证码] %s**\n\n%s", verificationCode, displayBody)
9797
}
9898

9999
sentTargets := make([]string, 0, len(targetIDs))

static/rules.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ function renderRules() {
6363
<td>${targetNames.length ? targetNames.map(name => `<span class="tag tag-info">${escapeHtml(name)}</span>`).join(' ') : '未知'}</td>
6464
<td>
6565
${selectedFilters.length ? selectedFilters.map(f => `<span class="tag tag-info">${escapeHtml(f.name)}</span>`).join(' ') : '无'}
66+
${rule.include_links ? '<span class="tag tag-neutral">保留链接</span>' : ''}
6667
</td>
6768
<td>
6869
<span class="tag ${rule.enabled ? 'tag-success' : 'tag-neutral'}">
@@ -140,6 +141,7 @@ async function openRuleModal(data = null) {
140141
renderRuleSourceDropdown(normalizeRuleSources(data || { source_account: 'all' }));
141142
renderRuleTargetDropdown(normalizeRuleTargets(data || {}));
142143
renderRuleFilterDropdown(data?.filter_rule_ids || []);
144+
document.getElementById('rule-include-links').checked = data?.include_links === true;
143145
document.getElementById('rule-enabled').checked = data?.enabled !== false;
144146
document.getElementById('rule-modal').classList.add('active');
145147
}
@@ -341,6 +343,7 @@ async function saveRule() {
341343
target_webhook: targetIDs[0] || '',
342344
target_webhooks: targetIDs,
343345
filter_rule_ids: getSelectedRuleFilterIDs(),
346+
include_links: document.getElementById('rule-include-links').checked,
344347
enabled: document.getElementById('rule-enabled').checked
345348
};
346349

templates/index.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,13 @@ <h3 class="modal-title" id="rule-modal-title">添加转发规则</h3>
805805
<div class="filter-dropdown-menu" id="rule-filter-menu"></div>
806806
</div>
807807
</div>
808+
<div class="form-group">
809+
<label class="form-checkbox">
810+
<input type="checkbox" id="rule-include-links">
811+
<span>保留邮件正文中的链接</span>
812+
</label>
813+
<div class="form-hint">默认关闭。关闭时转发正文会移除链接;开启时短链接会转换为 Markdown 超链接,超长链接会删除。</div>
814+
</div>
808815
<div class="form-group">
809816
<label class="form-checkbox">
810817
<input type="checkbox" id="rule-enabled" checked>

0 commit comments

Comments
 (0)