1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-08-15 20:13:16 +02:00

Merge branch 'release/v1.18.7'

This commit is contained in:
Ralph Slooten
2024-06-22 23:36:12 +12:00
40 changed files with 1587 additions and 691 deletions

View File

@@ -17,6 +17,24 @@ options:
fix: Fix
# perf: Performance Improvements
# refactor: Code Refactoring
sort_by: Custom
title_order:
- Feature
- Chore
- UI
- API
- Libs
- Docker
- Security
- Fix
- Bugfix
- Docs
- Swagger
- Build
- Testing
- Test
- Tests
- Pull Requests
header:
pattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$"
pattern_maps:

View File

@@ -29,13 +29,16 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- uses: benjlevesque/short-sha@v3.0
id: short-sha
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/386,linux/amd64,linux/arm64
build-args: |
"VERSION=edge-${{ github.sha }}"
"VERSION=edge-${{ steps.short-sha.outputs.sha }}"
push: true
tags: |
axllent/mailpit:edge

View File

@@ -26,7 +26,7 @@ jobs:
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test -p 1 ./internal/storage ./server ./internal/tools ./internal/html2text -v
- run: go test -p 1 ./internal/storage ./server ./server/pop3 ./internal/tools ./internal/html2text -v
- run: go test -p 1 ./internal/storage ./internal/html2text -bench=.
# build the assets

File diff suppressed because it is too large Load Diff

View File

@@ -82,6 +82,7 @@ func init() {
initConfigFromEnv()
rootCmd.Flags().StringVarP(&config.Database, "database", "d", config.Database, "Database to store persistent data")
rootCmd.Flags().StringVar(&config.Label, "label", config.Label, "Optional label identify this Mailpit instance")
rootCmd.Flags().StringVar(&config.TenantID, "tenant-id", config.TenantID, "Database tenant ID to isolate data")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().BoolVar(&config.UseMessageDates, "use-message-dates", config.UseMessageDates, "Use message dates as the received dates")
@@ -172,6 +173,8 @@ func initConfigFromEnv() {
config.TenantID = os.Getenv("MP_TENANT_ID")
config.Label = os.Getenv("MP_LABEL")
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/spamassassin"
"github.com/axllent/mailpit/internal/tools"
"gopkg.in/yaml.v3"
)
@@ -32,6 +33,10 @@ var (
// allowing multiple isolated instances of Mailpit to share a database.
TenantID = ""
// Label to identify this Mailpit instance (optional).
// This gets applied to web UI, SMTP and optional POP3 server.
Label = ""
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
@@ -201,7 +206,9 @@ func VerifyConfig() error {
Database = filepath.Join(Database, "mailpit.db")
}
TenantID = strings.TrimSpace(TenantID)
Label = tools.Normalize(Label)
TenantID = tools.Normalize(TenantID)
if TenantID != "" {
logger.Log().Infof("[db] using tenant \"%s\"", TenantID)
re := regexp.MustCompile(`[^a-zA-Z0-9\_]`)

View File

@@ -0,0 +1,448 @@
// Package pop3client is borrowed directly from https://github.com/knadh/go-pop3 to reduce dependencies.
// This is used solely for testing the POP3 server
package pop3client
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"net"
"net/mail"
"strconv"
"strings"
"time"
)
// Client implements a Client e-mail client.
type Client struct {
opt Opt
dialer Dialer
}
// Conn is a stateful connection with the POP3 server/
type Conn struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
}
// Opt represents the client configuration.
type Opt struct {
Host string `json:"host"`
Port int `json:"port"`
// Default is 3 seconds.
DialTimeout time.Duration `json:"dial_timeout"`
Dialer Dialer `json:"-"`
TLSEnabled bool `json:"tls_enabled"`
TLSSkipVerify bool `json:"tls_skip_verify"`
}
// Dialer interface
type Dialer interface {
Dial(network, address string) (net.Conn, error)
}
// MessageID contains the ID and size of an individual message.
type MessageID struct {
// ID is the numerical index (non-unique) of the message.
ID int
Size int
// UID is only present if the response is to the UIDL command.
UID string
}
var (
lineBreak = []byte("\r\n")
respOK = []byte("+OK") // `+OK` without additional info
respOKInfo = []byte("+OK ") // `+OK <info>`
respErr = []byte("-ERR") // `-ERR` without additional info
respErrInfo = []byte("-ERR ") // `-ERR <info>`
)
// New returns a new client object using an existing connection.
func New(opt Opt) *Client {
if opt.DialTimeout < time.Millisecond {
opt.DialTimeout = time.Second * 3
}
c := &Client{
opt: opt,
dialer: opt.Dialer,
}
if c.dialer == nil {
c.dialer = &net.Dialer{Timeout: opt.DialTimeout}
}
return c
}
// NewConn creates and returns live POP3 server connection.
func (c *Client) NewConn() (*Conn, error) {
var (
addr = fmt.Sprintf("%s:%d", c.opt.Host, c.opt.Port)
)
conn, err := c.dialer.Dial("tcp", addr)
if err != nil {
return nil, err
}
// No TLS.
if c.opt.TLSEnabled {
// Skip TLS host verification.
tlsCfg := tls.Config{} // #nosec
if c.opt.TLSSkipVerify {
tlsCfg.InsecureSkipVerify = c.opt.TLSSkipVerify // #nosec
} else {
tlsCfg.ServerName = c.opt.Host
}
conn = tls.Client(conn, &tlsCfg)
}
pCon := &Conn{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
}
// Verify the connection by reading the welcome +OK greeting.
if _, err := pCon.ReadOne(); err != nil {
return nil, err
}
return pCon, nil
}
// Send sends a POP3 command to the server. The given comand is suffixed with "\r\n".
func (c *Conn) Send(b string) error {
if _, err := c.w.WriteString(b + "\r\n"); err != nil {
return err
}
return c.w.Flush()
}
// Cmd sends a command to the server. POP3 responses are either single line or multi-line.
// The first line always with -ERR in case of an error or +OK in case of a successful operation.
// OK+ is always followed by a response on the same line which is either the actual response data
// in case of single line responses, or a help message followed by multiple lines of actual response
// data in case of multiline responses.
// See https://www.shellhacks.com/retrieve-email-pop3-server-command-line/ for examples.
func (c *Conn) Cmd(cmd string, isMulti bool, args ...interface{}) (*bytes.Buffer, error) {
var cmdLine string
// Repeat a %v to format each arg.
if len(args) > 0 {
format := " " + strings.TrimRight(strings.Repeat("%v ", len(args)), " ")
// CMD arg1 argn ...\r\n
cmdLine = fmt.Sprintf(cmd+format, args...)
} else {
cmdLine = cmd
}
if err := c.Send(cmdLine); err != nil {
return nil, err
}
// Read the first line of response to get the +OK/-ERR status.
b, err := c.ReadOne()
if err != nil {
return nil, err
}
// Single line response.
if !isMulti {
return bytes.NewBuffer(b), err
}
buf, err := c.ReadAll()
return buf, err
}
// ReadOne reads a single line response from the conn.
func (c *Conn) ReadOne() ([]byte, error) {
b, _, err := c.r.ReadLine()
if err != nil {
return nil, err
}
r, err := parseResp(b)
return r, err
}
// ReadAll reads all lines from the connection until the POP3 multiline terminator "." is encountered
// and returns a bytes.Buffer of all the read lines.
func (c *Conn) ReadAll() (*bytes.Buffer, error) {
buf := &bytes.Buffer{}
for {
b, _, err := c.r.ReadLine()
if err != nil {
return nil, err
}
// "." indicates the end of a multi-line response.
if bytes.Equal(b, []byte(".")) {
break
}
if _, err := buf.Write(b); err != nil {
return nil, err
}
if _, err := buf.Write(lineBreak); err != nil {
return nil, err
}
}
return buf, nil
}
// Auth authenticates the given credentials with the server.
func (c *Conn) Auth(user, password string) error {
if err := c.User(user); err != nil {
return err
}
if err := c.Pass(password); err != nil {
return err
}
// Issue a NOOP to force the server to respond to the auth.
// Courtesy: github.com/TheCreeper/go-pop3
return c.Noop()
}
// User sends the username to the server.
func (c *Conn) User(s string) error {
_, err := c.Cmd("USER", false, s)
return err
}
// Pass sends the password to the server.
func (c *Conn) Pass(s string) error {
_, err := c.Cmd("PASS", false, s)
return err
}
// Stat returns the number of messages and their total size in bytes in the inbox.
func (c *Conn) Stat() (int, int, error) {
b, err := c.Cmd("STAT", false)
if err != nil {
return 0, 0, err
}
// count size
f := bytes.Fields(b.Bytes())
// Total number of messages.
count, err := strconv.Atoi(string(f[0]))
if err != nil {
return 0, 0, err
}
if count == 0 {
return 0, 0, nil
}
// Total size of all messages in bytes.
size, err := strconv.Atoi(string(f[1]))
if err != nil {
return 0, 0, err
}
return count, size, nil
}
// List returns a list of (message ID, message Size) pairs.
// If the optional msgID > 0, then only that particular message is listed.
// The message IDs are sequential, 1 to N.
func (c *Conn) List(msgID int) ([]MessageID, error) {
var (
buf *bytes.Buffer
err error
)
if msgID <= 0 {
// Multiline response listing all messages.
buf, err = c.Cmd("LIST", true)
} else {
// Single line response listing one message.
buf, err = c.Cmd("LIST", false, msgID)
}
if err != nil {
return nil, err
}
var (
out []MessageID
lines = bytes.Split(buf.Bytes(), lineBreak)
)
for _, l := range lines {
// id size
f := bytes.Fields(l)
if len(f) == 0 {
break
}
id, err := strconv.Atoi(string(f[0]))
if err != nil {
return nil, err
}
size, err := strconv.Atoi(string(f[1]))
if err != nil {
return nil, err
}
out = append(out, MessageID{ID: id, Size: size})
}
return out, nil
}
// Uidl returns a list of (message ID, message UID) pairs. If the optional msgID
// is > 0, then only that particular message is listed. It works like Top() but only works on
// servers that support the UIDL command. Messages size field is not available in the UIDL response.
func (c *Conn) Uidl(msgID int) ([]MessageID, error) {
var (
buf *bytes.Buffer
err error
)
if msgID <= 0 {
// Multiline response listing all messages.
buf, err = c.Cmd("UIDL", true)
} else {
// Single line response listing one message.
buf, err = c.Cmd("UIDL", false, msgID)
}
if err != nil {
return nil, err
}
var (
out []MessageID
lines = bytes.Split(buf.Bytes(), lineBreak)
)
for _, l := range lines {
// id size
f := bytes.Fields(l)
if len(f) == 0 {
break
}
id, err := strconv.Atoi(string(f[0]))
if err != nil {
return nil, err
}
out = append(out, MessageID{ID: id, UID: string(f[1])})
}
return out, nil
}
// Retr downloads a message by the given msgID, parses it and returns it as a *mail.Message.
func (c *Conn) Retr(msgID int) (*mail.Message, error) {
b, err := c.Cmd("RETR", true, msgID)
if err != nil {
return nil, err
}
m, err := mail.ReadMessage(b)
if err != nil {
return nil, err
}
return m, nil
}
// RetrRaw downloads a message by the given msgID and returns the raw []byte
// of the entire message.
func (c *Conn) RetrRaw(msgID int) (*bytes.Buffer, error) {
b, err := c.Cmd("RETR", true, msgID)
return b, err
}
// Top retrieves a message by its ID with full headers and numLines lines of the body.
func (c *Conn) Top(msgID int, numLines int) (*mail.Message, error) {
b, err := c.Cmd("TOP", true, msgID, numLines)
if err != nil {
return nil, err
}
m, err := mail.ReadMessage(b)
if err != nil {
return nil, err
}
return m, nil
}
// Dele deletes one or more messages. The server only executes the
// deletions after a successful Quit().
func (c *Conn) Dele(msgID ...int) error {
for _, id := range msgID {
_, err := c.Cmd("DELE", false, id)
if err != nil {
return err
}
}
return nil
}
// Rset clears the messages marked for deletion in the current session.
func (c *Conn) Rset() error {
_, err := c.Cmd("RSET", false)
return err
}
// Noop issues a do-nothing NOOP command to the server. This is useful for
// prolonging open connections.
func (c *Conn) Noop() error {
_, err := c.Cmd("NOOP", false)
return err
}
// Quit sends the QUIT command to server and gracefully closes the connection.
// Message deletions (DELE command) are only executed by the server on a graceful
// quit and close.
func (c *Conn) Quit() error {
defer c.conn.Close()
if _, err := c.Cmd("QUIT", false); err != nil {
return err
}
return nil
}
// parseResp checks if the response is an error that starts with `-ERR`
// and returns an error with the message that succeeds the error indicator.
// For success `+OK` messages, it returns the remaining response bytes.
func parseResp(b []byte) ([]byte, error) {
if len(b) == 0 {
return nil, nil
}
if bytes.Equal(b, respOK) {
return nil, nil
} else if bytes.HasPrefix(b, respOKInfo) {
return bytes.TrimPrefix(b, respOKInfo), nil
} else if bytes.Equal(b, respErr) {
return nil, errors.New("unknown error (no info specified in response)")
} else if bytes.HasPrefix(b, respErrInfo) {
return nil, errors.New(string(bytes.TrimPrefix(b, respErrInfo)))
}
return nil, fmt.Errorf("unknown response: %s. Neither -ERR, nor +OK", string(b))
}

View File

@@ -2,6 +2,7 @@ package tools
import (
"fmt"
"regexp"
"strings"
)
@@ -24,3 +25,14 @@ func InArray(k string, arr []string) bool {
return false
}
// Normalize will remove any extra spaces, remove newlines, and trim leading and trailing spaces
func Normalize(s string) string {
nlRe := regexp.MustCompile(`\r?\r`)
re := regexp.MustCompile(`\s+`)
s = nlRe.ReplaceAllString(s, " ")
s = re.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}

View File

@@ -35,14 +35,14 @@ func Unzip(src string, dest string) ([]string, error) {
if f.FileInfo().IsDir() {
// Make Folder
if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
if err := os.MkdirAll(fpath, os.ModePerm); /* #nosec */ err != nil {
return filenames, err
}
continue
}
// Make File
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); /* #nosec */ err != nil {
return filenames, err
}

View File

@@ -329,7 +329,7 @@ func getTempDir() string {
// MkDirIfNotExists will create a directory if it doesn't exist
func mkDirIfNotExists(path string) error {
if !isDir(path) {
return os.MkdirAll(path, os.ModePerm)
return os.MkdirAll(path, os.ModePerm) // #nosec
}
return nil

View File

@@ -12,6 +12,8 @@ import (
//
// swagger:model WebUIConfiguration
type webUIConfiguration struct {
// Optional label to identify this Mailpit instance
Label string
// Message Relay information
MessageRelay struct {
// Whether message relaying (release) is enabled
@@ -53,6 +55,7 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) {
// default: ErrorResponse
conf := webUIConfiguration{}
conf.Label = config.Label
conf.MessageRelay.Enabled = config.ReleaseEnabled
if config.ReleaseEnabled {
conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port)

365
server/pop3/pop3_test.go Normal file
View File

@@ -0,0 +1,365 @@
package pop3
import (
"bytes"
"fmt"
"math/rand/v2"
"net"
"os"
"strings"
"testing"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/internal/auth"
"github.com/axllent/mailpit/internal/logger"
"github.com/axllent/mailpit/internal/pop3client"
"github.com/axllent/mailpit/internal/storage"
"github.com/jhillyerd/enmime"
)
var (
testingPort int
)
func TestPOP3(t *testing.T) {
t.Log("Testing POP3 server")
setup()
defer storage.Close()
// connect with bad password
t.Log("Testing invalid login")
c, err := connectBadAuth()
if err == nil {
t.Error("invalid login gained access")
return
}
t.Log("Testing valid login")
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, size, err := c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 0, "incorrect message count")
assertEqual(t, size, 0, "incorrect size")
// quit else we get old data
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
t.Log("Inserting 50 messages")
insertEmailData(t) // insert 50 messages
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, size, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 50, "incorrect message count")
t.Log("Fetching 20 messages")
for i := 1; i <= 20; i++ {
_, err := c.Retr(i)
if err != nil {
t.Errorf(err.Error())
return
}
}
t.Log("Deleting 25 messages")
for i := 1; i <= 25; i++ {
if err := c.Dele(i); err != nil {
t.Errorf(err.Error())
return
}
}
// messages get deleted after a QUIT
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
t.Log("Fetching message count")
count, _, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 25, "incorrect message count")
// messages get deleted after a QUIT
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
t.Log("Deleting 25 messages")
for i := 1; i <= 25; i++ {
if err := c.Dele(i); err != nil {
t.Errorf(err.Error())
return
}
}
t.Log("Undeleting messages")
if err := c.Rset(); err != nil {
t.Errorf(err.Error())
return
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
count, _, err = c.Stat()
if err != nil {
t.Errorf(err.Error())
return
}
assertEqual(t, count, 25, "incorrect message count")
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
}
func TestAuthentication(t *testing.T) {
// commands only allowed after authentication
authCommands := make(map[string]bool)
authCommands["STAT"] = false
authCommands["LIST"] = true
authCommands["NOOP"] = false
authCommands["RSET"] = false
authCommands["RETR 1"] = true
t.Log("Testing authenticated commands while not logged in")
setup()
defer storage.Close()
insertEmailData(t) // insert 50 messages
// non-authenticated connection
c, err := connect()
if err != nil {
t.Errorf(err.Error())
return
}
for cmd, multi := range authCommands {
if _, err := c.Cmd(cmd, multi); err == nil {
t.Errorf("%s should require authentication", cmd)
return
}
if _, err := c.Cmd(strings.ToLower(cmd), multi); err == nil {
t.Errorf("%s should require authentication", cmd)
return
}
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
t.Log("Testing authenticated commands while logged in")
// authenticated connection
c, err = connectAuth()
if err != nil {
t.Errorf(err.Error())
return
}
for cmd, multi := range authCommands {
if _, err := c.Cmd(cmd, multi); err != nil {
t.Errorf("%s should work when authenticated", cmd)
return
}
if _, err := c.Cmd(strings.ToLower(cmd), multi); err != nil {
t.Errorf("%s should work when authenticated", cmd)
return
}
}
if err := c.Quit(); err != nil {
t.Errorf(err.Error())
return
}
}
func setup() {
auth.SetPOP3Auth("username:password")
logger.NoLogging = true
config.MaxMessages = 0
config.Database = os.Getenv("MP_DATABASE")
var foundPort bool
for !foundPort {
testingPort = randRange(1111, 2000)
if portFree(testingPort) {
foundPort = true
}
}
config.POP3Listen = fmt.Sprintf("localhost:%d", testingPort)
if err := storage.InitDB(); err != nil {
panic(err)
}
if err := storage.DeleteAllMessages(); err != nil {
panic(err)
}
go Run()
time.Sleep(time.Second)
}
// connect and authenticate
func connectAuth() (*pop3client.Conn, error) {
c, err := connect()
if err != nil {
return c, err
}
err = c.Auth("username", "password")
return c, err
}
// connect and authenticate
func connectBadAuth() (*pop3client.Conn, error) {
c, err := connect()
if err != nil {
return c, err
}
err = c.Auth("username", "notPassword")
return c, err
}
// connect but do not authenticate
func connect() (*pop3client.Conn, error) {
p := pop3client.New(pop3client.Opt{
Host: "localhost",
Port: testingPort,
TLSEnabled: false,
})
c, err := p.NewConn()
if err != nil {
return c, err
}
return c, err
}
func portFree(port int) bool {
ln, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
return false
}
if err := ln.Close(); err != nil {
panic(err)
}
return true
}
func randRange(min, max int) int {
return rand.IntN(max-min) + min
}
func insertEmailData(t *testing.T) {
for i := 0; i < 50; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
Text([]byte(fmt.Sprintf("This is the email body %d <jdsauk;dwqmdqw;>.", i))).
To(fmt.Sprintf("To %d", i), fmt.Sprintf("to-%d@example.com", i))
env, err := msg.Build()
if err != nil {
t.Log("error ", err)
t.Fail()
}
buf := new(bytes.Buffer)
if err := env.Encode(buf); err != nil {
t.Log("error ", err)
t.Fail()
}
bufBytes := buf.Bytes()
id, err := storage.Store(&bufBytes)
if err != nil {
t.Log("error ", err)
t.Fail()
}
if err := storage.SetMessageTags(id, []string{fmt.Sprintf("Test tag %03d", i)}); err != nil {
t.Log("error ", err)
t.Fail()
}
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
if a == b {
return
}
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}

View File

@@ -112,7 +112,11 @@ func handleClient(conn net.Conn) {
logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String())
// First welcome the new connection
sendResponse(conn, "+OK Mailpit POP3 server")
serverName := "Mailpit"
if config.Label != "" {
serverName = fmt.Sprintf("Mailpit (%s)", config.Label)
}
sendResponse(conn, fmt.Sprintf("+OK %s POP3 server", serverName))
// Set 10 minutes timeout according to RFC1939
timeoutDuration := 600 * time.Second

View File

@@ -223,6 +223,10 @@ func listenAndServe(addr string, handler smtpd.MsgIDHandler, authHandler smtpd.A
DisableReverseDNS: DisableReverseDNS,
}
if config.Label != "" {
srv.Appname = fmt.Sprintf("Mailpit (%s)", config.Label)
}
if config.SMTPAuthAllowInsecure {
srv.AuthMechs = map[string]bool{"CRAM-MD5": false, "PLAIN": true, "LOGIN": true}
}

View File

@@ -14,11 +14,16 @@ export default {
},
beforeMount() {
document.title = document.title + ' - ' + location.hostname
// load global config
this.get(this.resolve('/api/v1/webui'), false, function (response) {
mailbox.uiConfig = response.data
if (mailbox.uiConfig.Label) {
document.title = document.title + ' - ' + mailbox.uiConfig.Label
} else {
document.title = document.title + ' - ' + location.hostname
}
})
},

View File

@@ -26,15 +26,14 @@ export default {
},
methods: {
loadInfo: function () {
let self = this
self.get(self.resolve('/api/v1/info'), false, function (response) {
loadInfo() {
this.get(this.resolve('/api/v1/info'), false, (response) => {
mailbox.appInfo = response.data
self.modal('AppInfoModal').show()
this.modal('AppInfoModal').show()
})
},
requestNotifications: function () {
requestNotifications() {
// check if the browser supports notifications
if (!("Notification" in window)) {
alert("This browser does not support desktop notifications")
@@ -42,7 +41,6 @@ export default {
// we need to ask the user for permission
else if (Notification.permission !== "denied") {
let self = this
Notification.requestPermission().then(function (permission) {
if (permission === "granted") {
mailbox.notificationsEnabled = true
@@ -180,7 +178,9 @@ export default {
<td>
{{ formatNumber(mailbox.appInfo.RuntimeStats.SMTPAccepted) }}
<small class="text-secondary">
({{ getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize) }})
({{
getFileSize(mailbox.appInfo.RuntimeStats.SMTPAcceptedSize)
}})
</small>
</td>
</tr>
@@ -245,6 +245,6 @@ export default {
<Settings />
</template>
<AjaxLoader :loading="loading" />
</template>

View File

@@ -39,10 +39,9 @@ export default {
}
this.iconProcessing = true
let self = this
window.setTimeout(() => {
self.icoUpdate()
this.icoUpdate()
}, this.iconTimeout)
},
},

View File

@@ -21,29 +21,25 @@ export default {
},
mounted() {
let relativeTime = require('dayjs/plugin/relativeTime')
const relativeTime = require('dayjs/plugin/relativeTime')
dayjs.extend(relativeTime)
this.refreshUI()
},
methods: {
refreshUI: function () {
let self = this
window.setTimeout(
() => {
self.$forceUpdate()
self.refreshUI()
},
30000
)
refreshUI() {
window.setTimeout(() => {
this.$forceUpdate()
this.refreshUI()
}, 30000)
},
getRelativeCreated: function (message) {
let d = new Date(message.Created)
getRelativeCreated(message) {
const d = new Date(message.Created)
return dayjs(d).fromNow()
},
getPrimaryEmailTo: function (message) {
getPrimaryEmailTo(message) {
for (let i in message.To) {
return message.To[i].Address
}
@@ -51,11 +47,11 @@ export default {
return '[ Undisclosed recipients ]'
},
isSelected: function (id) {
isSelected(id) {
return mailbox.selected.indexOf(id) != -1
},
toggleSelected: function (e, id) {
toggleSelected(e, id) {
e.preventDefault()
if (this.isSelected(id)) {
@@ -67,7 +63,7 @@ export default {
}
},
selectRange: function (e, id) {
selectRange(e, id) {
e.preventDefault()
let selecting = false
@@ -102,7 +98,7 @@ export default {
}
},
toTagUrl: function (t) {
toTagUrl(t) {
if (t.match(/ /)) {
t = `"${t}"`
}
@@ -133,16 +129,14 @@ export default {
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</span>
<span v-if="message.From" :title="'From: ' + message.From.Address">
{{ message.From.Name ? message.From.Name : message.From.Address }}
</span>
</div>
<div class="text-truncate d-none d-lg-block privacy">
<b v-if="message.From" :title="'From: ' + message.From.Address">{{
message.From.Name ?
message.From.Name : message.From.Address
}}</b>
<b v-if="message.From" :title="'From: ' + message.From.Address">
{{ message.From.Name ? message.From.Name : message.From.Address }}
</b>
</div>
<div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }}

View File

@@ -30,7 +30,7 @@ export default {
},
methods: {
reloadInbox: function () {
reloadInbox() {
const paginationParams = this.getPaginationParams()
const reload = paginationParams?.start ? false : true
@@ -41,25 +41,22 @@ export default {
}
},
loadMessages: function () {
loadMessages() {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
markAllRead: function () {
let self = this
self.put(self.resolve(`/api/v1/messages`), { 'read': true }, function (response) {
markAllRead() {
this.put(this.resolve(`/api/v1/messages`), { 'read': true }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
deleteAllMessages: function () {
let self = this
self.delete(self.resolve(`/api/v1/messages`), false, function (response) {
deleteAllMessages() {
this.delete(this.resolve(`/api/v1/messages`), false, (response) => {
pagination.start = 0
self.loadMessages()
this.loadMessages()
})
}
}
@@ -68,7 +65,12 @@ export default {
<template>
<template v-if="!modals">
<div class="list-group my-2">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
</div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="reloadInbox" class="list-group-item list-group-item-action active">
<i class="bi bi-envelope-fill me-1" v-if="mailbox.connected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>

View File

@@ -30,22 +30,20 @@ export default {
},
methods: {
loadMessages: function () {
loadMessages() {
this.hideNav() // hide mobile menu
this.$emit('loadMessages')
},
deleteAllMessages: function () {
let s = this.getSearch()
deleteAllMessages() {
const s = this.getSearch()
if (!s) {
return
}
let self = this
let uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, function (response) {
self.$router.push('/')
const uri = this.resolve(`/api/v1/search`) + '?query=' + encodeURIComponent(s)
this.delete(uri, false, (response) => {
this.$router.push('/')
})
}
}
@@ -54,7 +52,12 @@ export default {
<template>
<template v-if="!modals">
<div class="list-group my-2">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
</div>
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<RouterLink to="/" class="list-group-item list-group-item-action" @click="pagination.start = 0">
<i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">Inbox</span>
@@ -77,7 +80,8 @@ export default {
<template v-else>
<!-- Modals -->
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">

View File

@@ -19,54 +19,52 @@ export default {
},
methods: {
loadMessages: function () {
loadMessages() {
this.$emit('loadMessages')
},
// mark selected messages as read
markSelectedRead: function () {
let self = this
markSelectedRead() {
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, function (response) {
this.put(this.resolve(`/api/v1/messages`), { 'Read': true, 'IDs': mailbox.selected }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
isSelected: function (id) {
isSelected(id) {
return mailbox.selected.indexOf(id) != -1
},
// mark selected messages as unread
markSelectedUnread: function () {
let self = this
markSelectedUnread() {
if (!mailbox.selected.length) {
return false
}
self.put(self.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, function (response) {
this.put(this.resolve(`/api/v1/messages`), { 'Read': false, 'IDs': mailbox.selected }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
// universal handler to delete current or selected messages
deleteMessages: function () {
deleteMessages() {
let ids = []
let self = this
ids = JSON.parse(JSON.stringify(mailbox.selected))
if (!ids.length) {
return false
}
self.delete(self.resolve(`/api/v1/messages`), { 'IDs': ids }, function (response) {
this.delete(this.resolve(`/api/v1/messages`), { 'IDs': ids }, (response) => {
window.scrollInPlace = true
self.loadMessages()
this.loadMessages()
})
},
// test if any selected emails are unread
selectedHasUnread: function () {
selectedHasUnread() {
if (!mailbox.selected.length) {
return false
}
@@ -79,7 +77,7 @@ export default {
},
// test of any selected emails are read
selectedHasRead: function () {
selectedHasRead() {
if (!mailbox.selected.length) {
return false
}

View File

@@ -17,7 +17,7 @@ export default {
methods: {
// test whether a tag is currently being searched for (in the URL)
inSearch: function (tag) {
inSearch(tag) {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
if (!query) {
@@ -29,7 +29,7 @@ export default {
},
// toggle a tag search in the search URL, add or remove it accordingly
toggleTag: function (e, tag) {
toggleTag(e, tag) {
e.preventDefault()
const urlParams = new URLSearchParams(window.location.search)

View File

@@ -14,6 +14,8 @@ export default {
toastMessage: false,
reconnectRefresh: false,
socketURI: false,
socketLastConnection: 0, // timestamp to track reconnection times & avoid reloading mailbox on short disconnections
socketBreaks: 0, // to track sockets that continually connect & disconnect, reset every 15s
pauseNotifications: false, // prevent spamming
version: false,
paginationDelayed: false, // for delayed pagination URL changes
@@ -21,14 +23,15 @@ export default {
},
mounted() {
let d = document.getElementById('app')
const d = document.getElementById('app')
if (d) {
this.version = d.dataset.version
}
let proto = location.protocol == 'https:' ? 'wss' : 'ws'
const proto = location.protocol == 'https:' ? 'wss' : 'ws'
this.socketURI = proto + "://" + document.location.host + this.resolve(`/api/events`)
this.socketBreakReset()
this.connect()
mailbox.notificationsSupported = window.isSecureContext
@@ -38,10 +41,9 @@ export default {
methods: {
// websocket connect
connect: function () {
let ws = new WebSocket(this.socketURI)
let self = this
ws.onmessage = function (e) {
connect() {
const ws = new WebSocket(this.socketURI)
ws.onmessage = (e) => {
let response
try {
response = JSON.parse(e.data)
@@ -62,7 +64,7 @@ export default {
// update pagination offset
pagination.start++
// prevent "Too many calls to Location or History APIs within a short timeframe"
self.delayedPaginationUpdate()
this.delayedPaginationUpdate()
}
}
@@ -76,13 +78,13 @@ export default {
}
// send notifications
if (!self.pauseNotifications) {
self.pauseNotifications = true
if (!this.pauseNotifications) {
this.pauseNotifications = true
let from = response.Data.From != null ? response.Data.From.Address : '[unknown]'
self.browserNotify("New mail from: " + from, response.Data.Subject)
self.setMessageToast(response.Data)
this.browserNotify("New mail from: " + from, response.Data.Subject)
this.setMessageToast(response.Data)
// delay notifications by 2s
window.setTimeout(() => { self.pauseNotifications = false }, 2000)
window.setTimeout(() => { this.pauseNotifications = false }, 2000)
}
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
@@ -95,27 +97,52 @@ export default {
mailbox.unread = response.Data.Unread
// detect version updated, refresh is needed
if (self.version != response.Data.Version) {
if (this.version != response.Data.Version) {
location.reload()
}
}
}
ws.onopen = function () {
ws.onopen = () => {
mailbox.connected = true
if (self.reconnectRefresh) {
self.reconnectRefresh = false
this.socketLastConnection = Date.now()
if (this.reconnectRefresh) {
this.reconnectRefresh = false
mailbox.refresh = true // trigger refresh
window.setTimeout(() => { mailbox.refresh = false }, 500)
}
}
ws.onclose = function (e) {
mailbox.connected = false
self.reconnectRefresh = true
ws.onclose = (e) => {
if (this.socketLastConnection == 0) {
// connection failed immediately after connecting to Mailpit implies proxy websockets aren't configured
console.log('Unable to connect to websocket, disabling websocket support')
return
}
setTimeout(function () {
self.connect() // reconnect
if (mailbox.connected) {
// count disconnections
this.socketBreaks++
}
// set disconnected state
mailbox.connected = false
if (this.socketBreaks > 3) {
// give up after > 3 successful socket connections & disconnections within a 15 second window,
// something is not working right on their end, see issue #319
console.log('Unstable websocket connection, disabling websocket support')
return
}
if (Date.now() - this.socketLastConnection > 5000) {
// only refresh mailbox if the last successful connection was broken for > 5 seconds
this.reconnectRefresh = true
} else {
this.reconnectRefresh = false
}
setTimeout(() => {
this.connect() // reconnect
}, 1000)
}
@@ -124,9 +151,16 @@ export default {
}
},
socketBreakReset() {
window.setTimeout(() => {
this.socketBreaks = 0
this.socketBreakReset()
}, 15000)
},
// This will only update the pagination offset at a maximum of 2x per second
// when viewing the inbox on > page 1, while receiving an influx of new messages.
delayedPaginationUpdate: function () {
delayedPaginationUpdate() {
if (this.paginationDelayed) {
return
}
@@ -157,13 +191,12 @@ export default {
}, 500)
},
browserNotify: function (title, message) {
browserNotify(title, message) {
if (!("Notification" in window)) {
return
}
if (Notification.permission === "granted") {
let b = message.Subject
let options = {
body: message,
icon: this.resolve('/notification.png')
@@ -172,7 +205,7 @@ export default {
}
},
setMessageToast: function (m) {
setMessageToast(m) {
// don't display if browser notifications are enabled, or a toast is already displayed
if (mailbox.notificationsEnabled || this.toastMessage) {
return
@@ -180,19 +213,18 @@ export default {
this.toastMessage = m
let self = this
let el = document.getElementById('messageToast')
const el = document.getElementById('messageToast')
if (el) {
el.addEventListener('hidden.bs.toast', () => {
self.toastMessage = false
this.toastMessage = false
})
Toast.getOrCreateInstance(el).show()
}
},
closeToast: function () {
let el = document.getElementById('messageToast')
closeToast() {
const el = document.getElementById('messageToast')
if (el) {
Toast.getOrCreateInstance(el).hide()
}

View File

@@ -20,16 +20,16 @@ export default {
},
computed: {
canPrev: function () {
canPrev() {
return pagination.start > 0
},
canNext: function () {
canNext() {
return this.total > (pagination.start + mailbox.messages.length)
},
// returns the number of next X messages
nextMessages: function () {
nextMessages() {
let t = pagination.start + parseInt(pagination.limit, 10)
if (t > this.total) {
t = this.total
@@ -40,17 +40,17 @@ export default {
},
methods: {
changeLimit: function () {
changeLimit() {
pagination.start = 0
this.updateQueryParams()
},
viewNext: function () {
viewNext() {
pagination.start = parseInt(pagination.start, 10) + parseInt(pagination.limit, 10)
this.updateQueryParams()
},
viewPrev: function () {
viewPrev() {
let s = pagination.start - pagination.limit
if (s < 0) {
s = 0
@@ -59,7 +59,7 @@ export default {
this.updateQueryParams()
},
updateQueryParams: function () {
updateQueryParams() {
const path = this.$route.path
const p = {
...this.$route.query

View File

@@ -24,18 +24,18 @@ export default {
},
methods: {
searchFromURL: function () {
searchFromURL() {
const urlParams = new URLSearchParams(window.location.search)
this.search = urlParams.get('q') ? urlParams.get('q') : ''
},
doSearch: function (e) {
doSearch(e) {
pagination.start = 0
if (this.search == '') {
this.$router.push('/')
} else {
const urlParams = new URLSearchParams(window.location.search)
let curr = urlParams.get('q')
const curr = urlParams.get('q')
if (curr && curr == this.search) {
pagination.start = 0
this.$emit('loadMessages')
@@ -57,7 +57,7 @@ export default {
e.preventDefault()
},
resetSearch: function () {
resetSearch() {
this.search = ''
this.$router.push('/')
}

View File

@@ -16,7 +16,7 @@ export default {
},
watch: {
theme: function (v) {
theme(v) {
if (v == 'auto') {
localStorage.removeItem('theme')
} else {
@@ -34,7 +34,7 @@ export default {
},
methods: {
setTheme: function () {
setTheme() {
if (
this.theme === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches

View File

@@ -18,7 +18,7 @@ export default {
},
methods: {
openAttachment: function (part, e) {
openAttachment(part, e) {
let filename = part.FileName
let contentType = part.ContentType
let href = this.resolve('/api/v1/message/' + this.message.ID + '/part/' + part.PartID)

View File

@@ -41,9 +41,7 @@ export default {
},
computed: {
summary: function () {
let self = this
summary() {
if (!this.check) {
return false
}
@@ -65,8 +63,8 @@ export default {
}
// filter by enabled platforms
let results = o.Results.filter(function (w) {
return self.platforms.indexOf(w.Platform) != -1
let results = o.Results.filter((w) => {
return this.platforms.indexOf(w.Platform) != -1
})
if (results.length == 0) {
@@ -98,7 +96,7 @@ export default {
}
let maxPartial = 0, maxUnsupported = 0
result.Warnings.forEach(function (w) {
result.Warnings.forEach((w) => {
let scoreWeight = 1
if (w.Score.Found < result.Total.Nodes) {
// each error is weighted based on the number of occurrences vs: the total message nodes
@@ -108,7 +106,7 @@ export default {
// pseudo-classes & at-rules need to be weighted lower as we do not know how many times they
// are actually used in the HTML, and including things like bootstrap styles completely throws
// off the calculation as these dominate.
if (self.isPseudoClassOrAtRule(w.Title)) {
if (this.isPseudoClassOrAtRule(w.Title)) {
scoreWeight = 0.05
w.PseudoClassOrAtRule = true
}
@@ -124,15 +122,15 @@ export default {
})
// sort warnings by final score
result.Warnings.sort(function (a, b) {
result.Warnings.sort((a, b) => {
let aWeight = a.Score.Found > result.Total.Nodes ? result.Total.Nodes : a.Score.Found / result.Total.Nodes
let bWeight = b.Score.Found > result.Total.Nodes ? result.Total.Nodes : b.Score.Found / result.Total.Nodes
if (self.isPseudoClassOrAtRule(a.Title)) {
if (this.isPseudoClassOrAtRule(a.Title)) {
aWeight = 0.05
}
if (self.isPseudoClassOrAtRule(b.Title)) {
if (this.isPseudoClassOrAtRule(b.Title)) {
bWeight = 0.05
}
@@ -148,7 +146,7 @@ export default {
return result
},
graphSections: function () {
graphSections() {
let s = Math.round(this.summary.Total.Supported)
let p = Math.round(this.summary.Total.Partial)
let u = 100 - s - p
@@ -172,7 +170,7 @@ export default {
},
// colors depend on both varying unsupported & partially unsupported percentages
scoreColor: function () {
scoreColor() {
if (this.summary.Total.Unsupported < 5 && this.summary.Total.Partial < 10) {
this.$emit('setBadgeStyle', 'bg-success')
return 'text-success'
@@ -197,62 +195,51 @@ export default {
platforms(v) {
localStorage.setItem('html-check-platforms', JSON.stringify(v))
},
// enabled(v) {
// if (!v) {
// localStorage.setItem('htmlCheckDisabled', true)
// this.$emit('setHtmlScore', false)
// } else {
// localStorage.removeItem('htmlCheckDisabled')
// this.doCheck()
// }
// }
},
methods: {
doCheck: function () {
doCheck() {
this.check = false
if (this.message.HTML == "") {
return
}
let self = this
// ignore any error, do not show loader
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/html-check'), null)
.then(function (result) {
self.check = result.data
self.error = false
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/html-check'), null)
.then((result) => {
this.check = result.data
this.error = false
// set tooltips
window.setTimeout(function () {
window.setTimeout(() => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
}, 500)
})
.catch(function (error) {
.catch((error) => {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
},
loadConfig: function () {
loadConfig() {
let platforms = localStorage.getItem('html-check-platforms')
if (platforms) {
try {
@@ -268,7 +255,7 @@ export default {
},
// return a platform's families (email clients)
families: function (k) {
families(k) {
if (this.check.Platforms[k]) {
return this.check.Platforms[k]
}
@@ -277,19 +264,19 @@ export default {
},
// return whether the test string is a pseudo class (:<test>) or at rule (@<test>)
isPseudoClassOrAtRule: function (t) {
isPseudoClassOrAtRule(t) {
return t.match(/^(:|@)/)
},
round: function (v) {
round(v) {
return Math.round(v)
},
round2dm: function (v) {
round2dm(v) {
return Math.round(v * 100) / 100
},
scrollToWarnings: function () {
scrollToWarnings() {
if (!this.$refs.warnings) {
return
}
@@ -312,9 +299,9 @@ export default {
<div class="mt-5 mb-3">
<div class="row w-100">
<div class="col-md-8">
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px" :thickness="20"
has-legend legend-placement="bottom" :total="100" :start-angle="0" :auto-adjust-text-size="true"
@section-click="scrollToWarnings">
<Donut :sections="graphSections" background="var(--bs-body-bg)" :size="180" unit="px"
:thickness="20" has-legend legend-placement="bottom" :total="100" :start-angle="0"
:auto-adjust-text-size="true" @section-click="scrollToWarnings">
<h2 class="m-0" :class="scoreColor" @click="scrollToWarnings">
{{ round2dm(summary.Total.Supported) }}%
</h2>
@@ -388,8 +375,9 @@ export default {
<div class="col-sm mt-2 mt-sm-0">
<div class="progress-stacked">
<div class="progress" role="progressbar" aria-label="Supported"
:aria-valuenow="warning.Score.Supported" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Supported + '%' }" title="Supported">
:aria-valuenow="warning.Score.Supported" aria-valuemin="0"
aria-valuemax="100" :style="{ width: warning.Score.Supported + '%' }"
title="Supported">
<div class="progress-bar bg-success">
{{ round(warning.Score.Supported) + '%' }}
</div>
@@ -402,8 +390,9 @@ export default {
</div>
</div>
<div class="progress" role="progressbar" aria-label="No"
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0" aria-valuemax="100"
:style="{ width: warning.Score.Unsupported + '%' }" title="Not supported">
:aria-valuenow="warning.Score.Unsupported" aria-valuemin="0"
aria-valuemax="100" :style="{ width: warning.Score.Unsupported + '%' }"
title="Not supported">
<div class="progress-bar bg-danger">
{{ round(warning.Score.Unsupported) + '%' }}
</div>
@@ -420,7 +409,8 @@ export default {
<i class="bi bi-info-circle me-2"></i>
Detected {{ warning.Score.Found }} <code>{{ warning.Title }}</code>
propert<template v-if="warning.Score.Found === 1">y</template><template
v-else>ies</template> in the CSS styles, but unable to test if used or not.
v-else>ies</template> in the CSS
styles, but unable to test if used or not.
</span>
<span v-if="warning.Description != ''" v-html="warning.Description" class="me-2"></span>
</p>
@@ -487,9 +477,9 @@ export default {
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
The support for HTML/CSS messages varies greatly across email clients. HTML check
attempts to calculate the overall support for your email for all selected platforms
to give you some idea of the general compatibility of your HTML email.
The support for HTML/CSS messages varies greatly across email clients. HTML
check attempts to calculate the overall support for your email for all selected
platforms to give you some idea of the general compatibility of your HTML email.
</div>
</div>
</div>
@@ -507,29 +497,31 @@ export default {
Internally the original HTML message is run against
<b>{{ check.Total.Tests }}</b> different HTML and CSS tests. All tests
(except for <code>&lt;script&gt;</code>) correspond to a test on
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and the
final score is calculated using the available compatibility data.
<a href="https://www.caniemail.com/" target="_blank">caniemail.com</a>, and
the final score is calculated using the available compatibility data.
</p>
<p>
CSS support is very difficult to programmatically test, especially if a message
contains CSS style blocks or is linked to remote stylesheets. Remote stylesheets
are, unless blocked via <code>--block-remote-css-and-fonts</code>, downloaded
and injected into the message as style blocks. The email is then
<a href="https://github.com/vanng822/go-premailer" target="_blank">inlined</a>
CSS support is very difficult to programmatically test, especially if a
message contains CSS style blocks or is linked to remote stylesheets. Remote
stylesheets are, unless blocked via
<code>--block-remote-css-and-fonts</code>,
downloaded and injected into the message as style blocks. The email is then
<a href="https://github.com/vanng822/go-premailer"
target="_blank">inlined</a>
to matching HTML elements. This gives Mailpit fairly accurate results.
</p>
<p>
CSS properties such as <code>@font-face</code>, <code>:visited</code>,
<code>:hover</code> etc cannot be inlined however, so these are searched for
within CSS blocks. This method is not accurate as Mailpit does not know how many
nodes it actually applies to, if any, so they are weighted lightly (5%) as not
to affect the score. An example of this would be any email linking to the full
bootstrap CSS which contains dozens of unused attributes.
within CSS blocks. This method is not accurate as Mailpit does not know how
many nodes it actually applies to, if any, so they are weighted lightly (5%)
as not to affect the score. An example of this would be any email linking to
the full bootstrap CSS which contains dozens of unused attributes.
</p>
<p>
All warnings are displayed with their respective support, including any specific
notes, and it is up to you to decide what you do with that information and how
badly it may impact your message.
All warnings are displayed with their respective support, including any
specific notes, and it is up to you to decide what you do with that
information and how badly it may impact your message.
</p>
</div>
</div>
@@ -552,13 +544,15 @@ export default {
<p>
For each test, Mailpit calculates both the unsupported & partially-supported
percentages in relation to the number of matches against the total number of
nodes (elements) in the HTML. The maximum unsupported and partially-supported
weighted scores are then used for the final score (ie: worst case scenario).
nodes (elements) in the HTML. The maximum unsupported and
partially-supported weighted scores are then used for the final score (ie:
worst case scenario).
</p>
<p>
To try explain this logic in very simple terms: Assuming a
<code>&lt;script&gt;</code> node (element) has 100% failure (not supported in
any email client), and a <code>&lt;p&gt;</code> node has 100% pass (supported).
<code>&lt;script&gt;</code> node (element) has 100% failure (not supported
in any email client), and a <code>&lt;p&gt;</code> node has 100% pass
(supported).
</p>
<ul>
<li>
@@ -575,7 +569,8 @@ export default {
</li>
</ul>
<p>
Mailpit will sort the warnings according to their weighted unsupported scores.
Mailpit will sort the warnings according to their weighted unsupported
scores.
</p>
</div>
</div>
@@ -591,9 +586,9 @@ export default {
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#HTMLCheckAboutAccordion">
<div class="accordion-body">
HTML check does not detect if the original HTML is valid. In order to detect applied
styles to every node, the HTML email is run through a parser which is very good at
turning invalid input into valid output. It is what it is...
HTML check does not detect if the original HTML is valid. In order to detect
applied styles to every node, the HTML email is run through a parser which is
very good at turning invalid input into valid output. It is what it is...
</div>
</div>
</div>

View File

@@ -1,4 +1,3 @@
<script>
import commonMixins from '../../mixins/CommonMixins'
@@ -16,10 +15,9 @@ export default {
},
mounted() {
let self = this;
let uri = self.resolve('/api/v1/message/' + self.message.ID + '/headers')
self.get(uri, false, function (response) {
self.headers = response.data
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/headers')
this.get(uri, false, (response) => {
this.headers = response.data
});
},

View File

@@ -64,7 +64,7 @@ export default {
},
computed: {
groupedStatuses: function () {
groupedStatuses() {
let results = {}
if (!this.check) {
@@ -114,7 +114,7 @@ export default {
},
methods: {
doCheck: function () {
doCheck() {
this.check = false
this.loading = true
let uri = this.resolve('/api/v1/message/' + this.message.ID + '/link-check')
@@ -122,38 +122,37 @@ export default {
uri += '?follow=true'
}
let self = this
// ignore any error, do not show loader
axios.get(uri, null)
.then(function (result) {
self.check = result.data
self.error = false
.then((result) => {
this.check = result.data
this.error = false
self.$emit('setLinkErrors', result.data.Errors)
this.$emit('setLinkErrors', result.data.Errors)
})
.catch(function (error) {
.catch((error) => {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
.then(function (result) {
.then((result) => {
// always run
self.loading = false
this.loading = false
})
},
}
@@ -239,7 +238,8 @@ export default {
</div>
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel" aria-hidden="true">
<div class="modal fade" id="LinkCheckOptions" tabindex="-1" aria-labelledby="LinkCheckOptionsLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
@@ -296,11 +296,12 @@ export default {
What is Link check?
</button>
</h2>
<div id="col1" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col1" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
Link check scans your message HTML and text for all unique links, images and linked
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a time, to
test whether the link/image/stylesheet exists.
stylesheets. It then does a HTTP <code>HEAD</code> request to each link, 5 at a
time, to test whether the link/image/stylesheet exists.
</div>
</div>
</div>
@@ -311,14 +312,16 @@ export default {
What are "301" and "302" links?
</button>
</h2>
<div id="col2" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col2" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>
These are links that redirect you to another URL, for example newsletters
often use redirect links to track user clicks.
</p>
<p>
By default Link check will not follow these links, however you can turn this on via
By default Link check will not follow these links, however you can turn this on
via
the settings and Link check will "follow" those redirects.
</p>
</div>
@@ -331,7 +334,8 @@ export default {
Why are some links returning an error but work in my browser?
</button>
</h2>
<div id="col3" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col3" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>This may be due to various reasons, for instance:</p>
<ul>
@@ -353,11 +357,12 @@ export default {
What are the risks of running Link check automatically?
</button>
</h2>
<div id="col4" class="accordion-collapse collapse" data-bs-parent="#LinkCheckAboutAccordion">
<div id="col4" class="accordion-collapse collapse"
data-bs-parent="#LinkCheckAboutAccordion">
<div class="accordion-body">
<p>
Depending on the type of messages you are testing, opening all links on all messages
may have undesired consequences:
Depending on the type of messages you are testing, opening all links on all
messages may have undesired consequences:
</p>
<ul>
<li>If the message contains tracking links this may reveal your identity.</li>
@@ -366,13 +371,13 @@ export default {
unsubscribe you.
</li>
<li>
To speed up the checking process, Link check will attempt 5 URLs at a time. This
could lead to temporary heady load on the remote server.
To speed up the checking process, Link check will attempt 5 URLs at a time.
This could lead to temporary heady load on the remote server.
</li>
</ul>
<p>
Unless you know what messages you receive, it is advised to only run the Link check
manually.
Unless you know what messages you receive, it is advised to only run the Link
check manually.
</p>
</div>
</div>

View File

@@ -61,16 +61,15 @@ export default {
scaleHTMLPreview(v) {
if (v == 'display') {
let self = this
window.setTimeout(function () {
self.resizeIFrames()
window.setTimeout(() => {
this.resizeIFrames()
}, 500)
}
}
},
computed: {
hasAnyChecksEnabled: function () {
hasAnyChecksEnabled() {
return (mailbox.showHTMLCheck && this.message.HTML)
|| mailbox.showLinkCheck
|| (mailbox.showSpamCheck && mailbox.uiConfig.SpamAssassin)
@@ -78,56 +77,53 @@ export default {
},
mounted() {
let self = this
self.canSaveTags = false
self.messageTags = self.message.Tags
self.renderUI()
this.canSaveTags = false
this.messageTags = this.message.Tags
this.renderUI()
window.addEventListener("resize", self.resizeIFrames)
window.addEventListener("resize", this.resizeIFrames)
let headersTab = document.getElementById('nav-headers-tab')
headersTab.addEventListener('shown.bs.tab', function (event) {
self.loadHeaders = true
headersTab.addEventListener('shown.bs.tab', (event) => {
this.loadHeaders = true
})
let rawTab = document.getElementById('nav-raw-tab')
rawTab.addEventListener('shown.bs.tab', function (event) {
self.srcURI = self.resolve('/api/v1/message/' + self.message.ID + '/raw')
self.resizeIFrames()
rawTab.addEventListener('shown.bs.tab', (event) => {
this.srcURI = this.resolve('/api/v1/message/' + this.message.ID + '/raw')
this.resizeIFrames()
})
// manually refresh tags
self.get(self.resolve(`/api/v1/tags`), false, function (response) {
self.availableTags = response.data
self.$nextTick(function () {
this.get(this.resolve(`/api/v1/tags`), false, (response) => {
this.availableTags = response.data
this.$nextTick(() => {
Tags.init('select[multiple]')
// delay tag change detection to allow Tags to load
window.setTimeout(function () {
self.canSaveTags = true
window.setTimeout(() => {
this.canSaveTags = true
}, 200)
})
})
},
methods: {
isHTMLTabSelected: function () {
isHTMLTabSelected() {
this.showMobileButtons = this.$refs.navhtml
&& this.$refs.navhtml.classList.contains('active')
},
renderUI: function () {
let self = this
renderUI() {
// activate the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click()
document.activeElement.blur() // blur focus
document.getElementById('message-view').scrollTop = 0
self.isHTMLTabSelected()
this.isHTMLTabSelected()
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(function (listObj) {
listObj.addEventListener('shown.bs.tab', function (event) {
self.isHTMLTabSelected()
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach((listObj) => {
listObj.addEventListener('shown.bs.tab', (event) => {
this.isHTMLTabSelected()
})
})
@@ -135,7 +131,7 @@ export default {
[...tooltipTriggerList].map(tooltipTriggerEl => new Tooltip(tooltipTriggerEl))
// delay 0.2s until vue has rendered the iframe content
window.setTimeout(function () {
window.setTimeout(() => {
let p = document.getElementById('preview-html')
if (p) {
// make links open in new window
@@ -148,7 +144,7 @@ export default {
anchorEl.setAttribute('target', '_blank')
}
}
self.resizeIFrames()
this.resizeIFrames()
}
}, 200)
@@ -158,12 +154,12 @@ export default {
Prism.highlightAll()
},
resizeIframe: function (el) {
resizeIframe(el) {
let i = el.target
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px'
},
resizeIFrames: function () {
resizeIFrames() {
if (this.scaleHTMLPreview != 'display') {
return
}
@@ -175,7 +171,7 @@ export default {
},
// set the iframe body & text colors based on current theme
initRawIframe: function (el) {
initRawIframe(el) {
let bodyStyles = window.getComputedStyle(document.body, null)
let bg = bodyStyles.getPropertyValue('background-color')
let txt = bodyStyles.getPropertyValue('color')
@@ -189,27 +185,25 @@ export default {
this.resizeIframe(el)
},
sanitizeHTML: function (h) {
sanitizeHTML(h) {
// remove <base/> tag if set
return h.replace(/<base .*>/mi, '')
},
saveTags: function () {
let self = this
saveTags() {
var data = {
IDs: [this.message.ID],
Tags: this.messageTags
}
self.put(self.resolve('/api/v1/tags'), data, function (response) {
this.put(this.resolve('/api/v1/tags'), data, (response) => {
window.scrollInPlace = true
self.$emit('loadMessages')
this.$emit('loadMessages')
})
},
// Convert plain text to HTML including anchor links
textToHTML: function (s) {
textToHTML(s) {
let html = s
// full links with http(s)

View File

@@ -46,26 +46,25 @@ export default {
methods: {
// triggered manually after modal is shown
initTags: function () {
initTags() {
Tags.init("select[multiple]")
},
releaseMessage: function () {
let self = this
releaseMessage() {
// set timeout to allow for user clicking send before the tag filter has applied the tag
window.setTimeout(function () {
if (!self.addresses.length) {
window.setTimeout(() => {
if (!this.addresses.length) {
return false
}
let data = {
To: self.addresses
To: this.addresses
}
self.post(self.resolve('/api/v1/message/' + self.message.ID + '/release'), data, function (response) {
self.modal("ReleaseModal").hide()
if (self.deleteAfterRelease) {
self.$emit('delete')
this.post(this.resolve('/api/v1/message/' + this.message.ID + '/release'), data, (response) => {
this.modal("ReleaseModal").hide()
if (this.deleteAfterRelease) {
this.$emit('delete')
}
})
}, 100)

View File

@@ -1,4 +1,3 @@
<script>
import AjaxLoader from '../AjaxLoader.vue'
import CommonMixins from '../../mixins/CommonMixins'
@@ -23,9 +22,8 @@ export default {
},
methods: {
initScreenshot: function () {
initScreenshot() {
this.loading = 1
let self = this
// remove base tag, if set
let h = this.message.HTML.replace(/<base .*>/mi, '')
let proxy = this.resolve('/proxy')
@@ -38,11 +36,11 @@ export default {
// update any inline `url(...)` absolute links
const urlRegex = /(url\((\'|\")?(https?:\/\/[^\)\'\"]+)(\'|\")?\))/mgi;
h = h.replaceAll(urlRegex, function (match, p1, p2, p3) {
h = h.replaceAll(urlRegex, (match, p1, p2, p3) => {
if (typeof p2 === 'string') {
return `url(${p2}${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `${p2})`
return `url(${p2}${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `${p2})`
}
return `url(${proxy}?url=` + encodeURIComponent(self.decodeEntities(p3)) + `)`
return `url(${proxy}?url=` + encodeURIComponent(this.decodeEntities(p3)) + `)`
})
// create temporary document to manipulate
@@ -63,7 +61,7 @@ export default {
let src = i.getAttribute('href')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('href', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -72,7 +70,7 @@ export default {
for (let i of images) {
let src = i.getAttribute('src')
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('src', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -83,7 +81,7 @@ export default {
if (src && src.match(/^https?:\/\//i) && src.indexOf(window.location.origin + window.location.pathname) !== 0) {
// replace with proxy link
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(self.decodeEntities(src)))
i.setAttribute('background', `${proxy}?url=` + encodeURIComponent(this.decodeEntities(src)))
}
}
@@ -92,7 +90,7 @@ export default {
},
// HTML decode function
decodeEntities: function (s) {
decodeEntities(s) {
let e = document.createElement('div')
e.innerHTML = s
let str = e.textContent
@@ -100,8 +98,7 @@ export default {
return str
},
doScreenshot: function () {
let self = this
doScreenshot() {
let width = document.getElementById('message-view').getBoundingClientRect().width
let prev = document.getElementById('preview-html')
@@ -113,7 +110,7 @@ export default {
width = 300
}
let i = document.getElementById('screenshot-html')
const i = document.getElementById('screenshot-html')
// set the iframe width
i.style.width = width + 'px'
@@ -127,11 +124,11 @@ export default {
width: width,
}).then(dataUrl => {
const link = document.createElement('a')
link.download = self.message.ID + '.png'
link.download = this.message.ID + '.png'
link.href = dataUrl
link.click()
self.loading = 0
self.html = false
this.loading = 0
this.html = false
})
}
}

View File

@@ -38,41 +38,39 @@ export default {
},
methods: {
doCheck: function () {
doCheck() {
this.check = false
let self = this
// ignore any error, do not show loader
axios.get(self.resolve('/api/v1/message/' + self.message.ID + '/sa-check'), null)
.then(function (result) {
self.check = result.data
self.error = false
self.setIcons()
axios.get(this.resolve('/api/v1/message/' + this.message.ID + '/sa-check'), null)
.then((result) => {
this.check = result.data
this.error = false
this.setIcons()
})
.catch(function (error) {
.catch((error) => {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
self.error = error.response.data.Error
this.error = error.response.data.Error
} else {
self.error = error.response.data
this.error = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
self.error = 'Error sending data to the server. Please try again.'
this.error = 'Error sending data to the server. Please try again.'
} else {
// Something happened in setting up the request that triggered an Error
self.error = error.message
this.error = error.message
}
})
},
badgeStyle: function (ignorePadding = false) {
badgeStyle(ignorePadding = false) {
let badgeStyle = 'bg-success'
if (this.check.Error) {
badgeStyle = 'bg-warning text-primary'
@@ -90,7 +88,7 @@ export default {
return badgeStyle
},
setIcons: function () {
setIcons() {
let score = this.check.Score
if (this.check.Error && this.check.Error != '') {
score = '!'
@@ -102,7 +100,7 @@ export default {
},
computed: {
graphSections: function () {
graphSections() {
let score = this.check.Score
let p = Math.round(score / 5 * 100)
if (p > 100) {
@@ -125,7 +123,7 @@ export default {
]
},
scoreColor: function () {
scoreColor() {
return this.graphSections[0].color
},
}

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import dayjs from 'dayjs'
import ColorHash from 'color-hash'
import { Modal, Offcanvas } from 'bootstrap'
import {limitOptions} from "../stores/pagination";
import { limitOptions } from "../stores/pagination";
// BootstrapElement is used to return a fake Bootstrap element
// if the ID returns nothing to prevent errors.
@@ -25,15 +25,15 @@ export default {
},
methods: {
resolve: function (u) {
resolve(u) {
return this.$router.resolve(u).href
},
searchURI: function (s) {
searchURI(s) {
return this.resolve('/search') + '?q=' + encodeURIComponent(s)
},
getFileSize: function (bytes) {
getFileSize(bytes) {
if (bytes == 0) {
return '0B'
}
@@ -41,19 +41,19 @@ export default {
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
},
formatNumber: function (nr) {
formatNumber(nr) {
return new Intl.NumberFormat().format(nr)
},
messageDate: function (d) {
messageDate(d) {
return dayjs(d).format('ddd, D MMM YYYY, h:mm a')
},
secondsToRelative: function (d) {
secondsToRelative(d) {
return dayjs().subtract(d, 'seconds').fromNow()
},
tagEncodeURI: function (tag) {
tagEncodeURI(tag) {
if (tag.match(/ /)) {
tag = `"${tag}"`
}
@@ -61,7 +61,7 @@ export default {
return encodeURIComponent(`tag:${tag}`)
},
getSearch: function () {
getSearch() {
if (!window.location.search) {
return false
}
@@ -75,7 +75,7 @@ export default {
return q
},
getPaginationParams: function () {
getPaginationParams() {
if (!window.location.search) {
return null
}
@@ -90,8 +90,8 @@ export default {
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id)
modal(id) {
const e = document.getElementById(id)
if (e) {
return Modal.getOrCreateInstance(e)
}
@@ -100,8 +100,8 @@ export default {
},
// close mobile navigation
hideNav: function () {
let e = document.getElementById('offcanvas')
hideNav() {
const e = document.getElementById('offcanvas')
if (e) {
Offcanvas.getOrCreateInstance(e).hide()
}
@@ -115,22 +115,21 @@ export default {
* @params function callback function
* @params function error callback function
*/
get: function (url, values, callback, errorCallback) {
let self = this
self.loading++
get(url, values, callback, errorCallback) {
this.loading++
axios.get(url, { params: values })
.then(callback)
.catch(function (err) {
.catch((err) => {
if (typeof errorCallback == 'function') {
return errorCallback(err)
}
self.handleError(err)
this.handleError(err)
})
.then(function () {
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -142,16 +141,15 @@ export default {
* @params array object/array values
* @params function callback function
*/
post: function (url, data, callback) {
let self = this
self.loading++
post(url, data, callback) {
this.loading++
axios.post(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -163,16 +161,15 @@ export default {
* @params array object/array values
* @params function callback function
*/
delete: function (url, data, callback) {
let self = this
self.loading++
delete(url, data, callback) {
this.loading++
axios.delete(url, { data: data })
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
@@ -184,22 +181,21 @@ export default {
* @params array object/array values
* @params function callback function
*/
put: function (url, data, callback) {
let self = this
self.loading++
put(url, data, callback) {
this.loading++
axios.put(url, data)
.then(callback)
.catch(self.handleError)
.then(function () {
.catch(this.handleError)
.then(() => {
// always executed
if (self.loading > 0) {
self.loading--
if (this.loading > 0) {
this.loading--
}
})
},
// Ajax error message
handleError: function (error) {
handleError(error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
@@ -218,7 +214,7 @@ export default {
}
},
allAttachments: function (message) {
allAttachments(message) {
let a = []
for (let i in message.Attachments) {
a.push(message.Attachments[i])
@@ -237,7 +233,7 @@ export default {
return a.ContentType.match(/^image\//)
},
attachmentIcon: function (a) {
attachmentIcon(a) {
let ext = a.FileName.split('.').pop().toLowerCase()
if (a.ContentType.match(/^image\//)) {
@@ -279,7 +275,7 @@ export default {
// Returns a hex color based on a string.
// Values are stored in an array for faster lookup / processing.
colorHash: function (s) {
colorHash(s) {
if (this.tagColorCache[s] != undefined) {
return this.tagColorCache[s]
}

View File

@@ -25,12 +25,12 @@ export default {
},
methods: {
reloadMailbox: function () {
reloadMailbox() {
pagination.start = 0
this.loadMessages()
},
loadMessages: function () {
loadMessages() {
if (!this.apiURI) {
alert('apiURL not set!')
return
@@ -43,8 +43,7 @@ export default {
return
}
let self = this
let params = {}
const params = {}
mailbox.selected = []
params['limit'] = pagination.limit
@@ -52,7 +51,7 @@ export default {
params['start'] = pagination.start
}
self.get(this.apiURI, params, function (response) {
this.get(this.apiURI, params, (response) => {
mailbox.total = response.data.total // all messages
mailbox.unread = response.data.unread // all unread messages
mailbox.tags = response.data.tags // all tags
@@ -63,18 +62,18 @@ export default {
if (response.data.count == 0 && response.data.start > 0) {
pagination.start = 0
return self.loadMessages()
return this.loadMessages()
}
if (mailbox.lastMessage) {
window.setTimeout(() => {
let m = document.getElementById(mailbox.lastMessage)
const m = document.getElementById(mailbox.lastMessage)
if (m) {
m.focus()
// m.scrollIntoView({ behavior: 'smooth', block: 'center' })
m.scrollIntoView({ block: 'center' })
} else {
let mp = document.getElementById('message-page')
const mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}
@@ -84,7 +83,7 @@ export default {
}, 50)
} else if (!window.scrollInPlace) {
let mp = document.getElementById('message-page')
const mp = document.getElementById('message-page')
if (mp) {
mp.scrollTop = 0
}

View File

@@ -43,7 +43,7 @@ export default {
},
methods: {
loadMailbox: function () {
loadMailbox() {
const paginationParams = this.getPaginationParams()
if (paginationParams?.start) {
pagination.start = paginationParams.start

View File

@@ -41,16 +41,14 @@ export default {
},
methods: {
loadMessage: function () {
let self = this
loadMessage() {
this.message = false
let uri = self.resolve('/api/v1/message/' + this.$route.params.id)
self.get(uri, false, function (response) {
self.errorMessage = false
const uri = this.resolve('/api/v1/message/' + this.$route.params.id)
this.get(uri, false, (response) => {
this.errorMessage = false
const d = response.data
let d = response.data
if (self.wasUnread(d.ID)) {
if (this.wasUnread(d.ID)) {
mailbox.unread--
}
@@ -61,14 +59,14 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
}
@@ -81,43 +79,43 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(cid:' + a.ContentID + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
if (a.FileName.match(/^[a-zA-Z0-9\_\-\.]+$/)) {
// some old email clients use the filename
d.HTML = d.HTML.replace(
new RegExp('(=["\']?)(' + a.FileName + ')(["|\'|\\s|\\/|>|;])', 'g'),
'$1' + self.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
'$1' + this.resolve('/api/v1/message/' + d.ID + '/part/' + a.PartID) + '$3'
)
}
}
}
self.message = d
this.message = d
self.detectPrevNext()
this.detectPrevNext()
},
function (error) {
self.errorMessage = true
(error) => {
this.errorMessage = true
if (error.response && error.response.data) {
if (error.response.data.Error) {
self.errorMessage = error.response.data.Error
this.errorMessage = error.response.data.Error
} else {
self.errorMessage = error.response.data
this.errorMessage = error.response.data
}
} else if (error.request) {
// The request was made but no response was received
self.errorMessage = 'Error sending data to the server. Please refresh the page.'
this.errorMessage = 'Error sending data to the server. Please refresh the page.'
} else {
// Something happened in setting up the request that triggered an Error
self.errorMessage = error.message
this.errorMessage = error.message
}
})
},
// try detect whether this message was unread based on messages listing
wasUnread: function (id) {
wasUnread(id) {
for (let m in mailbox.messages) {
if (mailbox.messages[m].ID == id) {
if (!mailbox.messages[m].Read) {
@@ -129,7 +127,7 @@ export default {
}
},
detectPrevNext: function () {
detectPrevNext() {
// generate the prev/next links based on current message list
this.prevLink = false
this.nextLink = false
@@ -147,40 +145,38 @@ export default {
}
},
downloadMessageBody: function (str, ext) {
let dl = document.createElement('a')
downloadMessageBody(str, ext) {
const dl = document.createElement('a')
dl.href = "data:text/plain," + encodeURIComponent(str)
dl.target = '_blank'
dl.download = this.message.ID + '.' + ext
dl.click()
},
screenshotMessageHTML: function () {
screenshotMessageHTML() {
this.$refs.ScreenshotRef.initScreenshot()
},
// mark current message as read
markUnread: function () {
let self = this
if (!self.message) {
markUnread() {
if (!this.message) {
return false
}
let uri = self.resolve('/api/v1/messages')
self.put(uri, { 'read': false, 'ids': [self.message.ID] }, function (response) {
self.goBack()
const uri = this.resolve('/api/v1/messages')
this.put(uri, { 'read': false, 'ids': [this.message.ID] }, (response) => {
this.goBack()
})
},
deleteMessage: function () {
let self = this
let ids = [self.message.ID]
let uri = self.resolve('/api/v1/messages')
self.delete(uri, { 'ids': ids }, function () {
self.goBack()
deleteMessage() {
const ids = [this.message.ID]
const uri = this.resolve('/api/v1/messages')
this.delete(uri, { 'ids': ids }, () => {
this.goBack()
})
},
goBack: function () {
goBack() {
mailbox.lastMessage = this.$route.params.id
if (mailbox.searching) {
@@ -208,16 +204,13 @@ export default {
}
},
initReleaseModal: function () {
let self = this
self.modal('ReleaseModal').show()
window.setTimeout(function () {
window.setTimeout(function () {
// delay to allow elements to load / focus
self.$refs.ReleaseRef.initTags()
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
}, 500)
}, 300)
initReleaseModal() {
this.modal('ReleaseModal').show()
window.setTimeout(() => {
// delay to allow elements to load / focus
this.$refs.ReleaseRef.initTags()
document.querySelector('#ReleaseModal input[role="combobox"]').focus()
}, 500)
},
}
}
@@ -317,8 +310,12 @@ export default {
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-block col-xl-2 col-md-3 mh-100 position-relative"
style="overflow-y: auto; overflow-x: hidden;">
<div class="text-center badge text-bg-primary py-2 mt-2 w-100 text-truncate fw-normal"
v-if="mailbox.uiConfig.Label">
{{ mailbox.uiConfig.Label }}
</div>
<div class="list-group my-2">
<div class="list-group my-2" :class="mailbox.uiConfig.Label ? 'mt-0' : ''">
<button @click="goBack()" class="list-group-item list-group-item-action">
<i class="bi bi-arrow-return-left me-1"></i>
<span class="ms-1">Return</span>

View File

@@ -43,8 +43,8 @@ export default {
},
methods: {
doSearch: function () {
let s = this.getSearch()
doSearch() {
const s = this.getSearch()
if (!s) {
mailbox.searching = false

View File

@@ -1635,6 +1635,10 @@
"description": "Whether messages with duplicate IDs are ignored",
"type": "boolean"
},
"Label": {
"description": "Optional label to identify this Mailpit instance",
"type": "string"
},
"MessageRelay": {
"description": "Message Relay information",
"type": "object",