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:
parent
128796d4ca
commit
98a15e5918
@ -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
|
// mark message as read
|
||||||
if err := MarkRead(id); err != nil {
|
if err := MarkRead(id); err != nil {
|
||||||
return &obj, err
|
return &obj, err
|
||||||
|
@ -29,6 +29,9 @@ type Message struct {
|
|||||||
ReturnPath string
|
ReturnPath string
|
||||||
// Message subject
|
// Message subject
|
||||||
Subject string
|
Subject string
|
||||||
|
// List-Unsubscribe header information
|
||||||
|
// swagger:ignore
|
||||||
|
ListUnsubscribe ListUnsubscribe
|
||||||
// Message date if set, else date received
|
// Message date if set, else date received
|
||||||
Date time.Time
|
Date time.Time
|
||||||
// Message tags
|
// Message tags
|
||||||
@ -122,3 +125,16 @@ func AttachmentSummary(a *enmime.Part) Attachment {
|
|||||||
|
|
||||||
return o
|
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
|
||||||
|
}
|
||||||
|
99
internal/tools/listunsubscribeparser.go
Normal file
99
internal/tools/listunsubscribeparser.go
Normal 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
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -64,6 +64,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
@extend a;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.loader {
|
.loader {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -154,6 +159,7 @@
|
|||||||
padding-right: 1.5rem;
|
padding-right: 1.5rem;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
|
@ -7,6 +7,7 @@ import LinkCheck from './LinkCheck.vue'
|
|||||||
import SpamAssassin from './SpamAssassin.vue'
|
import SpamAssassin from './SpamAssassin.vue'
|
||||||
import Prism from 'prismjs'
|
import Prism from 'prismjs'
|
||||||
import Tags from 'bootstrap5-tags'
|
import Tags from 'bootstrap5-tags'
|
||||||
|
import { Tooltip } from 'bootstrap'
|
||||||
import commonMixins from '../../mixins/CommonMixins'
|
import commonMixins from '../../mixins/CommonMixins'
|
||||||
import { mailbox } from '../../stores/mailbox'
|
import { mailbox } from '../../stores/mailbox'
|
||||||
|
|
||||||
@ -39,6 +40,7 @@ export default {
|
|||||||
spamScore: false,
|
spamScore: false,
|
||||||
spamScoreColor: false,
|
spamScoreColor: false,
|
||||||
showMobileButtons: false,
|
showMobileButtons: false,
|
||||||
|
showUnsubscribe: false,
|
||||||
scaleHTMLPreview: 'display',
|
scaleHTMLPreview: 'display',
|
||||||
// keys names match bootstrap icon names
|
// keys names match bootstrap icon names
|
||||||
responsiveSizes: {
|
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
|
// delay 0.2s until vue has rendered the iframe content
|
||||||
window.setTimeout(function () {
|
window.setTimeout(function () {
|
||||||
let p = document.getElementById('preview-html')
|
let p = document.getElementById('preview-html')
|
||||||
@ -244,12 +249,20 @@ export default {
|
|||||||
<span v-else>
|
<span v-else>
|
||||||
[ Unknown ]
|
[ Unknown ]
|
||||||
</span>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="small">
|
<tr class="small">
|
||||||
<th>To</th>
|
<th>To</th>
|
||||||
<td class="privacy">
|
<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>
|
<template v-if="i > 0">, </template>
|
||||||
<span>
|
<span>
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
@ -264,7 +277,7 @@ export default {
|
|||||||
<tr v-if="message.Cc && message.Cc.length" class="small">
|
<tr v-if="message.Cc && message.Cc.length" class="small">
|
||||||
<th>Cc</th>
|
<th>Cc</th>
|
||||||
<td class="privacy">
|
<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>
|
<template v-if="i > 0">,</template>
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
<<a :href="searchURI(t.Address)" class="text-body">
|
<<a :href="searchURI(t.Address)" class="text-body">
|
||||||
@ -276,7 +289,7 @@ export default {
|
|||||||
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
<tr v-if="message.Bcc && message.Bcc.length" class="small">
|
||||||
<th>Bcc</th>
|
<th>Bcc</th>
|
||||||
<td class="privacy">
|
<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>
|
<template v-if="i > 0">,</template>
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
<<a :href="searchURI(t.Address)" class="text-body">
|
<<a :href="searchURI(t.Address)" class="text-body">
|
||||||
@ -288,7 +301,7 @@ export default {
|
|||||||
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
<tr v-if="message.ReplyTo && message.ReplyTo.length" class="small">
|
||||||
<th class="text-nowrap">Reply-To</th>
|
<th class="text-nowrap">Reply-To</th>
|
||||||
<td class="privacy text-body-secondary text-break">
|
<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>
|
<template v-if="i > 0">,</template>
|
||||||
<span class="text-spaces">{{ t.Name }}</span>
|
<span class="text-spaces">{{ t.Name }}</span>
|
||||||
<<a :href="searchURI(t.Address)" class="text-body-secondary">
|
<<a :href="searchURI(t.Address)" class="text-body-secondary">
|
||||||
@ -328,11 +341,34 @@ export default {
|
|||||||
data-separator="|,|">
|
data-separator="|,|">
|
||||||
<option value="">Type a tag...</option>
|
<option value="">Type a tag...</option>
|
||||||
<!-- you need at least one option with the placeholder -->
|
<!-- 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>
|
</select>
|
||||||
<div class="invalid-feedback">Invalid tag name</div>
|
<div class="invalid-feedback">Invalid tag name</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
|
<{{ u }}>
|
||||||
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -455,7 +491,7 @@ export default {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="d-none d-lg-block ms-auto me-3" v-if="showMobileButtons">
|
<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'"
|
<button class="btn" :disabled="scaleHTMLPreview == key" :title="'Switch to ' + key + ' view'"
|
||||||
v-on:click="scaleHTMLPreview = key">
|
v-on:click="scaleHTMLPreview = key">
|
||||||
<i class="bi" :class="'bi-' + key"></i>
|
<i class="bi" :class="'bi-' + key"></i>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user