diff --git a/internal/storage/database.go b/internal/storage/database.go index 6ae1276..ec8acfe 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -405,6 +405,20 @@ func GetMessage(id string) (*Message, error) { } } + // get List-Unsubscribe links if set + obj.ListUnsubscribe = ListUnsubscribe{} + obj.ListUnsubscribe.Links = []string{} + if env.GetHeader("List-Unsubscribe") != "" { + l := env.GetHeader("List-Unsubscribe") + links, err := tools.ListUnsubscribeParser(l) + obj.ListUnsubscribe.Header = l + obj.ListUnsubscribe.Links = links + if err != nil { + obj.ListUnsubscribe.Errors = err.Error() + } + obj.ListUnsubscribe.HeaderPost = env.GetHeader("List-Unsubscribe-Post") + } + // mark message as read if err := MarkRead(id); err != nil { return &obj, err diff --git a/internal/storage/structs.go b/internal/storage/structs.go index 2ad9721..ec92ec9 100644 --- a/internal/storage/structs.go +++ b/internal/storage/structs.go @@ -29,6 +29,9 @@ type Message struct { ReturnPath string // Message subject Subject string + // List-Unsubscribe header information + // swagger:ignore + ListUnsubscribe ListUnsubscribe // Message date if set, else date received Date time.Time // Message tags @@ -122,3 +125,16 @@ func AttachmentSummary(a *enmime.Part) Attachment { return o } + +// ListUnsubscribe contains a summary of List-Unsubscribe & List-Unsubscribe-Post headers +// including validation of the link structure +type ListUnsubscribe struct { + // List-Unsubscribe header value + Header string + // Detected links, maximum one email and one HTTP(S) + Links []string + // Validation errors if any + Errors string + // List-Unsubscribe-Post value if set + HeaderPost string +} diff --git a/internal/tools/listunsubscribeparser.go b/internal/tools/listunsubscribeparser.go new file mode 100644 index 0000000..ee5e573 --- /dev/null +++ b/internal/tools/listunsubscribeparser.go @@ -0,0 +1,99 @@ +package tools + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// ListUnsubscribeParser will attempt to parse a `List-Unsubscribe` header and return +// a slide of addresses (mail & URLs) +func ListUnsubscribeParser(v string) ([]string, error) { + var results = []string{} + var re = regexp.MustCompile(`(?mU)<(.*)>`) + var reJoins = regexp.MustCompile(`(?imUs)>(.*)<`) + var reValidJoinChars = regexp.MustCompile(`(?imUs)^(\s+)?,(\s+)?$`) + var reWrapper = regexp.MustCompile(`(?imUs)^<(.*)>$`) + var reMailTo = regexp.MustCompile(`^mailto:[a-zA-Z0-9]`) + var reHTTP = regexp.MustCompile(`^(?i)https?://[a-zA-Z0-9]`) + var reSpaces = regexp.MustCompile(`\s`) + var reComments = regexp.MustCompile(`(?mUs)\(.*\)`) + var hasMailTo bool + var hasHTTP bool + + v = strings.TrimSpace(v) + + comments := reComments.FindAllStringSubmatch(v, -1) + for _, c := range comments { + // strip comments + v = strings.Replace(v, c[0], "", -1) + v = strings.TrimSpace(v) + } + + if !re.MatchString(v) { + return results, fmt.Errorf("\"%s\" no valid unsubscribe links found", v) + } + + errors := []string{} + + if !reWrapper.MatchString(v) { + return results, fmt.Errorf("\"%s\" should be enclosed in <>", v) + } + + matches := re.FindAllStringSubmatch(v, -1) + + if len(matches) > 2 { + errors = append(errors, fmt.Sprintf("\"%s\" should include a maximum of one email and one HTTP link", v)) + } else { + splits := reJoins.FindAllStringSubmatch(v, -1) + for _, g := range splits { + if !reValidJoinChars.MatchString(g[1]) { + return results, fmt.Errorf("\"%s\" <> should be split with a comma and optional spaces", v) + } + } + + for _, m := range matches { + r := m[1] + if reSpaces.MatchString(r) { + errors = append(errors, fmt.Sprintf("\"%s\" should not contain spaces", r)) + continue + } + + if reMailTo.MatchString(r) { + if hasMailTo { + errors = append(errors, fmt.Sprintf("\"%s\" should only contain one mailto:", r)) + continue + } + + hasMailTo = true + } else if reHTTP.MatchString(r) { + if hasHTTP { + errors = append(errors, fmt.Sprintf("\"%s\" should only contain one HTTP link", r)) + continue + } + + hasHTTP = true + + } else { + errors = append(errors, fmt.Sprintf("\"%s\" should start with either http(s):// or mailto:", r)) + continue + } + + _, err := url.ParseRequestURI(r) + if err != nil { + errors = append(errors, err.Error()) + continue + } + + results = append(results, r) + } + } + + var err error + if len(errors) > 0 { + err = fmt.Errorf("%s", strings.Join(errors, ", ")) + } + + return results, err +} diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index ad2321d..d98529e 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -69,3 +69,51 @@ func TestSnippets(t *testing.T) { } } } + +func TestListUnsubscribeParser(t *testing.T) { + tests := map[string]bool{} + + // should pass + tests[""] = true + tests[""] = true + tests[""] = true + tests[", "] = true + tests[", "] = true + tests[", "] = true + tests[" , "] = true + tests[" ,"] = true + tests[","] = true + tests[` , + `] = true + tests[""] = true + tests["(Use this command to get off the list) "] = true + tests[" (Use this command to get off the list)"] = true + tests["(Use this command to get off the list) , (Click this link to unsubscribe) "] = true + + // should fail + tests["mailto:unsubscribe@example.com"] = false // no <> + tests[""] = false // :: + tests["https://example.com/"] = false // no <> + tests["mailto:unsubscribe@example.com, "] = false // no <> + tests[""] = false // capitals + tests[", "] = false // two emails + tests[", "] = false // two links + tests[", , "] = false // two links + tests[", "] = false // no mailto || http(s) + tests[", "] = false // space + tests[""] = false // space + tests[""] = false // http:/// + + for search, expected := range tests { + _, err := ListUnsubscribeParser(search) + hasError := err != nil + if expected == hasError { + if err != nil { + t.Logf("ListUnsubscribeParser: %v", err) + } else { + t.Logf("ListUnsubscribeParser: \"%s\" expected: %v", search, expected) + } + t.Fail() + } + } +} diff --git a/server/ui-src/assets/styles.scss b/server/ui-src/assets/styles.scss index bbc0c0b..d3d4b2f 100644 --- a/server/ui-src/assets/styles.scss +++ b/server/ui-src/assets/styles.scss @@ -64,6 +64,11 @@ } } +.link { + @extend a; + cursor: pointer; +} + .loader { position: fixed; top: 0; @@ -154,6 +159,7 @@ padding-right: 1.5rem; font-weight: normal; vertical-align: top; + min-width: 120px; } td { diff --git a/server/ui-src/components/message/Message.vue b/server/ui-src/components/message/Message.vue index 95d369b..2a04ce9 100644 --- a/server/ui-src/components/message/Message.vue +++ b/server/ui-src/components/message/Message.vue @@ -7,6 +7,7 @@ import LinkCheck from './LinkCheck.vue' import SpamAssassin from './SpamAssassin.vue' import Prism from 'prismjs' import Tags from 'bootstrap5-tags' +import { Tooltip } from 'bootstrap' import commonMixins from '../../mixins/CommonMixins' import { mailbox } from '../../stores/mailbox' @@ -39,6 +40,7 @@ export default { spamScore: false, spamScoreColor: false, showMobileButtons: false, + showUnsubscribe: false, scaleHTMLPreview: 'display', // keys names match bootstrap icon names responsiveSizes: { @@ -121,6 +123,9 @@ export default { }) }) + const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + [...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl)) + // delay 0.2s until vue has rendered the iframe content window.setTimeout(function () { let p = document.getElementById('preview-html') @@ -244,12 +249,20 @@ export default { [ Unknown ] + + + Unsubscribe + + To - + {{ t.Name }} @@ -264,7 +277,7 @@ export default { Cc - + {{ t.Name }} < @@ -276,7 +289,7 @@ export default { Bcc - + {{ t.Name }} < @@ -288,7 +301,7 @@ export default { Reply-To - + {{ t.Name }} < @@ -328,11 +341,34 @@ export default { data-separator="|,|"> - +
Invalid tag name
+ + + Unsubscribe + + + + + + + + + + @@ -455,7 +491,7 @@ export default {
-