1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-01-30 04:30:56 +02:00

Feature: Display List-Unsubscribe & List-Unsubscribe-Post header info with syntax validation (#236)

This commit is contained in:
Ralph Slooten 2024-01-20 23:05:28 +13:00
parent 128796d4ca
commit 98a15e5918
6 changed files with 225 additions and 6 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -69,3 +69,51 @@ func TestSnippets(t *testing.T) {
}
}
}
func TestListUnsubscribeParser(t *testing.T) {
tests := map[string]bool{}
// should pass
tests["<mailto:unsubscribe@example.com>"] = true
tests["<https://example.com>"] = true
tests["<HTTPS://EXAMPLE.COM>"] = true
tests["<mailto:unsubscribe@example.com>, <http://example.com>"] = true
tests["<mailto:unsubscribe@example.com>, <https://example.com>"] = true
tests["<https://example.com>, <mailto:unsubscribe@example.com>"] = true
tests["<https://example.com> , <mailto:unsubscribe@example.com>"] = true
tests["<https://example.com> ,<mailto:unsubscribe@example.com>"] = true
tests["<mailto:unsubscribe@example.com>,<https://example.com>"] = true
tests[`<https://example.com> ,
<mailto:unsubscribe@example.com>`] = true
tests["<mailto:unsubscribe@example.com?subject=unsubscribe%20me>"] = true
tests["(Use this command to get off the list) <mailto:unsubscribe@example.com?subject=unsubscribe%20me>"] = true
tests["<mailto:unsubscribe@example.com> (Use this command to get off the list)"] = true
tests["(Use this command to get off the list) <mailto:unsubscribe@example.com>, (Click this link to unsubscribe) <http://example.com>"] = true
// should fail
tests["mailto:unsubscribe@example.com"] = false // no <>
tests["<mailto::unsubscribe@example.com>"] = false // ::
tests["https://example.com/"] = false // no <>
tests["mailto:unsubscribe@example.com, <https://example.com/>"] = false // no <>
tests["<MAILTO:unsubscribe@example.com>"] = false // capitals
tests["<mailto:unsubscribe@example.com>, <mailto:test2@example.com>"] = false // two emails
tests["<http://exampl\\e2.com>, <http://example2.com>"] = false // two links
tests["<http://example.com>, <mailto:unsubscribe@example.com>, <http://example2.com>"] = false // two links
tests["<mailto:unsubscribe@example.com>, <example.com>"] = false // no mailto || http(s)
tests["<mailto: unsubscribe@example.com>, <unsubscribe@lol.com>"] = false // space
tests["<mailto:unsubscribe@example.com?subject=unsubscribe me>"] = false // space
tests["<http:///example.com>"] = 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()
}
}
}

View File

@ -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 {

View File

@ -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 {
<span v-else>
[ Unknown ]
</span>
<span v-if="message.ListUnsubscribe.Header != ''" class="small ms-3 link"
:title="showUnsubscribe ? 'Hide unsubscribe information' : 'Show unsubscribe information'"
@click="showUnsubscribe = !showUnsubscribe">
Unsubscribe
<i class="bi bi bi-info-circle"
:class="{ 'text-danger': message.ListUnsubscribe.Errors != '' }"></i>
</span>
</td>
</tr>
<tr class="small">
<th>To</th>
<td class="privacy">
<span v-if="message.To && message.To.length" v-for="(t, i) in message.To">
<span v-if="message.To && message.To.length" v-for="( t, i ) in message.To ">
<template v-if="i > 0">, </template>
<span>
<span class="text-spaces">{{ t.Name }}</span>
@ -264,7 +277,7 @@ export default {
<tr v-if="message.Cc && message.Cc.length" class="small">
<th>Cc</th>
<td class="privacy">
<span v-for="(t, i) in message.Cc">
<span v-for="( t, i ) in message.Cc ">
<template v-if="i > 0">,</template>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body">
@ -276,7 +289,7 @@ export default {
<tr v-if="message.Bcc && message.Bcc.length" class="small">
<th>Bcc</th>
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<span v-for="( t, i ) in message.Bcc ">
<template v-if="i > 0">,</template>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body">
@ -288,7 +301,7 @@ export default {
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
<th class="text-nowrap">Reply-To</th>
<td class="privacy text-body-secondary text-break">
<span v-for="(t, i) in message.ReplyTo">
<span v-for="( t, i ) in message.ReplyTo ">
<template v-if="i > 0">,</template>
<span class="text-spaces">{{ t.Name }}</span>
&lt;<a :href="searchURI(t.Address)" class="text-body-secondary">
@ -328,11 +341,34 @@ export default {
data-separator="|,|">
<option value="">Type a tag...</option>
<!-- you need at least one option with the placeholder -->
<option v-for="t in mailbox.tags" :value="t">{{ t }}</option>
<option v-for=" t in mailbox.tags " :value="t">{{ t }}</option>
</select>
<div class="invalid-feedback">Invalid tag name</div>
</td>
</tr>
<tr v-if="message.ListUnsubscribe.Header != ''" class="small"
:class="showUnsubscribe ? '' : 'd-none'">
<th>Unsubscribe</th>
<td>
<span v-if="message.ListUnsubscribe.Links.length" class="text-secondary small me-2">
<template v-for="(u, i) in message.ListUnsubscribe.Links">
<template v-if="i > 0">, </template>
&lt;{{ u }}&gt;
</template>
</span>
<i class="bi bi-info-circle text-success me-2 link"
v-if="message.ListUnsubscribe.HeaderPost != ''" data-bs-toggle="tooltip"
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
:data-bs-title="'List-Unsubscribe-Post: ' + message.ListUnsubscribe.HeaderPost">
</i>
<i class="bi bi-exclamation-circle text-danger link"
v-if="message.ListUnsubscribe.Errors != ''" data-bs-toggle="tooltip"
data-bs-placement="top" data-bs-custom-class="custom-tooltip"
:data-bs-title="message.ListUnsubscribe.Errors">
</i>
</td>
</tr>
</tbody>
</table>
</div>
@ -455,7 +491,7 @@ export default {
</button>
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
<template v-for="vals, key in responsiveSizes">
<template v-for=" vals, key in responsiveSizes ">
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
v-on:click="scaleHTMLPreview = key">
<i class="bi" :class="'bi-' + key"></i>