From 7a9b11a9e5e63b987fde0e7d8536f9fa76585097 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Fri, 29 Jul 2022 23:23:08 +1200 Subject: [PATCH] First commit --- .github/workflows/release-build.yml | 42 + .gitignore | 6 + LICENSE | 21 + README-BUILDING.md | 45 + README.md | 60 ++ cmd/root.go | 86 ++ cmd/sendmail.go | 33 + cmd/version.go | 69 ++ config/config.go | 44 + data/mailbox.go | 18 + data/message.go | 64 ++ esbuild.config.js | 22 + go.mod | 52 ++ go.sum | 291 +++++++ logger/logger.go | 43 + main.go | 24 + package-lock.json | 806 ++++++++++++++++++ package.json | 25 + sendmail/cmd/cmd.go | 85 ++ sendmail/main.go | 7 + server/api.go | 235 +++++ server/server.go | 131 +++ server/ui-src/App.vue | 416 +++++++++ server/ui-src/app.js | 8 + .../ui-src/assets/_bootstrap_variables.scss | 1 + server/ui-src/assets/bootstrap.scss | 49 ++ server/ui-src/assets/styles.scss | 54 ++ server/ui-src/mixins.js | 139 +++ server/ui-src/templates/Message.vue | 173 ++++ server/ui/favicon.ico | Bin 0 -> 15406 bytes server/ui/index.html | 22 + server/ui/mailpit.svg | 97 +++ server/websockets/client.go | 139 +++ server/websockets/hub.go | 81 ++ smtpd/smtpd.go | 39 + storage/database.go | 560 ++++++++++++ storage/database_test.go | 180 ++++ storage/testdata/mime-attachment.eml | 607 +++++++++++++ storage/testdata/plain-text.eml | 116 +++ storage/utils.go | 90 ++ updater/targz.go | 260 ++++++ updater/unzip.go | 75 ++ updater/updater.go | 344 ++++++++ 43 files changed, 5659 insertions(+) create mode 100644 .github/workflows/release-build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README-BUILDING.md create mode 100644 README.md create mode 100644 cmd/root.go create mode 100644 cmd/sendmail.go create mode 100644 cmd/version.go create mode 100644 config/config.go create mode 100644 data/mailbox.go create mode 100644 data/message.go create mode 100644 esbuild.config.js create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logger/logger.go create mode 100644 main.go create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 sendmail/cmd/cmd.go create mode 100644 sendmail/main.go create mode 100644 server/api.go create mode 100644 server/server.go create mode 100644 server/ui-src/App.vue create mode 100644 server/ui-src/app.js create mode 100644 server/ui-src/assets/_bootstrap_variables.scss create mode 100644 server/ui-src/assets/bootstrap.scss create mode 100644 server/ui-src/assets/styles.scss create mode 100644 server/ui-src/mixins.js create mode 100644 server/ui-src/templates/Message.vue create mode 100644 server/ui/favicon.ico create mode 100644 server/ui/index.html create mode 100644 server/ui/mailpit.svg create mode 100644 server/websockets/client.go create mode 100644 server/websockets/hub.go create mode 100644 smtpd/smtpd.go create mode 100644 storage/database.go create mode 100644 storage/database_test.go create mode 100644 storage/testdata/mime-attachment.eml create mode 100644 storage/testdata/plain-text.eml create mode 100644 storage/utils.go create mode 100644 updater/targz.go create mode 100644 updater/unzip.go create mode 100644 updater/updater.go diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml new file mode 100644 index 0000000..d40f6ee --- /dev/null +++ b/.github/workflows/release-build.yml @@ -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 }}' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8d3740 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/node_modules/ +/send +/server/ui/dist +/Makefile +/mailpit +*.old diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..66bfbe7 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README-BUILDING.md b/README-BUILDING.md new file mode 100644 index 0000000..7ceb95b --- /dev/null +++ b/README-BUILDING.md @@ -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" +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..9334686 --- /dev/null +++ b/README.md @@ -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. diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..5039746 --- /dev/null +++ b/cmd/root.go @@ -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") +} diff --git a/cmd/sendmail.go b/cmd/sendmail.go new file mode 100644 index 0000000..8dba8f8 --- /dev/null +++ b/cmd/sendmail.go @@ -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.") +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..bcf2abd --- /dev/null +++ b/cmd/version.go @@ -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 +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..c2eeb4d --- /dev/null +++ b/config/config.go @@ -0,0 +1,44 @@ +package config + +import ( + "errors" + "regexp" +) + +var ( + // SMTPListen to listen on : + SMTPListen = "0.0.0.0:1025" + + // HTTPListen to listen on : + 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 :") + } + if !re.MatchString(HTTPListen) { + return errors.New("HTTP bind should be in the format of :") + } + + return nil +} diff --git a/data/mailbox.go b/data/mailbox.go new file mode 100644 index 0000000..ae58ac2 --- /dev/null +++ b/data/mailbox.go @@ -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{} +} diff --git a/data/message.go b/data/message.go new file mode 100644 index 0000000..b08724a --- /dev/null +++ b/data/message.go @@ -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 +} diff --git a/esbuild.config.js b/esbuild.config.js new file mode 100644 index 0000000..b6a4bf9 --- /dev/null +++ b/esbuild.config.js @@ -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" +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..60fae06 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..54e5b2c --- /dev/null +++ b/go.sum @@ -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= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..1133213 --- /dev/null +++ b/logger/logger.go @@ -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)) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..cfbbce4 --- /dev/null +++ b/main.go @@ -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() + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..51efa5d --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0d2a08e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/sendmail/cmd/cmd.go b/sendmail/cmd/cmd.go new file mode 100644 index 0000000..f42f906 --- /dev/null +++ b/sendmail/cmd/cmd.go @@ -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) + } +} diff --git a/sendmail/main.go b/sendmail/main.go new file mode 100644 index 0000000..f609553 --- /dev/null +++ b/sendmail/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/axllent/mailpit/sendmail/cmd" + +func main() { + cmd.Run() +} diff --git a/server/api.go b/server/api.go new file mode 100644 index 0000000..e39083b --- /dev/null +++ b/server/api.go @@ -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) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..cd83c67 --- /dev/null +++ b/server/server.go @@ -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 +} diff --git a/server/ui-src/App.vue b/server/ui-src/App.vue new file mode 100644 index 0000000..25b4dcc --- /dev/null +++ b/server/ui-src/App.vue @@ -0,0 +1,416 @@ + + + diff --git a/server/ui-src/app.js b/server/ui-src/app.js new file mode 100644 index 0000000..d6ad63a --- /dev/null +++ b/server/ui-src/app.js @@ -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') diff --git a/server/ui-src/assets/_bootstrap_variables.scss b/server/ui-src/assets/_bootstrap_variables.scss new file mode 100644 index 0000000..3e26dec --- /dev/null +++ b/server/ui-src/assets/_bootstrap_variables.scss @@ -0,0 +1 @@ +$link-decoration: none; diff --git a/server/ui-src/assets/bootstrap.scss b/server/ui-src/assets/bootstrap.scss new file mode 100644 index 0000000..83e81d1 --- /dev/null +++ b/server/ui-src/assets/bootstrap.scss @@ -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 diff --git a/server/ui-src/assets/styles.scss b/server/ui-src/assets/styles.scss new file mode 100644 index 0000000..5628896 --- /dev/null +++ b/server/ui-src/assets/styles.scss @@ -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; + } +} diff --git a/server/ui-src/mixins.js b/server/ui-src/mixins.js new file mode 100644 index 0000000..c1604f0 --- /dev/null +++ b/server/ui-src/mixins.js @@ -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 diff --git a/server/ui-src/templates/Message.vue b/server/ui-src/templates/Message.vue new file mode 100644 index 0000000..bee7fd1 --- /dev/null +++ b/server/ui-src/templates/Message.vue @@ -0,0 +1,173 @@ + + + + diff --git a/server/ui/favicon.ico b/server/ui/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ecf37a3472659270b2ca4315df9d31100b60b548 GIT binary patch literal 15406 zcmeI336NC98GswasHpLZ7hXu#J!al0ia|hh1$JiM5Q1W{!tPuYLBt!x1Bg1Xmv{xQ zLW%K6EH6~9y;vzjh{h}6jd+AhMOhJ$h^c5q^Zjq8hd1}k?rKsMr>eK#d)@!pfB*gW z-@Qt8Q+ucb4^-5>)ubLu^;b&u?j3zEJxHnh`PR2@ti4#N=MPn?lsaM;+ zBzaz{4hD9;z{P03!TSoH*HV9L$H#knU5gq(+mc9&I$Get`+)T0+kN_*e3xH+n=uZe0|sTzESZTiA5oZ|@uQImggm9sg!JAMe*s?HTkqss&bmm*=4A z2umBfcr5!{N3A+M`BTz=f%A$OTCY`Q+veQfpCpttO7tB=oEBHx1^SGB-{fBE=9 zCHn_F&Ozwy6B|sDJs9nb$5{dQiTgrhnZO3zWv2y)l^#-EQR3p;#>}uW+cJ%h!k*H8 z@NuBubpAs09B>yOk6iEOgw^(G8tbY&#o{75{Szd;DQ8i{KXv9ewN35IoEU{|-OhaZ z3-+~%=dWabQh!eUDz^Lkx>i+r>*H#l&d{>Mg6~N1-wFPI=(&-rhR<#ASbNQUb$}gL zq0itM53L=!=w)h$?@#c5=M+j;`a}B`@Fw`zOmwn+hlg(|w^}~k#t6Q(=e(6_Pur*X zBfe->Hk=)5Yf+C+oEN26*QkzyrjJ@UAm3_7boCpQ8&ihqHE@y$a1x}y-&918(P%K@VZ&!$W@DtX?&3;bp-vtjKS=J zw+mu^eQelgOl3{D0{=M$KR7w$b6pcY`G!s!M-Mo9 zA80QvkoM=mY?HN9a3aGnN>;lRnpWRdb#6=_J0gozuaMW6S2%6g7UGdHf)6|QfjcjM z8Cj?fgwMN$J~lif06B)s|%vhkZn4Ae3*_t z_I?-icU^SRu?3Agv17g9xBt+=ZJK}44L+eM3@xe z%~=z#g45ccR4TPcWo3NtLmX<{n+%UmRuOleA8;RT`EX<&0L{lJU!#-N=w=E2?7?KX zwD0s2np$pTydBuNnxFD=)dLxnUEj;$!^CGfQi)iLmJJ!3x`-`bQ8#{LZ* zJvp&ozxbNXdqlSfO=pAV$ux`hqAj!UG+kRnPxP^D1RiAUn>DU7#WxTJ{JWL#MWKY(Owsg2`-xf%JS$p`FnZ9YP@S8_Or2A%~&zQYjRbKqFct4NB%&-TscmU5<@Q%{`1r{FC?*ZA* z>VE0ygXfLV&=Zc*eS+T7f!X&k(R~Q(^QQryW2lA`dxPiKwA}I9bT#Te$@HQ9UTBEZ z$_GfFfVj z$b9n;bvQI0zqh0Wxz=hP&{MN+)AVUi;Ct*)<8Z#cs$mMPMds=uWyfhc>_bno=`o4E z(e7sts{4xlLi@QwX{Pk|8m|Q0eGb!j{buO^`h1LCw&-u@Y6ovDrO7Fz;MzUD z+TQc5jb}w3jX&gZ4uO8${>8p@oA^-Vj^_fpiU)AE+I?76McE0OrmgOVAG^D&XeQ$U z_k5AhMlaz}*41Q~u6~Ecy%4q5&jjNCr!SHx=}Q?$>1>_^6e&L|cN@)PoJfwZUp??0&ma4o8+70 zv0J)$Qh$?vl%13piEnPMZ&E{kNI7bolnL#X6xj#G_uQJl0_$Di*-jkYR@1Cp->2$t zd|LH_mgKwsSs;%&_gaDv+&jU2HT9ugEeARu1P#g2bXMP4WdZ+JjKyMT4eTlzm}g$_ zO5P$b`xaexra(*Re8k)urO#TDWvN}Nj+wLEIx0$TN_eknRVCodo$u%*&*|ia|IN(p zbM5C(l53pRqRMG|7rx39m`!@Ad&DO1hx*2R*aDxwhVLFS7CevToACZEIg#^| zSdPD1ex1Cg^vlL8xHjVB@2PE7LF}x*l`rJ1t*%Y=rA_h=4b%zy;%xZRwSi-MU6UF| zeG}yi-A8oQ?I}P&7#f(usxKhsb3Y7^zwK_TXF0{yV zPe_v|+r?Trv$N+Gf*<|#fc6A*kutu8$N>C@at16o^)IITk2D?`1Nv=2=hog^a<@TW zX|e3#r1(vT!(M{Jon=<3pr&vZT52z{PmAYjS}mJyPabDYJ68={dp+mKns=$;^;$a| zeI!n~@X{8wSFplyEa#1Pus2!9K8-wyYm4ls$z`-##dK|!J-8I&@{cK-cqaeJ*`Lq& zPzw7D_OFgPf=lQ@Yb5yJqa@g>org@%@Vbq1hTxH3ZJRnCyIEuJ&zVN8oaKxehR#i6 z6+CV2f<41l@>*?yaz~N)MDgeFvB6F^pXZovM@PB0a?_LKn*~dp!1H36AJP0mhCE3J z_MGTpJ7cmT=qc0rpl-~qb2=~vU*T6T68`1P40y>K>Tz5h;r!pq@pzpJpqG-9>hL&_ z1Cd;o?88O>>>CC%zY^|B&VtXMuD_v^3&iGQ^EJ;LN}4+?HW|p_yio0So{p(@ z7klL{$Vu@2lBU_w8XPOE^T8JNl;FS*d^q)H#XSjULzmf&-0Lunw*sEBb1EIt{q973 zTBJ=K*3f3%&ykoy&%1)+3bB>i#rVxu^}67|o^G)GylK4IS=)*F8P`Vc#)Kp%Zmk34 zoFzvsxm!I4h@tjl{KuiY7vNzlyyQI>q`#b%11oXrPMJ?~_sF7c8~={TVy$If=jDw)MqmtE2Cn^{ZLKw!Q` z2QN>%PxWDK`FltBO@@KLYbza&cz#6Wh}or{Q(KXVwcL2bKP-ADN92&TUd~^L@2`fx zPjbm%yYsaWSt4#xrOqZ&|hLA(|M~spKIgYdXzg?axR7( z#7?_qh&@a0d6CSQfQR@Z=CA4(tHa^H8eSh_&Dw02gYOUWzG2pr-Q^xar*h)x@nMNk z?L3#W&bnjggZg{*;A0O$&WF?Ruh%(=4cD+v-zN9GG@P|oEmOtpi)CMD-5Z#8}$39ocu>=ah5834C75~Yns1*^z4(^|IDeg z4xLHcd->q8`dBfI@z>F|b3|dwI^Xd{?{OyMik{^f5&Kz*!1&9Hjut&Ij(Nwq=wm(i zMdezwYFMTYnKJ!|srm8`E%abKGj?dPbxtifphg;3;)ugL3B~BM(*^drJJH z-(uYMCk#$_$m>j!ejEIzYk&qH=XITti+`a_pEUtb$si3UwcH2e4(^%A#a=hXr^kJX zy-nr@b8)1OgET(5A4}WnY&x*;$Uewx#Phtl_>bZm9~2yg46FULzl*?tTMpA+&$L3myDkCj-1?SnNhnfOOXYl$T%=zhB9{&z9<-k275R$epA57YI$+yT(|b&V~Z zmv&y}!@p^?bDn;oj?v>dxiiC+>G)^7SfiaaW|7)U zw@WSmGa&Kt_r-ghH;*$Vo)SG;_ZYR0hX0V;b*AP|>z}zwJmzfBc~;#ndnvg~Xpd>) z^t4X8uIB$XjJfu^UBX9{2j>iIsoh#9hbQq*%ngr^CdY}<|1TjfNWbSuyFPVVHkl7z z=d2O^j_j#*D7C~+%<`RFp73b(^lM-NmOf zSM!glJIrH-H9_NJ&W0)+RmpLN<}+PgO`a8 + + + + + + + + + Mailpit + + + + +
+ +
+ + + + + diff --git a/server/ui/mailpit.svg b/server/ui/mailpit.svg new file mode 100644 index 0000000..94499f5 --- /dev/null +++ b/server/ui/mailpit.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/server/websockets/client.go b/server/websockets/client.go new file mode 100644 index 0000000..43f4e3f --- /dev/null +++ b/server/websockets/client.go @@ -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() +} diff --git a/server/websockets/hub.go b/server/websockets/hub.go new file mode 100644 index 0000000..85a3f7c --- /dev/null +++ b/server/websockets/hub.go @@ -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 }() +} diff --git a/smtpd/smtpd.go b/smtpd/smtpd.go new file mode 100644 index 0000000..00fbcea --- /dev/null +++ b/smtpd/smtpd.go @@ -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 +} diff --git a/storage/database.go b/storage/database.go new file mode 100644 index 0000000..4d1886e --- /dev/null +++ b/storage/database.go @@ -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)`) + 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 +} diff --git a/storage/database_test.go b/storage/database_test.go new file mode 100644 index 0000000..9d473bc --- /dev/null +++ b/storage/database_test.go @@ -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) +} diff --git a/storage/testdata/mime-attachment.eml b/storage/testdata/mime-attachment.eml new file mode 100644 index 0000000..58267a4 --- /dev/null +++ b/storage/testdata/mime-attachment.eml @@ -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: +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 + (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: +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 + (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" +From: Sender Smith +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 + + + + + + + + Message with inline image and attachment:
+
+
+
+
+ + +--------------z0ttbxz8BplvjsfeE7Zogcgs +Content-Type: image/jpeg; name="inline-image.jpg" +Content-Disposition: inline; filename="inline-image.jpg" +Content-Id: +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-- diff --git a/storage/testdata/plain-text.eml b/storage/testdata/plain-text.eml new file mode 100644 index 0000000..bbcacfb --- /dev/null +++ b/storage/testdata/plain-text.eml @@ -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: +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 + (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: +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 + (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 +To: Recipient Ross +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. diff --git a/storage/utils.go b/storage/utils.go new file mode 100644 index 0000000..8086865 --- /dev/null +++ b/storage/utils.go @@ -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) + } + } + } + } +} diff --git a/updater/targz.go b/updater/targz.go new file mode 100644 index 0000000..4819b05 --- /dev/null +++ b/updater/targz.go @@ -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 +} diff --git a/updater/unzip.go b/updater/unzip.go new file mode 100644 index 0000000..8b0b432 --- /dev/null +++ b/updater/unzip.go @@ -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 +} diff --git a/updater/updater.go b/updater/updater.go new file mode 100644 index 0000000..18ccc1c --- /dev/null +++ b/updater/updater.go @@ -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 .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 .old + if err := os.Rename(dst, oldTmpAbs); err != nil { + return err + } + + // rename the .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 +}