1
0
mirror of https://github.com/axllent/mailpit.git synced 2025-03-11 14:59:57 +02:00

First commit

This commit is contained in:
Ralph Slooten 2022-07-29 23:23:08 +12:00
commit 7a9b11a9e5
43 changed files with 5659 additions and 0 deletions

42
.github/workflows/release-build.yml vendored Normal file
View File

@ -0,0 +1,42 @@
on:
release:
types: [created]
name: Build & release
jobs:
releases-matrix:
name: Release Go Binary
runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, windows, darwin]
goarch: ["386", amd64, arm64]
exclude:
- goarch: "386"
goos: darwin
- goarch: arm64
goos: windows
steps:
- uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
# build the assets
- uses: actions/setup-node@v3
with:
node-version: 16
cache: 'npm'
- run: npm install
- run: npm run package
# build the binaries
- uses: wangyoucao577/go-release-action@v1.30
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
binary_name: "mailpit"
extra_files: LICENSE README.md
build_command: go build -trimpath -ldflags '-s -w -X github.com/axllent/mailpit/cmd.Version=${{ steps.tag.outputs.tag }}'

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/node_modules/
/send
/server/ui/dist
/Makefile
/mailpit
*.old

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2022-Now() Ralph Slooten
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

45
README-BUILDING.md Normal file
View File

@ -0,0 +1,45 @@
# Building Mailpit from source
Go (>= version 1.8) and npm are required to compile mailpit from source.
```
git clone git@github.com:axllent/mailpit.git
cd mailpit
```
## Building the UI
The Mailpit web user interface is built with node. In the project's root (top) directory run the following to install the required node modules:
### Installing the node modules
```
npm install
```
### Building the web UI
```
npm run build
```
You can also run `npm run watch` which will watch for changes and rebuild the HTML/CSS/JS automatically when changes are detected.
Please note that you must restart Mailpit (`go run .`) to run with the changes.
## Build the mailpit binary
One you have the assets compiled, you can build mailpit as follows:
```
go build -ldflags "-s -w"
```
## Building a stand-alone sendmail binary
This step is unnecessary, however if you do not intend to either symlink `sendmail` to mailpit or configure your existing sendmail to route mail to mailpit, you can optionally build a stand-alone sendmail binary.
```
cd sendmail
go build -ldflags "-s -w"
```

60
README.md Normal file
View File

@ -0,0 +1,60 @@
# 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.
Mailpit is inspired by [MailHog](#why-rewrite-mailhog), but much, much faster.
## Features
- Runs completely on a single binary
- SMTP server (default `127.0.0.1:1025`)
- Web UI to view emails (HTML format, text, source and MIME attachments, default `127.0.0.1:8025`)
- Real-time web UI updates using websockets for new mail
- 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
- Configurable automatic email pruning (default keeps the most recent 500 emails)
- Fast SMTP processing & storing - approximately 300-600 emails per second depending on CPU, network speed & email size
- Can handle tens of thousands of emails
## Planned features
- Optional HTTPS for web UI
- Optional basic authentication for web UI
- Optional authentication for SMTP
- Browser notifications for new mail (HTTPS only)
- Docker container
## Installation
Download a pre-built binary in the [releases](https://github.com/axllent/mailpit/releases/latest). The `mailpit` can be placed in your `$PATH`, or simply run as `./mailpit`. See `mailpit -h` for options.
To build mailpit from source see [building from source](README-BUILDING.md).
### Configuring sendmail
There are several different options available:
You can use `mailpit sendmail` as your sendmail configuration in `php.ini`:
```
sendmail_path = /usr/local/bin/mailpit sendmail
```
If mailpit is found on the same host as sendmail, you can symlink the mailpit binary to sendmail, eg: `ln -s /usr/local/bin/mailpit /usr/sbin/sendmail` (only if mailpit is running on default 1025 port).
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](README-BUILDING.md)).
## Why rewrite MailHog?
I had been using MailHog for a few years to intercept and test emails generated from several projects. Mailhog has a number of severe performance issues, many of the modules are horribly out of date, and other than a few accepted MRs, it is not actively developed.
Initially I started trying to upgrade a fork of MailHog (both the UI as well as the HTTP server & API), but soon discovered that it is (with all due respect) very poorly designed. It is over-engineered (split over 9 separate projects), has too many unnecessary features for my purpose, and performs exceptionally poorly when dealing with large lumbers of emails or processing any email with an attachment (a single email with a 3MB attachment can take over a minute). The API transmits a lot of duplicate and unnecessary data on every message request for all web calls, and there is no HTTP compression.
In order to improve it I felt it needed to be completely rewritten, and so Mailpit was born.

86
cmd/root.go Normal file
View File

@ -0,0 +1,86 @@
package cmd
import (
"os"
"strconv"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server"
"github.com/axllent/mailpit/smtpd"
"github.com/axllent/mailpit/storage"
"github.com/spf13/cobra"
)
var cfgFile string
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "mailpit",
Short: "Mailpit is an email testing tool for developers",
Long: `Mailpit is an email testing tool for developers.
It acts as an SMTP server, and provides a web interface to view all captured emails.`,
Run: func(_ *cobra.Command, _ []string) {
if err := config.VerifyConfig(); err != nil {
logger.Log().Error(err.Error())
os.Exit(1)
}
if err := storage.InitDB(); err != nil {
logger.Log().Error(err.Error())
os.Exit(1)
}
go server.Listen()
if err := smtpd.Listen(); err != nil {
logger.Log().Error(err.Error())
os.Exit(1)
}
},
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
// SendmailExecute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func SendmailExecute() {
args := []string{"mailpit", "sendmail"}
rootCmd.Run(sendmailCmd, args)
}
func init() {
// hide autocompletion
rootCmd.CompletionOptions.HiddenDefaultCmd = true
// rootCmd.Flags().SortFlags = false
// hide help
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
// defaults from envars if provided
if len(os.Getenv("MP_DATA_DIR")) > 0 {
config.DataDir = os.Getenv("MP_DATA_DIR")
}
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
}
if len(os.Getenv("MP_UI_BIND_ADDR")) > 0 {
config.HTTPListen = os.Getenv("MP_UI_BIND_ADDR")
}
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent 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 per mailbox")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
}

33
cmd/sendmail.go Normal file
View File

@ -0,0 +1,33 @@
package cmd
import (
sendmail "github.com/axllent/mailpit/sendmail/cmd"
"github.com/spf13/cobra"
)
var (
smtpAddr = "localhost:1025"
fromAddr string
)
// sendmailCmd represents the sendmail command
var sendmailCmd = &cobra.Command{
Use: "sendmail",
Short: "A sendmail command replacement",
Long: `A sendmail command replacement.
You can optionally create a symlink called 'sendmail' to the main binary.`,
Run: func(_ *cobra.Command, _ []string) {
sendmail.Run()
},
}
func init() {
rootCmd.AddCommand(sendmailCmd)
// these are simply repeated for cli consistency
sendmailCmd.Flags().StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
sendmailCmd.Flags().StringVarP(&fromAddr, "from", "f", "", "SMTP sender")
sendmailCmd.Flags().BoolP("long-i", "i", false, "Ignored. This flag exists for sendmail compatibility.")
sendmailCmd.Flags().BoolP("long-t", "t", false, "Ignored. This flag exists for sendmail compatibility.")
}

69
cmd/version.go Normal file
View File

@ -0,0 +1,69 @@
package cmd
import (
"fmt"
"os"
"runtime"
"github.com/axllent/mailpit/updater"
"github.com/spf13/cobra"
)
var (
// Version is the default application version, updated on release
Version = "dev"
// Repo on Github for updater
Repo = "axllent/mailpit"
// RepoBinaryName on Github for updater
RepoBinaryName = "mailpit"
)
// versionCmd represents the version command
var versionCmd = &cobra.Command{
Use: "version",
Short: "Display the current version & update information",
Long: `Display the current version & update information (if available).`,
RunE: func(cmd *cobra.Command, args []string) error {
updater.AllowPrereleases = true
update, _ := cmd.Flags().GetBool("update")
if update {
return updateApp()
}
fmt.Printf("%s %s compiled with %s on %s/%s\n",
os.Args[0], Version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
latest, _, _, err := updater.GithubLatest(Repo, RepoBinaryName)
if err == nil && updater.GreaterThan(latest, Version) {
fmt.Printf(
"\nUpdate available: %s\nRun `%s version -u` to update (requires read/write access to install directory).\n",
latest,
os.Args[0],
)
}
return nil
},
}
func init() {
rootCmd.AddCommand(versionCmd)
versionCmd.Flags().
BoolP("update", "u", false, "update to latest version")
}
func updateApp() error {
rel, err := updater.GithubUpdate(Repo, RepoBinaryName, Version)
if err != nil {
return err
}
fmt.Printf("Updated %s to version %s\n", os.Args[0], rel)
return nil
}

44
config/config.go Normal file
View File

@ -0,0 +1,44 @@
package config
import (
"errors"
"regexp"
)
var (
// SMTPListen to listen on <interface>:<port>
SMTPListen = "0.0.0.0:1025"
// HTTPListen to listen on <interface>:<port>
HTTPListen = "0.0.0.0:8025"
// DataDir for mail (optional)
DataDir string
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
// VerboseLogging for console output
VerboseLogging = false
// NoLogging for testing
NoLogging = false
// SSLCert @TODO
SSLCert string
// SSLKey @TODO
SSLKey string
)
// VerifyConfig wil do some basic checking
func VerifyConfig() error {
re := regexp.MustCompile(`^[a-zA-Z0-9\.\-]{3,}:\d{2,}$`)
if !re.MatchString(SMTPListen) {
return errors.New("SMTP bind should be in the format of <ip>:<port>")
}
if !re.MatchString(HTTPListen) {
return errors.New("HTTP bind should be in the format of <ip>:<port>")
}
return nil
}

18
data/mailbox.go Normal file
View File

@ -0,0 +1,18 @@
package data
import "time"
// MailboxSummary struct
type MailboxSummary struct {
Name string
Slug string
Total int
Unread int
LastMessage time.Time
}
// WebsocketNotification struct for responses
type WebsocketNotification struct {
Type string
Data interface{}
}

64
data/message.go Normal file
View File

@ -0,0 +1,64 @@
package data
import (
"net/mail"
"time"
"github.com/jhillyerd/enmime"
)
// Message struct for loading messages. It does not include physical attachments.
type Message struct {
ID string
Read bool
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Date time.Time
Created time.Time
Text string
HTML string
Size int
Inline []Attachment
Attachments []Attachment
}
// Attachment struct for inline and attachments
type Attachment struct {
PartID string
FileName string
ContentType string
ContentID string
Size int
}
// Summary struct for frontend messages
type Summary struct {
ID string
Read bool
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Created time.Time
Size int
Attachments int
}
// AttachmentSummary returns a summary of the attachment without any binary data
func AttachmentSummary(a *enmime.Part) Attachment {
o := Attachment{}
o.PartID = a.PartID
o.FileName = a.FileName
if o.FileName == "" {
o.FileName = a.ContentID
}
o.ContentType = a.ContentType
o.ContentID = a.ContentID
o.Size = len(a.Content)
return o
}

22
esbuild.config.js Normal file
View File

@ -0,0 +1,22 @@
const { build } = require('esbuild')
const pluginVue = require('esbuild-plugin-vue-next')
const sassPlugin = require("esbuild-plugin-sass");
const doWatch = process.env.WATCH == 'true' ? true : false;
const doMinify = process.env.MINIFY == 'true' ? true : false;
build({
entryPoints: ["server/ui-src/app.js"],
bundle: true,
watch: doWatch,
minify: doMinify,
sourcemap: false,
outfile: "server/ui/dist/app.js",
plugins: [pluginVue(), sassPlugin()],
loader: {
".svg": "file",
".woff": "file",
".woff2": "file",
},
logLevel: "info"
})

52
go.mod Normal file
View File

@ -0,0 +1,52 @@
module github.com/axllent/mailpit
go 1.18
require (
github.com/axllent/semver v0.0.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.5.0
github.com/jhillyerd/enmime v0.10.0
github.com/k3a/html2text v1.0.8
github.com/mhale/smtpd v0.8.0
github.com/ostafen/clover v1.2.1-0.20220728200552-0b95f72b304c
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.5.0
github.com/spf13/pflag v1.0.5
)
require (
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang/glog v1.0.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v2.0.6+incompatible // indirect
github.com/google/go-cmp v0.5.8 // indirect
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
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.3.0 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/testify v1.7.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220726230323-06994584191e // indirect
golang.org/x/sys v0.0.0-20220727055044-e65921a090b8 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.28.0 // indirect
)

291
go.sum Normal file
View File

@ -0,0 +1,291 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/axllent/semver v0.0.1 h1:QqF+KSGxgj8QZzSXAvKFqjGWE5792ksOnQhludToK8E=
github.com/axllent/semver v0.0.1/go.mod h1:2xSPzvG8n9mRfdtxSvWvfTfQGWfHsMsHO1iZnKATMSc=
github.com/brianvoe/gofakeit/v6 v6.17.0 h1:obbQTJeHfktJtiZzq0Q1bEpsNUs+yHrYlPVWt7BtmJ4=
github.com/brianvoe/gofakeit/v6 v6.17.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/flatbuffers v2.0.6+incompatible h1:XHFReMv7nFFusa+CEokzWbzaYocKXI6C7hdU5Kgh9Lw=
github.com/google/flatbuffers v2.0.6+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/orderedcode v0.0.1 h1:UzfcAexk9Vhv8+9pNOgRu41f16lHq725vPwnSeiG/Us=
github.com/google/orderedcode v0.0.1/go.mod h1:iVyU4/qPKHY5h/wSd6rZZCDcLJNxiWO6dvsYES2Sb20=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.10.0 h1:DZEzhptPRBesvN3gf7K1BOh4rfpqdsdrEoxW1Edr/3s=
github.com/jhillyerd/enmime v0.10.0/go.mod h1:Qpe8EEemJMFAF8+NZoWdpXvK2Yb9dRF0k/z6mkcDHsA=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k3a/html2text v1.0.8 h1:rVanLhKilpnJUJs/CNKWzMC4YaQINGxK0rSG8ssmnV0=
github.com/k3a/html2text v1.0.8/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.15.7/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
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/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=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/ostafen/clover v1.2.1-0.20220728200552-0b95f72b304c h1:hFGWRJPoIP3e73jFTdeMTyG1kwoe7r5Ayf1o9Wqyqh8=
github.com/ostafen/clover v1.2.1-0.20220728200552-0b95f72b304c/go.mod h1:KVMcjgoq15v0S/I0GGAZPPtwO6+w6rYM0ZW/6XSO2Ic=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.3.0 h1:eyC18g7xB83Dv/xlJXLgNkRidVoR7nqFZBJvqo/K188=
github.com/rivo/uniseg v0.3.0/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220630215102-69896b714898/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220726230323-06994584191e h1:wOQNKh1uuDGRnmgF0jDxh7ctgGy/3P4rYWQRVJD4/Yg=
golang.org/x/net v0.0.0-20220726230323-06994584191e/go.mod h1:AaygXjzTFtRAg2ttMY5RMuhpJ3cNnI0XpyFJD1iQRSM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220727055044-e65921a090b8 h1:dyU22nBWzrmTQxtNrr4dzVOvaw35nUYE279vF9UmsI8=
golang.org/x/sys v0.0.0-20220727055044-e65921a090b8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

43
logger/logger.go Normal file
View File

@ -0,0 +1,43 @@
package logger
import (
"encoding/json"
"fmt"
"os"
"github.com/axllent/mailpit/config"
"github.com/sirupsen/logrus"
)
var (
log *logrus.Logger
)
// Log returns the logger instance
func Log() *logrus.Logger {
if log == nil {
log = logrus.New()
log.SetLevel(logrus.InfoLevel)
if config.VerboseLogging {
log.SetLevel(logrus.DebugLevel)
}
if config.NoLogging {
log.SetLevel(logrus.PanicLevel)
}
log.Out = os.Stdout
log.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "15:04:05",
ForceColors: true,
})
}
return log
}
// PrettyPrint for debugging
func PrettyPrint(i interface{}) {
s, _ := json.MarshalIndent(i, "", "\t")
fmt.Println(string(s))
}

24
main.go Normal file
View File

@ -0,0 +1,24 @@
package main
import (
"os"
"path/filepath"
"github.com/axllent/mailpit/cmd"
sendmail "github.com/axllent/mailpit/sendmail/cmd"
)
func main() {
exec, err := os.Executable()
if err != nil {
panic(err)
}
// running directly
if filepath.Base(exec) == filepath.Base(os.Args[0]) {
cmd.Execute()
} else {
// symlinked
sendmail.Run()
}
}

806
package-lock.json generated Normal file
View File

@ -0,0 +1,806 @@
{
"name": "mailpit",
"version": "0.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@babel/parser": {
"version": "7.18.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.8.tgz",
"integrity": "sha512-RSKRfYX20dyH+elbJK2uqAkVyucL+xXzhqlMD5/ZXx+dAAwpyB7HsvnHe/ZUGOF+xLr5Wx9/JoXVTj6BQE2/oA=="
},
"@popperjs/core": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz",
"integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==",
"dev": true
},
"@vue/compiler-core": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.37.tgz",
"integrity": "sha512-81KhEjo7YAOh0vQJoSmAD68wLfYqJvoiD4ulyedzF+OEk/bk6/hx3fTNVfuzugIIaTrOx4PGx6pAiBRe5e9Zmg==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/shared": "3.2.37",
"estree-walker": "^2.0.2",
"source-map": "^0.6.1"
}
},
"@vue/compiler-dom": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.37.tgz",
"integrity": "sha512-yxJLH167fucHKxaqXpYk7x8z7mMEnXOw3G2q62FTkmsvNxu4FQSu5+3UMb+L7fjKa26DEzhrmCxAgFLLIzVfqQ==",
"requires": {
"@vue/compiler-core": "3.2.37",
"@vue/shared": "3.2.37"
}
},
"@vue/compiler-sfc": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.37.tgz",
"integrity": "sha512-+7i/2+9LYlpqDv+KTtWhOZH+pa8/HnX/905MdVmAcI/mPQOBwkHHIzrsEsucyOIZQYMkXUiTkmZq5am/NyXKkg==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.37",
"@vue/compiler-dom": "3.2.37",
"@vue/compiler-ssr": "3.2.37",
"@vue/reactivity-transform": "3.2.37",
"@vue/shared": "3.2.37",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7",
"postcss": "^8.1.10",
"source-map": "^0.6.1"
}
},
"@vue/compiler-ssr": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.37.tgz",
"integrity": "sha512-7mQJD7HdXxQjktmsWp/J67lThEIcxLemz1Vb5I6rYJHR5vI+lON3nPGOH3ubmbvYGt8xEUaAr1j7/tIFWiEOqw==",
"requires": {
"@vue/compiler-dom": "3.2.37",
"@vue/shared": "3.2.37"
}
},
"@vue/reactivity": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.2.37.tgz",
"integrity": "sha512-/7WRafBOshOc6m3F7plwzPeCu/RCVv9uMpOwa/5PiY1Zz+WLVRWiy0MYKwmg19KBdGtFWsmZ4cD+LOdVPcs52A==",
"requires": {
"@vue/shared": "3.2.37"
}
},
"@vue/reactivity-transform": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.37.tgz",
"integrity": "sha512-IWopkKEb+8qpu/1eMKVeXrK0NLw9HicGviJzhJDEyfxTR9e1WtpnnbYkJWurX6WwoFP0sz10xQg8yL8lgskAZg==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.37",
"@vue/shared": "3.2.37",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7"
}
},
"@vue/runtime-core": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.2.37.tgz",
"integrity": "sha512-JPcd9kFyEdXLl/i0ClS7lwgcs0QpUAWj+SKX2ZC3ANKi1U4DOtiEr6cRqFXsPwY5u1L9fAjkinIdB8Rz3FoYNQ==",
"requires": {
"@vue/reactivity": "3.2.37",
"@vue/shared": "3.2.37"
}
},
"@vue/runtime-dom": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.2.37.tgz",
"integrity": "sha512-HimKdh9BepShW6YozwRKAYjYQWg9mQn63RGEiSswMbW+ssIht1MILYlVGkAGGQbkhSh31PCdoUcfiu4apXJoPw==",
"requires": {
"@vue/runtime-core": "3.2.37",
"@vue/shared": "3.2.37",
"csstype": "^2.6.8"
}
},
"@vue/server-renderer": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.2.37.tgz",
"integrity": "sha512-kLITEJvaYgZQ2h47hIzPh2K3jG8c1zCVbp/o/bzQOyvzaKiCquKS7AaioPI28GNxIsE/zSx+EwWYsNxDCX95MA==",
"requires": {
"@vue/compiler-ssr": "3.2.37",
"@vue/shared": "3.2.37"
}
},
"@vue/shared": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.37.tgz",
"integrity": "sha512-4rSJemR2NQIo9Klm1vabqWjD8rs/ZaJSzMxkMNeJS6lHiUjjUeYFbooN19NgFjztubEKh3WlZUeOLVdbbUWHsw=="
},
"anymatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
"integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
"dev": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
}
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"requires": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
"bootstrap": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.0.tgz",
"integrity": "sha512-qlnS9GL6YZE6Wnef46GxGv1UpGGzAwO0aPL1yOjzDIJpeApeMvqV24iL+pjr2kU4dduoBA9fINKWKgMToobx9A=="
},
"bootstrap-icons": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.9.1.tgz",
"integrity": "sha512-d4ZkO30MIkAhQ2nNRJqKXJVEQorALGbLWTuRxyCTJF96lRIV6imcgMehWGJUiJMJhglN0o2tqLIeDnMdiQEE9g=="
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"dev": true,
"requires": {
"fill-range": "^7.0.1"
}
},
"chainsaw": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.0.9.tgz",
"integrity": "sha512-nG8PYH+/4xB+8zkV4G844EtfvZ5tTiLFoX3dZ4nhF4t3OCKIb9UvaFyNmeZO2zOSmRWzBoTD+napN6hiL+EgcA==",
"requires": {
"traverse": ">=0.3.0 <0.4"
}
},
"chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
"integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
"dev": true,
"requires": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"fsevents": "~2.3.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
}
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"convert-source-map": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz",
"integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.1"
}
},
"css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"dev": true,
"requires": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
}
},
"csstype": {
"version": "2.6.20",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.20.tgz",
"integrity": "sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA=="
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"esbuild": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.50.tgz",
"integrity": "sha512-SbC3k35Ih2IC6trhbMYW7hYeGdjPKf9atTKwBUHqMCYFZZ9z8zhuvfnZihsnJypl74FjiAKjBRqFkBkAd0rS/w==",
"dev": true,
"requires": {
"esbuild-android-64": "0.14.50",
"esbuild-android-arm64": "0.14.50",
"esbuild-darwin-64": "0.14.50",
"esbuild-darwin-arm64": "0.14.50",
"esbuild-freebsd-64": "0.14.50",
"esbuild-freebsd-arm64": "0.14.50",
"esbuild-linux-32": "0.14.50",
"esbuild-linux-64": "0.14.50",
"esbuild-linux-arm": "0.14.50",
"esbuild-linux-arm64": "0.14.50",
"esbuild-linux-mips64le": "0.14.50",
"esbuild-linux-ppc64le": "0.14.50",
"esbuild-linux-riscv64": "0.14.50",
"esbuild-linux-s390x": "0.14.50",
"esbuild-netbsd-64": "0.14.50",
"esbuild-openbsd-64": "0.14.50",
"esbuild-sunos-64": "0.14.50",
"esbuild-windows-32": "0.14.50",
"esbuild-windows-64": "0.14.50",
"esbuild-windows-arm64": "0.14.50"
}
},
"esbuild-android-64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.50.tgz",
"integrity": "sha512-H7iUEm7gUJHzidsBlFPGF6FTExazcgXL/46xxLo6i6bMtPim6ZmXyTccS8yOMpy6HAC6dPZ/JCQqrkkin69n6Q==",
"dev": true,
"optional": true
},
"esbuild-android-arm64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.50.tgz",
"integrity": "sha512-NFaoqEwa+OYfoYVpQWDMdKII7wZZkAjtJFo1WdnBeCYlYikvUhTnf2aPwPu5qEAw/ie1NYK0yn3cafwP+kP+OQ==",
"dev": true,
"optional": true
},
"esbuild-darwin-64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.50.tgz",
"integrity": "sha512-gDQsCvGnZiJv9cfdO48QqxkRV8oKAXgR2CGp7TdIpccwFdJMHf8hyIJhMW/05b/HJjET/26Us27Jx91BFfEVSA==",
"dev": true,
"optional": true
},
"esbuild-darwin-arm64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.50.tgz",
"integrity": "sha512-36nNs5OjKIb/Q50Sgp8+rYW/PqirRiFN0NFc9hEvgPzNJxeJedktXwzfJSln4EcRFRh5Vz4IlqFRScp+aiBBzA==",
"dev": true,
"optional": true
},
"esbuild-freebsd-64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.50.tgz",
"integrity": "sha512-/1pHHCUem8e/R86/uR+4v5diI2CtBdiWKiqGuPa9b/0x3Nwdh5AOH7lj+8823C6uX1e0ufwkSLkS+aFZiBCWxA==",
"dev": true,
"optional": true
},
"esbuild-freebsd-arm64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.50.tgz",
"integrity": "sha512-iKwUVMQztnPZe5pUYHdMkRc9aSpvoV1mkuHlCoPtxZA3V+Kg/ptpzkcSY+fKd0kuom+l6Rc93k0UPVkP7xoqrw==",
"dev": true,
"optional": true
},
"esbuild-linux-32": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.50.tgz",
"integrity": "sha512-sWUwvf3uz7dFOpLzYuih+WQ7dRycrBWHCdoXJ4I4XdMxEHCECd8b7a9N9u7FzT6XR2gHPk9EzvchQUtiEMRwqw==",
"dev": true,
"optional": true
},
"esbuild-linux-64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.50.tgz",
"integrity": "sha512-u0PQxPhaeI629t4Y3EEcQ0wmWG+tC/LpP2K7yDFvwuPq0jSQ8SIN+ARNYfRjGW15O2we3XJvklbGV0wRuUCPig==",
"dev": true,
"optional": true
},
"esbuild-linux-arm": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.50.tgz",
"integrity": "sha512-VALZq13bhmFJYFE/mLEb+9A0w5vo8z+YDVOWeaf9vOTrSC31RohRIwtxXBnVJ7YKLYfEMzcgFYf+OFln3Y0cWg==",
"dev": true,
"optional": true
},
"esbuild-linux-arm64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.50.tgz",
"integrity": "sha512-ZyfoNgsTftD7Rp5S7La5auomKdNeB3Ck+kSKXC4pp96VnHyYGjHHXWIlcbH8i+efRn9brszo1/Thl1qn8RqmhQ==",
"dev": true,
"optional": true
},
"esbuild-linux-mips64le": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.50.tgz",
"integrity": "sha512-ygo31Vxn/WrmjKCHkBoutOlFG5yM9J2UhzHb0oWD9O61dGg+Hzjz9hjf5cmM7FBhAzdpOdEWHIrVOg2YAi6rTw==",
"dev": true,
"optional": true
},
"esbuild-linux-ppc64le": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.50.tgz",
"integrity": "sha512-xWCKU5UaiTUT6Wz/O7GKP9KWdfbsb7vhfgQzRfX4ahh5NZV4ozZ4+SdzYG8WxetsLy84UzLX3Pi++xpVn1OkFQ==",
"dev": true,
"optional": true
},
"esbuild-linux-riscv64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.50.tgz",
"integrity": "sha512-0+dsneSEihZTopoO9B6Z6K4j3uI7EdxBP7YSF5rTwUgCID+wHD3vM1gGT0m+pjCW+NOacU9kH/WE9N686FHAJg==",
"dev": true,
"optional": true
},
"esbuild-linux-s390x": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.50.tgz",
"integrity": "sha512-tVjqcu8o0P9H4StwbIhL1sQYm5mWATlodKB6dpEZFkcyTI8kfIGWiWcrGmkNGH2i1kBUOsdlBafPxR3nzp3TDA==",
"dev": true,
"optional": true
},
"esbuild-netbsd-64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.50.tgz",
"integrity": "sha512-0R/glfqAQ2q6MHDf7YJw/TulibugjizBxyPvZIcorH0Mb7vSimdHy0XF5uCba5CKt+r4wjax1mvO9lZ4jiAhEg==",
"dev": true,
"optional": true
},
"esbuild-openbsd-64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.50.tgz",
"integrity": "sha512-7PAtmrR5mDOFubXIkuxYQ4bdNS6XCK8AIIHUiZxq1kL8cFIH5731jPcXQ4JNy/wbj1C9sZ8rzD8BIM80Tqk29w==",
"dev": true,
"optional": true
},
"esbuild-plugin-sass": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/esbuild-plugin-sass/-/esbuild-plugin-sass-1.0.1.tgz",
"integrity": "sha512-YFxjzD9Z1vz92QCJcAmCO15WVCUiOobw9ypdVeMsW+xa6S+zqryLUIh8d3fe/UkRHRO5PODZz/3xDAQuEXZwmQ==",
"dev": true,
"requires": {
"css-tree": "1.1.3",
"fs-extra": "10.0.0",
"sass": "1.47.0",
"tmp": "0.2.1"
},
"dependencies": {
"sass": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.47.0.tgz",
"integrity": "sha512-GtXwvwgD7/6MLUZPnlA5/8cdRgC9SzT5kAnnJMRmEZQFRE3J56Foswig4NyyyQGsnmNvg6EUM/FP0Pe9Y2zywQ==",
"dev": true,
"requires": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
"source-map-js": ">=0.6.2 <2.0.0"
}
}
}
},
"esbuild-plugin-vue-next": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/esbuild-plugin-vue-next/-/esbuild-plugin-vue-next-0.1.4.tgz",
"integrity": "sha512-n4DF5xY/GJ9DdRM4+MvV14Rrr+7xGhtv9/0xIxfzN6qSIMdXfZ6g4PVX735NYC7vGRr9KyZGRWST5jCyHQ6n5g==",
"dev": true,
"requires": {
"convert-source-map": "^1.8.0",
"hash-sum": "^2.0.0"
},
"dependencies": {
"hash-sum": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
"dev": true
}
}
},
"esbuild-sunos-64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.50.tgz",
"integrity": "sha512-gBxNY/wyptvD7PkHIYcq7se6SQEXcSC8Y7mE0FJB+CGgssEWf6vBPfTTZ2b6BWKnmaP6P6qb7s/KRIV5T2PxsQ==",
"dev": true,
"optional": true
},
"esbuild-windows-32": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.50.tgz",
"integrity": "sha512-MOOe6J9cqe/iW1qbIVYSAqzJFh0p2LBLhVUIWdMVnNUNjvg2/4QNX4oT4IzgDeldU+Bym9/Tn6+DxvUHJXL5Zw==",
"dev": true,
"optional": true
},
"esbuild-windows-64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.50.tgz",
"integrity": "sha512-r/qE5Ex3w1jjGv/JlpPoWB365ldkppUlnizhMxJgojp907ZF1PgLTuW207kgzZcSCXyquL9qJkMsY+MRtaZ5yQ==",
"dev": true,
"optional": true
},
"esbuild-windows-arm64": {
"version": "0.14.50",
"resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.50.tgz",
"integrity": "sha512-EMS4lQnsIe12ZyAinOINx7eq2mjpDdhGZZWDwPZE/yUTN9cnc2Ze/xUTYIAyaJqrqQda3LnDpADKpvLvol6ENQ==",
"dev": true,
"optional": true
},
"estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"dev": true,
"requires": {
"to-regex-range": "^5.0.1"
}
},
"follow-redirects": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"fs-extra": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz",
"integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==",
"dev": true,
"requires": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
}
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"dev": true
},
"fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"optional": true
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"requires": {
"is-glob": "^4.0.1"
}
},
"graceful-fs": {
"version": "4.2.10",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
"integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
"dev": true
},
"hashish": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/hashish/-/hashish-0.0.4.tgz",
"integrity": "sha512-xyD4XgslstNAs72ENaoFvgMwtv8xhiDtC2AtzCG+8yF7W/Knxxm9BX+e2s25mm+HxMKh0rBmXVOEGF3zNImXvA==",
"requires": {
"traverse": ">=0.2.4"
}
},
"immutable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz",
"integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==",
"dev": true
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"dev": true,
"requires": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
},
"is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"requires": {
"binary-extensions": "^2.0.0"
}
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true
},
"is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"requires": {
"is-extglob": "^2.1.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true
},
"jsonfile": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
"integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.6",
"universalify": "^2.0.0"
}
},
"magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
"integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==",
"requires": {
"sourcemap-codec": "^1.4.8"
}
},
"mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"dev": true
},
"mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
"mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"requires": {
"mime-db": "1.52.0"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"moment": {
"version": "2.29.4",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="
},
"nanoid": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
"integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw=="
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"requires": {
"wrappy": "1"
}
},
"path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
},
"postcss": {
"version": "8.4.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
"integrity": "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==",
"requires": {
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"dependencies": {
"picocolors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
}
}
},
"readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"requires": {
"picomatch": "^2.2.1"
}
},
"remove": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/remove/-/remove-0.1.5.tgz",
"integrity": "sha512-AJMA9oWvJzdTjwIGwSQZsjGQiRx73YTmiOWmfCp1fpLa/D4n7jKcpoA+CZiVLJqKcEKUuh1Suq80c5wF+L/qVQ==",
"requires": {
"seq": ">= 0.3.5"
}
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"requires": {
"glob": "^7.1.3"
}
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
},
"seq": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/seq/-/seq-0.3.5.tgz",
"integrity": "sha512-sisY2Ln1fj43KBkRtXkesnRHYNdswIkIibvNe/0UKm2GZxjMbqmccpiatoKr/k2qX5VKiLU8xm+tz/74LAho4g==",
"requires": {
"chainsaw": ">=0.0.7 <0.1",
"hashish": ">=0.0.2 <0.1"
}
},
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
"integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
},
"sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
},
"tmp": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
"integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
"dev": true,
"requires": {
"rimraf": "^3.0.0"
}
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"requires": {
"is-number": "^7.0.0"
}
},
"traverse": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="
},
"universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
"dev": true
},
"vue": {
"version": "3.2.37",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.2.37.tgz",
"integrity": "sha512-bOKEZxrm8Eh+fveCqS1/NkG/n6aMidsI6hahas7pa0w/l7jkbssJVsRhVDs07IdDq7h9KHswZOgItnwJAgtVtQ==",
"requires": {
"@vue/compiler-dom": "3.2.37",
"@vue/compiler-sfc": "3.2.37",
"@vue/runtime-dom": "3.2.37",
"@vue/server-renderer": "3.2.37",
"@vue/shared": "3.2.37"
}
},
"wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
}
}
}

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "mailpit",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "node esbuild.config.js",
"watch": "WATCH=true node esbuild.config.js",
"package": "MINIFY=true node esbuild.config.js"
},
"dependencies": {
"axios": "^0.27.2",
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"moment": "^2.29.4",
"remove": "^0.1.5",
"vue": "^3.2.13"
},
"devDependencies": {
"@popperjs/core": "^2.11.5",
"@vue/compiler-sfc": "^3.2.37",
"esbuild": "^0.14.50",
"esbuild-plugin-sass": "^1.0.1",
"esbuild-plugin-vue-next": "^0.1.4"
}
}

85
sendmail/cmd/cmd.go Normal file
View File

@ -0,0 +1,85 @@
package cmd
/**
* Bare bones sendmail drop-in replacement borrowed from Mailhog
*/
import (
"bytes"
"fmt"
"io/ioutil"
"log"
"net/mail"
"net/smtp"
"os"
"os/user"
flag "github.com/spf13/pflag"
)
// Run the Mailpit sendmail replacement.
func Run() {
host, err := os.Hostname()
if err != nil {
host = "localhost"
}
username := "nobody"
user, err := user.Current()
if err == nil && user != nil && len(user.Username) > 0 {
username = user.Username
}
fromAddr := username + "@" + host
smtpAddr := "localhost:1025"
var recip []string
// defaults from envars if provided
if len(os.Getenv("MP_SENDMAIL_SMTP_ADDR")) > 0 {
smtpAddr = os.Getenv("MP_SENDMAIL_SMTP_ADDR")
}
if len(os.Getenv("MP_SENDMAIL_FROM")) > 0 {
fromAddr = os.Getenv("MP_SENDMAIL_FROM")
}
var verbose bool
// override defaults from cli flags
flag.StringVar(&smtpAddr, "smtp-addr", smtpAddr, "SMTP server address")
flag.StringVarP(&fromAddr, "from", "f", fromAddr, "SMTP sender")
flag.BoolP("long-i", "i", true, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolP("long-t", "t", true, "Ignored. This flag exists for sendmail compatibility.")
flag.BoolVarP(&verbose, "verbose", "v", false, "Verbose mode (sends debug output to stderr)")
flag.Parse()
// allow recipient to be passed as an argument
recip = flag.Args()
if verbose {
fmt.Fprintln(os.Stderr, smtpAddr, fromAddr)
}
body, err := ioutil.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "error reading stdin")
os.Exit(11)
}
msg, err := mail.ReadMessage(bytes.NewReader(body))
if err != nil {
fmt.Fprintln(os.Stderr, fmt.Sprintf("error parsing message body: %s", err))
os.Exit(11)
}
if len(recip) == 0 {
// We only need to parse the message to get a recipient if none where
// provided on the command line.
recip = append(recip, msg.Header.Get("To"))
}
err = smtp.SendMail(smtpAddr, nil, fromAddr, recip, body)
if err != nil {
fmt.Fprintln(os.Stderr, "error sending mail")
log.Fatal(err)
}
}

7
sendmail/main.go Normal file
View File

@ -0,0 +1,7 @@
package main
import "github.com/axllent/mailpit/sendmail/cmd"
func main() {
cmd.Run()
}

235
server/api.go Normal file
View File

@ -0,0 +1,235 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/server/websockets"
"github.com/axllent/mailpit/storage"
"github.com/gorilla/mux"
)
type messagesResult struct {
Total int `json:"total"`
Count int `json:"count"`
Start int `json:"start"`
Items []data.Summary `json:"items"`
}
// Return a list of available mailboxes
func apiListMailboxes(w http.ResponseWriter, _ *http.Request) {
res, err := storage.ListMailboxes()
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
}
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
if !storage.MailboxExists(mailbox) {
fourOFour(w)
return
}
start, limit := getStartLimit(r)
messages, err := storage.List(mailbox, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
total, err := storage.Count(mailbox)
if err != nil {
httpError(w, err.Error())
return
}
var res messagesResult
res.Start = start
res.Items = messages
res.Count = len(res.Items)
res.Total = total
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
}
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
search := strings.TrimSpace(r.URL.Query().Get("query"))
if search == "" {
fourOFour(w)
return
}
vars := mux.Vars(r)
mailbox := vars["mailbox"]
if !storage.MailboxExists(mailbox) {
fourOFour(w)
return
}
// we will only return up to 200 results
start := 0
limit := 200
messages, err := storage.Search(mailbox, search, start, limit)
if err != nil {
httpError(w, err.Error())
return
}
total, err := storage.Count(mailbox)
if err != nil {
httpError(w, err.Error())
return
}
// total := limit
// count := len(messages)
// if total > count {
// total = count
// }
var res messagesResult
res.Start = start
res.Items = messages
res.Count = len(messages)
res.Total = total
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
}
// Open a message
func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
msg, err := storage.GetMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
}
// Download/view an attachment
func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(mailbox, id, partID)
if err != nil {
httpError(w, err.Error())
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
w.Write(a.Content)
}
// View the full email source as plain text
func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
dl := r.FormValue("dl")
data, err := storage.GetMessageRaw(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Set("Content-Type", "text/plain")
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
w.Write(data)
}
// Delete all messages in the mailbox
func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
err := storage.DeleteAllMessages(mailbox)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
// Delete a single message
func apiDeleteOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
err := storage.DeleteOneMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
// Mark single message as unread
func apiUnreadOne(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
err := storage.UnreadMessage(mailbox, id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
}
// Websocket to broadcast changes
func apiWebsocket(w http.ResponseWriter, r *http.Request) {
websockets.ServeWs(websockets.MessageHub, w, r)
}

131
server/server.go Normal file
View File

@ -0,0 +1,131 @@
package server
import (
"compress/gzip"
"embed"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"strconv"
"strings"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/websockets"
"github.com/gorilla/mux"
)
//go:embed ui
var embeddedFS embed.FS
// Listen will start the httpd
func Listen() {
serverRoot, err := fs.Sub(embeddedFS, "ui")
if err != nil {
logger.Log().Errorf("[http] %s", err)
os.Exit(1)
}
websockets.MessageHub = websockets.NewHub()
go websockets.MessageHub.Run()
r := mux.NewRouter()
r.HandleFunc("/api/mailboxes", gzipHandlerFunc(apiListMailboxes))
r.HandleFunc("/api/{mailbox}/messages", gzipHandlerFunc(apiListMailbox))
r.HandleFunc("/api/{mailbox}/search", gzipHandlerFunc(apiSearchMailbox))
r.HandleFunc("/api/{mailbox}/delete", gzipHandlerFunc(apiDeleteAll))
r.HandleFunc("/api/{mailbox}/events", apiWebsocket)
r.HandleFunc("/api/{mailbox}/{id}/source", gzipHandlerFunc(apiDownloadSource))
r.HandleFunc("/api/{mailbox}/{id}/part/{partID}", gzipHandlerFunc(apiDownloadAttachment))
r.HandleFunc("/api/{mailbox}/{id}/delete", gzipHandlerFunc(apiDeleteOne))
r.HandleFunc("/api/{mailbox}/{id}/unread", gzipHandlerFunc(apiUnreadOne))
r.HandleFunc("/api/{mailbox}/{id}", gzipHandlerFunc(apiOpenMessage))
r.HandleFunc("/api/{mailbox}/search", gzipHandlerFunc(apiSearchMailbox))
r.PathPrefix("/").Handler(gzipHandler(http.FileServer(http.FS(serverRoot))))
http.Handle("/", r)
if config.SSLCert != "" && config.SSLKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.SSLCert, config.SSLKey, nil))
} else {
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
log.Fatal(http.ListenAndServe(config.HTTPListen, nil))
}
}
type gzipResponseWriter struct {
io.Writer
http.ResponseWriter
}
func (w gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}
// GzipHandlerFunc http middleware
func gzipHandlerFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
fn(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
gzr := gzipResponseWriter{Writer: gz, ResponseWriter: w}
fn(gzr, r)
}
}
func gzipHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
h.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
h.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r)
})
}
// FourOFour returns a standard 404 meesage
func fourOFour(w http.ResponseWriter) {
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, "404 page not found")
}
// HTTPError returns a standard 404 meesage
func httpError(w http.ResponseWriter, msg string) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
fmt.Fprint(w, msg)
}
// Get the start and limit based on query params. Defaults to 0, 50
func getStartLimit(req *http.Request) (start int, limit int) {
start = 0
limit = 50
s := req.URL.Query().Get("start")
if n, e := strconv.ParseInt(s, 10, 64); e == nil && n > 0 {
start = int(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)
}
return start, limit
}

416
server/ui-src/App.vue Normal file
View File

@ -0,0 +1,416 @@
<script>
import commonMixins from './mixins.js'
import Message from './templates/Message.vue';
import moment from 'moment'
export default {
mixins: [commonMixins],
components: {
Message
},
data() {
return {
currentPath: window.location.hash,
mailbox: "catchall",
items: [],
limit: 50,
total: 0,
start: 0,
search: "",
searching: false,
isConnected: false,
scrollInPlace: false,
message: false
}
},
watch: {
currentPath(v, old) {
if (v && v.match(/^[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+-[a-z0-9]+$/)) {
this.openMessage();
} else {
this.message = false;
}
}
},
computed: {
canPrev: function () {
return this.start > 0;
},
canNext: function () {
return this.total > (this.start + this.count);
}
},
mounted() {
this.currentPath = window.location.hash.slice(1);
window.addEventListener('hashchange', () => {
this.currentPath = window.location.hash.slice(1);
});
this.connect();
this.loadMessages();
},
methods: {
loadMessages: function () {
let self = this;
let params = {};
let uri = 'api/'+self.mailbox+'/messages';
if (self.search) {
self.searching = true;
self.items = [];
uri = 'api/'+self.mailbox+'/search'
self.start = 0; // search is displayed on one page
params['query'] = self.search;
} else {
self.searching = false;
params['limit'] = self.limit;
if (self.start > 0) {
params['start'] = self.start;
}
}
self.get(uri, params, function(response){
self.total = response.data.total;
self.count = response.data.count;
self.start = response.data.start;
self.items = response.data.items;
if (!self.scrollInPlace) {
let mp = document.getElementById('message-page');
if (mp) {
mp.scrollTop = 0;
}
}
self.scrollInPlace = false
});
},
doSearch: function(e) {
e.preventDefault();
this.loadMessages();
},
reloadMessages: function() {
this.search = "";
this.start = 0;
this.loadMessages();
},
viewNext: function () {
this.start = parseInt(this.start, 10) + parseInt(this.limit, 10);
this.loadMessages();
},
viewPrev: function () {
let s = this.start - this.limit;
if (s < 0) {
s = 0;
}
this.start = s;
this.loadMessages();
},
openMessage: function(id) {
let self = this;
let params = {};
let uri = 'api/' + self.mailbox + '/' + self.currentPath
self.get(uri, params, function(response) {
for (let i in self.items) {
if (self.items[i].ID == self.currentPath) {
self.items[i].Read = true;
}
}
let d = response.data;
// replace inline images
if (d.HTML && d.Inline) {
for (let i in d.Inline) {
let a = d.Inline[i];
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:'+a.ContentID, 'g'),
window.location.origin+'/api/'+self.mailbox+'/'+d.ID+'/part/'+a.PartID
);
}
}
}
// replace inline images
if (d.HTML && d.Attachments) {
for (let i in d.Attachments) {
let a = d.Attachments[i];
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:'+a.ContentID, 'g'),
window.location.origin+'/api/'+self.mailbox+'/'+d.ID+'/part/'+a.PartID
);
}
}
}
self.message = d;
});
},
deleteAll: function() {
let self = this;
let uri = 'api/' + self.mailbox + '/delete'
self.get(uri, false, function(response) {
self.reloadMessages();
});
},
deleteOne: function() {
let self = this;
if (!self.message) {
return false;
}
let uri = 'api/' + self.mailbox + '/' + self.message.ID + '/delete'
self.get(uri, false, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
markUnread: function() {
let self = this;
if (!self.message) {
return false;
}
let uri = 'api/' + self.mailbox + '/' + self.message.ID + '/unread'
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';
let ws = new WebSocket(wsproto + "://" + document.location.host + "/api/"+this.mailbox+"/events");
let self = this;
ws.onmessage = function (e) {
let response = JSON.parse(e.data);
if (!response) {
return;
}
// new messages
if (response.Type == "new" && response.Data) {
if (self.start < 1) {
if (!self.searching) {
self.items.unshift(response.Data);
if (self.items.length > self.limit) {
self.items.pop();
}
}
}
self.total++;
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
self.scrollInPlace = true;
self.loadMessages();
}
}
ws.onopen = function () {
self.isConnected = true;
self.loadMessages();
}
ws.onclose = function (e) {
self.isConnected = false;
setTimeout(function () {
self.connect(); // reconnect
}, 1000);
}
ws.onerror = function (err) {
ws.close();
}
},
getPrimaryEmailTo: function(message) {
for (let i in message.To) {
return message.To[i].Address;
}
return '[ Unknown ]';
},
getRelativeCreated: function(message) {
let d = new Date(message.Created)
return moment(d).fromNow().toString();
},
}
}
</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">
<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>
</a>
</div>
<div class="col col-md-9 col-lg-8" 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>
</button>
<button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread">
<i class="bi bi-envelope"></i>
</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>
</div>
<div class="col col-md-9 col-lg-5" v-if="!message && total">
<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>
</div>
</form>
</div>
<div class="col-12 col-lg-5 text-end" v-if="!message && total">
<select v-model="limit" v-on:change="loadMessages"
class="form-select form-select-sm d-inline w-auto me-1" v-if="!searching">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<span v-if="searching">
<b>{{ formatNumber(items.length) }} results</b>
</span>
<span v-else>
<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"
v-if="!searching">
<i class="bi bi-caret-left-fill"></i>
</button>
<button class="btn btn-outline-secondary" :disabled="!canNext" v-on:click="viewNext" v-if="!searching">
<i class="bi bi-caret-right-fill"></i>
</button>
</span>
</div>
</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">
<i class="bi bi-power text-success"></i>
Connected
</li>
<li v-else title="Messages will auto-load">
<i class="bi bi-power text-danger"></i>
Disconnected
</li>
<li class="mt-3">
<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>
Inbox
<span class="position-absolute mt-2 ms-4 start-100 translate-middle badge rounded-pill text-bg-secondary" v-if="total">
{{ formatNumber(total) }}
</span>
</a>
</li>
<li class="mt-3 mb-5">
<a v-if="total" 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">
<i class="bi bi-github"></i>
GitHub
</a>
</li>
</ul>
</div>
<div class="col-lg-10 col-md-9 mh-100 pe-0">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none':''" id="message-page">
<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">
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-md-none">
<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">
<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">
{{ 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">
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
</div>
<div class="d-none d-md-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">
{{ getRelativeCreated(message) }}
</div>
</a>
</div>
<div v-else class="text-muted py-3">No messages</div>
</div>
<Message v-if="message" :message="message" :mailbox="mailbox"></Message>
</div>
<div id="loading" v-if="loading">
<div class="d-flex justify-content-center align-items-center h-100">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
<!-- Modal -->
<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>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete all messages.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" v-on:click="deleteAll">Delete</button>
</div>
</div>
</div>
</div>
</template>

8
server/ui-src/app.js Normal file
View File

@ -0,0 +1,8 @@
import { createApp } from 'vue';
import App from './App.vue';
import "./assets/bootstrap.scss";
import "./assets/styles.scss";
import "../../node_modules/bootstrap-icons/font/bootstrap-icons.scss";
import "bootstrap";
createApp(App).mount('#app')

View File

@ -0,0 +1 @@
$link-decoration: none;

49
server/ui-src/assets/bootstrap.scss vendored Normal file
View File

@ -0,0 +1,49 @@
@import "_bootstrap_variables";
// scss-docs-start import-stack
// Configuration
@import "../../../node_modules/bootstrap/scss/functions";
@import "../../../node_modules/bootstrap/scss/variables";
@import "../../../node_modules/bootstrap/scss/maps";
@import "../../../node_modules/bootstrap/scss/mixins";
@import "../../../node_modules/bootstrap/scss/utilities";
// Layout & components
@import "../../../node_modules/bootstrap/scss/root";
@import "../../../node_modules/bootstrap/scss/reboot";
@import "../../../node_modules/bootstrap/scss/type";
@import "../../../node_modules/bootstrap/scss/images";
@import "../../../node_modules/bootstrap/scss/containers";
@import "../../../node_modules/bootstrap/scss/grid";
// @import "../../../node_modules/bootstrap/scss/tables";
@import "../../../node_modules/bootstrap/scss/forms";
@import "../../../node_modules/bootstrap/scss/buttons";
// @import "../../../node_modules/bootstrap/scss/transitions";
@import "../../../node_modules/bootstrap/scss/dropdown";
@import "../../../node_modules/bootstrap/scss/button-group";
@import "../../../node_modules/bootstrap/scss/nav";
@import "../../../node_modules/bootstrap/scss/navbar";
@import "../../../node_modules/bootstrap/scss/card";
// @import "../../../node_modules/bootstrap/scss/accordion";
// @import "../../../node_modules/bootstrap/scss/breadcrumb";
// @import "../../../node_modules/bootstrap/scss/pagination";
@import "../../../node_modules/bootstrap/scss/badge";
// @import "../../../node_modules/bootstrap/scss/alert";
// @import "../../../node_modules/bootstrap/scss/progress";
@import "../../../node_modules/bootstrap/scss/list-group";
@import "../../../node_modules/bootstrap/scss/close";
// @import "../../../node_modules/bootstrap/scss/toasts";
@import "../../../node_modules/bootstrap/scss/modal";
// @import "../../../node_modules/bootstrap/scss/tooltip";
// @import "../../../node_modules/bootstrap/scss/popover";
// @import "../../../node_modules/bootstrap/scss/carousel";
@import "../../../node_modules/bootstrap/scss/spinners";
// @import "../../../node_modules/bootstrap/scss/offcanvas";
// @import "../../../node_modules/bootstrap/scss/popover";
// Helpers
@import "../../../node_modules/bootstrap/scss/helpers";
// Utilities
@import "../../../node_modules/bootstrap/scss/utilities/api";
// scss-docs-end import-stack

View File

@ -0,0 +1,54 @@
// @import "../../../node_modules/bootstrap-icons"; ///scss/root";
@import "bootstrap";
[v-cloak] {
display: none !important;
}
.navbar-brand {
color: #2d4a5d;
img {
width: 40px;
}
}
#loading {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(255, 255, 255, 0.4);
z-index: 1500;
}
.message.read:not(.active) {
// background: $gray-100;
color: $gray-500;
}
#nav-plain-text,
#nav-source {
white-space: pre;
font-family: Courier New, Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-plain-text {
white-space: pre-wrap;
}
.messageHeaders {
margin: 15px 0 0;
th {
padding-right: 10px;
font-weight: normal;
vertical-align: top;
}
td {
vertical-align: top;
}
}

139
server/ui-src/mixins.js Normal file
View File

@ -0,0 +1,139 @@
import axios from 'axios'
// FakeModal is used to return a fake Bootstrap modal
// if the ID returns nothing
function FakeModal() { }
FakeModal.prototype.hide = function () { alert('close fake modal') }
FakeModal.prototype.show = function () { alert('open fake modal') }
/* Common mixin functions used in apps */
const commonMixins = {
data() {
return {
loading: 0,
}
},
methods: {
getFileSize: function (bytes) {
var i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
},
formatNumber: function (nr) {
return new Intl.NumberFormat().format(nr);
},
// Ajax error message
handleError: function (error) {
// handle error
if (error.response) {
// 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) {
alert(error.response.data.Error)
} else {
alert(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
alert('Error sending data to the server. Please try again.');
} else {
// Something happened in setting up the request that triggered an Error
alert(error.message);
}
},
// generic modal get/set function
modal: function (id) {
let e = document.getElementById(id);
if (e) {
return bootstrap.Modal.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
// generic modal get/set function
offcanvas: function (id) {
var e = document.getElementById(id);
if (e) {
return bootstrap.Offcanvas.getOrCreateInstance(e);
}
// in case there are open/close actions
return new FakeModal();
},
/**
* Axios GET request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
get: function (url, values, callback) {
let self = this;
self.loading++;
axios.get(url, { params: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios Post request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
post: function (url, values, callback) {
let self = this;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
params.append(key, value);
}
self.loading++;
axios.post(url, params)
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
},
/**
* Axios DELETE request (REST only)
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
delete: function (url, values, callback) {
let self = this;
self.loading++;
axios.delete(url, { data: values })
.then(callback)
.catch(self.handleError)
.then(function () {
// always executed
if (self.loading > 0) {
self.loading--;
}
});
}
}
}
export default commonMixins

View File

@ -0,0 +1,173 @@
<script>
import commonMixins from '../mixins.js';
import moment from 'moment'
export default {
props: {
message: Object,
mailbox: Object,
},
mixins: [commonMixins],
data() {
return {
srcURI: false,
iframes: [], // for resizing
}
},
mounted() {
var self = this;
window.addEventListener("resize", self.resizeIframes);
// click the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click();
document.activeElement.blur(); // blur focus
window.setTimeout(function(){
let p = document.getElementById('preview-html');
if (p) {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i];
let href = anchorEl.getAttribute('href');
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank');
}
}
}
}, 200);
var tabEl = document.getElementById('nav-source-tab');
tabEl.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/' + self.mailbox + '/' + self.message.ID + '/source';
});
},
methods: {
resizeIframe: function(el) {
let i = el.target;
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px';
},
allAttachments: function(message){
let a = [];
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
}
return a.length ? a : false;
},
messageDate: function(d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
}
}
}
</script>
<template>
<div v-if="message" class="mh-100" style="overflow-y: scroll;">
<table class="messageHeaders">
<tbody>
<tr class="small">
<th>From</th>
<td>
<span v-if="message.From">
{{ message.From.Name + " <" + message.From.Address +">" }}
</span>
<span v-else>
[ Unknown ]
</span>
</td>
</tr>
<tr class="small">
<th>To</th>
<td>
<span v-for="(t, i) in message.To">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Cc" class="small">
<th>CC</th>
<td>
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Bcc" class="small">
<th>CC</th>
<td>
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr>
<th class="small">Subject</th>
<td><strong>{{ message.Subject }}</strong></td>
</tr>
</tbody>
</table>
<nav>
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab"
data-bs-target="#nav-html" type="button" role="tab" aria-controls="nav-html"
aria-selected="true" :disabled="message.HTML == ''" :class="message.HTML == '' ? 'disabled':''">HTML</button>
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab"
data-bs-target="#nav-plain-text" type="button" role="tab" aria-controls="nav-plain-text"
aria-selected="false" :class="message.HTML == '' ? 'show':''">Plain<span class="d-none d-md-inline"> text</span></button>
<button class="nav-link" id="nav-source-tab" data-bs-toggle="tab"
data-bs-target="#nav-source" type="button" role="tab" aria-controls="nav-source"
aria-selected="false">Source</button>
<button class="nav-link" id="nav-mime-tab" data-bs-toggle="tab" data-bs-target="#nav-mime"
type="button" role="tab" aria-controls="nav-mime" aria-selected="false"
:disabled="!allAttachments(message)" :class="!allAttachments(message) ? 'disabled':''"
>Attachments <span v-if="allAttachments(message)">({{allAttachments(message).length}})</span></button>
<div class="d-none d-lg-block ms-auto small mt-3 me-2 text-muted">
<small>{{ messageDate(message.Date) }}</small>
</div>
</div>
</nav>
<div class="tab-content mb-5" id="nav-tabContent">
<div v-if="message.HTML != ''" class="tab-pane fade show" id="nav-html" role="tabpanel"
aria-labelledby="nav-html-tab" tabindex="0">
<iframe target-blank="" class="tab-pane" id="preview-html" :srcdoc="message.HTML" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 100%;">
</iframe>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel"
aria-labelledby="nav-plain-text-tab" tabindex="0" :class="message.HTML == '' ? 'show':''">
{{ message.Text }}
</div>
<div class="tab-pane fade" id="nav-source" role="tabpanel" aria-labelledby="nav-source-tab"
tabindex="0">
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 300px;" id="message-src"></iframe>
</div>
<div class="tab-pane fade" id="nav-mime" role="tabpanel" aria-labelledby="nav-mime-tab"
tabindex="0">
<div v-if="allAttachments(message)" v-for="part in allAttachments(message)" class="mime-part mb-2">
<a :href="'api/'+mailbox+'/'+message.ID+'/part/'+part.PartID" type="button"
class="btn btn-outline-secondary btn-sm me-2" target="_blank">
<i class="bi bi-file-arrow-down-fill"></i>
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</a>
<small class="text-muted">{{ getFileSize(part.Size) }}</small>
</div>
</div>
</div>
</div>
</template>

BIN
server/ui/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

22
server/ui/index.html Normal file
View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en" class="h-100">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow, noarchive">
<link rel="icon" href="mailpit.svg">
<title>Mailpit</title>
<link rel=stylesheet href="dist/app.css">
</head>
<body class="h-100">
<div class="container-fluid h-100 d-flex flex-column" id="app">
<noscript>You require JavaScript to use this app.</noscript>
</div>
<script src="dist/app.js"></script>
</body>
</html>

97
server/ui/mailpit.svg Normal file
View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="460"
viewBox="0 0 132.29167 121.70833"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="mailpit.svg"
inkscape:export-filename="/home/ralph/bitmap.png"
inkscape:export-xdpi="176.09"
inkscape:export-ydpi="176.09">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.98994949"
inkscape:cx="90.98717"
inkscape:cy="229.51456"
inkscape:document-units="mm"
inkscape:current-layer="layer2"
showgrid="false"
showguides="true"
inkscape:guide-bbox="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
units="px"
inkscape:window-width="1548"
inkscape:window-height="838"
inkscape:window-x="52"
inkscape:window-y="25"
inkscape:window-maximized="1">
<sodipodi:guide
position="39.014182,62.44412"
orientation="0,1"
id="guide4529"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Layer 2"
style="display:inline"
transform="translate(-55.479864,-26.541592)">
<g
id="g4547"
transform="matrix(1.9570423,0,0,1.9490788,-53.096581,-140.70068)"
style="opacity:1">
<path
sodipodi:nodetypes="cccc"
inkscape:connector-curvature="0"
id="path4534"
d="M 61.775483,85.805801 89.296873,113.46893 116.98363,85.8058 Z"
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="cccccccccc"
inkscape:connector-curvature="0"
id="path4540"
d="m 58.113837,90.436008 31.088544,30.616072 31.277529,-30.521576 -0.0945,18.898806 -30.71057,12.56771 7.748511,6.47285 -4.157737,3.07105 -21.26116,0.0945 c -2.471939,-0.0114 -13.222442,-9.40933 -13.890627,-21.16666 z"
style="fill:#2d4a5f;fill-opacity:0.94117647;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
<path
sodipodi:nodetypes="cccczzcccccc"
inkscape:connector-curvature="0"
id="path4542"
d="m 95.532643,122.7713 27.544977,-11.12272 -4.10354,29.40775 -6.05271,-4.68532 c -11.10189,11.88809 -23.124233,13.48775 -34.745034,10.69078 -11.620801,-2.79697 -16.420919,-10.7759 -20.062499,-18.2612 -3.64158,-7.4853 -2.976265,-15.74301 -1.181174,-23.10379 0.577547,5.393 -0.671158,8.37123 3.260045,17.24516 3.224283,5.84857 7.36483,10.47545 13.229166,12.80395 7.102803,3.17859 16.477397,1.7222 21.308409,-1.55916 l 7.276037,-6.2366 z"
style="fill:#00b786;fill-opacity:1;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

139
server/websockets/client.go Normal file
View File

@ -0,0 +1,139 @@
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package websockets
import (
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer.
maxMessageSize = 512
)
var (
newline = []byte{'\n'}
space = []byte{' '}
// MessageHub global
MessageHub *Hub
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true }, // allow multi-domain
EnableCompression: true, // experimental compression
}
// Client is a middleman between the websocket connection and the hub.
type Client struct {
hub *Hub
// The websocket connection.
conn *websocket.Conn
// Buffered channel of outbound messages.
send chan []byte
}
// // readPump pumps messages from the websocket connection to the hub.
// //
// // The application runs readPump in a per-connection goroutine. The application
// // ensures that there is at most one reader on a connection by executing all
// // reads from this goroutine.
// func (c *Client) readPump() {
// defer func() {
// c.hub.unregister <- c
// c.conn.Close()
// }()
// c.conn.SetReadLimit(maxMessageSize)
// c.conn.SetReadDeadline(time.Now().Add(pongWait))
// c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
// for {
// _, message, err := c.conn.ReadMessage()
// if err != nil {
// if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
// log.Printf("error: %v", err)
// }
// break
// }
// message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1))
// c.hub.Broadcast <- message
// }
// }
// writePump pumps messages from the hub to the websocket connection.
//
// A goroutine running writePump is started for each connection. The
// application ensures that there is at most one writer to a connection by
// executing all writes from this goroutine.
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
_ = c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The hub closed the channel.
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
_, _ = w.Write(message)
// Add queued chat messages to the current websocket message.
n := len(c.send)
for i := 0; i < n; i++ {
_, _ = w.Write(newline)
_, _ = w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
_ = c.conn.WriteMessage(websocket.PingMessage, []byte{})
}
}
}
// ServeWs handles websocket requests from the peer.
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
client.hub.register <- client
// Allow collection of memory referenced by the caller by doing all work in
// new goroutines.
go client.writePump()
// go client.readPump()
}

81
server/websockets/hub.go Normal file
View File

@ -0,0 +1,81 @@
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package websockets
import (
"encoding/json"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger"
)
// Hub maintains the set of active clients and broadcasts messages to the
// clients.
type Hub struct {
// Registered clients.
Clients map[*Client]bool
// Inbound messages from the clients.
Broadcast chan []byte
// Register requests from the clients.
register chan *Client
// Unregister requests from clients.
unregister chan *Client
}
// NewHub returns a new hub configuration
func NewHub() *Hub {
return &Hub{
Broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
Clients: make(map[*Client]bool),
}
}
// Run runs the listener
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.Clients[client] = true
case client := <-h.unregister:
if _, ok := h.Clients[client]; ok {
delete(h.Clients, client)
close(client.send)
}
case message := <-h.Broadcast:
logger.Log().Debugf("Message received: %s", message)
for client := range h.Clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.Clients, client)
}
}
}
}
}
// Broadcast will spawn a broadcast message to all connected clients
func Broadcast(t string, msg interface{}) {
if MessageHub == nil {
return
}
w := data.WebsocketNotification{}
w.Type = t
w.Data = msg
b, err := json.Marshal(w)
if err != nil {
logger.Log().Errorf("[http] broadcast received invalid data: %s", err)
}
go func() { MessageHub.Broadcast <- b }()
}

39
smtpd/smtpd.go Normal file
View File

@ -0,0 +1,39 @@
package smtpd
import (
"bytes"
"net"
"net/mail"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/storage"
s "github.com/mhale/smtpd"
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
msg, err := mail.ReadMessage(bytes.NewReader(data))
if err != nil {
logger.Log().Errorf("error parsing message: %s", err.Error())
return err
}
if _, err := storage.Store(storage.DefaultMailbox, data); err != nil {
logger.Log().Errorf("error storing message: %s", err.Error())
return err
}
subject := msg.Header.Get("Subject")
logger.Log().Debugf("[smtp] received mail from %s for %s with subject %s", from, to[0], subject)
return 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
}
return nil
}

560
storage/database.go Normal file
View File

@ -0,0 +1,560 @@
package storage
import (
"bytes"
"errors"
"fmt"
"net/mail"
"os"
"os/signal"
"regexp"
"syscall"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime"
"github.com/ostafen/clover"
)
var (
db *clover.DB
// DefaultMailbox allowing for potential exampnsion in the future
DefaultMailbox = "catchall"
count int
per100start = time.Now()
)
// CloverStore struct
type CloverStore struct {
Created time.Time
Read bool
From *mail.Address
To []*mail.Address
Cc []*mail.Address
Bcc []*mail.Address
Subject string
Size int
Inline int
Attachments int
SearchText string
}
// InitDB will initialise the database.
// If config.DataDir is empty then it will be in memory.
func InitDB() error {
var err error
if config.DataDir != "" {
logger.Log().Infof("[db] initialising data storage: %s", config.DataDir)
db, err = clover.Open(config.DataDir)
if err != nil {
return err
}
sigs := make(chan os.Signal, 1)
// catch all signals since not explicitly listing
// Program that will listen to the SIGINT and SIGTERM
// SIGINT will listen to CTRL-C.
// SIGTERM will be caught if kill command executed
signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
// method invoked upon seeing signal
go func() {
s := <-sigs
logger.Log().Infof("[db] got %s signal, saving persistant data & shutting down", s)
if err := db.Close(); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
os.Exit(0)
}()
} else {
logger.Log().Debug("[db] initialising memory data storage")
db, err = clover.Open("", clover.InMemoryMode(true))
if err != nil {
return err
}
}
// auto-prune
if config.MaxMessages > 0 {
go pruneCron()
}
// create catch-all collection
return CreateMailbox(DefaultMailbox)
}
// ListMailboxes returns a slice of mailboxes (collections)
func ListMailboxes() ([]data.MailboxSummary, error) {
mailboxes, err := db.ListCollections()
if err != nil {
return nil, err
}
results := []data.MailboxSummary{}
for _, m := range mailboxes {
total, err := Count(m)
if err != nil {
return nil, err
}
unread, err := CountUnread(m)
if err != nil {
return nil, err
}
mb := data.MailboxSummary{}
mb.Name = m
mb.Slug = m
mb.Total = total
mb.Unread = unread
if total > 0 {
q, err := db.FindFirst(
clover.NewQuery(m).Sort(clover.SortOption{Field: "Created", Direction: -1}),
)
if err != nil {
return nil, err
}
mb.LastMessage = q.Get("Created").(time.Time)
}
results = append(results, mb)
}
return results, nil
}
// MailboxExists is used to return whether a collection (aka: mailbox) exists
func MailboxExists(name string) bool {
ok, err := db.HasCollection(name)
if err != nil {
return false
}
return ok
}
// 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)
if err := db.CreateCollection(name); err != nil {
return err
}
// create Created index
if err := db.CreateIndex(name, "Created"); err != nil {
return err
}
// create Read index
if err := db.CreateIndex(name, "Read"); err != nil {
return err
}
// create separate collection for data
if err := db.CreateCollection(name + "_data"); err != nil {
return err
}
// create Created index
if err := db.CreateIndex(name+"_data", "Created"); err != nil {
return err
}
}
return nil
}
// Store will store a message in the database and return the unique ID
func Store(mailbox string, b []byte) (string, error) {
r := bytes.NewReader(b)
// Parse message body with enmime.
env, err := enmime.ReadEnvelope(r)
if err != nil {
return "", err
}
var from *mail.Address
fromData := addressToSlice(env, "From")
if len(fromData) > 0 {
from = fromData[0]
}
obj := CloverStore{
Created: time.Now(),
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
Subject: env.GetHeader("Subject"),
Size: len(b),
Inline: len(env.Inlines),
Attachments: len(env.Attachments),
SearchText: createSearchText(env),
}
doc := clover.NewDocumentOf(obj)
id, err := db.InsertOne(mailbox, doc)
if err != nil {
return "", err
}
// save the raw email in a separate collection
raw := clover.NewDocument()
raw.Set("_id", id)
raw.Set("Created", time.Now())
raw.Set("Data", string(b))
_, 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)
return "", err
}
count++
if count%100 == 0 {
logger.Log().Infof("%d messages added (%s per 100)", count, time.Since(per100start))
per100start = time.Now()
}
d, err := db.FindById(DefaultMailbox, id)
if err != nil {
return "", err
}
c := &data.Summary{}
if err := d.Unmarshal(c); err != nil {
return "", err
}
c.ID = id
websockets.Broadcast("new", c)
return id, nil
}
// List returns a summary of messages.
// For pertformance reasons we manually paginate over queries of 100 results
// 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) {
var lastDoc *clover.Document
count := 0
startAddingAt := start + 1
adding := false
results := []data.Summary{}
for {
var instant time.Time
if lastDoc == nil {
instant = time.Now()
} else {
instant = lastDoc.Get("Created").(time.Time)
}
all, err := db.FindAll(
clover.NewQuery(mailbox).
Where(clover.Field("Created").Lt(instant)).
Sort(clover.SortOption{Field: "Created", Direction: -1}).
Limit(100),
)
if err != nil {
return nil, err
}
for _, d := range all {
count++
if count == startAddingAt {
adding = true
}
resultsLen := len(results)
if adding && resultsLen < limit {
cs := &data.Summary{}
if err := d.Unmarshal(cs); err != nil {
return nil, err
}
cs.ID = d.ObjectId()
results = append(results, *cs)
}
}
// we have enough resuts
if len(results) == limit {
return results, nil
}
if len(all) > 0 {
lastDoc = all[len(all)-1]
} else {
break
}
}
return results, nil
}
// 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", regexp.QuoteMeta(search))
q, err := db.FindAll(clover.NewQuery(mailbox).
Skip(start).
Limit(limit).
Sort(clover.SortOption{Field: "Created", Direction: -1}).
Where(clover.Field("SearchText").Like(sq)))
if err != nil {
return nil, err
}
results := []data.Summary{}
for _, d := range q {
cs := &CloverStore{}
if err := d.Unmarshal(cs); err != nil {
return nil, err
}
results = append(results, cs.Summary(d.ObjectId()))
}
return results, nil
}
// Count returns the total number of messages in a mailbox
func Count(mailbox string) (int, error) {
return db.Count(clover.NewQuery(mailbox))
}
// CountUnread returns the unread number of messages in a mailbox
func CountUnread(mailbox string) (int, error) {
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
}
if q == nil {
return nil, errors.New("message not found")
}
raw := q.Get("Data").(string)
r := bytes.NewReader([]byte(raw))
env, err := enmime.ReadEnvelope(r)
if err != nil {
return nil, err
}
var from *mail.Address
fromData := addressToSlice(env, "From")
if len(fromData) > 0 {
from = fromData[0]
}
date, err := env.Date()
if err != nil {
// date =
}
obj := data.Message{
ID: q.ObjectId(),
Read: true,
Created: q.Get("Created").(time.Time),
From: from,
Date: date,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
Subject: env.GetHeader("Subject"),
Size: len(raw),
Text: env.Text,
}
html := env.HTML
// strip base tags
var re = regexp.MustCompile(`(?U)<base .*>`)
html = re.ReplaceAllString(html, "")
for _, i := range env.Inlines {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, data.AttachmentSummary(i))
}
}
for _, i := range env.OtherParts {
if i.FileName != "" || i.ContentID != "" {
obj.Inline = append(obj.Inline, data.AttachmentSummary(i))
}
}
for _, a := range env.Attachments {
if a.FileName != "" || a.ContentID != "" {
obj.Attachments = append(obj.Attachments, data.AttachmentSummary(a))
}
}
obj.HTML = html
updates := make(map[string]interface{})
updates["Read"] = true
if err := db.UpdateById(mailbox, id, updates); err != nil {
return nil, err
}
return &obj, nil
}
// 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)
if err != nil {
return nil, err
}
r := bytes.NewReader(data)
env, err := enmime.ReadEnvelope(r)
if err != nil {
return nil, err
}
for _, a := range env.Inlines {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.OtherParts {
if a.PartID == partID {
return a, nil
}
}
for _, a := range env.Attachments {
if a.PartID == partID {
return a, nil
}
}
return nil, errors.New("attachment not found")
}
// GetMessageRaw returns an []byte of the full message
func GetMessageRaw(mailbox, id string) ([]byte, error) {
q, err := db.FindById(mailbox+"_data", id)
if err != nil {
return nil, err
}
if q == nil {
return nil, errors.New("message not found")
}
data := q.Get("Data").(string)
return []byte(data), err
}
// UnreadMessage will delete all messages from a mailbox
func UnreadMessage(mailbox, id string) error {
updates := make(map[string]interface{})
updates["Read"] = false
return db.UpdateById(mailbox, id, updates)
}
// DeleteOneMessage will delete a single message from a mailbox
func DeleteOneMessage(mailbox, id string) error {
if err := db.DeleteById(mailbox, id); err != nil {
return err
}
return db.DeleteById(mailbox+"_data", id)
}
// DeleteAllMessages will delete all messages from a mailbox
func DeleteAllMessages(mailbox string) error {
totalStart := time.Now()
totalMessages, err := db.Count(clover.NewQuery(mailbox))
if err != nil {
return err
}
for {
toDelete, err := db.Count(clover.NewQuery(mailbox))
if err != nil {
return err
}
if toDelete == 0 {
break
}
if err := db.Delete(clover.NewQuery(mailbox).Limit(2500)); err != nil {
return err
}
if err := db.Delete(clover.NewQuery(mailbox + "_data").Limit(2500)); err != nil {
return err
}
}
// if err := db.Delete(clover.NewQuery(mailbox)); err != nil {
// return err
// }
// if err := db.Delete(clover.NewQuery(mailbox + "_data")); err != nil {
// return err
// }
elapsed := time.Since(totalStart)
logger.Log().Infof("Deleted %d messages from %s in %s", totalMessages, mailbox, elapsed)
return nil
}

180
storage/database_test.go Normal file
View File

@ -0,0 +1,180 @@
package storage
import (
"fmt"
"io/ioutil"
"testing"
"time"
"github.com/axllent/mailpit/config"
)
var (
testTextEmail []byte
testMimeEmail []byte
)
func TestTextEmailInserts(t *testing.T) {
setup()
start := time.Now()
for i := 0; i < 1000; i++ {
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
count, err := Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, count, 1000, "incorrect number of text emails stored")
t.Logf("inserted 1,000 text emails in %s\n", time.Since(start))
delStart := time.Now()
if err := DeleteAllMessages(DefaultMailbox); err != nil {
t.Log("error ", err)
t.Fail()
}
count, err = Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, count, 0, "incorrect number of text emails deleted")
t.Logf("deleted 1,000 text emails in %s\n", time.Since(delStart))
db.Close()
}
func TestMimeEmailInserts(t *testing.T) {
setup()
start := time.Now()
for i := 0; i < 1000; i++ {
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
count, err := Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, count, 1000, "incorrect number of mime emails stored")
t.Logf("inserted 1,000 emails with mime attachments in %s\n", time.Since(start))
delStart := time.Now()
if err := DeleteAllMessages(DefaultMailbox); err != nil {
t.Log("error ", err)
t.Fail()
}
count, err = Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, count, 0, "incorrect number of mime emails deleted")
t.Logf("deleted 1,000 mime emails in %s\n", time.Since(delStart))
db.Close()
}
func TestRetrieveMimeEmail(t *testing.T) {
setup()
id, err := Store(DefaultMailbox, testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
msg, err := GetMessage(DefaultMailbox, id)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, msg.From.Name, "Sender Smith", "\"From\" name does not match")
assertEqual(t, msg.From.Address, "sender@example.com", "\"From\" address does not match")
assertEqual(t, msg.Subject, "inline + attachment", "subject does not match")
assertEqual(t, len(msg.To), 1, "incorrect number of recipients")
assertEqual(t, msg.To[0].Name, "Recipient Ross", "\"To\" name does not match")
assertEqual(t, msg.To[0].Address, "recipient@example.com", "\"To\" address does not match")
assertEqual(t, len(msg.Attachments), 1, "incorrect number of attachments")
assertEqual(t, msg.Attachments[0].FileName, "Sample PDF.pdf", "attachment filename does not match")
assertEqual(t, len(msg.Inline), 1, "incorrect number of inline attachments")
assertEqual(t, msg.Inline[0].FileName, "inline-image.jpg", "inline attachment filename does not match")
attachmentData, err := GetAttachmentPart(DefaultMailbox, id, msg.Attachments[0].PartID)
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(DefaultMailbox, id, msg.Inline[0].PartID)
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
}
func BenchmarkImportText(b *testing.B) {
setup()
for i := 0; i < b.N; i++ {
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
b.Log("error ", err)
b.Fail()
}
}
db.Close()
}
func BenchmarkImportMime(b *testing.B) {
setup()
for i := 0; i < b.N; i++ {
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
b.Log("error ", err)
b.Fail()
}
}
db.Close()
}
func setup() {
config.NoLogging = true
config.MaxMessages = 0
if err := InitDB(); err != nil {
panic(err)
}
var err error
testTextEmail, err = ioutil.ReadFile("testdata/plain-text.eml")
if err != nil {
panic(err)
}
testMimeEmail, err = ioutil.ReadFile("testdata/mime-attachment.eml")
if err != nil {
panic(err)
}
}
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)
}

607
storage/testdata/mime-attachment.eml vendored Normal file
View File

@ -0,0 +1,607 @@
Delivered-To: recipient@example.com
Received: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp145570qvs;
Tue, 26 Jul 2022 20:42:36 -0700 (PDT)
X-Received: by 2002:a17:902:f788:b0:16c:f48b:905e with SMTP id q8-20020a170902f78800b0016cf48b905emr19885972pln.60.1658893355881;
Tue, 26 Jul 2022 20:42:35 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1658893355; cv=none;
d=google.com; s=arc-20160816;
b=WkNqsJS6Q7RhLY79RZAXgq+Moe0ZcMpGfkZMPq+v1YvG9yAao+QVeY+lN0vjM27H39
0QcXaTd4me7k0f96We657eNyjXSVaJyvvEYMA/Eu/bM51DrzsqywIfMq/O/xsA64mHph
o8LBjV3YjjfNY1uN3q/eLLd5ZLEiHulQSyKJwXxPs7FXaCiihK1iys4U/wEcVubANo0K
3DLhQ2NYrFOjN4jEyw8Agv3PjmLwgAFFisjt49Zm0N6sIDjgWLncXPQ0dA7MjKKE6pjQ
terzh43sjNeI6O+WQJ+aZ6nDxLzhgc+tk0sa290o4u7mjH8/qRx8/krqSPPlgGjLbdyo
Utlg==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;
h=subject:from:to:content-language:user-agent:mime-version:date
:message-id:dkim-signature;
bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=;
b=hnIfinlV6u631zlofA336KWFWzAQrScmCiXIxlBoBrZfgy0FsVJ07tRXSzqqeofkHU
k9pEKVJtD0FfkKzVdrAjetlBAbWCmbQf2u0AzaWqYVLk1rGSQj+UdpuIzMSuB5tX6sX5
XgGvkQC6cYoSd/pRGcxmrA6+jnW531pGvaQzxyv3rpcnYrOT+LBgxaaFVn3fEeUC+AWs
ZQHfciTV9hRCrmu2JWo47Z8RDr9SV3TLU/Mbf8G/p+PiaxhfxYarcTEoiV8+PuD9g6Et
tm1PAqdGq7NAWezv943ueamREZHWiD9+h1gSOro/BmdpWmigEhKFovxRlbAzwsZtW7xo
uSfA==
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
Return-Path: <sender@example.com>
Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41])
by mx.google.com with SMTPS id 11-20020aa7914b000000b0052ab192de4fsor8543241pfi.101.2022.07.26.20.42.35
for <recipient@example.com>
(Google Transport Security);
Tue, 26 Jul 2022 20:42:35 -0700 (PDT)
Received-SPF: pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;
Authentication-Results: mx.google.com;
dkim=pass header.i=@gmail.com header.s=20210112 header.b=mywi6bMa;
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20210112;
h=message-id:date:mime-version:user-agent:content-language:to:from
:subject;
bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=;
b=mywi6bMa68lM9RENvBG2mjVlMvGhyZCrh3z9gE57KY0ZK0RLLPFxzAVOXtJpaTGQ0M
C4W33O+7h5cvgFkLQJHc5YCemxEjCE5Dz5/uH4iSBYowkvn7Gu4TudNZtkNw8TGxH/Lf
lKJiaqtdnm8YdLWCzG1M/scBbbjZxDrTLddshu/Q1ireNliVwl9WdN25zXQLxsEqHFXc
5rVjyruB7cnshL8m14LYi+m5iN3H+o42oGzVce3+wQ31s+Bo/LBezb0qD8TRfTjnhp8u
77RU61IOSMbuwQWNQCywxCnoZolZpR9qRgzd5rg73dGpXHIyNfBsYyb5vr28+fp93Ayo
LXyw==
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20210112;
h=x-gm-message-state:message-id:date:mime-version:user-agent
:content-language:to:from:subject;
bh=ofRVIgn/FP/zLSWstZbrzzKm87NpwtZOSgBYqfbXIl0=;
b=BvDmv5WC7f4WSVvuypzr9WNT7AUCQeEexvjmGur1rfkZcmqr62punbNEvcyk6T5Iy7
8XstlNbijtU9zT3qm5LBTEw1e7q8VACWVVHUbI5uE4NhqXbY6vfN4bxrDzRO/P+Ntr90
BwH1dYSBLpYOmFGX6GlrOCg0X1MZgzGI92YakpQitGBjhKnWvvQ4NlX7Ivk6W6W2aHt5
xkIVmZNdC13evcdFUOrQxcfFAkIe3kSR8eGVt++yoHlCt/fFv/QQjf5L9fEbteuA8h2V
pnfH4fN5z+GF3rpeSl1VebfW8NtPy/iHAze6dlodAVM0jtaom8MtHSXfquCea/2giq0o
YXQQ==
X-Gm-Message-State: AJIora/WUqr3biShTHQBjSlCKazFbrLxeYpxmr1VF0TpBUbjnJrcLT77
pdFYYiNICxragxqhNqXvw7/elR8u6B8=
X-Google-Smtp-Source: AGRyM1tai6X1Bx130Y1yHG5w2e0r8wx6bbI+H+YppWmQoT28TV3dSoYCqmeQK5VViW8WuvdOpQzhPQ==
X-Received: by 2002:a62:29c3:0:b0:52b:f774:7242 with SMTP id p186-20020a6229c3000000b0052bf7747242mr12504553pfp.67.1658893354675;
Tue, 26 Jul 2022 20:42:34 -0700 (PDT)
Return-Path: <sender@example.com>
Received: from [192.168.1.2] ([8.8.8.8])
by smtp.gmail.com with ESMTPSA id oj16-20020a17090b4d9000b001f291c9d3bdsm387578pjb.48.2022.07.26.20.42.32
for <recipient@example.com>
(version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);
Tue, 26 Jul 2022 20:42:33 -0700 (PDT)
Content-Type: multipart/mixed; boundary="------------ae0qIOkrNQLQHe1YyfTsUXrk"
Message-ID: <33af2ac1-c33d-9738-35e3-a6daf90bbd89@gmail.com>
Date: Wed, 27 Jul 2022 15:42:29 +1200
MIME-Version: 1.0
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101
Thunderbird/91.11.0
Content-Language: en-NZ
To: "Recipient Ross" <recipient@example.com>
From: Sender Smith <sender@example.com>
Subject: inline + attachment
This is a multi-part message in MIME format.
--------------ae0qIOkrNQLQHe1YyfTsUXrk
Content-Type: multipart/alternative;
boundary="------------GGc8vauWscgVN0JHIav4AOeV"
--------------GGc8vauWscgVN0JHIav4AOeV
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
Message with inline image and attachment:
--------------GGc8vauWscgVN0JHIav4AOeV
Content-Type: multipart/related;
boundary="------------z0ttbxz8BplvjsfeE7Zogcgs"
--------------z0ttbxz8BplvjsfeE7Zogcgs
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
</head>
<body>
Message with inline image and attachment:<br>
<br>
<img src="cid:part1.845LaYlX.wtWMpWwa@gmail.com"
moz-do-not-send="false"><br>
<br>
<br>
</body>
</html>
--------------z0ttbxz8BplvjsfeE7Zogcgs
Content-Type: image/jpeg; name="inline-image.jpg"
Content-Disposition: inline; filename="inline-image.jpg"
Content-Id: <part1.845LaYlX.wtWMpWwa@gmail.com>
Content-Transfer-Encoding: base64
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
0sFHRw0lPGGQwxtjjaBsa0DACuAF9DV7DVk1eQFTqH8XHs/SO5XAb4lY1DteUnkGwL0vjPFj
Pm3bqPbi87yJw4/XcrfC+Y2KphfMFfWPnFMjkXwhVhGXZwM4GVXobfVVtSynpoXOe44GzZ51
EzEe5WiJmfSx1QvJZg5V3PTSxSGNzTrDfs3Kv+TahsLJZWtjZIMs1jglRyiPunjKLLSvrG5O
CqxZk4AyhYWnaMFW2hSezmXloIcpS0Wi5Xep63tlFPVS7MtjYTjJwM8yy+Pgm0qdqh7aKN5c
W6rqgZyP/o3c6yv5GPH6vbTSmHJf3WGIaNvqHXOGmhYZH1MjYxGN7nE4GPaVpXqo9HTatM6e
6tiMYuERZM0twRNHhpz49Ut+4rvPgv4PaLRS3y1F2ioq25cbxrJeL1jCG7g0kZB5cjxcy566
uqxw1Oi5v9NHxcbaxk+7lP2bh7S9p86+a+Qy0zZeVI/v8vb8THfHj43cZoiLgdIiIgIiICIi
AiIgIiICIiApmx9yO9IegKGUzY+5HekPQElMLS/9/bh61J8xVir6/wDf24etSfMVYogREQFu
DqZYg65XubG1sMTR7XOP9Fp9bn6l8Zqr9/wQdMiielq9t2Bq9BqqBq9Bqo0UZe1icfEo7Ck6
sfYHxlWOovpvhqRGGbfmXg/KW3liPxCiWr4WqqWr5gr13mPVFG507WsIDjs2nAWe26ens+sa
Uyumdsa+XaADyhYHGSx4c04IUrR3ioiJ4/7Zp5Hci5fIxzd1ePkinaaZUurrlJRVTMPJyXtb
gEHnyFK1lk69g60kbF9m37EjYdg51jE93455nBMc+tkareTxqS0bvk8taIqyXWjAOq7dhcmT
HeI5V9adVMtJnU+9vkFjpaG4QmSmJY9vauLgdoG0q1nssMt0oouLw+plDDh2wA7M43//AIvF
2rZH3cvMuvGDqtYNwblZ3adFdIK6C33iAw00gLXU7XjacneR5ktktjiLWntNa1vutY6ZroPo
rQaK6PTOjw2qnAEshB2kZxs9qib5cmiqjMQPGMxgDnWfV8Dm0bWzuBYR25zgZxtWFXaCjpZO
O1WuO8DmXjRkm9ptb3MvSmsVrEV6XFxvTnxRySs+2LcapcRyeJae6pCCW8cC2kHXDO1pqfjm
jlGq5p6QPuWxamcSRcYY8uz2uN6xThcoHVHApppI53F8RZppNU8uBuUWiIrJuZl+eKIi5Wgi
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
P//Z
--------------z0ttbxz8BplvjsfeE7Zogcgs--
--------------GGc8vauWscgVN0JHIav4AOeV--
--------------ae0qIOkrNQLQHe1YyfTsUXrk
Content-Type: application/pdf; name="Sample PDF.pdf"
Content-Disposition: attachment; filename="Sample PDF.pdf"
Content-Transfer-Encoding: base64
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
lVBVBLNnVSqzPTCrEi6uzFcungCV+TDTgBkGVEyAcpSzvAhYCMqOQSmKUBqCkgKYjhqcbsC0
Y3BRPUzFyVQDpoRgMmpqsgGT8NCkqeA0oNiAiQYUIUCRAQ6U1VEP9s0wIQSFBhSMn6QUGDAe
ocdPgnwDqB3GGZCHYHkG5BZDTghk3JTRA5yAq2CADee2OSDZgRggDUuhrXdIl/xv+CEfNwNn
/Cn5H+T5xf0KZW5kc3RyZWFtCmVuZG9iagoKNiAwIG9iago3MjA5CmVuZG9iagoKNyAwIG9i
ago8PC9UeXBlL0ZvbnREZXNjcmlwdG9yL0ZvbnROYW1lL0JBQUFBQStEZWphVnVTYW5zCi9G
bGFncyA0Ci9Gb250QkJveFstMTAyMCAtNDYyIDE3OTIgMTIzMl0vSXRhbGljQW5nbGUgMAov
QXNjZW50IDkyOAovRGVzY2VudCAtMjM1Ci9DYXBIZWlnaHQgMTIzMgovU3RlbVYgODAKL0Zv
bnRGaWxlMiA1IDAgUgo+PgplbmRvYmoKCjggMCBvYmoKPDwvTGVuZ3RoIDI2NS9GaWx0ZXIv
RmxhdGVEZWNvZGU+PgpzdHJlYW0KeJxdkE1uwyAQhfecgmW6iMCO7TSSZalyFMmLtFXdHgDD
2EGKAWG88O3LT9pKXYC+YeYN84a03blT0pF3q3kPDo9SCQuLXi0HPMAkFcpyLCR3jyjefGYG
Ea/tt8XB3KlR1zUiHz63OLvh3YvQAzwh8mYFWKkmvPtqex/3qzF3mEE5TFHTYAGj73Nl5pXN
QKJq3wmflm7be8lfwedmAOcxztIoXAtYDONgmZoA1ZQ2uL5cGgRK/MudkmIY+Y1ZX5n5SkrL
Q+M5j1xlgQ+Jz4GLyEcauEzvbeAqcRn4mPrEmufIRRH4lLiKszx+DVOFtf24xXy11juNu40W
gzmp4Hf9Rpugiucbox+AAgplbmRzdHJlYW0KZW5kb2JqCgo5IDAgb2JqCjw8L1R5cGUvRm9u
dC9TdWJ0eXBlL1RydWVUeXBlL0Jhc2VGb250L0JBQUFBQStEZWphVnVTYW5zCi9GaXJzdENo
YXIgMAovTGFzdENoYXIgOQovV2lkdGhzWzYwMCA2MzQgNjEyIDk3NCA2MzQgMjc3IDYxNSA2
MDMgNzcwIDU3NSBdCi9Gb250RGVzY3JpcHRvciA3IDAgUgovVG9Vbmljb2RlIDggMCBSCj4+
CmVuZG9iagoKMTAgMCBvYmoKPDwvRjEgOSAwIFIKPj4KZW5kb2JqCgoxMSAwIG9iago8PC9G
b250IDEwIDAgUgovUHJvY1NldFsvUERGL1RleHRdCj4+CmVuZG9iagoKMSAwIG9iago8PC9U
eXBlL1BhZ2UvUGFyZW50IDQgMCBSL1Jlc291cmNlcyAxMSAwIFIvTWVkaWFCb3hbMCAwIDU5
NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+L0Nv
bnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvUGFnZXMKL1Jlc291cmNl
cyAxMSAwIFIKL01lZGlhQm94WyAwIDAgNTk1IDg0MiBdCi9LaWRzWyAxIDAgUiBdCi9Db3Vu
dCAxPj4KZW5kb2JqCgoxMiAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgNCAwIFIKL09w
ZW5BY3Rpb25bMSAwIFIgL1hZWiBudWxsIG51bGwgMF0KL0xhbmcoZW4tTlopCj4+CmVuZG9i
agoKMTMgMCBvYmoKPDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgov
UHJvZHVjZXI8RkVGRjAwNEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAwNjYwMDY2MDA2OTAwNjMw
MDY1MDAyMDAwMzUwMDJFMDAzMT4KL0NyZWF0aW9uRGF0ZShEOjIwMTYwNjE2MTM0NDU4KzEy
JzAwJyk+PgplbmRvYmoKCnhyZWYKMCAxNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDgz
NTggMDAwMDAgbiAKMDAwMDAwMDAxOSAwMDAwMCBuIAowMDAwMDAwMjE5IDAwMDAwIG4gCjAw
MDAwMDg1MDEgMDAwMDAgbiAKMDAwMDAwMDIzOSAwMDAwMCBuIAowMDAwMDA3NTMzIDAwMDAw
IG4gCjAwMDAwMDc1NTQgMDAwMDAgbiAKMDAwMDAwNzc0NyAwMDAwMCBuIAowMDAwMDA4MDgx
IDAwMDAwIG4gCjAwMDAwMDgyNzEgMDAwMDAgbiAKMDAwMDAwODMwMyAwMDAwMCBuIAowMDAw
MDA4NjAwIDAwMDAwIG4gCjAwMDAwMDg2OTcgMDAwMDAgbiAKdHJhaWxlcgo8PC9TaXplIDE0
L1Jvb3QgMTIgMCBSCi9JbmZvIDEzIDAgUgovSUQgWyA8Nzg2RkVDMTY2OUIxOURDMTJBNEU2
ODQzN0YxQjIzRTE+Cjw3ODZGRUMxNjY5QjE5REMxMkE0RTY4NDM3RjFCMjNFMT4gXQovRG9j
Q2hlY2tzdW0gLzkzRjFCMUZBQjVENzc2Q0JFNDc2MzA1QzdENUVCRUUxCj4+CnN0YXJ0eHJl
Zgo4ODcyCiUlRU9GCg==
--------------ae0qIOkrNQLQHe1YyfTsUXrk--

116
storage/testdata/plain-text.eml vendored Normal file
View File

@ -0,0 +1,116 @@
Delivered-To: recipient@example.com
Received: by 2002:a0c:fe87:0:0:0:0:0 with SMTP id d7csp146390qvs;
Tue, 26 Jul 2022 20:45:20 -0700 (PDT)
X-Received: by 2002:a17:90a:1943:b0:1ef:8146:f32f with SMTP id 3-20020a17090a194300b001ef8146f32fmr2327371pjh.112.1658893508159;
Tue, 26 Jul 2022 20:45:08 -0700 (PDT)
ARC-Seal: i=1; a=rsa-sha256; t=1658893507; cv=none;
d=google.com; s=arc-20160816;
b=KrXcumoy4Oldq3Ny6ZLUfED4+/+4ndNbrM3uw1COEhqCVWWv7lLfFeNHTyxJQJLBK3
tVgmPBX2XRmX+531CFRNquUDrqhsvc4kgIq0ExWPz99wG2vgsKWQ2x89AIfQ8sEYMwxY
HOwErTH6XQuJ45YE+5Lt4pjMP+7NqnJ1NTRQyc7FB/c1Wt1JdTWscgaJGqUMnIFSbCPG
xi0xpJnrIkh4giARIhabCRmVoo1g8BfzYrmy8uHtbIcDDuCJ8tN2lMLscwfw3u8hZWm6
e1nAx4iDYyShdMZPPoUVoMHDf9P39DKwhdfb/xP/cQ6ulv7ECzVSp5DM8aLpfjw6SU9G
JYJA==
ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20160816;
h=content-disposition:mime-version:message-id:subject:to:from:date
:dkim-signature;
bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=;
b=TGK9vlNQRpyHvcpQonLjrFuLubL2mo9vT15CPwtC6ltsrYccKUozKiyb+id79dPatM
y2unMpJqJFB4rZnASRm20Ck9dFRulM8bowO4l9BWKAUti9+u7bmLYbOPQCgDmJRA88ij
YTkSKE8TuFMZQMJTkyZZTwE3F/Vrv84fAekWzGlwFoV3D6r6t1D5EUYUoR4xCVZdpMo1
Ic0bEqgmRXl44uEqyVNpIC0w86Hzz84zl2V+nca+gxfObMzbJheDkOwVKkNNmr0ja936
QZK+aO9s9VQGtqmjWtWhc1OWO50Bc5vE/krLFvZM6+vbMBEuDE5rkfHdf5mSD9Ix4xWl
6/Rg==
ARC-Authentication-Results: i=1; mx.google.com;
dkim=pass header.i=@gmail.com header.s=20210112 header.b=fpxRepVP;
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
Return-Path: <sender@example.com>
Received: from mail-sor-f41.google.com (mail-sor-f41.google.com. [209.85.220.41])
by mx.google.com with SMTPS id t3-20020a17090a2f8300b001f25e258dfasor335081pjd.34.2022.07.26.20.45.07
for <recipient@example.com>
(Google Transport Security);
Tue, 26 Jul 2022 20:45:07 -0700 (PDT)
Received-SPF: pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) client-ip=209.85.220.41;
Authentication-Results: mx.google.com;
dkim=pass header.i=@gmail.com header.s=20210112 header.b=fpxRepVP;
spf=pass (google.com: domain of sender@example.com designates 209.85.220.41 as permitted sender) smtp.mailfrom=sender@example.com;
dmarc=pass (p=NONE sp=QUARANTINE dis=NONE) header.from=gmail.com
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20210112;
h=date:from:to:subject:message-id:mime-version:content-disposition;
bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=;
b=fpxRepVPdRgZF9VI4rCzO4n1l9+OHrm254/c1PaNcNnC1+0Rr78o1ASLvDKoQY4INc
gRN1kJIk+ozQumJSfQPEIe+rHbJxe+wzjbYhEfUwBUnFHZykqvYWl6Xmjwg61IhxwwWk
b3Gp/ODHkdQrm5QqIFACEn1fQmqkk4XBlcKMYEU/NOswGDOFULfbrhDcBWmR/gp2kHmT
DkqRA9UJ1Cc6GO9lG+McRi8uLNaTymuLwzBydVV0bZOQTLxHQcQBTfUFrp/fwjHc9V19
l9uQcn5rOOsh3vR37NGpv8WPi7BORLRFGjMVD0DZ7CtJwTDHz4EVvdLijt6YbUV9ecp1
df3Q==
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=1e100.net; s=20210112;
h=x-gm-message-state:date:from:to:subject:message-id:mime-version
:content-disposition;
bh=8shE8duj4atyKhQhO1qlS4/NgHN4ubjWq86U+mmAH9M=;
b=Z8ndxERf1NU67swjZ7cSjkSTTaa2YzhtrRyJkg0vnRxi87af7ECZNT+Zaxuxmxmqvb
5T3IN2ymjPu1Y52EqRdZQpnzS/E5OjHbA6AYSn5qneNXNDxqJwp5qVSXuyB265QOo/9M
bGp4fqfi8Qe5pmgkzyTqyrigWFOzcl23sCGXqvnrD8+0e+/n1dqo2tYk4v2KpSoAUxF0
SNwHocpTDBDxOMEulUkQpqNlyZsgqNGdRhZmUN+2tQnpCQULd4B7+pydyWBCp9o8J1W4
0IqmhJiNT8pB8MVzyUsWNG+WX9GBh8PK6XndOjmp2WvYh0LcUKeEYQ6zBsIdDFNEkMD1
dU9w==
X-Gm-Message-State: AJIora+ZXWhiNwKn6ik6LuIUHc1hskP3Nneo2J0m0wSC9wwGXI1RPi1a
Ml5Ex/pAryQwTi7MXqbUQkCIrEe5kU0=
X-Google-Smtp-Source: AGRyM1v7CWOR6/X4d18Wv11XTnkfT25QfmsqBowwGsebQlPqhR1ogD3bo1sZRs/OSAHP7AjywIebfw==
X-Received: by 2002:a17:90a:5e0b:b0:1f0:5565:ee6e with SMTP id w11-20020a17090a5e0b00b001f05565ee6emr2290528pjf.128.1658893506447;
Tue, 26 Jul 2022 20:45:06 -0700 (PDT)
Return-Path: <sender@example.com>
Received: from localhost.localhost ([8.8.8.8])
by smtp.gmail.com with ESMTPSA id s7-20020a170902ea0700b0016a3f9e4865sm12488166plg.148.2022.07.26.20.45.04
for <recipient@example.com>
(version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
Tue, 26 Jul 2022 20:45:06 -0700 (PDT)
Date: Wed, 27 Jul 2022 15:44:41 +1200
From: Sender Smith <sender@example.com>
To: Recipient Ross <recipient@example.com>
Subject: Plain text message
Message-ID: <20220727034441.7za34h6ljuzfpmj6@localhost.localhost>
MIME-Version: 1.0
Content-Type: text/plain; charset=us-ascii
Content-Disposition: inline
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non massa lacinia,
fringilla ex vel, ornare nulla. Suspendisse dapibus commodo sapien, non
hendrerit diam feugiat sit amet. Nulla lorem quam, laoreet vitae nisl volutpat,
mollis bibendum felis. In eget ultricies justo. Donec vitae hendrerit tortor, at
posuere libero. Fusce a gravida nibh. Nulla ac odio ex.
Aliquam sem turpis, cursus vitae condimentum at, scelerisque pulvinar lectus.
Cras tempor nisl ut arcu interdum, et luctus arcu cursus. Maecenas mollis
sagittis commodo. Mauris ac lorem nec ex interdum consequat. Morbi congue
ultrices ullamcorper. Aenean ex tortor, dapibus quis dapibus iaculis, iaculis
eget felis. Vestibulum purus ante, efficitur in turpis ac, tristique laoreet
orci. Nulla facilisi. Praesent mollis orci posuere elementum laoreet.
Pellentesque enim nibh, varius at ante id, consequat posuere ante.
Cras maximus venenatis nulla nec cursus. Morbi convallis, enim eget viverra
vulputate, ipsum arcu tincidunt tortor, ut cursus dui enim commodo quam. Donec
et vulputate quam. Vivamus non posuere erat. Nam commodo pellentesque
condimentum. Vivamus condimentum eros at odio dictum feugiat. Ut imperdiet
tempor luctus. Aenean varius libero ac faucibus dictum. Aliquam sed finibus
massa. Morbi dolor lorem, feugiat quis neque et, suscipit posuere ex. Sed auctor
et augue at finibus. Vestibulum interdum mi ac justo porta aliquam. Curabitur
nec enim sit amet enim aliquet accumsan. Etiam accumsan tellus tortor, interdum
sodales odio finibus eu. Integer eget ante eu nisi lobortis pulvinar et vel
ipsum. Cras condimentum posuere vulputate.
Cras nulla felis, blandit vitae egestas quis, fringilla ut dolor. Phasellus est
augue, feugiat eu risus quis, posuere ultrices libero. Phasellus non nunc eget
justo sollicitudin tincidunt. Praesent pretium dui id felis bibendum sodales.
Phasellus eget dictum libero, auctor tempor nibh. Suspendisse posuere libero
venenatis elit imperdiet porttitor. In condimentum dictum luctus. Nullam in
nulla vitae augue blandit posuere. Vestibulum consectetur ultricies tincidunt.
Vivamus dolor quam, pharetra sed eros sed, hendrerit ultrices diam. Vestibulum
vulputate tellus eget tellus lacinia, a pulvinar velit vulputate. Suspendisse
mauris odio, scelerisque eget turpis sed, tincidunt ultrices magna. Nunc arcu
arcu, commodo et porttitor quis, accumsan viverra purus. Fusce id libero iaculis
lorem tristique commodo porttitor id ipsum. Vestibulum odio dui, tincidunt eget
lectus vel, tristique lacinia libero. Aliquam dapibus ac felis vitae cursus.

90
storage/utils.go Normal file
View File

@ -0,0 +1,90 @@
package storage
import (
"net/mail"
"regexp"
"strings"
"time"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime"
"github.com/k3a/html2text"
"github.com/ostafen/clover"
)
// Return a header field as a []*mail.Address, or "null" is not found/empty
func addressToSlice(env *enmime.Envelope, key string) []*mail.Address {
data, _ := env.AddressList(key)
return data
}
// Generate the search text based on some header fields (to, from, subject etc)
// and either the stripped HTML body (if exists) or text body
func createSearchText(env *enmime.Envelope) string {
var b strings.Builder
b.WriteString(env.GetHeader("From") + " ")
b.WriteString(env.GetHeader("Subject") + " ")
b.WriteString(env.GetHeader("To") + " ")
b.WriteString(env.GetHeader("Cc") + " ")
b.WriteString(env.GetHeader("Bcc") + " ")
h := strings.TrimSpace(html2text.HTML2Text(env.HTML))
if h != "" {
b.WriteString(h + " ")
} else {
b.WriteString(env.Text + " ")
}
// add attachment filenames
for _, a := range env.Attachments {
b.WriteString(a.FileName + " ")
}
d := b.String()
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|:|\,|;)`)
d = re.ReplaceAllString(d, " ")
// remove duplicate whitespace and trim
d = strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(d)), " "))
return d
}
// Auto-prune runs every 5 minutes 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 {
logger.Log().Errorf("[db] %s", err)
continue
}
for _, m := range mailboxes {
total, _ := db.Count(clover.NewQuery(m))
if total > config.MaxMessages {
limit := total - config.MaxMessages
if limit > 5000 {
limit = 5000
}
start := time.Now()
if err := db.Delete(clover.NewQuery(m).
Sort(clover.SortOption{Field: "Created", Direction: 1}).
Limit(limit)); err != nil {
logger.Log().Warnf("Error pruning: %s", err.Error())
continue
}
elapsed := time.Since(start)
logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed)
if !strings.HasSuffix(m, "_data") {
websockets.Broadcast("prune", nil)
}
}
}
}
}

260
updater/targz.go Normal file
View File

@ -0,0 +1,260 @@
package updater
import (
"archive/tar"
"bufio"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
)
// TarGZExtract extracts a archive from the file inputFilePath.
// It tries to create the directory structure outputFilePath contains if it doesn't exist.
// It returns potential errors to be checked or nil if everything works.
func TarGZExtract(inputFilePath, outputFilePath string) (err error) {
outputFilePath = stripTrailingSlashes(outputFilePath)
inputFilePath, outputFilePath, err = makeAbsolute(inputFilePath, outputFilePath)
if err != nil {
return err
}
undoDir, err := mkdirAll(outputFilePath, 0750)
if err != nil {
return err
}
defer func() {
if err != nil {
undoDir()
}
}()
return extract(inputFilePath, outputFilePath)
}
// Creates all directories with os.MakedirAll and returns a function to remove the first created directory so cleanup is possible.
func mkdirAll(dirPath string, perm os.FileMode) (func(), error) {
var undoDir string
for p := dirPath; ; p = filepath.Dir(p) {
finfo, err := os.Stat(p)
if err == nil {
if finfo.IsDir() {
break
}
finfo, err = os.Lstat(p)
if err != nil {
return nil, err
}
if finfo.IsDir() {
break
}
return nil, fmt.Errorf("mkdirAll (%s): %v", p, syscall.ENOTDIR)
}
if os.IsNotExist(err) {
undoDir = p
} else {
return nil, err
}
}
if undoDir == "" {
return func() {}, nil
}
if err := os.MkdirAll(dirPath, perm); err != nil {
return nil, err
}
return func() {
if err := os.RemoveAll(undoDir); err != nil {
panic(err)
}
}, nil
}
// Remove trailing slash if any.
func stripTrailingSlashes(path string) string {
if len(path) > 0 && path[len(path)-1] == '/' {
path = path[0 : len(path)-1]
}
return path
}
// Make input and output paths absolute.
func makeAbsolute(inputFilePath, outputFilePath string) (string, string, error) {
inputFilePath, err := filepath.Abs(inputFilePath)
if err == nil {
outputFilePath, err = filepath.Abs(outputFilePath)
}
return inputFilePath, outputFilePath, err
}
// Write path without the prefix in subPath to tar writer.
func writeTarGz(path string, tarWriter *tar.Writer, fileInfo os.FileInfo, subPath string) error {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
fmt.Printf("Error closing file: %s\n", err)
}
}()
evaledPath, err := filepath.EvalSymlinks(path)
if err != nil {
return err
}
subPath, err = filepath.EvalSymlinks(subPath)
if err != nil {
return err
}
link := ""
if evaledPath != path {
link = evaledPath
}
header, err := tar.FileInfoHeader(fileInfo, link)
if err != nil {
return err
}
header.Name = evaledPath[len(subPath):]
err = tarWriter.WriteHeader(header)
if err != nil {
return err
}
_, err = io.Copy(tarWriter, file)
if err != nil {
return err
}
return err
}
// Extract the file in filePath to directory.
func extract(filePath string, directory string) error {
file, err := os.Open(filepath.Clean(filePath))
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
fmt.Printf("Error closing file: %s\n", err)
}
}()
gzipReader, err := gzip.NewReader(bufio.NewReader(file))
if err != nil {
return err
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
// Post extraction directory permissions & timestamps
type DirInfo struct {
Path string
Header *tar.Header
}
// slice to add all extracted directory info for post-processing
postExtraction := []DirInfo{}
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
fileInfo := header.FileInfo()
dir := filepath.Join(directory, filepath.Dir(header.Name))
filename := filepath.Join(dir, fileInfo.Name())
if fileInfo.IsDir() {
// create the directory 755 in case writing permissions prohibit writing before files added
if err := os.MkdirAll(filename, 0750); err != nil {
return err
}
// set file ownership (if allowed)
// Chtimes() && Chmod() only set after once extraction is complete
os.Chown(filename, header.Uid, header.Gid) // #nosec
// add directory info to slice to process afterwards
postExtraction = append(postExtraction, DirInfo{filename, header})
continue
}
// make sure parent directory exists (may not be included in tar)
if !fileInfo.IsDir() && !isDir(dir) {
err = os.MkdirAll(dir, 0750)
if err != nil {
return err
}
}
file, err := os.Create(filepath.Clean(filename))
if err != nil {
return err
}
writer := bufio.NewWriter(file)
buffer := make([]byte, 4096)
for {
n, err := tarReader.Read(buffer)
if err != nil && err != io.EOF {
panic(err)
}
if n == 0 {
break
}
_, err = writer.Write(buffer[:n])
if err != nil {
return err
}
}
err = writer.Flush()
if err != nil {
return err
}
err = file.Close()
if err != nil {
return err
}
// set file permissions, timestamps & uid/gid
os.Chmod(filename, os.FileMode(header.Mode)) // #nosec
os.Chtimes(filename, header.AccessTime, header.ModTime) // #nosec
os.Chown(filename, header.Uid, header.Gid) // #nosec
}
if len(postExtraction) > 0 {
for _, dir := range postExtraction {
os.Chtimes(dir.Path, dir.Header.AccessTime, dir.Header.ModTime) // #nosec
os.Chmod(dir.Path, dir.Header.FileInfo().Mode().Perm()) // #nosec
}
}
return nil
}

75
updater/unzip.go Normal file
View File

@ -0,0 +1,75 @@
package updater
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Unzip will decompress a zip archive, moving all files and folders
// within the zip file (src) to an output directory (dest).
func Unzip(src string, dest string) ([]string, error) {
var filenames []string
r, err := zip.OpenReader(src)
if err != nil {
return filenames, err
}
defer r.Close()
for _, f := range r.File {
// Store filename/path for returning and using later on
fpath := filepath.Join(dest, filepath.Clean(f.Name))
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
return filenames, fmt.Errorf("%s: illegal file path", fpath)
}
filenames = append(filenames, fpath)
if f.FileInfo().IsDir() {
// Make Folder
if err := os.MkdirAll(fpath, os.ModePerm); err != nil {
return filenames, err
}
continue
}
// Make File
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
return filenames, err
}
outFile, err := os.OpenFile(filepath.Clean(fpath), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return filenames, err
}
rc, err := f.Open()
if err != nil {
return filenames, err
}
_, err = io.Copy(outFile, rc) // #nosec - file is streamed from zip to file
// Close the file without defer to close before next iteration of loop
if err := outFile.Close(); err != nil {
return filenames, err
}
if err := rc.Close(); err != nil {
return filenames, err
}
if err != nil {
return filenames, err
}
}
return filenames, nil
}

344
updater/updater.go Normal file
View File

@ -0,0 +1,344 @@
package updater
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/axllent/mailpit/logger"
"github.com/axllent/semver"
)
var (
// AllowPrereleases defines whether pre-releases may be included
AllowPrereleases = false
tempDir string
)
// Releases struct for Github releases json
type Releases []struct {
Name string `json:"name"` // release name
Tag string `json:"tag_name"` // release tag
Prerelease bool `json:"prerelease"` // Github pre-release
Assets []struct {
BrowserDownloadURL string `json:"browser_download_url"`
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
} `json:"assets"`
}
// Release struct contains the file data for downloadable release
type Release struct {
Name string
Tag string
URL string
Size int64
}
// GithubLatest fetches the latest release info & returns release tag, filename & download url
func GithubLatest(repo, name string) (string, string, string, error) {
releaseURL := fmt.Sprintf("https://api.github.com/repos/%s/releases", repo)
resp, err := http.Get(releaseURL) // #nosec
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", "", "", err
}
linkOS := runtime.GOOS
linkArch := runtime.GOARCH
linkExt := ".tar.gz"
if linkOS == "windows" {
// Windows uses .zip instead
linkExt = ".zip"
}
var allReleases = []Release{}
var releases Releases
if err := json.Unmarshal(body, &releases); err != nil {
return "", "", "", err
}
archiveName := fmt.Sprintf("%s_%s_%s%s", name, linkOS, linkArch, linkExt)
// loop through releases
for _, r := range releases {
if !semver.IsValid(r.Tag) {
// Invalid semversion, skip
continue
}
if !AllowPrereleases && (semver.Prerelease(r.Tag) != "" || r.Prerelease) {
// we don't accept AllowPrereleases, skip
continue
}
for _, a := range r.Assets {
if a.Name == archiveName {
thisRelease := Release{a.Name, r.Tag, a.BrowserDownloadURL, a.Size}
allReleases = append(allReleases, thisRelease)
break
}
}
}
if len(allReleases) == 0 {
// no releases with suitable assets found
return "", "", "", fmt.Errorf("No binary releases found")
}
var latestRelease = Release{}
for _, r := range allReleases {
// detect the latest release
if semver.Compare(r.Tag, latestRelease.Tag) == 1 {
latestRelease = r
}
}
return latestRelease.Tag, latestRelease.Name, latestRelease.URL, nil
}
// GreaterThan compares the current version to a different version
// returning < 1 not upgradeable
func GreaterThan(toVer, fromVer string) bool {
return semver.Compare(toVer, fromVer) == 1
}
// GithubUpdate the running binary with the latest release binary from Github
func GithubUpdate(repo, appName, currentVersion string) (string, error) {
ver, filename, downloadURL, err := GithubLatest(repo, appName)
if err != nil {
return "", err
}
if ver == currentVersion {
return "", fmt.Errorf("No new release found")
}
if semver.Compare(ver, currentVersion) < 1 {
return "", fmt.Errorf("No newer releases found (latest %s)", ver)
}
tmpDir := getTempDir()
// outFile can be a tar.gz or a zip, depending on architecture
outFile := filepath.Join(tmpDir, filename)
if err := downloadToFile(downloadURL, outFile); err != nil {
return "", err
}
newExec := filepath.Join(tmpDir, "golp")
if runtime.GOOS == "windows" {
if _, err := Unzip(outFile, tmpDir); err != nil {
return "", err
}
newExec = filepath.Join(tmpDir, "golp.exe")
} else {
if err := TarGZExtract(outFile, tmpDir); err != nil {
return "", err
}
}
if runtime.GOOS != "windows" {
/* #nosec G302 */
if err := os.Chmod(newExec, 0755); err != nil {
return "", err
}
}
// ensure the new binary is executable (mainly for inconsistent darwin builds)
/* #nosec G204 */
cmd := exec.Command(newExec)
if err := cmd.Run(); err != nil {
return "", err
}
// get the running binary
oldExec, err := os.Executable()
if err != nil {
panic(err)
}
if err = replaceFile(oldExec, newExec); err != nil {
return "", err
}
return ver, nil
}
// DownloadToFile downloads a URL to a file
func downloadToFile(url, fileName string) error {
// Get the data
resp, err := http.Get(url) // #nosec
if err != nil {
return err
}
defer resp.Body.Close()
// Create the file
out, err := os.Create(filepath.Clean(fileName))
if err != nil {
return err
}
defer func() {
if err := out.Close(); err != nil {
logger.Log().Errorf("Error closing file: %s\n", err)
}
}()
// Write the body to file
_, err = io.Copy(out, resp.Body)
return err
}
// ReplaceFile replaces one file with another.
// Running files cannot be overwritten, so it has to be moved
// and the new binary saved to the original path. This requires
// read & write permissions to both the original file and directory.
// Note, on Windows it is not possible to delete a running program,
// so the old exe is renamed and moved to os.TempDir()
func replaceFile(dst, src string) error {
// open the source file for reading
source, err := os.Open(filepath.Clean(src))
if err != nil {
return err
}
// destination directory eg: /usr/local/bin
dstDir := filepath.Dir(dst)
// binary filename
binaryFilename := filepath.Base(dst)
// old binary tmp name
dstOld := fmt.Sprintf("%s.old", binaryFilename)
// new binary tmp name
dstNew := fmt.Sprintf("%s.new", binaryFilename)
// absolute path of new tmp file
newTmpAbs := filepath.Join(dstDir, dstNew)
// absolute path of old tmp file
oldTmpAbs := filepath.Join(dstDir, dstOld)
// get src permissions
fi, _ := os.Stat(dst)
srcPerms := fi.Mode().Perm()
// create the new file
tmpNew, err := os.OpenFile(filepath.Clean(newTmpAbs), os.O_CREATE|os.O_RDWR, srcPerms) // #nosec
if err != nil {
return err
}
// copy new binary to <binary>.new
if _, err := io.Copy(tmpNew, source); err != nil {
return err
}
// close immediately else Windows has a fit
if err := tmpNew.Close(); err != nil {
return err
}
if err := source.Close(); err != nil {
return err
}
// rename the current executable to <binary>.old
if err := os.Rename(dst, oldTmpAbs); err != nil {
return err
}
// rename the <binary>.new to current executable
if err := os.Rename(newTmpAbs, dst); err != nil {
return err
}
// delete the old binary
if runtime.GOOS == "windows" {
tmpDir := os.TempDir()
delFile := filepath.Join(tmpDir, filepath.Base(oldTmpAbs))
if err := os.Rename(oldTmpAbs, delFile); err != nil {
return err
}
} else {
if err := os.Remove(oldTmpAbs); err != nil {
return err
}
}
// remove the src file
if err := os.Remove(src); err != nil {
return err
}
return nil
}
// GetTempDir will create & return a temporary directory if one has not been specified
func getTempDir() string {
if tempDir == "" {
randBytes := make([]byte, 6)
if _, err := rand.Read(randBytes); err != nil {
panic(err)
}
tempDir = filepath.Join(os.TempDir(), "updater-"+hex.EncodeToString(randBytes))
}
if err := mkDirIfNotExists(tempDir); err != nil {
// need a better way to exit
logger.Log().Errorf("Error: %v", err)
os.Exit(2)
}
return tempDir
}
// 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 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
}
// IsDir returns if a path is a directory
func isDir(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.IsDir() {
return false
}
return true
}