mirror of
https://github.com/axllent/mailpit.git
synced 2026-03-03 17:37:02 +00:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6b6640d5 | ||
|
|
a8945bd303 | ||
|
|
53e199b20f | ||
|
|
a6693481fa | ||
|
|
1aa58eeaaf | ||
|
|
133b36c34c | ||
|
|
ed28a4cc0d | ||
|
|
bc30b012cf | ||
|
|
2ae51c3f64 | ||
|
|
b6a87b9410 | ||
|
|
1f7dd0287a | ||
|
|
f33cbce63f | ||
|
|
79b6892320 | ||
|
|
799987ecb1 | ||
|
|
2d57839b3e | ||
|
|
86cc237c78 | ||
|
|
cc15ada304 | ||
|
|
49bc62f0aa | ||
|
|
444b65d371 | ||
|
|
15859f7be9 | ||
|
|
486388a798 | ||
|
|
9ab28d606a | ||
|
|
18b5ce8c18 | ||
|
|
93d5289d25 | ||
|
|
97bf9c257c | ||
|
|
18b0f5b790 | ||
|
|
94feb2ccaa | ||
|
|
aba3c46eb1 | ||
|
|
c9c910ab7c | ||
|
|
29c7295d16 | ||
|
|
61e15e4155 | ||
|
|
e03618570d | ||
|
|
d4cf95363f | ||
|
|
f260495495 | ||
|
|
d9f1f88107 | ||
|
|
09b704bcd7 | ||
|
|
a14cdce07f | ||
|
|
9fc5318e86 | ||
|
|
8affa0f375 | ||
|
|
cf8994ceaf | ||
|
|
39132723db | ||
|
|
642487742c | ||
|
|
544f0175d9 | ||
|
|
788e390e01 | ||
|
|
f6ae6bbdbb | ||
|
|
1155443785 | ||
|
|
056bef7d5e | ||
|
|
37eec298d7 | ||
|
|
a77b532328 | ||
|
|
00d6463de1 | ||
|
|
a3b92711a9 | ||
|
|
ba8c4cd2aa | ||
|
|
ec5267f5a5 | ||
|
|
73d2b1ba93 | ||
|
|
56fdaa1224 | ||
|
|
25090aeb2a | ||
|
|
9bc8d005fb | ||
|
|
b57e340389 | ||
|
|
b9043b6c39 | ||
|
|
5860171002 | ||
|
|
ad49bf2898 | ||
|
|
2d221a6b67 | ||
|
|
4f266cd3f3 |
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "develop" ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "develop" ]
|
||||
schedule:
|
||||
- cron: '34 23 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Test
|
||||
name: Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ develop ]
|
||||
push:
|
||||
branches: [ develop ]
|
||||
branches: [ develop, 'feature/**' ]
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -3,6 +3,79 @@
|
||||
Notable changes to Mailpit will be documented in this file.
|
||||
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Feature
|
||||
- Improved message search - any order & phrase quoting
|
||||
|
||||
### UI
|
||||
- Change breakpoints for mobile view of messages
|
||||
- Resize iframes with viewport resize
|
||||
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Feature
|
||||
- Email compression in storage
|
||||
|
||||
### Testing
|
||||
- Enable testing on feature branches
|
||||
- Database total/unread statistics tests
|
||||
|
||||
### UI
|
||||
- Mobile compatibility improvements & functionality
|
||||
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Feature
|
||||
- Mark all messages as read
|
||||
|
||||
### UI
|
||||
- Better error handling when connection to server is broken
|
||||
- Add reset search button
|
||||
- Minor UI tweaks
|
||||
- Update pagination values when new mail arrives when not on first page
|
||||
|
||||
|
||||
### Pull Requests
|
||||
- Merge pull request [#6](https://github.com/axllent/mailpit/issues/6) from KaptinLin/develop
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Feature
|
||||
- Optional browser notifications (HTTPS only)
|
||||
|
||||
### Security
|
||||
- Don't allow tar files containing a ".."
|
||||
- Sanitize mailbox names
|
||||
- Use strconv.Atoi() for safe string to int conversions
|
||||
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Bugfix
|
||||
- Fix env variable for MP_UI_SSL_KEY
|
||||
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Feature
|
||||
- SMTP STARTTLS & SMTP authentication support
|
||||
|
||||
|
||||
## 0.0.9
|
||||
|
||||
### Bugfix
|
||||
- Include read status in search results
|
||||
|
||||
### Feature
|
||||
- HTTPS option for web UI
|
||||
|
||||
### Testing
|
||||
- Memory & physical database tests
|
||||
|
||||
|
||||
## 0.0.8
|
||||
|
||||
### Bugfix
|
||||
|
||||
24
README.md
24
README.md
@@ -1,5 +1,11 @@
|
||||
# Mailpit
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](https://goreportcard.com/report/github.com/axllent/mailpit)
|
||||
|
||||
Mailpit is an email testing tool for developers.
|
||||
|
||||
It acts as both an SMTP server, and provides a web interface to view all captured emails.
|
||||
@@ -8,24 +14,22 @@ Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
|
||||
|
||||

|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- Runs completely on a single binary
|
||||
- SMTP server (default `0.0.0.0:1025`)
|
||||
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
|
||||
- Real-time web UI updates using web sockets for new mail
|
||||
- Optional basic authentication for web UI (see [wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication))
|
||||
- Email storage in either memory or disk (using [CloverDB](https://github.com/ostafen/clover)) - note that in-memory has a physical limit of 1MB per email
|
||||
- Optional browser notifications for new mail (HTTPS only)
|
||||
- Configurable automatic email pruning (default keeps the most recent 500 emails)
|
||||
- Email storage either in memory or disk ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
|
||||
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
|
||||
- Can handle tens of thousands of emails
|
||||
- Multi-arch [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
|
||||
|
||||
|
||||
## Planned features
|
||||
|
||||
- Optional HTTPS for web UI
|
||||
- Browser notifications for new mail (HTTPS only)
|
||||
- Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
|
||||
- Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS))
|
||||
- Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication))
|
||||
- Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
|
||||
|
||||
|
||||
## Installation
|
||||
@@ -48,7 +52,7 @@ If Mailpit is found on the same host as sendmail, you can symlink the Mailpit bi
|
||||
|
||||
You can use your default system `sendmail` binary to route directly to port `1025` (configurable) by calling `/usr/sbin/sendmail -S localhost:1025`.
|
||||
|
||||
You can build a Mailpit-specific sendmail binary from source ( see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source)).
|
||||
You can build a Mailpit-specific sendmail binary from source (see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source)).
|
||||
|
||||
|
||||
## Why rewrite MailHog?
|
||||
|
||||
55
cmd/root.go
55
cmd/root.go
@@ -84,14 +84,61 @@ func init() {
|
||||
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
|
||||
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
|
||||
}
|
||||
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
|
||||
config.AuthFile = os.Getenv("MP_AUTH_FILE")
|
||||
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
|
||||
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
|
||||
config.UISSLCert = os.Getenv("MP_UI_SSL_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
|
||||
config.UISSLKey = os.Getenv("MP_UI_SSL_KEY")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
|
||||
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
|
||||
config.SMTPSSLCert = os.Getenv("MP_SMTP_SSL_CERT")
|
||||
}
|
||||
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
|
||||
config.SMTPSSLKey = os.Getenv("MP_SMTP_SSL_KEY")
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent data")
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
|
||||
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
|
||||
}
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_SSL_CERT")) > 0 {
|
||||
config.UISSLCert = os.Getenv("MP_SSL_CERT")
|
||||
}
|
||||
// deprecated 2022/08/06
|
||||
if len(os.Getenv("MP_SSL_KEY")) > 0 {
|
||||
config.UISSLKey = os.Getenv("MP_SSL_KEY")
|
||||
}
|
||||
|
||||
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store persistent data")
|
||||
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
|
||||
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
|
||||
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
|
||||
rootCmd.Flags().StringVarP(&config.AuthFile, "auth-file", "a", config.AuthFile, "A password file for authentication (see wiki)")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
|
||||
rootCmd.Flags().StringVar(&config.UISSLCert, "ui-ssl-cert", config.UISSLCert, "SSL certificate for web UI - requires ui-ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.UISSLKey, "ui-ssl-key", config.UISSLKey, "SSL key for web UI - requires ui-ssl-cert")
|
||||
|
||||
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
|
||||
rootCmd.Flags().StringVar(&config.SMTPSSLCert, "smtp-ssl-cert", config.SMTPSSLCert, "SSL certificate for SMTP - requires smtp-ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.SMTPSSLKey, "smtp-ssl-key", config.SMTPSSLKey, "SSL key for SMTP - requires smtp-ssl-cert")
|
||||
|
||||
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
|
||||
|
||||
// deprecated 2022/08/06
|
||||
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
|
||||
rootCmd.Flags().StringVar(&config.UISSLCert, "ssl-cert", config.UISSLCert, "SSL certificate - requires ssl-key")
|
||||
rootCmd.Flags().StringVar(&config.UISSLKey, "ssl-key", config.UISSLKey, "SSL key - requires ssl-cert")
|
||||
rootCmd.Flags().Lookup("auth-file").Hidden = true
|
||||
rootCmd.Flags().Lookup("auth-file").Deprecated = "use --ui-auth-file"
|
||||
rootCmd.Flags().Lookup("ssl-cert").Hidden = true
|
||||
rootCmd.Flags().Lookup("ssl-cert").Deprecated = "use --ui-ssl-cert"
|
||||
rootCmd.Flags().Lookup("ssl-key").Hidden = true
|
||||
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-ssl-key"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/tg123/go-htpasswd"
|
||||
@@ -26,16 +28,29 @@ var (
|
||||
// NoLogging for tests
|
||||
NoLogging = false
|
||||
|
||||
// SSLCert @TODO
|
||||
SSLCert string
|
||||
// SSLKey @TODO
|
||||
SSLKey string
|
||||
// UISSLCert file
|
||||
UISSLCert string
|
||||
|
||||
// AuthFile for basic authentication
|
||||
AuthFile string
|
||||
// UISSLKey file
|
||||
UISSLKey string
|
||||
|
||||
// Auth used for euthentication
|
||||
Auth *htpasswd.File
|
||||
// UIAuthFile for basic authentication
|
||||
UIAuthFile string
|
||||
|
||||
// UIAuth used for euthentication
|
||||
UIAuth *htpasswd.File
|
||||
|
||||
// SMTPSSLCert file
|
||||
SMTPSSLCert string
|
||||
|
||||
// SMTPSSLKey file
|
||||
SMTPSSLKey string
|
||||
|
||||
// SMTPAuthFile for SMTP authentication
|
||||
SMTPAuthFile string
|
||||
|
||||
// SMTPAuth used for euthentication
|
||||
SMTPAuth *htpasswd.File
|
||||
)
|
||||
|
||||
// VerifyConfig wil do some basic checking
|
||||
@@ -48,13 +63,71 @@ func VerifyConfig() error {
|
||||
return errors.New("HTTP bind should be in the format of <ip>:<port>")
|
||||
}
|
||||
|
||||
if AuthFile != "" {
|
||||
a, err := htpasswd.New(AuthFile, htpasswd.DefaultSystems, nil)
|
||||
if UIAuthFile != "" {
|
||||
if !isFile(UIAuthFile) {
|
||||
return fmt.Errorf("HTTP password file not found: %s", UIAuthFile)
|
||||
}
|
||||
|
||||
a, err := htpasswd.New(UIAuthFile, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Auth = a
|
||||
UIAuth = a
|
||||
}
|
||||
|
||||
if UISSLCert != "" && UISSLKey == "" || UISSLCert == "" && UISSLKey != "" {
|
||||
return errors.New("you must provide both a UI SSL certificate and a key")
|
||||
}
|
||||
|
||||
if UISSLCert != "" {
|
||||
if !isFile(UISSLCert) {
|
||||
return fmt.Errorf("SSL certificate not found: %s", UISSLCert)
|
||||
}
|
||||
|
||||
if !isFile(UISSLKey) {
|
||||
return fmt.Errorf("SSL key not found: %s", UISSLKey)
|
||||
}
|
||||
}
|
||||
|
||||
if SMTPSSLCert != "" && SMTPSSLKey == "" || SMTPSSLCert == "" && SMTPSSLKey != "" {
|
||||
return errors.New("you must provide both an SMTP SSL certificate and a key")
|
||||
}
|
||||
|
||||
if SMTPSSLCert != "" {
|
||||
if !isFile(SMTPSSLCert) {
|
||||
return fmt.Errorf("SMTP SSL certificate not found: %s", SMTPSSLCert)
|
||||
}
|
||||
|
||||
if !isFile(SMTPSSLKey) {
|
||||
return fmt.Errorf("SMTP SSL key not found: %s", SMTPSSLKey)
|
||||
}
|
||||
}
|
||||
|
||||
if SMTPAuthFile != "" {
|
||||
if !isFile(SMTPAuthFile) {
|
||||
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
|
||||
}
|
||||
|
||||
if SMTPSSLCert == "" {
|
||||
return errors.New("SMTP authentication requires SMTP encryption")
|
||||
}
|
||||
|
||||
a, err := htpasswd.New(SMTPAuthFile, htpasswd.DefaultSystems, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SMTPAuth = a
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFile returns if a path is a file
|
||||
func isFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if os.IsNotExist(err) || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ type Message struct {
|
||||
Bcc []*mail.Address
|
||||
Subject string
|
||||
Date time.Time
|
||||
Created time.Time
|
||||
Text string
|
||||
HTML string
|
||||
Size int
|
||||
|
||||
3
go.mod
3
go.mod
@@ -8,6 +8,8 @@ require (
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jhillyerd/enmime v0.10.0
|
||||
github.com/k3a/html2text v1.0.8
|
||||
github.com/klauspost/compress v1.15.9
|
||||
github.com/mattn/go-shellwords v1.0.12
|
||||
github.com/mhale/smtpd v0.8.0
|
||||
github.com/ostafen/clover/v2 v2.0.0-alpha.2
|
||||
github.com/sirupsen/logrus v1.9.0
|
||||
@@ -35,7 +37,6 @@ require (
|
||||
github.com/google/orderedcode v0.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
|
||||
github.com/klauspost/compress v1.15.9 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -124,6 +124,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
|
||||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||
github.com/mhale/smtpd v0.8.0 h1:5JvdsehCg33PQrZBvFyDMMUDQmvbzVpZgKob7eYBJc0=
|
||||
github.com/mhale/smtpd v0.8.0/go.mod h1:MQl+y2hwIEQCXtNhe5+55n0GZOjSmeqORDIXbqUL3x4=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 63 KiB |
@@ -29,7 +29,7 @@ func apiListMailboxes(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -62,7 +62,7 @@ func apiListMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -102,7 +102,7 @@ func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
bytes, _ := json.Marshal(res)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Open a message
|
||||
@@ -120,7 +120,7 @@ func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
bytes, _ := json.Marshal(msg)
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(bytes)
|
||||
_, _ = w.Write(bytes)
|
||||
}
|
||||
|
||||
// Download/view an attachment
|
||||
@@ -143,7 +143,7 @@ func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Add("Content-Type", a.ContentType)
|
||||
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
|
||||
w.Write(a.Content)
|
||||
_, _ = w.Write(a.Content)
|
||||
}
|
||||
|
||||
// View the full email source as plain text
|
||||
@@ -165,7 +165,7 @@ func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
|
||||
if dl == "1" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
|
||||
}
|
||||
w.Write(data)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// Delete all messages in the mailbox
|
||||
@@ -181,7 +181,7 @@ func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Delete a single message
|
||||
@@ -198,7 +198,7 @@ func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Mark single message as unread
|
||||
@@ -215,7 +215,23 @@ func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte("ok"))
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Mark single message as unread
|
||||
func apiMarkAllRead(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
mailbox := vars["mailbox"]
|
||||
|
||||
err := storage.MarkAllRead(mailbox)
|
||||
if err != nil {
|
||||
httpError(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
// Websocket to broadcast changes
|
||||
|
||||
@@ -39,6 +39,7 @@ func Listen() {
|
||||
r.HandleFunc("/api/{mailbox}/search", middleWareFunc(apiSearchMailbox))
|
||||
r.HandleFunc("/api/{mailbox}/delete", middleWareFunc(apiDeleteAll))
|
||||
r.HandleFunc("/api/{mailbox}/events", apiWebsocket)
|
||||
r.HandleFunc("/api/{mailbox}/read", apiMarkAllRead)
|
||||
r.HandleFunc("/api/{mailbox}/{id}/source", middleWareFunc(apiDownloadSource))
|
||||
r.HandleFunc("/api/{mailbox}/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment))
|
||||
r.HandleFunc("/api/{mailbox}/{id}/delete", middleWareFunc(apiDeleteOne))
|
||||
@@ -47,9 +48,13 @@ func Listen() {
|
||||
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
|
||||
http.Handle("/", r)
|
||||
|
||||
if config.SSLCert != "" && config.SSLKey != "" {
|
||||
if config.UIAuthFile != "" {
|
||||
logger.Log().Info("[http] enabling web UI basic authentication")
|
||||
}
|
||||
|
||||
if config.UISSLCert != "" && config.UISSLKey != "" {
|
||||
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
|
||||
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.SSLCert, config.SSLKey, nil))
|
||||
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
|
||||
} else {
|
||||
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
|
||||
log.Fatal(http.ListenAndServe(config.HTTPListen, nil))
|
||||
@@ -60,7 +65,7 @@ func Listen() {
|
||||
func basicAuthResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Unauthorised.\n"))
|
||||
_, _ = w.Write([]byte("Unauthorised.\n"))
|
||||
}
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
@@ -76,7 +81,7 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
// and gzip compression.
|
||||
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if config.AuthFile != "" {
|
||||
if config.UIAuthFile != "" {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
@@ -84,7 +89,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if !config.Auth.Match(user, pass) {
|
||||
if !config.UIAuth.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
@@ -107,7 +112,7 @@ func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
|
||||
func middlewareHandler(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if config.AuthFile != "" {
|
||||
if config.UIAuthFile != "" {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
@@ -115,7 +120,7 @@ func middlewareHandler(h http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if !config.Auth.Match(user, pass) {
|
||||
if !config.UIAuth.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
@@ -152,16 +157,13 @@ func getStartLimit(req *http.Request) (start int, limit int) {
|
||||
limit = 50
|
||||
|
||||
s := req.URL.Query().Get("start")
|
||||
if n, e := strconv.ParseInt(s, 10, 64); e == nil && n > 0 {
|
||||
start = int(n)
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||||
start = n
|
||||
}
|
||||
|
||||
l := req.URL.Query().Get("limit")
|
||||
if n, e := strconv.ParseInt(l, 10, 64); e == nil && n > 0 {
|
||||
if n > 500 {
|
||||
n = 500
|
||||
}
|
||||
limit = int(n)
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
|
||||
return start, limit
|
||||
|
||||
@@ -17,11 +17,14 @@ export default {
|
||||
total: 0,
|
||||
unread: 0,
|
||||
start: 0,
|
||||
count: 0,
|
||||
search: "",
|
||||
searching: false,
|
||||
isConnected: false,
|
||||
scrollInPlace: false,
|
||||
message: false
|
||||
message: false,
|
||||
notificationsSupported: false,
|
||||
notificationsEnabled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -47,6 +50,10 @@ export default {
|
||||
this.currentPath = window.location.hash.slice(1);
|
||||
});
|
||||
|
||||
this.notificationsSupported = 'https:' == document.location.protocol
|
||||
&& ("Notification" in window && Notification.permission !== "denied");
|
||||
this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted";
|
||||
|
||||
this.connect();
|
||||
this.loadMessages();
|
||||
},
|
||||
@@ -93,6 +100,13 @@ export default {
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
resetSearch: function(e) {
|
||||
e.preventDefault();
|
||||
this.search = '';
|
||||
this.scrollInPlace = true;
|
||||
this.loadMessages();
|
||||
},
|
||||
|
||||
reloadMessages: function() {
|
||||
this.search = "";
|
||||
this.start = 0;
|
||||
@@ -192,6 +206,16 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
markAllRead: function() {
|
||||
let self = this;
|
||||
let uri = 'api/' + self.mailbox + '/read'
|
||||
self.get(uri, false, function(response) {
|
||||
window.location.hash = "";
|
||||
self.scrollInPlace = true;
|
||||
self.loadMessages();
|
||||
});
|
||||
},
|
||||
|
||||
// websocket connect
|
||||
connect: function () {
|
||||
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
|
||||
@@ -204,16 +228,19 @@ export default {
|
||||
}
|
||||
// new messages
|
||||
if (response.Type == "new" && response.Data) {
|
||||
if (self.start < 1) {
|
||||
if (!self.searching) {
|
||||
if (!self.searching) {
|
||||
if (self.start < 1) {
|
||||
self.items.unshift(response.Data);
|
||||
if (self.items.length > self.limit) {
|
||||
self.items.pop();
|
||||
}
|
||||
} else {
|
||||
self.start++;
|
||||
}
|
||||
}
|
||||
self.total++;
|
||||
self.unread++;
|
||||
self.browserNotify("New mail from: " + response.Data.From.Address, response.Data.Subject);
|
||||
} else if (response.Type == "prune") {
|
||||
// messages have been deleted, reload messages to adjust
|
||||
self.scrollInPlace = true;
|
||||
@@ -252,45 +279,95 @@ export default {
|
||||
let d = new Date(message.Created)
|
||||
return moment(d).fromNow().toString();
|
||||
},
|
||||
|
||||
browserNotify: function(title, message) {
|
||||
if (!("Notification" in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
let b = message.Subject;
|
||||
let options = {
|
||||
body: message,
|
||||
icon: 'mailpit.png'
|
||||
}
|
||||
new Notification(title, options);
|
||||
}
|
||||
},
|
||||
|
||||
requestNotifications: function() {
|
||||
// check if the browser supports notifications
|
||||
if (!("Notification" in window)) {
|
||||
alert("This browser does not support desktop notification");
|
||||
}
|
||||
|
||||
// we need to ask the user for permission
|
||||
else if (Notification.permission !== "denied") {
|
||||
let self = this;
|
||||
Notification.requestPermission().then(function (permission) {
|
||||
// If the user accepts, let's create a notification
|
||||
if (permission === "granted") {
|
||||
self.browserNotify("Notifications enabled", "You will receive notifications when new mails are received.");
|
||||
self.notificationsEnabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navbar navbar-expand-lg navbar-light row flex-shrink-0 bg-light">
|
||||
<div class="col-lg-2 col-md-3 col-auto">
|
||||
<div class="navbar navbar-expand-lg navbar-light row flex-shrink-0 bg-light shadow-sm">
|
||||
<div class="col-lg-2 col-md-3 d-none d-md-block">
|
||||
<a class="navbar-brand" href="#" v-on:click="reloadMessages">
|
||||
<img src="mailpit.svg" alt="Mailpit">
|
||||
<span class="d-none d-md-inline-block ms-2">Mailpit</span>
|
||||
<span class="ms-2">Mailpit</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-9 col-lg-8" v-if="message">
|
||||
<div class="col col-md-9 col-lg-10" v-if="message">
|
||||
<a class="btn btn-outline-secondary me-4 px-3" href="#" v-on:click="message=false" title="Return to messages">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary me-2" title="Delete message" v-on:click="deleteOne">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<i class="bi bi-envelope"></i> <span class="d-none d-md-inline">Mark unread</span>
|
||||
</button>
|
||||
<a :href="'api/' + mailbox + '/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2" title="Download message">
|
||||
<i class="bi bi-file-arrow-down-fill"></i>
|
||||
<a :href="'api/' + mailbox + '/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2 float-end" title="Download message">
|
||||
<i class="bi bi-file-arrow-down-fill"></i> <span class="d-none d-md-inline">Download</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-9 col-lg-5" v-if="!message && total">
|
||||
<div class="col col-md-9 col-lg-5 LOL" v-if="!message">
|
||||
<form v-on:submit="doSearch">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" v-model.trim="search" placeholder="Search mailbox">
|
||||
<button class="btn btn-outline-secondary" type="submit"><i class="bi bi-search"></i></button>
|
||||
<a class="navbar-brand d-md-none" href="#" v-on:click="reloadMessages">
|
||||
<img src="mailpit.svg" alt="Mailpit">
|
||||
<span v-if="!total" class="ms-2">Mailpit</span>
|
||||
</a>
|
||||
<div v-if="total" class="d-flex bg-white border rounded-start flex-fill position-relative">
|
||||
<input type="text" class="form-control border-0" v-model.trim="search" placeholder="Search mailbox">
|
||||
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search" v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
|
||||
</div>
|
||||
<button v-if="total" class="btn btn-outline-secondary" type="submit"><i class="bi bi-search"></i></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-12 col-lg-5 text-end" v-if="!message && total">
|
||||
<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0" v-if="!message && total">
|
||||
<button v-if="total" class="btn btn-outline-danger float-start d-md-none me-2" data-bs-toggle="modal" data-bs-target="#DeleteAllModal" title="Delete all messages">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
|
||||
<button v-if="unread" class="btn btn-outline-primary float-start d-md-none" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal" title="Mark all read">
|
||||
<i class="bi bi-check2-square"></i>
|
||||
</button>
|
||||
|
||||
<select v-model="limit" v-on:change="loadMessages"
|
||||
class="form-select form-select-sm d-inline w-auto me-1" v-if="!searching">
|
||||
class="form-select form-select-sm d-inline w-auto me-2" v-if="!searching">
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
@@ -303,7 +380,7 @@ export default {
|
||||
<small>
|
||||
<b>{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }}</b> of <b>{{ formatNumber(total) }}</b>
|
||||
</small>
|
||||
<button class="btn btn-outline-secondary ms-3 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
<button class="btn btn-outline-secondary ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev"
|
||||
v-if="!searching">
|
||||
<i class="bi bi-caret-left-fill"></i>
|
||||
</button>
|
||||
@@ -315,16 +392,16 @@ export default {
|
||||
</div>
|
||||
<div class="row flex-fill" style="min-height:0">
|
||||
<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative" style="overflow-y: auto;">
|
||||
<ul class="list-unstyled mt-3">
|
||||
<li v-if="isConnected" title="Messages will auto-load">
|
||||
<ul class="list-unstyled mt-3 mb-5">
|
||||
<li v-if="isConnected" title="Messages will auto-load" class="mb-2">
|
||||
<i class="bi bi-power text-success"></i>
|
||||
Connected
|
||||
</li>
|
||||
<li v-else title="Messages will auto-load">
|
||||
<li v-else title="You need to manually refresh your mailbox" class="mb-3">
|
||||
<i class="bi bi-power text-danger"></i>
|
||||
Disconnected
|
||||
</li>
|
||||
<li class="mt-3">
|
||||
<li class="mb-5">
|
||||
<a class="position-relative ps-0" href="#" v-on:click="reloadMessages">
|
||||
<i class="bi bi-envelope me-1" v-if="isConnected"></i>
|
||||
<i class="bi bi-arrow-clockwise me-1" v-else></i>
|
||||
@@ -334,14 +411,26 @@ export default {
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-3 mb-5">
|
||||
<a v-if="total" href="#" data-bs-toggle="modal" data-bs-target="#deleteAllModal">
|
||||
<li class="my-3" v-if="unread">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal">
|
||||
<i class="bi bi-check2-square"></i>
|
||||
Mark all read
|
||||
</a>
|
||||
</li>
|
||||
<li class="my-3" v-if="total">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#DeleteAllModal">
|
||||
<i class="bi bi-trash-fill me-1 text-danger"></i>
|
||||
Delete all
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-5 position-fixed bottom-0 w-100">
|
||||
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white py-2">
|
||||
<li class="my-3" v-if="notificationsSupported && !notificationsEnabled">
|
||||
<a href="#" data-bs-toggle="modal" data-bs-target="#EnableNotificationsModal" title="Enable browser notifications">
|
||||
<i class="bi bi-bell"></i>
|
||||
Enable alerts
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-5 position-fixed bottom-0">
|
||||
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white my-3">
|
||||
<i class="bi bi-github"></i>
|
||||
GitHub
|
||||
</a>
|
||||
@@ -354,38 +443,45 @@ export default {
|
||||
<div class="list-group" v-if="items.length">
|
||||
<a v-for="message in items" :href="'#'+message.ID" class="row message d-flex small list-group-item list-group-item-action"
|
||||
:class="message.Read ? 'read':''" XXXv-on:click="openMessage(message)">
|
||||
<div class="col-md-3">
|
||||
<div class="d-md-none float-end text-muted text-nowrap small">
|
||||
<div class="col-lg-3">
|
||||
<div class="d-lg-none float-end text-muted text-nowrap small">
|
||||
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
|
||||
<div class="text-truncate d-md-none">
|
||||
<div class="text-truncate d-lg-none privacy">
|
||||
<span v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</span>
|
||||
</div>
|
||||
<div class="text-truncate d-none d-md-block">
|
||||
<div class="text-truncate d-none d-lg-block privacy">
|
||||
<b v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</b>
|
||||
</div>
|
||||
<div class="d-none d-md-block text-truncate text-muted small">
|
||||
<div class="d-none d-lg-block text-truncate text-muted small privacy">
|
||||
{{ getPrimaryEmailTo(message) }}
|
||||
<span v-if="message.To && message.To.length > 1">
|
||||
[+{{message.To.length - 1}}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-2 mt-md-0">
|
||||
<div class="col-lg-6 mt-2 mt-lg-0">
|
||||
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
|
||||
</div>
|
||||
<div class="d-none d-md-block col-1 small text-end text-muted">
|
||||
<div class="d-none d-lg-block col-1 small text-end text-muted">
|
||||
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
|
||||
{{ getFileSize(message.Size) }}
|
||||
</div>
|
||||
<div class="d-none d-md-block col-2 small text-end text-muted">
|
||||
<div class="d-none d-lg-block col-2 small text-end text-muted">
|
||||
{{ getRelativeCreated(message) }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div v-else class="text-muted py-3">No messages</div>
|
||||
<div v-else class="text-muted my-3">
|
||||
<span v-if="searching">
|
||||
No results matching your search
|
||||
</span>
|
||||
<span v-else>
|
||||
There are no emails in your mailbox
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="message" :message="message" :mailbox="mailbox"></Message>
|
||||
@@ -400,11 +496,11 @@ export default {
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<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">
|
||||
<h5 class="modal-title" id="deleteAllModalLabel">Delete all messages?</h5>
|
||||
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -418,4 +514,46 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
This will mark {{ formatNumber(unread) }} message<span v-if="unread > 1">s</span> as read.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="markAllRead">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="h4">Get browser notifications when Mailpit receives a new mail?</p>
|
||||
<p>
|
||||
Note that your browser will ask you for confirmation when you click <code>enable notifications</code>,
|
||||
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="requestNotifications">Enable notifications</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
// @import "../../../node_modules/bootstrap-icons"; ///scss/root";
|
||||
|
||||
@import "bootstrap";
|
||||
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: #2d4a5d;
|
||||
.navbar {
|
||||
z-index: 99;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
.navbar-brand {
|
||||
color: #2d4a5d;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
width: 35px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +35,6 @@
|
||||
}
|
||||
|
||||
.message.read:not(.active) {
|
||||
// background: $gray-100;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
@@ -35,6 +44,7 @@
|
||||
font-family: Courier New, Courier, System, fixed-width;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
#nav-plain-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -43,7 +53,7 @@
|
||||
margin: 15px 0 0;
|
||||
|
||||
th {
|
||||
padding-right: 10px;
|
||||
padding-right: 1.5rem;
|
||||
font-weight: normal;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -52,3 +62,21 @@
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
#nav-html {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
#preview-html {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.list-group-item:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
body.blur {
|
||||
.privacy {
|
||||
filter: blur(3px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const commonMixins = {
|
||||
// Ajax error message
|
||||
handleError: function (error) {
|
||||
// handle error
|
||||
if (error.response) {
|
||||
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) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export default {
|
||||
iframes: [], // for resizing
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
var self = this;
|
||||
|
||||
@@ -46,11 +47,29 @@ export default {
|
||||
self.srcURI = 'api/' + self.mailbox + '/' + self.message.ID + '/source';
|
||||
});
|
||||
},
|
||||
|
||||
unmounted: function() {
|
||||
window.removeEventListener("resize", this.resizeIframes);
|
||||
},
|
||||
|
||||
methods: {
|
||||
resizeIframe: function(el) {
|
||||
let i = el.target;
|
||||
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px';
|
||||
},
|
||||
|
||||
resizeIframes: function() {
|
||||
let h = document.getElementById('preview-html');
|
||||
if (h) {
|
||||
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px';
|
||||
}
|
||||
|
||||
let s = document.getElementById('message-src');
|
||||
if (s) {
|
||||
s.style.height = s.contentWindow.document.body.scrollHeight + 50 + 'px';
|
||||
}
|
||||
},
|
||||
|
||||
allAttachments: function(message){
|
||||
let a = [];
|
||||
for (let i in message.Attachments) {
|
||||
@@ -78,7 +97,7 @@ export default {
|
||||
<tbody>
|
||||
<tr class="small">
|
||||
<th>From</th>
|
||||
<td>
|
||||
<td class="privacy">
|
||||
<span v-if="message.From">
|
||||
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
|
||||
<span v-if="message.From.Address"><{{ message.From.Address }}></span>
|
||||
@@ -90,7 +109,7 @@ export default {
|
||||
</tr>
|
||||
<tr class="small">
|
||||
<th>To</th>
|
||||
<td>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.To">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
@@ -99,7 +118,7 @@ export default {
|
||||
</tr>
|
||||
<tr v-if="message.Cc" class="small">
|
||||
<th>CC</th>
|
||||
<td>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Cc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
@@ -108,7 +127,7 @@ export default {
|
||||
</tr>
|
||||
<tr v-if="message.Bcc" class="small">
|
||||
<th>CC</th>
|
||||
<td>
|
||||
<td class="privacy">
|
||||
<span v-for="(t, i) in message.Bcc">
|
||||
<template v-if="i > 0">,</template>
|
||||
{{ t.Name + " <" + t.Address +">" }}
|
||||
|
||||
BIN
server/ui/mailpit.png
Normal file
BIN
server/ui/mailpit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -99,8 +99,8 @@ func (c *Client) writePump() {
|
||||
|
||||
// ServeWs handles websocket requests from the peer.
|
||||
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||
if config.AuthFile != "" {
|
||||
if config.AuthFile != "" {
|
||||
if config.UIAuthFile != "" {
|
||||
if config.UIAuthFile != "" {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
|
||||
if !ok {
|
||||
@@ -108,7 +108,7 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !config.Auth.Match(user, pass) {
|
||||
if !config.UIAuth.Match(user, pass) {
|
||||
basicAuthResponse(w)
|
||||
return
|
||||
}
|
||||
@@ -133,5 +133,5 @@ func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||
func basicAuthResponse(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Unauthorised.\n"))
|
||||
_, _ = w.Write([]byte("Unauthorised.\n"))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/storage"
|
||||
s "github.com/mhale/smtpd"
|
||||
"github.com/mhale/smtpd"
|
||||
)
|
||||
|
||||
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
@@ -37,12 +37,45 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
|
||||
return config.SMTPAuth.Match(string(username), string(password)), nil
|
||||
}
|
||||
|
||||
// Listen starts the SMTPD server
|
||||
func Listen() error {
|
||||
logger.Log().Infof("[smtp] starting on %s", config.SMTPListen)
|
||||
if err := s.ListenAndServe(config.SMTPListen, mailHandler, "Mailpit", ""); err != nil {
|
||||
return err
|
||||
if config.SMTPSSLCert != "" {
|
||||
logger.Log().Info("[smtp] enabling TLS")
|
||||
}
|
||||
if config.SMTPAuthFile != "" {
|
||||
logger.Log().Info("[smtp] enabling authentication")
|
||||
}
|
||||
|
||||
return nil
|
||||
logger.Log().Infof("[smtp] starting on %s", config.SMTPListen)
|
||||
|
||||
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
|
||||
}
|
||||
|
||||
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
|
||||
srv := &smtpd.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
Appname: "Mailpit",
|
||||
Hostname: "",
|
||||
AuthHandler: nil,
|
||||
AuthRequired: false,
|
||||
}
|
||||
|
||||
if config.SMTPAuthFile != "" {
|
||||
srv.AuthHandler = authHandler
|
||||
srv.AuthRequired = true
|
||||
}
|
||||
|
||||
if config.SMTPSSLCert != "" {
|
||||
err := srv.ConfigureTLS(config.SMTPSSLCert, config.SMTPSSLKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/axllent/mailpit/logger"
|
||||
"github.com/axllent/mailpit/server/websockets"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/mattn/go-shellwords"
|
||||
"github.com/ostafen/clover/v2"
|
||||
)
|
||||
|
||||
@@ -28,6 +30,10 @@ var (
|
||||
|
||||
count int
|
||||
per100start = time.Now()
|
||||
|
||||
// zstd encoder & decoder
|
||||
encoder, _ = zstd.NewWriter(nil)
|
||||
decoder, _ = zstd.NewReader(nil)
|
||||
)
|
||||
|
||||
// CloverStore struct
|
||||
@@ -65,7 +71,7 @@ func InitDB() error {
|
||||
// method invoked upon seeing signal
|
||||
go func() {
|
||||
s := <-sigs
|
||||
logger.Log().Infof("[db] got %s signal, saving persistant data & shutting down", s)
|
||||
logger.Log().Infof("[db] got %s signal, saving persistent data & shutting down", s)
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Log().Errorf("[db] %s", err.Error())
|
||||
}
|
||||
@@ -140,40 +146,44 @@ func MailboxExists(name string) bool {
|
||||
}
|
||||
|
||||
// CreateMailbox will create a collection if it does not exist
|
||||
func CreateMailbox(name string) error {
|
||||
if !MailboxExists(name) {
|
||||
logger.Log().Infof("[db] creating mailbox: %s", name)
|
||||
func CreateMailbox(mailbox string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
if err := db.CreateCollection(name); err != nil {
|
||||
if !MailboxExists(mailbox) {
|
||||
logger.Log().Infof("[db] creating mailbox: %s", mailbox)
|
||||
|
||||
if err := db.CreateCollection(mailbox); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create Created index
|
||||
if err := db.CreateIndex(name, "Created"); err != nil {
|
||||
if err := db.CreateIndex(mailbox, "Created"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create Read index
|
||||
if err := db.CreateIndex(name, "Read"); err != nil {
|
||||
if err := db.CreateIndex(mailbox, "Read"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create separate collection for data
|
||||
if err := db.CreateCollection(name + "_data"); err != nil {
|
||||
if err := db.CreateCollection(mailbox + "_data"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create Created index
|
||||
if err := db.CreateIndex(name+"_data", "Created"); err != nil {
|
||||
if err := db.CreateIndex(mailbox+"_data", "Created"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return statsRefresh(name)
|
||||
return statsRefresh(mailbox)
|
||||
}
|
||||
|
||||
// Store will store a message in the database and return the unique ID
|
||||
func Store(mailbox string, b []byte) (string, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
r := bytes.NewReader(b)
|
||||
// Parse message body with enmime.
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
@@ -215,12 +225,15 @@ func Store(mailbox string, b []byte) (string, error) {
|
||||
raw := clover.NewDocument()
|
||||
raw.Set("_id", id)
|
||||
raw.Set("Created", time.Now())
|
||||
raw.Set("Data", string(b))
|
||||
|
||||
compressed := encoder.EncodeAll(b, make([]byte, 0, len(b)))
|
||||
raw.Set("Email", string(compressed))
|
||||
|
||||
_, err = db.InsertOne(mailbox+"_data", raw)
|
||||
if err != nil {
|
||||
// delete the summary because the data insert failed
|
||||
logger.Log().Debugf("[db] error inserting raw message, rolling back")
|
||||
DeleteOneMessage(mailbox, id)
|
||||
_ = DeleteOneMessage(mailbox, id)
|
||||
|
||||
return "", err
|
||||
}
|
||||
@@ -254,6 +267,8 @@ func Store(mailbox string, b []byte) (string, error) {
|
||||
// as clover's `Skip()` returns a subset of all results which is much slower.
|
||||
// @see https://github.com/ostafen/clover/issues/73
|
||||
func List(mailbox string, start, limit int) ([]data.Summary, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
var lastDoc *clover.Document
|
||||
count := 0
|
||||
startAddingAt := start + 1
|
||||
@@ -313,26 +328,65 @@ func List(mailbox string, start, limit int) ([]data.Summary, error) {
|
||||
}
|
||||
|
||||
// Search returns a summary of items mathing a search. It searched the SearchText field.
|
||||
func Search(mailbox, search string, start, limit int) ([]data.Summary, error) {
|
||||
sq := fmt.Sprintf("(?i)%s", cleanString(regexp.QuoteMeta(search)))
|
||||
func Search(mailbox, s string, start, limit int) ([]data.Summary, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
s = strings.ToLower(s)
|
||||
s = strings.Replace(s, "'", `\'`, -1)
|
||||
s = strings.Replace(s, "(", ``, -1)
|
||||
s = strings.Replace(s, ")", ``, -1)
|
||||
// add another quote if quotes are odd
|
||||
quotes := strings.Count(s, `"`)
|
||||
if quotes%2 != 0 {
|
||||
s += `"`
|
||||
}
|
||||
|
||||
p := shellwords.NewParser()
|
||||
args, err := p.Parse(s)
|
||||
if err != nil {
|
||||
return nil, errors.New("Your search contains invalid characters")
|
||||
}
|
||||
|
||||
results := []data.Summary{}
|
||||
include := []string{}
|
||||
|
||||
for _, w := range args {
|
||||
word := cleanString(w)
|
||||
if word != "" {
|
||||
include = append(include, fmt.Sprintf("%s", regexp.QuoteMeta(word)))
|
||||
}
|
||||
}
|
||||
|
||||
if len(include) == 0 {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
var where clover.Criteria
|
||||
|
||||
for i, w := range include {
|
||||
if i == 0 {
|
||||
where = clover.Field("SearchText").Like(w)
|
||||
} else {
|
||||
where = where.And(clover.Field("SearchText").Like(w))
|
||||
}
|
||||
}
|
||||
|
||||
q, err := db.FindAll(clover.NewQuery(mailbox).
|
||||
Skip(start).
|
||||
Limit(limit).
|
||||
Sort(clover.SortOption{Field: "Created", Direction: -1}).
|
||||
Where(clover.Field("SearchText").Like(sq)))
|
||||
Where(where))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := []data.Summary{}
|
||||
|
||||
for _, d := range q {
|
||||
cs := &CloverStore{}
|
||||
cs := &data.Summary{}
|
||||
if err := d.Unmarshal(cs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results = append(results, cs.Summary(d.ObjectId()))
|
||||
cs.ID = d.ObjectId()
|
||||
results = append(results, *cs)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
@@ -340,52 +394,33 @@ func Search(mailbox, search string, start, limit int) ([]data.Summary, error) {
|
||||
|
||||
// Count returns the total number of messages in a mailbox
|
||||
func Count(mailbox string) (int, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
return db.Count(clover.NewQuery(mailbox))
|
||||
}
|
||||
|
||||
// CountUnread returns the unread number of messages in a mailbox
|
||||
func CountUnread(mailbox string) (int, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
return db.Count(
|
||||
clover.NewQuery(mailbox).
|
||||
Where(clover.Field("Read").IsFalse()),
|
||||
)
|
||||
}
|
||||
|
||||
// Summary generated a message summary. ID must be supplied
|
||||
// as this is not stored within the CloverStore but rather the
|
||||
// *clover.Document
|
||||
func (c *CloverStore) Summary(id string) data.Summary {
|
||||
s := data.Summary{
|
||||
ID: id,
|
||||
From: c.From,
|
||||
To: c.To,
|
||||
Cc: c.Cc,
|
||||
Bcc: c.Bcc,
|
||||
Subject: c.Subject,
|
||||
Created: c.Created,
|
||||
Size: c.Size,
|
||||
Attachments: c.Attachments,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// GetMessage returns a data.Message generated from the {mailbox}_data collection.
|
||||
// ID must be supplied as this is not stored within the CloverStore but rather the
|
||||
// *clover.Document
|
||||
func GetMessage(mailbox, id string) (*data.Message, error) {
|
||||
q, err := db.FindById(mailbox+"_data", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
if q == nil {
|
||||
raw, err := GetMessageRaw(mailbox, id)
|
||||
if err != nil || raw == nil {
|
||||
return nil, errors.New("message not found")
|
||||
}
|
||||
|
||||
raw := q.Get("Data").(string)
|
||||
|
||||
r := bytes.NewReader([]byte(raw))
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
@@ -403,9 +438,8 @@ func GetMessage(mailbox, id string) (*data.Message, error) {
|
||||
date, _ := env.Date()
|
||||
|
||||
obj := data.Message{
|
||||
ID: q.ObjectId(),
|
||||
ID: id,
|
||||
Read: true,
|
||||
Created: q.Get("Created").(time.Time),
|
||||
From: from,
|
||||
Date: date,
|
||||
To: addressToSlice(env, "To"),
|
||||
@@ -459,12 +493,14 @@ func GetMessage(mailbox, id string) (*data.Message, error) {
|
||||
|
||||
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
|
||||
func GetAttachmentPart(mailbox, id, partID string) (*enmime.Part, error) {
|
||||
data, err := GetMessageRaw(mailbox, id)
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
raw, err := GetMessageRaw(mailbox, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(data)
|
||||
r := bytes.NewReader(raw)
|
||||
|
||||
env, err := enmime.ReadEnvelope(r)
|
||||
if err != nil {
|
||||
@@ -494,6 +530,8 @@ func GetAttachmentPart(mailbox, id, partID string) (*enmime.Part, error) {
|
||||
|
||||
// GetMessageRaw returns an []byte of the full message
|
||||
func GetMessageRaw(mailbox, id string) ([]byte, error) {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
q, err := db.FindById(mailbox+"_data", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -503,13 +541,26 @@ func GetMessageRaw(mailbox, id string) ([]byte, error) {
|
||||
return nil, errors.New("message not found")
|
||||
}
|
||||
|
||||
data := q.Get("Data").(string)
|
||||
var raw []byte
|
||||
|
||||
return []byte(data), err
|
||||
if q.Has("Email") {
|
||||
msg := q.Get("Email").(string)
|
||||
raw, err = decoder.DecodeAll([]byte(msg), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
|
||||
}
|
||||
} else {
|
||||
// deprecated 2022/08/10 - can be eventually removed
|
||||
raw = []byte(q.Get("Data").(string))
|
||||
}
|
||||
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// UnreadMessage will delete all messages from a mailbox
|
||||
func UnreadMessage(mailbox, id string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
updates["Read"] = false
|
||||
|
||||
@@ -520,6 +571,8 @@ func UnreadMessage(mailbox, id string) error {
|
||||
|
||||
// DeleteOneMessage will delete a single message from a mailbox
|
||||
func DeleteOneMessage(mailbox, id string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
q, err := db.FindById(mailbox, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -538,6 +591,7 @@ func DeleteOneMessage(mailbox, id string) error {
|
||||
|
||||
// DeleteAllMessages will delete all messages from a mailbox
|
||||
func DeleteAllMessages(mailbox string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
totalStart := time.Now()
|
||||
|
||||
@@ -563,10 +617,45 @@ func DeleteAllMessages(mailbox string) error {
|
||||
}
|
||||
|
||||
// resets stats for mailbox
|
||||
statsRefresh(mailbox)
|
||||
_ = statsRefresh(mailbox)
|
||||
|
||||
elapsed := time.Since(totalStart)
|
||||
logger.Log().Infof("Deleted %d messages from %s in %s", totalMessages, mailbox, elapsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllRead will mark every unread message in a mailbox as read
|
||||
func MarkAllRead(mailbox string) error {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
totalStart := time.Now()
|
||||
|
||||
q, err := db.FindAll(clover.NewQuery(mailbox).
|
||||
Where(clover.Field("Read").IsFalse()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
total := len(q)
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
updates["Read"] = true
|
||||
|
||||
for _, m := range q {
|
||||
if err := db.UpdateById(mailbox, m.ObjectId(), updates); err != nil {
|
||||
logger.Log().Error(err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := statsRefresh(mailbox); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
elapsed := time.Since(totalStart)
|
||||
|
||||
logger.Log().Debugf("[db] marked %d messages in %s as read in %s", total, mailbox, elapsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,23 +5,32 @@ import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/axllent/mailpit/config"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/ostafen/clover/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
testTextEmail []byte
|
||||
testMimeEmail []byte
|
||||
testRuns = 1000
|
||||
)
|
||||
|
||||
func TestTextEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
setup(false)
|
||||
t.Log("Testing memory storage")
|
||||
|
||||
RepeatTest:
|
||||
start := time.Now()
|
||||
for i := 0; i < 1000; i++ {
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -34,9 +43,11 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, count, 1000, "incorrect number of text emails stored")
|
||||
assertEqual(t, count, testRuns, "incorrect number of text emails stored")
|
||||
|
||||
t.Logf("inserted 1,000 text emails in %s\n", time.Since(start))
|
||||
t.Logf("inserted %d text emails in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(DefaultMailbox); err != nil {
|
||||
@@ -52,16 +63,30 @@ func TestTextEmailInserts(t *testing.T) {
|
||||
|
||||
assertEqual(t, count, 0, "incorrect number of text emails deleted")
|
||||
|
||||
t.Logf("deleted 1,000 text emails in %s\n", time.Since(delStart))
|
||||
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
db.Close()
|
||||
if config.DataDir == "" {
|
||||
setup(true)
|
||||
t.Logf("Testing physical storage to %s", config.DataDir)
|
||||
defer os.RemoveAll(config.DataDir)
|
||||
goto RepeatTest
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMimeEmailInserts(t *testing.T) {
|
||||
setup()
|
||||
setup(false)
|
||||
t.Log("Testing memory storage")
|
||||
|
||||
RepeatTest:
|
||||
start := time.Now()
|
||||
for i := 0; i < 1000; i++ {
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < testRuns; i++ {
|
||||
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -74,9 +99,11 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, count, 1000, "incorrect number of mime emails stored")
|
||||
assertEqual(t, count, testRuns, "incorrect number of emails with mime attachments stored")
|
||||
|
||||
t.Logf("inserted 1,000 emails with mime attachments in %s\n", time.Since(start))
|
||||
t.Logf("inserted %d emails with mime attachments in %s", testRuns, time.Since(start))
|
||||
|
||||
assertEqualStats(t, testRuns, testRuns)
|
||||
|
||||
delStart := time.Now()
|
||||
if err := DeleteAllMessages(DefaultMailbox); err != nil {
|
||||
@@ -90,16 +117,26 @@ func TestMimeEmailInserts(t *testing.T) {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqual(t, count, 0, "incorrect number of mime emails deleted")
|
||||
assertEqual(t, count, 0, "incorrect number of emails with mime attachments deleted")
|
||||
|
||||
t.Logf("deleted 1,000 mime emails in %s\n", time.Since(delStart))
|
||||
t.Logf("deleted %d emails with mime attachments in %s", testRuns, time.Since(delStart))
|
||||
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
db.Close()
|
||||
if config.DataDir == "" {
|
||||
setup(true)
|
||||
t.Logf("Testing physical storage to %s", config.DataDir)
|
||||
defer os.RemoveAll(config.DataDir)
|
||||
goto RepeatTest
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetrieveMimeEmail(t *testing.T) {
|
||||
setup()
|
||||
setup(false)
|
||||
t.Log("Testing memory storage")
|
||||
|
||||
RepeatTest:
|
||||
id, err := Store(DefaultMailbox, testMimeEmail)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
@@ -128,12 +165,65 @@ func TestRetrieveMimeEmail(t *testing.T) {
|
||||
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
|
||||
|
||||
db.Close()
|
||||
|
||||
if config.DataDir == "" {
|
||||
setup(true)
|
||||
t.Logf("Testing physical storage to %s", config.DataDir)
|
||||
defer os.RemoveAll(config.DataDir)
|
||||
goto RepeatTest
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseStats(t *testing.T) {
|
||||
setup(false)
|
||||
t.Log("Testing database stats")
|
||||
assertEqualStats(t, 0, 0)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqualStats(t, 100, 100)
|
||||
|
||||
// mark 10 as read
|
||||
docs, err := db.FindAll(
|
||||
clover.NewQuery(DefaultMailbox).
|
||||
Limit(10),
|
||||
)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
for _, d := range docs {
|
||||
_, err := GetMessage(DefaultMailbox, d.ObjectId())
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
assertEqualStats(t, 100, 90)
|
||||
|
||||
if err := MarkAllRead(DefaultMailbox); err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
assertEqualStats(t, 100, 0)
|
||||
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func TestSearch(t *testing.T) {
|
||||
setup()
|
||||
setup(false)
|
||||
t.Log("Testing memory storage")
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
RepeatTest:
|
||||
for i := 0; i < testRuns; 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)).
|
||||
@@ -159,7 +249,7 @@ func TestSearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < 101; i++ {
|
||||
for i := 1; i < 51; i++ {
|
||||
// search a random something that will return a single result
|
||||
searchIndx := rand.Intn(4) + 1
|
||||
var search string
|
||||
@@ -169,12 +259,12 @@ func TestSearch(t *testing.T) {
|
||||
case 2:
|
||||
search = fmt.Sprintf("to-%d@example.com", i)
|
||||
case 3:
|
||||
search = fmt.Sprintf("Subject line %d end", i)
|
||||
search = fmt.Sprintf("\"Subject line %d end\"", i)
|
||||
default:
|
||||
search = fmt.Sprintf("the email body %d jdsauk dwqmdqw", i)
|
||||
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
|
||||
}
|
||||
|
||||
summaries, err := Search(DefaultMailbox, search, 0, 200)
|
||||
summaries, err := Search(DefaultMailbox, search, 0, 10)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
@@ -190,18 +280,25 @@ func TestSearch(t *testing.T) {
|
||||
}
|
||||
|
||||
// search something that will return 200 rsults
|
||||
summaries, err := Search(DefaultMailbox, "This is the email body", 0, 200)
|
||||
summaries, err := Search(DefaultMailbox, "This is the email body", 0, 50)
|
||||
if err != nil {
|
||||
t.Log("error ", err)
|
||||
t.Fail()
|
||||
}
|
||||
assertEqual(t, len(summaries), 200, "200 search results expected")
|
||||
assertEqual(t, len(summaries), 50, "50 search results expected")
|
||||
|
||||
db.Close()
|
||||
|
||||
if config.DataDir == "" {
|
||||
setup(true)
|
||||
t.Logf("Testing physical storage to %s", config.DataDir)
|
||||
defer os.RemoveAll(config.DataDir)
|
||||
goto RepeatTest
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkImportText(b *testing.B) {
|
||||
setup()
|
||||
setup(false)
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
|
||||
@@ -214,7 +311,7 @@ func BenchmarkImportText(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkImportMime(b *testing.B) {
|
||||
setup()
|
||||
setup(false)
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
|
||||
@@ -225,9 +322,16 @@ func BenchmarkImportMime(b *testing.B) {
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func setup() {
|
||||
func setup(dataDir bool) {
|
||||
config.NoLogging = true
|
||||
config.MaxMessages = 0
|
||||
|
||||
if dataDir {
|
||||
config.DataDir = fmt.Sprintf("%s-%d", path.Join(os.TempDir(), "mailpit-tests"), time.Now().UnixNano())
|
||||
} else {
|
||||
config.DataDir = ""
|
||||
}
|
||||
|
||||
if err := InitDB(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -243,7 +347,6 @@ func setup() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
@@ -253,3 +356,14 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
|
||||
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
|
||||
t.Fatal(message)
|
||||
}
|
||||
|
||||
func assertEqualStats(t *testing.T, total int, unread int) {
|
||||
s := StatsGet(DefaultMailbox)
|
||||
if total != s.Total {
|
||||
t.Fatal(fmt.Sprintf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total))
|
||||
}
|
||||
|
||||
if unread != s.Unread {
|
||||
t.Fatal(fmt.Sprintf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ var (
|
||||
|
||||
// StatsGet returns the total/unread statistics for a mailbox
|
||||
func StatsGet(mailbox string) data.MailboxStats {
|
||||
mailbox = sanitizeMailboxName(mailbox)
|
||||
|
||||
statsLock.Lock()
|
||||
defer statsLock.Unlock()
|
||||
s, ok := mailboxStats[mailbox]
|
||||
|
||||
@@ -56,11 +56,10 @@ func cleanString(str string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
|
||||
}
|
||||
|
||||
// Auto-prune runs every 5 minutes to automatically delete oldest messages
|
||||
// Auto-prune runs every minute to automatically delete oldest messages
|
||||
// if total is greater than the threshold
|
||||
func pruneCron() {
|
||||
for {
|
||||
// time.Sleep(5 * 60 * time.Second)
|
||||
time.Sleep(60 * time.Second)
|
||||
mailboxes, err := db.ListCollections()
|
||||
if err != nil {
|
||||
@@ -84,7 +83,7 @@ func pruneCron() {
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed)
|
||||
statsRefresh(m)
|
||||
_ = statsRefresh(m)
|
||||
if !strings.HasSuffix(m, "_data") {
|
||||
websockets.Broadcast("prune", nil)
|
||||
}
|
||||
@@ -92,3 +91,11 @@ func pruneCron() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SanitizeMailboxName returns a clean mailbox name
|
||||
// allowing only `alphanumeric` characters and `-“
|
||||
func sanitizeMailboxName(mailbox string) string {
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
|
||||
|
||||
return re.ReplaceAllString(mailbox, "")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
@@ -184,6 +185,10 @@ func extract(filePath string, directory string) error {
|
||||
}
|
||||
|
||||
fileInfo := header.FileInfo()
|
||||
// paths could contain a '..', is used in a file system operations
|
||||
if strings.Contains(fileInfo.Name(), "..") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(directory, filepath.Dir(header.Name))
|
||||
filename := filepath.Join(dir, fileInfo.Name())
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ func GithubUpdate(repo, appName, currentVersion string) (string, error) {
|
||||
// get the running binary
|
||||
oldExec, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = replaceFile(oldExec, newExec); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user