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:
@@ -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:
|
||||
|
5
.github/workflows/build-docker-edge.yml
vendored
5
.github/workflows/build-docker-edge.yml
vendored
@@ -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
|
||||
|
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -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
|
||||
|
549
CHANGELOG.md
549
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
||||
}
|
||||
|
@@ -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\_]`)
|
||||
|
448
internal/pop3client/client.go
Normal file
448
internal/pop3client/client.go
Normal 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))
|
||||
}
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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
365
server/pop3/pop3_test.go
Normal 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)
|
||||
}
|
@@ -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
|
||||
|
@@ -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}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -39,10 +39,9 @@ export default {
|
||||
}
|
||||
|
||||
this.iconProcessing = true
|
||||
let self = this
|
||||
|
||||
window.setTimeout(() => {
|
||||
self.icoUpdate()
|
||||
this.icoUpdate()
|
||||
}, this.iconTimeout)
|
||||
},
|
||||
},
|
||||
|
@@ -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) }}
|
||||
|
@@ -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>
|
||||
|
@@ -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">
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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('/')
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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><script></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><script></code> node (element) has 100% failure (not supported in
|
||||
any email client), and a <code><p></code> node has 100% pass (supported).
|
||||
<code><script></code> node (element) has 100% failure (not supported
|
||||
in any email client), and a <code><p></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>
|
||||
|
@@ -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
|
||||
});
|
||||
},
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
},
|
||||
}
|
||||
|
@@ -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]
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -43,7 +43,7 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
loadMailbox: function () {
|
||||
loadMailbox() {
|
||||
const paginationParams = this.getPaginationParams()
|
||||
if (paginationParams?.start) {
|
||||
pagination.start = paginationParams.start
|
||||
|
@@ -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>
|
||||
|
@@ -43,8 +43,8 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
doSearch: function () {
|
||||
let s = this.getSearch()
|
||||
doSearch() {
|
||||
const s = this.getSearch()
|
||||
|
||||
if (!s) {
|
||||
mailbox.searching = false
|
||||
|
@@ -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",
|
||||
|
Reference in New Issue
Block a user