Compare commits

...

127 Commits
0.0.4 ... 1.1.2

Author SHA1 Message Date
Ralph Slooten
66aead387e Merge branch 'release/1.1.2' 2022-09-14 13:44:23 +12:00
Ralph Slooten
efe1ac732e Release 1.1.2 2022-09-14 13:44:23 +12:00
Ralph Slooten
33dcd489eb UI: Allow reverse proxy subdirectories 2022-09-14 13:43:38 +12:00
Ralph Slooten
6b2e5b2e41 mod tidy 2022-09-14 13:42:13 +12:00
Ralph Slooten
812c9b99d1 Update installation instructions 2022-09-14 12:11:52 +12:00
Ralph Slooten
8202c94a43 Merge tag '1.1.1' into develop
Release 1.1.1
2022-09-12 22:12:54 +12:00
Ralph Slooten
c1d4a73440 Merge branch 'release/1.1.1' 2022-09-12 22:12:51 +12:00
Ralph Slooten
8e100ff21b Release 1.1.1 2022-09-12 22:12:51 +12:00
Ralph Slooten
088b772de5 UI: Attachment icons and image thumbnails 2022-09-12 22:11:51 +12:00
Ralph Slooten
faf8bd4a08 Merge tag '1.1.0' into develop
Release 1.1.0
2022-09-10 00:00:42 +12:00
Ralph Slooten
0e83a5a985 Merge branch 'release/1.1.0' 2022-09-10 00:00:36 +12:00
Ralph Slooten
3ee91eb6c8 Release 1.1.0 2022-09-10 00:00:36 +12:00
Ralph Slooten
5cd0a6e2f3 UI tweaks 2022-09-09 23:57:53 +12:00
Ralph Slooten
fea733a43e UI: HTML source & highlighting 2022-09-09 23:34:35 +12:00
Ralph Slooten
d4e520772e Remove redundant npm dependency ('remove') 2022-09-04 22:04:26 +12:00
Ralph Slooten
e4a7212f89 Reload UI on prev/next message 2022-09-03 23:02:10 +12:00
Ralph Slooten
e6a5fceedd UI: Add previous/next message links 2022-09-03 22:46:38 +12:00
Ralph Slooten
bf4d5fbc6b Update changelog format 2022-09-03 19:20:51 +12:00
Ralph Slooten
93c3dec66e Merge tag '1.0.0' into develop
Release 1.0.0
2022-09-03 19:13:46 +12:00
Ralph Slooten
98026e0685 Merge branch 'release/1.0.0' 2022-09-03 19:13:42 +12:00
Ralph Slooten
ecd3a97853 Release 1.0.0 2022-09-03 19:13:20 +12:00
Ralph Slooten
695270e515 Merge branch 'feature/multi-selection' into develop 2022-09-03 19:09:57 +12:00
Ralph Slooten
43403bc6f7 Feature: Multiple message selection for group actions using shift/ctrl click
Allow group actions for deleting & marking as read/unread, resolves #11
2022-09-03 19:01:54 +12:00
Ralph Slooten
6dbdbf1637 UI: Post data using 'application/json' 2022-09-03 19:01:54 +12:00
Ralph Slooten
3c81e152e6 UI: Display unknown recipients as as Undisclosed recipients 2022-09-03 19:01:53 +12:00
Ralph Slooten
9501b460c5 Update README 2022-09-03 19:01:53 +12:00
Ralph Slooten
6233cb1e07 UI: Update frontend modules & esbuild 2022-09-03 19:01:53 +12:00
Ralph Slooten
f64f377199 Feature: Search parser improvements 2022-09-03 19:01:34 +12:00
Ralph Slooten
f872424526 UI: Update frontend modules & esbuild 2022-09-01 22:01:56 +12:00
Ralph Slooten
5d530edfab feature: Search parser improvements 2022-09-01 21:45:35 +12:00
Ralph Slooten
12c54f4bb3 Update changelog format 2022-08-30 23:52:53 +12:00
Ralph Slooten
23e47c567a Merge tag '1.0.0-beta1' into develop
Release 1.0.0-beta1
2022-08-30 23:15:36 +12:00
Ralph Slooten
b6940eccff Merge branch 'release/1.0.0-beta1' 2022-08-30 23:15:31 +12:00
Ralph Slooten
eb796924b1 Merge branch 'feature/sqlite' into develop 2022-08-30 23:10:25 +12:00
Ralph Slooten
54ba59872e Deprecate --data flag (replaced by --db-file) 2022-08-30 23:02:56 +12:00
Ralph Slooten
eff483c1c4 feature: Switch backend storage to use SQLite
BREAKING CHANGE: This release includes a major backend storage change (SQLite) that will render any previously-saved messages useless. Please delete old data to free up space. For more information see https://github.com/axllent/mailpit/issues/10
2022-08-30 22:42:43 +12:00
Ralph Slooten
9f5d329105 Update CHANGELOG format 2022-08-29 22:44:32 +12:00
Ralph Slooten
77e6b88c5d UI: Resize preview iframe on load 2022-08-29 22:22:07 +12:00
Ralph Slooten
5a9fd0686e Update README 2022-08-18 21:41:37 +12:00
Ralph Slooten
3054dfe79e Catch error in testing 2022-08-18 21:28:46 +12:00
Ralph Slooten
40cb76810e Merge tag '0.1.5' into develop
Release 0.1.5
2022-08-16 08:17:24 +12:00
Ralph Slooten
8b6b6640d5 Merge branch 'release/0.1.5' 2022-08-16 08:17:21 +12:00
Ralph Slooten
a8945bd303 Release 0.1.5 2022-08-16 08:17:20 +12:00
Ralph Slooten
53e199b20f Better error handling on failed upgrade if file corrupt 2022-08-16 08:16:03 +12:00
Ralph Slooten
a6693481fa Quote exact string matches in search test 2022-08-12 10:19:49 +12:00
Ralph Slooten
1aa58eeaaf Feature: Improved message search - any order & phrase quoting 2022-08-12 10:16:21 +12:00
Ralph Slooten
133b36c34c UI: Change breakpoints for mobile view of messages 2022-08-11 00:32:10 +12:00
Ralph Slooten
ed28a4cc0d UI: Resize iframes with viewport resize 2022-08-11 00:31:22 +12:00
Ralph Slooten
bc30b012cf Merge tag '0.1.4' into develop
Release 0.1.4
2022-08-10 20:32:02 +12:00
Ralph Slooten
2ae51c3f64 Merge branch 'release/0.1.4' 2022-08-10 20:31:59 +12:00
Ralph Slooten
b6a87b9410 Release 0.1.4 2022-08-10 20:31:59 +12:00
Ralph Slooten
1f7dd0287a Merge branch 'feature/ui-tweaks' into develop 2022-08-10 20:31:25 +12:00
Ralph Slooten
f33cbce63f Merge tag '0.1.4' into develop
Release 0.1.4
2022-08-10 20:30:05 +12:00
Ralph Slooten
79b6892320 Merge branch 'release/0.1.4' 2022-08-10 20:30:02 +12:00
Ralph Slooten
799987ecb1 Release 0.1.4 2022-08-10 20:30:01 +12:00
Ralph Slooten
2d57839b3e UI: Mobile compatibility improvements & functionality 2022-08-10 20:21:27 +12:00
Ralph Slooten
86cc237c78 Feature: Email compression in storage
Reduces storage requirements +-25% & speeds up database read & writes by between 25-33%, depending on email content (attachments).
2022-08-10 14:33:16 +12:00
Ralph Slooten
cc15ada304 Testing: Enable testing on feature branches 2022-08-10 09:48:06 +12:00
Ralph Slooten
49bc62f0aa Update screenshot 2022-08-08 23:16:48 +12:00
Ralph Slooten
444b65d371 Testing: Database total/unread statistics tests 2022-08-07 23:07:36 +12:00
Ralph Slooten
15859f7be9 Add Go Report Card 2022-08-07 22:38:23 +12:00
Ralph Slooten
486388a798 Fix typos 2022-08-07 22:35:42 +12:00
Ralph Slooten
9ab28d606a Add privacy classes for screenshots 2022-08-07 13:38:53 +12:00
Ralph Slooten
18b5ce8c18 Add build status to README 2022-08-07 10:57:45 +12:00
Ralph Slooten
93d5289d25 Merge tag '0.1.3' into develop
Release 0.1.3
2022-08-07 10:41:02 +12:00
Ralph Slooten
97bf9c257c Merge branch 'release/0.1.3' 2022-08-07 10:40:59 +12:00
Ralph Slooten
18b0f5b790 Release 0.1.3 2022-08-07 10:40:59 +12:00
Ralph Slooten
94feb2ccaa Update screenshot 2022-08-07 10:38:40 +12:00
Ralph Slooten
aba3c46eb1 Update wording for "no emails/results message" 2022-08-07 10:28:33 +12:00
Ralph Slooten
c9c910ab7c UI: Better error handling when connection to server is broken 2022-08-07 10:21:08 +12:00
Ralph Slooten
29c7295d16 Merge branch 'feature/ui-tweaks' into develop 2022-08-07 10:14:40 +12:00
Ralph Slooten
61e15e4155 UI: Add reset search button 2022-08-07 10:11:48 +12:00
Ralph Slooten
e03618570d UI: Minor UI tweaks 2022-08-07 10:11:21 +12:00
Ralph Slooten
d4cf95363f Feature: Mark all messages as read 2022-08-07 09:34:06 +12:00
Ralph Slooten
f260495495 UI: Update pagination values when new mail arrives when not on first page 2022-08-07 08:38:52 +12:00
Ralph Slooten
d9f1f88107 Merge pull request #6 from KaptinLin/develop
Bugfix: Add MP_SMTP_SSL_CERT and MP_SMTP_SSL_KEY env variables
2022-08-07 08:12:48 +12:00
KaptinLin
09b704bcd7 Add MP_SMTP_SSL_CERT and MP_SMTP_SSL_KEY env variables 2022-08-06 22:34:33 +08:00
Ralph Slooten
a14cdce07f Update disconnected state hover title 2022-08-07 01:15:40 +12:00
Ralph Slooten
9fc5318e86 Merge tag '0.1.2' into develop
Release 0.1.2
2022-08-07 01:08:03 +12:00
Ralph Slooten
8affa0f375 Merge branch 'release/0.1.2' 2022-08-07 01:07:58 +12:00
Ralph Slooten
cf8994ceaf Release 0.1.2 2022-08-07 01:07:58 +12:00
Ralph Slooten
39132723db Update README 2022-08-07 01:07:04 +12:00
Ralph Slooten
642487742c Feature: Optional browser notifications (HTTPS only) 2022-08-07 01:04:55 +12:00
Ralph Slooten
544f0175d9 Security: Don't allow tar files containing a ".." 2022-08-07 00:26:18 +12:00
Ralph Slooten
788e390e01 Ignore http.RsponseWriter errors 2022-08-07 00:09:32 +12:00
Ralph Slooten
f6ae6bbdbb Merge branch 'feature/security' into develop 2022-08-06 23:55:36 +12:00
Ralph Slooten
1155443785 Security: Sanitize mailbox names 2022-08-06 23:54:34 +12:00
Ralph Slooten
056bef7d5e Security: Use strconv.Atoi() for safe string to int conversions 2022-08-06 23:54:19 +12:00
Ralph Slooten
37eec298d7 0.1.1 2022-08-06 23:11:55 +12:00
Ralph Slooten
a77b532328 Merge tag '0.1.1' into develop
Merge
2022-08-06 23:11:12 +12:00
Ralph Slooten
00d6463de1 Merge branch 'hotfix/0.1.1' 2022-08-06 23:08:49 +12:00
Ralph Slooten
a3b92711a9 Bugfix: Fix env variable for MP_UI_SSL_KEY 2022-08-06 23:08:34 +12:00
Ralph Slooten
ba8c4cd2aa Merge tag '0.1.0' into develop
Release 0.1.0
2022-08-06 20:01:50 +12:00
Ralph Slooten
ec5267f5a5 Merge branch 'release/0.1.0' 2022-08-06 20:01:45 +12:00
Ralph Slooten
73d2b1ba93 Release 0.1.0 2022-08-06 20:01:45 +12:00
Ralph Slooten
56fdaa1224 Feature: SMTP STARTTLS & SMTP authentication support
Resolves #4
2022-08-06 20:00:05 +12:00
Ralph Slooten
25090aeb2a Create codeql-analysis.yml 2022-08-06 00:29:42 +12:00
Ralph Slooten
9bc8d005fb Merge tag '0.0.9' into develop
Release 0.0.9
2022-08-06 00:12:19 +12:00
Ralph Slooten
b57e340389 Merge branch 'release/0.0.9' 2022-08-06 00:12:10 +12:00
Ralph Slooten
b9043b6c39 Release 0.0.9 2022-08-06 00:12:09 +12:00
Ralph Slooten
5860171002 Feature: HTTPS option for web UI 2022-08-06 00:09:20 +12:00
Ralph Slooten
ad49bf2898 Bugfix: Include read status in search results 2022-08-05 23:04:14 +12:00
Ralph Slooten
2d221a6b67 Testing: Memory & physical database tests 2022-08-05 21:35:57 +12:00
Ralph Slooten
4f266cd3f3 Merge tag '0.0.8' into develop
Release 0.0.8
2022-08-05 16:17:17 +12:00
Ralph Slooten
9fc7202552 Merge branch 'release/0.0.8' 2022-08-05 16:17:15 +12:00
Ralph Slooten
22a476ded5 Release 0.0.8 2022-08-05 16:17:15 +12:00
Ralph Slooten
54d3f6e3ad UI: Add project links to help in CLI 2022-08-05 15:53:22 +12:00
Ralph Slooten
cbe61e3f2e Add screenshot 2022-08-05 15:40:32 +12:00
Ralph Slooten
3b65a8852e Bugfix: Fix total/unread count after failed message inserts 2022-08-05 15:15:27 +12:00
Ralph Slooten
970a534d77 Update link to wiki 2022-08-04 23:18:06 +12:00
Ralph Slooten
f7502b1c14 Refer to wiki for build instructions 2022-08-04 23:17:01 +12:00
Ralph Slooten
e0f7d88d61 Merge tag '0.0.7' into develop
Release 0.0.7
2022-08-04 23:00:17 +12:00
Ralph Slooten
fc8148bfb3 Merge branch 'release/0.0.7' 2022-08-04 22:59:57 +12:00
Ralph Slooten
74fe6d55b4 Release 0.0.7 2022-08-04 22:59:57 +12:00
Ralph Slooten
47376d4db9 Update README 2022-08-04 22:59:07 +12:00
Ralph Slooten
4b9b60f247 Merge branch 'feature/docker' into develop 2022-08-04 22:51:28 +12:00
Ralph Slooten
123b0f19db Feature:: Add multi-arch docker image
Resolves #2
2022-08-04 22:51:20 +12:00
Ralph Slooten
9fed08245a Bugfix: Command flag should be --auth-file 2022-08-04 22:44:54 +12:00
Ralph Slooten
f807c166f7 Merge tag '0.0.6' into develop
Release 0.0.6
2022-08-04 20:48:07 +12:00
Ralph Slooten
9d257dd3c0 Merge branch 'release/0.0.6' 2022-08-04 20:48:05 +12:00
Ralph Slooten
f74bb70499 Release 0.0.6 2022-08-04 20:48:05 +12:00
Ralph Slooten
802f6f5672 Bugfix: Disable CGO when building multi-arch binaries 2022-08-04 20:46:39 +12:00
Ralph Slooten
19966fad81 Merge tag '0.0.5' into develop
Release 0.0.5
2022-08-04 17:19:28 +12:00
Ralph Slooten
48db1437b3 Merge branch 'release/0.0.5' 2022-08-04 17:19:18 +12:00
Ralph Slooten
1df270bab3 Release 0.0.5 2022-08-04 17:19:18 +12:00
Ralph Slooten
6fe1bdb579 Feature: Basic authentication support 2022-08-04 17:18:07 +12:00
Ralph Slooten
9a27f33079 Merge tag '0.0.4' into develop
Release 0.0.4
2022-08-02 07:57:19 +12:00
39 changed files with 3149 additions and 1644 deletions

View File

@@ -2,7 +2,6 @@
Notable changes to Mailpit will be documented in this file.
{{ if .Versions -}}
{{ if .Unreleased.CommitGroups -}}
## [Unreleased]
@@ -22,27 +21,27 @@ Notable changes to Mailpit will be documented in this file.
{{- if .CommitGroups -}}
## {{ .Tag.Name }}
{{ if .NoteGroups -}}
{{ range .NoteGroups -}}
### {{ .Title }}
{{ range .Notes }}
{{ .Body }}
{{ end -}}
{{ end }}
{{ end -}}
{{ end -}}
{{ range .CommitGroups -}}
### {{ .Title }}
{{ range .Commits -}}
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
{{ end }}
{{ end }}
{{ end -}}
{{- if .MergeCommits -}}
### Pull Requests
{{ range .MergeCommits -}}
- {{ .Header }}
{{ end }}
{{ end -}}
{{- if .NoteGroups -}}
{{ range .NoteGroups -}}
### {{ .Title }}
{{ range .Notes }}
{{ .Body }}
{{ end }}
{{ end -}}
{{ end -}}
{{ end -}}

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
/node_modules
/mailpit

37
.github/workflows/build-docker.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
on:
release:
types: [created]
name: Build docker images
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Get tag
id: tag
uses: dawidd6/action-get-tag@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm
build-args: |
"VERSION=${{ steps.tag.outputs.tag }}"
push: true
tags: axllent/mailpit:latest,axllent/mailpit:${{ steps.tag.outputs.tag }}

72
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ "develop" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "develop" ]
schedule:
- cron: '34 23 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@@ -38,6 +38,7 @@ jobs:
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
binary_name: "mailpit"
pre_command: export CGO_ENABLED=0
asset_name: mailpit-${{ matrix.goos }}-${{ matrix.goarch }}
extra_files: LICENSE README.md
md5sum: false

View File

@@ -1,9 +1,9 @@
name: Test
name: Tests
on:
pull_request:
branches: [ develop ]
push:
branches: [ develop ]
branches: [ develop, 'feature/**' ]
jobs:
test:
strategy:

3
.gitignore vendored
View File

@@ -2,5 +2,6 @@
/send
/server/ui/dist
/Makefile
/mailpit
/mailpit*
*.old
*.db

View File

@@ -2,6 +2,153 @@
Notable changes to Mailpit will be documented in this file.
## 1.1.2
### UI
- Allow reverse proxy subdirectories
## 1.1.1
### UI
- Attachment icons and image thumbnails
## 1.1.0
### UI
- HTML source & highlighting
- Add previous/next message links
## 1.0.0
### Feature
- Multiple message selection for group actions using shift/ctrl click
- Search parser improvements
### Feature
- Search parser improvements
### UI
- Post data using 'application/json'
- Display unknown recipients as as `Undisclosed recipients`
- Update frontend modules & esbuild
- Update frontend modules & esbuild
## 1.0.0-beta1
### BREAKING CHANGE
This release includes a major backend storage change (SQLite) that will render any previously-saved messages useless. Please delete old data to free up space. For more information see https://github.com/axllent/mailpit/issues/10
### Feature
- Switch backend storage to use SQLite
### UI
- Resize preview iframe on load
## 0.1.5
### Feature
- Improved message search - any order & phrase quoting
### UI
- Change breakpoints for mobile view of messages
- Resize iframes with viewport resize
## 0.1.4
### Feature
- Email compression in storage
### Testing
- Enable testing on feature branches
- Database total/unread statistics tests
### UI
- Mobile compatibility improvements & functionality
## 0.1.3
### Feature
- Mark all messages as read
### UI
- Better error handling when connection to server is broken
- Add reset search button
- Minor UI tweaks
- Update pagination values when new mail arrives when not on first page
### Pull Requests
- Merge pull request [#6](https://github.com/axllent/mailpit/issues/6) from KaptinLin/develop
## 0.1.2
### Feature
- Optional browser notifications (HTTPS only)
### Security
- Don't allow tar files containing a ".."
- Sanitize mailbox names
- Use strconv.Atoi() for safe string to int conversions
## 0.1.1
### Bugfix
- Fix env variable for MP_UI_SSL_KEY
## 0.1.0
### Feature
- SMTP STARTTLS & SMTP authentication support
## 0.0.9
### Bugfix
- Include read status in search results
### Feature
- HTTPS option for web UI
### Testing
- Memory & physical database tests
## 0.0.8
### Bugfix
- Fix total/unread count after failed message inserts
### UI
- Add project links to help in CLI
## 0.0.7
### Bugfix
- Command flag should be `--auth-file`
## 0.0.6
### Bugfix
- Disable CGO when building multi-arch binaries
## 0.0.5
### Feature
- Basic authentication support
## 0.0.4
@@ -32,3 +179,4 @@ Notable changes to Mailpit will be documented in this file.
- Unread statistics

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM golang:alpine as builder
ARG VERSION=dev
COPY . /app
WORKDIR /app
RUN apk add --no-cache git npm && \
npm install && npm run package && \
CGO_ENABLED=0 go build -ldflags "-s -w -X github.com/axllent/mailpit/cmd.Version=${VERSION}" -o /mailpit
FROM alpine:latest
COPY --from=builder /mailpit /mailpit
RUN apk add --no-cache tzdata
ENTRYPOINT ["/mailpit"]

View File

@@ -1,45 +0,0 @@
# 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"
```

View File

@@ -1,38 +1,48 @@
# Mailpit
# Mailpit - email testing for developers
Mailpit is an email testing tool for developers.
![Tests](https://github.com/axllent/mailpit/actions/workflows/tests.yml/badge.svg)
![Build status](https://github.com/axllent/mailpit/actions/workflows/release-build.yml/badge.svg)
![Docker builds](https://github.com/axllent/mailpit/actions/workflows/build-docker.yml/badge.svg)
![CodeQL](https://github.com/axllent/mailpit/actions/workflows/codeql-analysis.yml/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/axllent/mailpit)](https://goreportcard.com/report/github.com/axllent/mailpit)
Mailpit is a multi-platform 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.
![Mailpit](https://raw.githubusercontent.com/axllent/mailpit/develop/screenshot.png)
## Features
- Runs completely on a single binary
- Runs entirely from a single binary, no installation required
- SMTP server (default `0.0.0.0:1025`)
- Web UI to view emails (HTML format, text, source and MIME attachments, default `0.0.0.0:8025`)
- Real-time web UI updates using web sockets for new mail
- Email storage in either memory or disk (using [CloverDB](https://github.com/ostafen/clover)) - note that in-memory has a physical limit of 1MB per email
- Optional browser notifications for new mail (HTTPS only)
- Configurable automatic email pruning (default keeps the most recent 500 emails)
- 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
- Email storage either in a temporary or persistent database ([see wiki](https://github.com/axllent/mailpit/wiki/Email-storage))
- Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size
- Can handle hundreds of thousands of emails
- Optional SMTP with STARTTLS & SMTP authentication ([see wiki](https://github.com/axllent/mailpit/wiki/SMTP-with-STARTTLS-and-authentication))
- Optional HTTPS for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/HTTPS))
- Optional basic authentication for web UI ([see wiki](https://github.com/axllent/mailpit/wiki/Basic-authentication))
- Multi-architecture [Docker images](https://github.com/axllent/mailpit/wiki/Docker-images)
## Installation
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.
Linux & Mac users can install it directly to `/usr/local/bin/mailpit` with:
To build mailpit from source see [building from source](README-BUILDING.md).
```bash
sudo bash < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)
```
Or 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, or see [the wiki](https://github.com/axllent/mailpit/wiki/Runtime-options) for additional information.
To build Mailpit from source see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source).
### Configuring sendmail
@@ -44,11 +54,11 @@ 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).
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)).
You can build a Mailpit-specific sendmail binary from source (see [building from source](https://github.com/axllent/mailpit/wiki/Building-from-source)).
## Why rewrite MailHog?

View File

@@ -1,6 +1,7 @@
package cmd
import (
"fmt"
"os"
"strconv"
@@ -20,7 +21,11 @@ var rootCmd = &cobra.Command{
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.`,
It acts as an SMTP server, and provides a web interface to view all captured emails.
Documentation:
https://github.com/axllent/mailpit
https://github.com/axllent/mailpit/wiki`,
Run: func(_ *cobra.Command, _ []string) {
if err := config.VerifyConfig(); err != nil {
logger.Log().Error(err.Error())
@@ -60,13 +65,16 @@ func SendmailExecute() {
func init() {
// hide autocompletion
rootCmd.CompletionOptions.HiddenDefaultCmd = true
// rootCmd.Flags().SortFlags = false
// hide help
rootCmd.Flags().SortFlags = false
// hide help command
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
// hide help flag
rootCmd.PersistentFlags().BoolP("help", "h", false, "This help")
rootCmd.PersistentFlags().Lookup("help").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_DATA_FILE")) > 0 {
config.DataFile = os.Getenv("MP_DATA_FILE")
}
if len(os.Getenv("MP_SMTP_BIND_ADDR")) > 0 {
config.SMTPListen = os.Getenv("MP_SMTP_BIND_ADDR")
@@ -77,10 +85,72 @@ func init() {
if len(os.Getenv("MP_MAX_MESSAGES")) > 0 {
config.MaxMessages, _ = strconv.Atoi(os.Getenv("MP_MAX_MESSAGES"))
}
if len(os.Getenv("MP_UI_AUTH_FILE")) > 0 {
config.UIAuthFile = os.Getenv("MP_UI_AUTH_FILE")
}
if len(os.Getenv("MP_UI_SSL_CERT")) > 0 {
config.UISSLCert = os.Getenv("MP_UI_SSL_CERT")
}
if len(os.Getenv("MP_UI_SSL_KEY")) > 0 {
config.UISSLKey = os.Getenv("MP_UI_SSL_KEY")
}
if len(os.Getenv("MP_SMTP_AUTH_FILE")) > 0 {
config.SMTPAuthFile = os.Getenv("MP_SMTP_AUTH_FILE")
}
if len(os.Getenv("MP_SMTP_SSL_CERT")) > 0 {
config.SMTPSSLCert = os.Getenv("MP_SMTP_SSL_CERT")
}
if len(os.Getenv("MP_SMTP_SSL_KEY")) > 0 {
config.SMTPSSLKey = os.Getenv("MP_SMTP_SSL_KEY")
}
rootCmd.Flags().StringVarP(&config.DataDir, "data", "d", config.DataDir, "Optional path to store peristent data")
// deprecated 2022/08/06
if len(os.Getenv("MP_AUTH_FILE")) > 0 {
config.UIAuthFile = os.Getenv("MP_AUTH_FILE")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_CERT")) > 0 {
config.UISSLCert = os.Getenv("MP_SSL_CERT")
}
// deprecated 2022/08/06
if len(os.Getenv("MP_SSL_KEY")) > 0 {
config.UISSLKey = os.Getenv("MP_SSL_KEY")
}
// deprecated 2022/08/28
if len(os.Getenv("MP_DATA_DIR")) > 0 {
fmt.Println("MP_DATA_DIR has been deprecated, use MP_DATA_FILE")
config.DataFile = os.Getenv("MP_DATA_DIR")
}
rootCmd.Flags().StringVarP(&config.DataFile, "db-file", "d", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().StringVarP(&config.SMTPListen, "smtp", "s", config.SMTPListen, "SMTP bind interface and port")
rootCmd.Flags().StringVarP(&config.HTTPListen, "listen", "l", config.HTTPListen, "HTTP bind interface and port for UI")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages per mailbox")
rootCmd.Flags().IntVarP(&config.MaxMessages, "max", "m", config.MaxMessages, "Max number of messages to store")
rootCmd.Flags().StringVar(&config.UIAuthFile, "ui-auth-file", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UISSLCert, "ui-ssl-cert", config.UISSLCert, "SSL certificate for web UI - requires ui-ssl-key")
rootCmd.Flags().StringVar(&config.UISSLKey, "ui-ssl-key", config.UISSLKey, "SSL key for web UI - requires ui-ssl-cert")
rootCmd.Flags().StringVar(&config.SMTPAuthFile, "smtp-auth-file", config.SMTPAuthFile, "A password file for SMTP authentication")
rootCmd.Flags().StringVar(&config.SMTPSSLCert, "smtp-ssl-cert", config.SMTPSSLCert, "SSL certificate for SMTP - requires smtp-ssl-key")
rootCmd.Flags().StringVar(&config.SMTPSSLKey, "smtp-ssl-key", config.SMTPSSLKey, "SSL key for SMTP - requires smtp-ssl-cert")
rootCmd.Flags().BoolVarP(&config.VerboseLogging, "verbose", "v", false, "Verbose logging")
// deprecated 2022/08/06
rootCmd.Flags().StringVarP(&config.UIAuthFile, "auth-file", "a", config.UIAuthFile, "A password file for web UI authentication")
rootCmd.Flags().StringVar(&config.UISSLCert, "ssl-cert", config.UISSLCert, "SSL certificate - requires ssl-key")
rootCmd.Flags().StringVar(&config.UISSLKey, "ssl-key", config.UISSLKey, "SSL key - requires ssl-cert")
rootCmd.Flags().Lookup("auth-file").Hidden = true
rootCmd.Flags().Lookup("auth-file").Deprecated = "use --ui-auth-file"
rootCmd.Flags().Lookup("ssl-cert").Hidden = true
rootCmd.Flags().Lookup("ssl-cert").Deprecated = "use --ui-ssl-cert"
rootCmd.Flags().Lookup("ssl-key").Hidden = true
rootCmd.Flags().Lookup("ssl-key").Deprecated = "use --ui-ssl-key"
// deprecated 2022/08/30
rootCmd.Flags().StringVar(&config.DataFile, "data", config.DataFile, "Database file to store persistent data")
rootCmd.Flags().Lookup("data").Hidden = true
rootCmd.Flags().Lookup("data").Deprecated = "use --db-file"
}

View File

@@ -2,7 +2,12 @@ package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"github.com/tg123/go-htpasswd"
)
var (
@@ -12,8 +17,8 @@ var (
// HTTPListen to listen on <interface>:<port>
HTTPListen = "0.0.0.0:8025"
// DataDir for mail (optional)
DataDir string
// DataFile for mail (optional)
DataFile string
// MaxMessages is the maximum number of messages a mailbox can have (auto-pruned every minute)
MaxMessages = 500
@@ -21,17 +26,40 @@ var (
// VerboseLogging for console output
VerboseLogging = false
// NoLogging for testing
// NoLogging for tests
NoLogging = false
// SSLCert @TODO
SSLCert string
// SSLKey @TODO
SSLKey string
// UISSLCert file
UISSLCert string
// UISSLKey file
UISSLKey string
// UIAuthFile for basic authentication
UIAuthFile string
// UIAuth used for euthentication
UIAuth *htpasswd.File
// SMTPSSLCert file
SMTPSSLCert string
// SMTPSSLKey file
SMTPSSLKey string
// SMTPAuthFile for SMTP authentication
SMTPAuthFile string
// SMTPAuth used for euthentication
SMTPAuth *htpasswd.File
)
// VerifyConfig wil do some basic checking
func VerifyConfig() error {
if DataFile != "" && isDir(DataFile) {
DataFile = filepath.Join(DataFile, "mailpit.db")
}
re := regexp.MustCompile(`^[a-zA-Z0-9\.\-]{3,}:\d{2,}$`)
if !re.MatchString(SMTPListen) {
return errors.New("SMTP bind should be in the format of <ip>:<port>")
@@ -40,5 +68,81 @@ func VerifyConfig() error {
return errors.New("HTTP bind should be in the format of <ip>:<port>")
}
if UIAuthFile != "" {
if !isFile(UIAuthFile) {
return fmt.Errorf("HTTP password file not found: %s", UIAuthFile)
}
a, err := htpasswd.New(UIAuthFile, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
UIAuth = a
}
if UISSLCert != "" && UISSLKey == "" || UISSLCert == "" && UISSLKey != "" {
return errors.New("you must provide both a UI SSL certificate and a key")
}
if UISSLCert != "" {
if !isFile(UISSLCert) {
return fmt.Errorf("SSL certificate not found: %s", UISSLCert)
}
if !isFile(UISSLKey) {
return fmt.Errorf("SSL key not found: %s", UISSLKey)
}
}
if SMTPSSLCert != "" && SMTPSSLKey == "" || SMTPSSLCert == "" && SMTPSSLKey != "" {
return errors.New("you must provide both an SMTP SSL certificate and a key")
}
if SMTPSSLCert != "" {
if !isFile(SMTPSSLCert) {
return fmt.Errorf("SMTP SSL certificate not found: %s", SMTPSSLCert)
}
if !isFile(SMTPSSLKey) {
return fmt.Errorf("SMTP SSL key not found: %s", SMTPSSLKey)
}
}
if SMTPAuthFile != "" {
if !isFile(SMTPAuthFile) {
return fmt.Errorf("SMTP password file not found: %s", SMTPAuthFile)
}
if SMTPSSLCert == "" {
return errors.New("SMTP authentication requires SMTP encryption")
}
a, err := htpasswd.New(SMTPAuthFile, htpasswd.DefaultSystems, nil)
if err != nil {
return err
}
SMTPAuth = a
}
return nil
}
// IsFile returns if a path is a file
func isFile(path string) bool {
info, err := os.Stat(path)
if os.IsNotExist(err) || !info.Mode().IsRegular() {
return false
}
return true
}
// IsDir returns whether 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
}

View File

@@ -1,3 +1,4 @@
// Package data contains the message & mailbox structs
package data
import (
@@ -17,9 +18,9 @@ type Message struct {
Bcc []*mail.Address
Subject string
Date time.Time
Created time.Time
Text string
HTML string
HTMLSource string
Size int
Inline []Attachment
Attachments []Attachment

59
go.mod
View File

@@ -3,50 +3,61 @@ module github.com/axllent/mailpit
go 1.18
require (
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244
github.com/axllent/semver v0.0.1
github.com/disintegration/imaging v1.6.2
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/klauspost/compress v1.15.9
github.com/leporo/sqlf v1.3.0
github.com/mattn/go-shellwords v1.0.12
github.com/mhale/smtpd v0.8.0
github.com/ostafen/clover/v2 v2.0.0-alpha.2
github.com/satori/go.uuid v1.2.0
github.com/sirupsen/logrus v1.9.0
github.com/spf13/cobra v1.5.0
github.com/spf13/pflag v1.0.5
github.com/tg123/go-htpasswd v1.2.0
golang.org/x/text v0.3.7
modernc.org/sqlite v1.18.1
)
require (
github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect
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/cznic/ql v1.2.0 // 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/google/uuid v1.3.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/klauspost/compress v1.15.9 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/mattn/go-isatty v0.0.16 // 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.1 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rivo/uniseg v0.3.4 // 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-20220728211354-c7608f3a8462 // indirect
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect
golang.org/x/tools v0.1.12 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.36.3 // indirect
modernc.org/ccgo/v3 v3.16.9 // indirect
modernc.org/libc v1.17.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.2.1 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)

274
go.sum
View File

@@ -1,100 +1,64 @@
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/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 h1:KeNholpO2xKjgaaSyd+DyQRrsQjhbSeS7qe4nEw8aQw=
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962/go.mod h1:kC29dT1vFpj7py2OvG1khBdQpo3kInWP+6QipLbdngo=
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244 h1:dqzm54OhCqY8RinR/cx+Ppb0y56Ds5I3wwWhx4XybDg=
github.com/GuiaBolso/darwin v0.0.0-20191218124601-fd6d2aa3d244/go.mod h1:3sqgkckuISJ5rs1EpOp6vCvwOUKe/z9vPmyuIlq8Q/A=
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/cznic/b v0.0.0-20180115125044-35e9bbe41f07 h1:UHFGPvSxX4C4YBApSPvmUfL8tTvWLj2ryqvT9K4Jcuk=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f h1:7uSNgsgcarNk4oiN/nNkO0J7KAjlsF5Yv5Gf/tFdHas=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4 h1:CVAqftqbj+exlab+8KJQrE+kNIVlQfJt58j4GxCMF1s=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00 h1:FHpbUtp2K8X53/b4aFNj4my5n+i3x+CQCZWNuHWH/+E=
github.com/cznic/internal v0.0.0-20180608152220-f44710a21d00/go.mod h1:olo7eAdKwJdXxb55TKGLiJ6xt1H0/tiiRCWKVLmtjY4=
github.com/cznic/lldb v1.1.0 h1:AIA+ham6TSJ+XkMe8imQ/g8KPzMUVWAwqUQQdtuMsHs=
github.com/cznic/lldb v1.1.0/go.mod h1:FIZVUmYUVhPwRiPzL8nD/mpFcJ/G7SSXjjXYG4uRI3A=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369 h1:XNT/Zf5l++1Pyg08/HV04ppB0gKxAqtZQBRYiYrUuYk=
github.com/cznic/mathutil v0.0.0-20180504122225-ca4c9f2c1369/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/cznic/ql v1.2.0 h1:lcKp95ZtdF0XkWhGnVIXGF8dVD2X+ClS08tglKtf+ak=
github.com/cznic/ql v1.2.0/go.mod h1:FbpzhyZrqr0PVlK6ury+PoW3T0ODUV22OeWIxcaOrSE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65 h1:hxuZop6tSoOi0sxFzoGGYdRqNrPubyaIf9KoBG9tPiE=
github.com/cznic/sortutil v0.0.0-20150617083342-4c7342852e65/go.mod h1:q2w6Bg5jeox1B+QkJ6Wp/+Vn0G/bo3f1uY7Fn3vivIQ=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186 h1:0rkFMAbn5KBKNpJyHQ6Prb95vIKanmAe62KxsrN+sqA=
github.com/cznic/strutil v0.0.0-20171016134553-529a34b1c186/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc h1:YKKpTb2BrXN2GYyGaygIdis1vXbE7SSAG9axGWIMClg=
github.com/cznic/zappy v0.0.0-20160723133515-2533cb5b45cc/go.mod h1:Y1SNZ4dRUOKXshKUbwUapqNncRrho4mkjQebgEHZLj8=
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/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
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/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712 h1:aaQcKT9WumO6JEJcRyTqFVq4XUZiUcKR2/GI31TOcz8=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
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/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
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/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/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/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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=
@@ -104,45 +68,46 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
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/leporo/sqlf v1.3.0 h1:nAkuPYxMIJg/sUmcd1h4avV5iYo8tBTGEGOIR4BIZO8=
github.com/leporo/sqlf v1.3.0/go.mod h1:f4dHqIi1+nLl6k1IsNQ8QIEbGWK49th2ei1IxTXk+2E=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw=
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/v2 v2.0.0-alpha.2 h1:PgOWohvpj4qNCyASJ7Q8Ke8ld/wsoi+dQJ05b1ebwus=
github.com/ostafen/clover/v2 v2.0.0-alpha.2/go.mod h1:7UyIG46NglzTDRKB4LJiS/enXpuo67Lj05eM8mdhL6M=
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/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk=
github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw=
github.com/rivo/uniseg v0.3.4/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=
@@ -152,136 +117,109 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE
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/tg123/go-htpasswd v1.2.0 h1:UKp34m9H467/xklxUxU15wKRru7fwXoTojtxg25ITF0=
github.com/tg123/go-htpasswd v1.2.0/go.mod h1:h7IzlfpvIWnVJhNZ0nQ9HaFxHb7pn5uFJYLlEUJa2sM=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
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-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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/crypto v0.0.0-20220826181053-bd7e27e6170d h1:3qF+Z8Hkrw9sOhrFHti9TlB1Hkac1x+DNRkv0XQiFjo=
golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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-20220728211354-c7608f3a8462 h1:UreQrH7DbFXSi9ZFox6FNT3WBooWmdANpU+IfkT1T4I=
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
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/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
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-20200116001909-b77594299b42/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-20211007075335-d3039528d8ac/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-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/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/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
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.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/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=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg=
modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM=
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI=
modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs=
modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8=
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM=

98
install.sh Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
GH_REPO="axllent/mailpit"
TIMEOUT=90
set -e
VERSION=$(curl --silent --location --max-time "${TIMEOUT}" "https://api.github.com/repos/${GH_REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ $? -ne 0 ]; then
echo -ne "\nThere was an error trying to check what is the latest version of ssbak.\nPlease try again later.\n"
exit 1
fi
# detect the platform
OS="$(uname)"
case $OS in
Linux)
OS='linux'
;;
FreeBSD)
OS='freebsd'
echo 'OS not supported'
exit 2
;;
NetBSD)
OS='netbsd'
echo 'OS not supported'
exit 2
;;
OpenBSD)
OS='openbsd'
echo 'OS not supported'
exit 2
;;
Darwin)
OS='darwin'
;;
SunOS)
OS='solaris'
echo 'OS not supported'
exit 2
;;
*)
echo 'OS not supported'
exit 2
;;
esac
# detect the arch
OS_type="$(uname -m)"
case "$OS_type" in
x86_64 | amd64)
OS_type='amd64'
;;
i?86 | x86)
OS_type='386'
;;
aarch64 | arm64)
OS_type='arm64'
;;
*)
echo 'OS type not supported'
exit 2
;;
esac
GH_REPO_BIN="mailpit-${OS}-${OS_type}.tar.gz"
#create tmp directory and move to it with macOS compatibility fallback
tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mailpit-install.XXXXXXXXXX')
cd "$tmp_dir"
echo "Downloading Mailpit $VERSION"
LINK="https://github.com/${GH_REPO}/releases/download/${VERSION}/${GH_REPO_BIN}"
curl --silent --location --max-time "${TIMEOUT}" "${LINK}" | tar zxf - || {
echo "Error downloading"
exit 2
}
mkdir -p /usr/local/bin || exit 2
cp mailpit /usr/local/bin/ || exit 2
chmod 755 /usr/local/bin/mailpit || exit 2
case "$OS" in
'linux')
chown root:root /usr/local/bin/mailpit || exit 2
;;
'freebsd' | 'openbsd' | 'netbsd' | 'darwin')
chown root:wheel /usr/local/bin/mailpit || exit 2
;;
*)
echo 'OS not supported'
exit 2
;;
esac
rm -rf "$tmp_dir"
echo "Installed successfully to /usr/local/bin/mailpit"

726
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"bootstrap": "^5.2.0",
"bootstrap-icons": "^1.9.1",
"moment": "^2.29.4",
"remove": "^0.1.5",
"prismjs": "^1.29.0",
"vue": "^3.2.13"
},
"devDependencies": {

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -20,37 +20,25 @@ type messagesResult struct {
}
// 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
}
func apiMailboxStats(w http.ResponseWriter, _ *http.Request) {
res := storage.StatsGet()
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
_, _ = w.Write(bytes)
}
func apiListMailbox(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
if !storage.MailboxExists(mailbox) {
fourOFour(w)
return
}
// List messages
func apiListMessages(w http.ResponseWriter, r *http.Request) {
start, limit := getStartLimit(r)
messages, err := storage.List(mailbox, start, limit)
messages, err := storage.List(start, limit)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet(mailbox)
stats := storage.StatsGet()
var res messagesResult
@@ -62,39 +50,28 @@ func apiListMailbox(w http.ResponseWriter, r *http.Request) {
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
_, _ = w.Write(bytes)
}
func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
// Search all messages
func apiSearchMessages(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)
messages, err := storage.Search(search)
if err != nil {
httpError(w, err.Error())
return
}
stats := storage.StatsGet(mailbox)
stats := storage.StatsGet()
var res messagesResult
res.Start = start
res.Start = 0
res.Items = messages
res.Count = len(messages)
res.Total = stats.Total
@@ -102,36 +79,34 @@ func apiSearchMailbox(w http.ResponseWriter, r *http.Request) {
bytes, _ := json.Marshal(res)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
_, _ = w.Write(bytes)
}
// Open a message
func apiOpenMessage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
id := vars["id"]
msg, err := storage.GetMessage(mailbox, id)
msg, err := storage.GetMessage(id)
if err != nil {
httpError(w, err.Error())
httpError(w, "Message not found")
return
}
bytes, _ := json.Marshal(msg)
w.Header().Add("Content-Type", "application/json")
w.Write(bytes)
_, _ = 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)
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
httpError(w, err.Error())
return
@@ -143,19 +118,18 @@ func apiDownloadAttachment(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", a.ContentType)
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
w.Write(a.Content)
_, _ = w.Write(a.Content)
}
// View the full email source as plain text
func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
// Download the full email source as plain text
func apiDownloadRaw(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)
data, err := storage.GetMessageRaw(id)
if err != nil {
httpError(w, err.Error())
return
@@ -165,57 +139,138 @@ func apiDownloadSource(w http.ResponseWriter, r *http.Request) {
if dl == "1" {
w.Header().Set("Content-Disposition", "attachment; filename=\""+id+".eml\"")
}
w.Write(data)
_, _ = w.Write(data)
}
// Delete all messages in the mailbox
// Delete all messages
func apiDeleteAll(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
mailbox := vars["mailbox"]
err := storage.DeleteAllMessages(mailbox)
err := storage.DeleteAllMessages()
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
_, _ = w.Write([]byte("ok"))
}
// Delete all selected messages
func apiDeleteSelected(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.DeleteOneMessage(id); 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)
err := storage.DeleteOneMessage(id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
_, _ = 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)
err := storage.MarkUnread(id)
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte("ok"))
_, _ = w.Write([]byte("ok"))
}
// Mark all messages as read
func apiMarkAllRead(w http.ResponseWriter, r *http.Request) {
err := storage.MarkAllRead()
if err != nil {
httpError(w, err.Error())
return
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Mark selected message as read
func apiMarkSelectedRead(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.MarkRead(id); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Mark selected message as unread
func apiMarkSelectedUnread(w http.ResponseWriter, r *http.Request) {
decoder := json.NewDecoder(r.Body)
var data struct {
IDs []string
}
err := decoder.Decode(&data)
if err != nil {
panic(err)
}
ids := data.IDs
for _, id := range ids {
if err := storage.MarkUnread(id); err != nil {
httpError(w, err.Error())
return
}
}
w.Header().Add("Content-Type", "text/plain")
_, _ = w.Write([]byte("ok"))
}
// Websocket to broadcast changes

View File

@@ -34,28 +34,42 @@ func Listen() {
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))))
r.HandleFunc("/api/stats", middleWareFunc(apiMailboxStats)).Methods("GET")
r.HandleFunc("/api/messages", middleWareFunc(apiListMessages)).Methods("GET")
r.HandleFunc("/api/search", middleWareFunc(apiSearchMessages)).Methods("GET")
r.HandleFunc("/api/delete", middleWareFunc(apiDeleteAll)).Methods("GET")
r.HandleFunc("/api/delete", middleWareFunc(apiDeleteSelected)).Methods("POST")
r.HandleFunc("/api/events", apiWebsocket).Methods("GET")
r.HandleFunc("/api/read", apiMarkAllRead).Methods("GET")
r.HandleFunc("/api/read", apiMarkSelectedRead).Methods("POST")
r.HandleFunc("/api/unread", apiMarkSelectedUnread).Methods("POST")
r.HandleFunc("/api/{id}/raw", middleWareFunc(apiDownloadRaw)).Methods("GET")
r.HandleFunc("/api/{id}/part/{partID}", middleWareFunc(apiDownloadAttachment)).Methods("GET")
r.HandleFunc("/api/{id}/part/{partID}/thumb", middleWareFunc(apiAttachmentThumbnail)).Methods("GET")
r.HandleFunc("/api/{id}/delete", middleWareFunc(apiDeleteOne)).Methods("GET")
r.HandleFunc("/api/{id}/unread", middleWareFunc(apiUnreadOne)).Methods("GET")
r.HandleFunc("/api/{id}", middleWareFunc(apiOpenMessage)).Methods("GET")
r.PathPrefix("/").Handler(middlewareHandler(http.FileServer(http.FS(serverRoot))))
http.Handle("/", r)
if config.SSLCert != "" && config.SSLKey != "" {
if config.UIAuthFile != "" {
logger.Log().Info("[http] enabling web UI basic authentication")
}
if config.UISSLCert != "" && config.UISSLKey != "" {
logger.Log().Infof("[http] starting secure server on https://%s", config.HTTPListen)
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.SSLCert, config.SSLKey, nil))
log.Fatal(http.ListenAndServeTLS(config.HTTPListen, config.UISSLCert, config.UISSLKey, nil))
} else {
logger.Log().Infof("[http] starting server on http://%s", config.HTTPListen)
log.Fatal(http.ListenAndServe(config.HTTPListen, nil))
}
}
// BasicAuthResponse returns an basic auth response to the browser
func basicAuthResponse(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorised.\n"))
}
type gzipResponseWriter struct {
@@ -67,9 +81,24 @@ func (w gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}
// GzipHandlerFunc http middleware
func gzipHandlerFunc(fn http.HandlerFunc) http.HandlerFunc {
// MiddleWareFunc http middleware adds optional basic authentication
// and gzip compression.
func middleWareFunc(fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !config.UIAuth.Match(user, pass) {
basicAuthResponse(w)
return
}
}
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
fn(w, r)
return
@@ -82,8 +111,25 @@ func gzipHandlerFunc(fn http.HandlerFunc) http.HandlerFunc {
}
}
func gzipHandler(h http.Handler) http.Handler {
// MiddlewareHandler http middleware adds optional basic authentication
// and gzip compression
func middlewareHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !config.UIAuth.Match(user, pass) {
basicAuthResponse(w)
return
}
}
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
h.ServeHTTP(w, r)
return
@@ -95,14 +141,14 @@ func gzipHandler(h http.Handler) http.Handler {
})
}
// FourOFour returns a standard 404 meesage
// FourOFour returns a basic 404 message
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
// HTTPError returns a basic error message (400 response)
func httpError(w http.ResponseWriter, msg string) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
@@ -115,16 +161,13 @@ func getStartLimit(req *http.Request) (start int, limit int) {
limit = 50
s := req.URL.Query().Get("start")
if n, e := strconv.ParseInt(s, 10, 64); e == nil && n > 0 {
start = int(n)
if n, err := strconv.Atoi(s); err == nil && n > 0 {
start = n
}
l := req.URL.Query().Get("limit")
if n, e := strconv.ParseInt(l, 10, 64); e == nil && n > 0 {
if n > 500 {
n = 500
}
limit = int(n)
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = n
}
return start, limit

107
server/thumbnails.go Normal file
View File

@@ -0,0 +1,107 @@
package server
import (
"bufio"
"bytes"
"image"
"image/color"
"image/draw"
"image/jpeg"
"net/http"
"strings"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/storage"
"github.com/disintegration/imaging"
"github.com/gorilla/mux"
"github.com/jhillyerd/enmime"
)
var (
thumbWidth = 180
thumbHeight = 120
)
// Attachment thumbnail (images only)
func apiAttachmentThumbnail(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
partID := vars["partID"]
a, err := storage.GetAttachmentPart(id, partID)
if err != nil {
httpError(w, err.Error())
return
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
if !strings.HasPrefix(a.ContentType, "image/") {
blankImage(a, w)
return
}
buf := bytes.NewBuffer(a.Content)
img, err := imaging.Decode(buf)
if err != nil {
// it's not an image, return default
logger.Log().Warning(err)
blankImage(a, w)
return
}
var b bytes.Buffer
foo := bufio.NewWriter(&b)
var dstImageFill *image.NRGBA
if img.Bounds().Dx() < thumbWidth || img.Bounds().Dy() < thumbHeight {
dstImageFill = imaging.Fit(img, thumbWidth, thumbHeight, imaging.Lanczos)
} else {
dstImageFill = imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
}
// create white image and paste image over the top
// preventing black backgrounds for transparent GIF/PNG images
dst := imaging.New(thumbWidth, thumbHeight, color.White)
// paste the original over the top
dst = imaging.OverlayCenter(dst, dstImageFill, 1.0)
if err := jpeg.Encode(foo, dst, &jpeg.Options{Quality: 70}); err != nil {
logger.Log().Warning(err)
blankImage(a, w)
return
}
w.Header().Add("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(b.Bytes())
}
// Return a blank image instead of an error when file or image not supported
func blankImage(a *enmime.Part, w http.ResponseWriter) {
rect := image.Rect(0, 0, thumbWidth, thumbHeight)
img := image.NewRGBA(rect)
background := color.RGBA{255, 255, 255, 255}
draw.Draw(img, img.Bounds(), &image.Uniform{background}, image.ZP, draw.Src)
var b bytes.Buffer
foo := bufio.NewWriter(&b)
dstImageFill := imaging.Fill(img, thumbWidth, thumbHeight, imaging.Center, imaging.Lanczos)
if err := jpeg.Encode(foo, dstImageFill, &jpeg.Options{Quality: 70}); err != nil {
logger.Log().Warning(err)
}
fileName := a.FileName
if fileName == "" {
fileName = a.ContentID
}
w.Header().Add("Content-Type", "image/jpeg")
w.Header().Set("Content-Disposition", "filename=\""+fileName+"\"")
_, _ = w.Write(b.Bytes())
}

View File

@@ -11,17 +11,22 @@ export default {
data() {
return {
currentPath: window.location.hash,
mailbox: "catchall",
items: [],
limit: 50,
total: 0,
unread: 0,
start: 0,
count: 0,
search: "",
searching: false,
isConnected: false,
scrollInPlace: false,
message: false
message: false,
messagePrev: false,
messageNext: false,
notificationsSupported: false,
notificationsEnabled: false,
selected: []
}
},
watch: {
@@ -47,6 +52,10 @@ export default {
this.currentPath = window.location.hash.slice(1);
});
this.notificationsSupported = 'https:' == document.location.protocol
&& ("Notification" in window && Notification.permission !== "denied");
this.notificationsEnabled = this.notificationsSupported && Notification.permission == "granted";
this.connect();
this.loadMessages();
},
@@ -54,12 +63,13 @@ export default {
loadMessages: function () {
let self = this;
let params = {};
this.selected = [];
let uri = 'api/'+self.mailbox+'/messages';
let uri = 'api/messages';
if (self.search) {
self.searching = true;
self.items = [];
uri = 'api/'+self.mailbox+'/search'
uri = 'api/search'
self.start = 0; // search is displayed on one page
params['query'] = self.search;
} else {
@@ -93,6 +103,13 @@ export default {
this.loadMessages();
},
resetSearch: function(e) {
e.preventDefault();
this.search = '';
this.scrollInPlace = true;
this.loadMessages();
},
reloadMessages: function() {
this.search = "";
this.start = 0;
@@ -115,10 +132,10 @@ export default {
openMessage: function(id) {
let self = this;
let params = {};
self.selected = [];
let uri = 'api/' + self.mailbox + '/' + self.currentPath
self.get(uri, params, function(response) {
let uri = 'api/' + self.currentPath
self.get(uri, false, function(response) {
for (let i in self.items) {
if (self.items[i].ID == self.currentPath) {
if (!self.items[i].Read) {
@@ -135,7 +152,7 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:'+a.ContentID, 'g'),
window.location.origin+'/api/'+self.mailbox+'/'+d.ID+'/part/'+a.PartID
window.location.origin+'/api/'+d.ID+'/part/'+a.PartID
);
}
}
@@ -147,19 +164,33 @@ export default {
if (a.ContentID != '') {
d.HTML = d.HTML.replace(
new RegExp('cid:'+a.ContentID, 'g'),
window.location.origin+'/api/'+self.mailbox+'/'+d.ID+'/part/'+a.PartID
window.location.origin+'/api/'+d.ID+'/part/'+a.PartID
);
}
}
}
self.message = d;
// generate the prev/next links based on current message list
self.messagePrev = false;
self.messageNext = false;
let found = false;
for (let i in self.items) {
if (self.items[i].ID == self.message.ID) {
found = true;
} else if (found && !self.messageNext) {
self.messageNext = self.items[i].ID;
break;
} else {
self.messagePrev = self.items[i].ID;
}
}
});
},
deleteAll: function() {
let self = this;
let uri = 'api/' + self.mailbox + '/delete'
let uri = 'api/delete'
self.get(uri, false, function(response) {
self.reloadMessages();
});
@@ -170,7 +201,7 @@ export default {
if (!self.message) {
return false;
}
let uri = 'api/' + self.mailbox + '/' + self.message.ID + '/delete'
let uri = 'api/' + self.message.ID + '/delete'
self.get(uri, false, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
@@ -179,12 +210,25 @@ export default {
});
},
deleteSelected: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/delete'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
markUnread: function() {
let self = this;
if (!self.message) {
return false;
}
let uri = 'api/' + self.mailbox + '/' + self.message.ID + '/unread'
let uri = 'api/' + self.message.ID + '/unread'
self.get(uri, false, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
@@ -192,10 +236,48 @@ export default {
});
},
markAllRead: function() {
let self = this;
let uri = 'api/read'
self.get(uri, false, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
markSelectedRead: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/read'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
markSelectedUnread: function() {
let self = this;
if (!self.selected.length) {
return false;
}
let uri = 'api/unread'
self.post(uri, {'ids': self.selected}, function(response) {
window.location.hash = "";
self.scrollInPlace = true;
self.loadMessages();
});
},
// websocket connect
connect: function () {
let wsproto = location.protocol == 'https:' ? 'wss' : 'ws';
let ws = new WebSocket(wsproto + "://" + document.location.host + "/api/"+this.mailbox+"/events");
let ws = new WebSocket(
wsproto + "://" + document.location.host + document.location.pathname + "api/events"
);
let self = this;
ws.onmessage = function (e) {
let response = JSON.parse(e.data);
@@ -204,22 +286,24 @@ export default {
}
// new messages
if (response.Type == "new" && response.Data) {
if (self.start < 1) {
if (!self.searching) {
if (!self.searching) {
if (self.start < 1) {
self.items.unshift(response.Data);
if (self.items.length > self.limit) {
self.items.pop();
}
} else {
self.start++;
}
}
self.total++;
self.unread++;
self.browserNotify("New mail from: " + response.Data.From.Address, response.Data.Subject);
} else if (response.Type == "prune") {
// messages have been deleted, reload messages to adjust
self.scrollInPlace = true;
self.loadMessages();
}
}
ws.onopen = function () {
@@ -245,52 +329,148 @@ export default {
return message.To[i].Address;
}
return '[ Unknown ]';
return '[ Undisclosed recipients ]';
},
getRelativeCreated: function(message) {
let d = new Date(message.Created)
return moment(d).fromNow().toString();
},
browserNotify: function(title, message) {
if (!("Notification" in window)) {
return;
}
if (Notification.permission === "granted") {
let b = message.Subject;
let options = {
body: message,
icon: 'mailpit.png'
}
new Notification(title, options);
}
},
requestNotifications: function() {
// check if the browser supports notifications
if (!("Notification" in window)) {
alert("This browser does not support desktop notification");
}
// we need to ask the user for permission
else if (Notification.permission !== "denied") {
let self = this;
Notification.requestPermission().then(function (permission) {
// If the user accepts, let's create a notification
if (permission === "granted") {
self.browserNotify("Notifications enabled", "You will receive notifications when new mails are received.");
self.notificationsEnabled = true;
}
});
}
},
toggleSelected: function(e, id) {
e.preventDefault();
if (this.isSelected(id)) {
this.selected = this.selected.filter(function(ele){
return ele != id;
});
} else {
this.selected.push(id);
}
},
selectRange: function(e, id) {
e.preventDefault();
let selecting = false;
let lastSelected = this.selected.length > 0 && this.selected[this.selected.length - 1];
if (lastSelected === false) {
this.selected.push(id);
return;
}
for (let d of this.items) {
if (selecting) {
this.selected.push(d.ID);
if (d.ID == lastSelected || d.ID == id) {
// reached backwards select
break;
}
} else if (d.ID == id || d.ID == lastSelected) {
this.selected.push(d.ID);
selecting = true;
}
}
},
isSelected: function(id) {
return this.selected.indexOf(id) != -1;
}
}
}
</script>
<template>
<div class="navbar navbar-expand-lg navbar-light row flex-shrink-0 bg-light">
<div class="col-lg-2 col-md-3 col-auto">
<div class="navbar navbar-expand-lg navbar-light row flex-shrink-0 bg-light shadow-sm">
<div class="col-lg-2 col-md-3 d-none d-md-block">
<a class="navbar-brand" href="#" v-on:click="reloadMessages">
<img src="mailpit.svg" alt="Mailpit">
<span class="d-none d-md-inline-block ms-2">Mailpit</span>
<span class="ms-2">Mailpit</span>
</a>
</div>
<div class="col col-md-9 col-lg-8" v-if="message">
<div class="col col-md-9 col-lg-10" v-if="message">
<a class="btn btn-outline-secondary me-4 px-3" href="#" v-on:click="message=false" title="Return to messages">
<i class="bi bi-arrow-return-left"></i>
</a>
<button class="btn btn-outline-secondary me-2" title="Delete message" v-on:click="deleteOne">
<i class="bi bi-trash-fill"></i>
<i class="bi bi-trash-fill"></i> <span class="d-none d-md-inline">Delete</span>
</button>
<button class="btn btn-outline-secondary me-2" title="Mark unread" v-on:click="markUnread">
<i class="bi bi-envelope"></i>
<i class="bi bi-eye-slash"></i> <span class="d-none d-md-inline">Mark unread</span>
</button>
<a :href="'api/' + mailbox + '/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2" title="Download message">
<i class="bi bi-file-arrow-down-fill"></i>
<a class="btn btn-outline-secondary float-end" :class="messageNext ? '':'disabled'" :href="'#'+messageNext" title="View next message">
<i class="bi bi-caret-right-fill"></i>
</a>
<a class="btn btn-outline-secondary ms-2 me-1 float-end" :class="messagePrev ? '': 'disabled'" :href="'#'+messagePrev" title="View previous message">
<i class="bi bi-caret-left-fill"></i>
</a>
<a :href="'api/' + message.ID + '/source?dl=1'" class="btn btn-outline-secondary me-2 float-end" title="Download message">
<i class="bi bi-file-arrow-down-fill"></i> <span class="d-none d-md-inline">Download</span>
</a>
</div>
<div class="col col-md-9 col-lg-5" v-if="!message && total">
<div class="col col-md-9 col-lg-5 LOL" v-if="!message">
<form v-on:submit="doSearch">
<div class="input-group">
<input type="text" class="form-control" v-model.trim="search" placeholder="Search mailbox">
<button class="btn btn-outline-secondary" type="submit"><i class="bi bi-search"></i></button>
<a class="navbar-brand d-md-none" href="#" v-on:click="reloadMessages">
<img src="mailpit.svg" alt="Mailpit">
<span v-if="!total" class="ms-2">Mailpit</span>
</a>
<div v-if="total" class="d-flex bg-white border rounded-start flex-fill position-relative">
<input type="text" class="form-control border-0" v-model.trim="search" placeholder="Search mailbox">
<span class="btn btn-link position-absolute end-0 text-muted" v-if="search" v-on:click="resetSearch"><i class="bi bi-x-circle"></i></span>
</div>
<button v-if="total" class="btn btn-outline-secondary" type="submit"><i class="bi bi-search"></i></button>
</div>
</form>
</div>
<div class="col-12 col-lg-5 text-end" v-if="!message && total">
<div class="col-12 col-lg-5 text-end mt-2 mt-lg-0" v-if="!message && total">
<button v-if="total" class="btn btn-outline-danger float-start d-md-none me-2" data-bs-toggle="modal" data-bs-target="#DeleteAllModal" title="Delete all messages">
<i class="bi bi-trash-fill"></i>
</button>
<button v-if="unread" class="btn btn-outline-primary float-start d-md-none" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal" title="Mark all read">
<i class="bi bi-check2-square"></i>
</button>
<select v-model="limit" v-on:change="loadMessages"
class="form-select form-select-sm d-inline w-auto me-1" v-if="!searching">
class="form-select form-select-sm d-inline w-auto me-2" v-if="!searching">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
@@ -303,11 +483,10 @@ export default {
<small>
<b>{{ formatNumber(start + 1) }}-{{ formatNumber(start + items.length) }}</b> of <b>{{ formatNumber(total) }}</b>
</small>
<button class="btn btn-outline-secondary ms-3 me-1" :disabled="!canPrev" v-on:click="viewPrev"
v-if="!searching">
<button class="btn btn-outline-secondary ms-2 me-1" :disabled="!canPrev" v-on:click="viewPrev" v-if="!searching" :title="'View previous '+limit+' messages'">
<i class="bi bi-caret-left-fill"></i>
</button>
<button class="btn btn-outline-secondary" :disabled="!canNext" v-on:click="viewNext" v-if="!searching">
<button class="btn btn-outline-secondary" :disabled="!canNext" v-on:click="viewNext" v-if="!searching" :title="'View next '+limit+' messages'">
<i class="bi bi-caret-right-fill"></i>
</button>
</span>
@@ -315,33 +494,69 @@ export default {
</div>
<div class="row flex-fill" style="min-height:0">
<div class="d-none d-md-block col-lg-2 col-md-3 mh-100 position-relative" style="overflow-y: auto;">
<ul class="list-unstyled mt-3">
<li v-if="isConnected" title="Messages will auto-load">
<ul class="list-unstyled mt-3 mb-5">
<li v-if="isConnected" title="Messages will auto-load" class="mb-3">
<i class="bi bi-power text-success"></i>
Connected
</li>
<li v-else title="Messages will auto-load">
<li v-else title="You need to manually refresh your mailbox" class="mb-3">
<i class="bi bi-power text-danger"></i>
Disconnected
</li>
<li class="mt-3">
<li class="mb-5">
<a class="position-relative ps-0" href="#" v-on:click="reloadMessages">
<i class="bi bi-envelope me-1" v-if="isConnected"></i>
<i class="bi bi-arrow-clockwise me-1" v-else></i>
Inbox
<span class="position-absolute mt-2 ms-4 start-100 translate-middle badge rounded-pill text-bg-secondary" title="Unread messages" v-if="unread">
<span style="margin-top: -5px; margin-left: 5px;" class="position-absolute badge rounded-pill text-bg-secondary" title="Unread messages" v-if="unread">
{{ formatNumber(unread) }}
</span>
</a>
</li>
<li class="mt-3 mb-5">
<a v-if="total" href="#" data-bs-toggle="modal" data-bs-target="#deleteAllModal">
<li class="my-3" v-if="unread && !selected.length">
<a href="#" data-bs-toggle="modal" data-bs-target="#MarkAllReadModal">
<i class="bi bi-eye-fill"></i>
Mark all read
</a>
</li>
<li class="my-3" v-if="total && !selected.length">
<a href="#" data-bs-toggle="modal" data-bs-target="#DeleteAllModal">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete all
</a>
</li>
<li class="mt-5 position-fixed bottom-0 w-100">
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white py-2">
<li class="my-3" v-if="selected.length > 0">
<b class="me-2">Selected {{selected.length}}</b>
<button class="btn btn-sm text-muted" v-on:click="selected=[]" title="Unselect messages"><i class="bi bi-x-circle"></i></button>
</li>
<li class="my-3 ms-2" v-if="unread && selected.length > 0">
<a href="#" v-on:click="markSelectedRead">
<i class="bi bi-eye-fill"></i>
Mark read
</a>
</li>
<li class="my-3 ms-2" v-if="selected.length > 0">
<a href="#" v-on:click="markSelectedUnread">
<i class="bi bi-eye-slash"></i>
Mark unread
</a>
</li>
<li class="my-3 ms-2" v-if="total && selected.length > 0">
<a href="#" v-on:click="deleteSelected">
<i class="bi bi-trash-fill me-1 text-danger"></i>
Delete
</a>
</li>
<li class="my-3" v-if="notificationsSupported && !notificationsEnabled">
<a href="#" data-bs-toggle="modal" data-bs-target="#EnableNotificationsModal" title="Enable browser notifications">
<i class="bi bi-bell"></i>
Enable alerts
</a>
</li>
<li class="mt-5 position-fixed bottom-0">
<a href="https://github.com/axllent/mailpit" target="_blank" class="text-muted w-100 d-block bg-white my-3">
<i class="bi bi-github"></i>
GitHub
</a>
@@ -352,43 +567,52 @@ export default {
<div class="col-lg-10 col-md-9 mh-100 pe-0">
<div class="mh-100" style="overflow-y: auto;" :class="message ? 'd-none':''" id="message-page">
<div class="list-group" v-if="items.length">
<a v-for="message in items" :href="'#'+message.ID" class="row message d-flex small list-group-item list-group-item-action"
:class="message.Read ? 'read':''" XXXv-on:click="openMessage(message)">
<div class="col-md-3">
<div class="d-md-none float-end text-muted text-nowrap small">
<a v-for="message in items" :href="'#'+message.ID"
v-on:click.ctrl="toggleSelected($event, message.ID)" v-on:click.shift="selectRange($event, message.ID)"
class="row message d-flex small list-group-item list-group-item-action"
:class="message.Read ? 'read':'', isSelected(message.ID) ? 'selected':''">
<div class="col-lg-3">
<div class="d-lg-none float-end text-muted text-nowrap small">
<i class="bi bi-paperclip h6 me-1" v-if="message.Attachments"></i>
{{ getRelativeCreated(message) }}
</div>
<div class="text-truncate d-md-none">
<div class="text-truncate d-lg-none privacy">
<span v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</span>
</div>
<div class="text-truncate d-none d-md-block">
<div class="text-truncate d-none d-lg-block privacy">
<b v-if="message.From" :title="message.From.Address">{{ message.From.Name ? message.From.Name : message.From.Address }}</b>
</div>
<div class="d-none d-md-block text-truncate text-muted small">
<div class="d-none d-lg-block text-truncate text-muted small privacy">
{{ getPrimaryEmailTo(message) }}
<span v-if="message.To && message.To.length > 1">
[+{{message.To.length - 1}}]
</span>
</div>
</div>
<div class="col-md-6 mt-2 mt-md-0">
<div class="col-lg-6 mt-2 mt-lg-0">
<b>{{ message.Subject != "" ? message.Subject : "[ no subject ]" }}</b>
</div>
<div class="d-none d-md-block col-1 small text-end text-muted">
<div class="d-none d-lg-block col-1 small text-end text-muted">
<i class="bi bi-paperclip float-start h6" v-if="message.Attachments"></i>
{{ getFileSize(message.Size) }}
</div>
<div class="d-none d-md-block col-2 small text-end text-muted">
<div class="d-none d-lg-block col-2 small text-end text-muted">
{{ getRelativeCreated(message) }}
</div>
</a>
</div>
<div v-else class="text-muted py-3">No messages</div>
<div v-else class="text-muted my-3">
<span v-if="searching">
No results matching your search
</span>
<span v-else>
There are no emails in your mailbox
</span>
</div>
</div>
<Message v-if="message" :message="message" :mailbox="mailbox"></Message>
<Message v-if="message" :message="message"></Message>
</div>
<div id="loading" v-if="loading">
<div class="d-flex justify-content-center align-items-center h-100">
@@ -400,20 +624,62 @@ export default {
</div>
<!-- Modal -->
<div class="modal fade" id="deleteAllModal" tabindex="-1" aria-labelledby="deleteAllModalLabel" aria-hidden="true">
<div class="modal fade" id="DeleteAllModal" tabindex="-1" aria-labelledby="DeleteAllModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteAllModalLabel">Delete all messages?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div class="modal-header">
<h5 class="modal-title" id="DeleteAllModalLabel">Delete all messages?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(total) }} message<span v-if="total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" v-on:click="deleteAll">Delete</button>
</div>
</div>
<div class="modal-body">
This will permanently delete {{ formatNumber(total) }} message<span v-if="total > 1">s</span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" v-on:click="deleteAll">Delete</button>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="MarkAllReadModal" tabindex="-1" aria-labelledby="MarkAllReadModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="MarkAllReadModalLabel">Mark all messages as read?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will mark {{ formatNumber(unread) }} message<span v-if="unread > 1">s</span> as read.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="markAllRead">Confirm</button>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="EnableNotificationsModal" tabindex="-1" aria-labelledby="EnableNotificationsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="EnableNotificationsModalLabel">Enable browser notifications?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="h4">Get browser notifications when Mailpit receives a new mail?</p>
<p>
Note that your browser will ask you for confirmation when you click <code>enable notifications</code>,
and that you must have Mailpit open in a browser tab to be able to receive the notifications.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" v-on:click="requestNotifications">Enable notifications</button>
</div>
</div>
</div>
</div>

View File

@@ -1,16 +1,26 @@
// @import "../../../node_modules/bootstrap-icons"; ///scss/root";
@import "bootstrap";
[v-cloak] {
display: none !important;
}
.navbar-brand {
color: #2d4a5d;
.navbar {
z-index: 99;
img {
width: 40px;
.navbar-brand {
color: #2d4a5d;
img {
width: 40px;
}
@include media-breakpoint-down(md) {
padding: 0;
img {
width: 35px;
}
}
}
}
@@ -24,18 +34,18 @@
z-index: 1500;
}
.message.read:not(.active) {
// background: $gray-100;
.message.read:not(.active):not(.selected) {
color: $gray-500;
}
#nav-plain-text,
#nav-plain-text .text-view,
#nav-source {
white-space: pre;
font-family: Courier New, Courier, System, fixed-width;
font-size: 0.85em;
}
#nav-plain-text {
#nav-plain-text .text-view {
white-space: pre-wrap;
}
@@ -43,7 +53,7 @@
margin: 15px 0 0;
th {
padding-right: 10px;
padding-right: 1.5rem;
font-weight: normal;
vertical-align: top;
}
@@ -52,3 +62,225 @@
vertical-align: top;
}
}
#nav-html {
padding-right: 1.5rem;
}
#preview-html {
min-height: 300px;
}
.list-group-item:first-child {
border-top: 0;
}
.message.selected {
background: $primary;
color: #fff;
.text-muted {
color: #fff !important;
}
&.read {
b {
font-weight: normal;
}
}
}
body.blur {
.privacy {
filter: blur(3px);
}
}
.card.attachment {
color: $gray-800;
.icon {
position: absolute;
top: 18px;
left: 0;
right: 0;
font-size: 3.5rem;
text-align: center;
color: $gray-300;
}
.card-body {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
opacity: 0;
}
.card-footer {
background: $gray-300;
.bi {
font-size: 1.3em;
margin-left: -10px;
}
}
&:hover {
.card-body {
opacity: 1;
background: $gray-300;
}
}
}
/* PrismJS 1.29.0 - modified!
https://prismjs.com/download.html#themes=prism-coy&languages=markup+css */
code[class*="language-"],
pre[class*="language-"] {
color: #000;
background: 0 0;
font-size: 0.85em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
position: relative;
overflow: visible;
}
pre[class*="language-"] > code {
position: relative;
z-index: 1;
}
code[class*="language-"] {
max-height: inherit;
height: inherit;
padding: 0 1em;
display: block;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background-color: #fdfdfd;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
margin-bottom: 1em;
}
:not(pre) > code[class*="language-"] {
position: relative;
padding: 0.2em;
border-radius: 0.3em;
color: #c92c2c;
border: 1px solid rgba(0, 0, 0, 0.1);
display: inline;
white-space: normal;
}
.token.block-comment,
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #7d8b99;
}
.token.punctuation {
color: #5f6364;
}
.token.boolean,
.token.constant,
.token.deleted,
.token.function-name,
.token.number,
.token.property,
.token.symbol,
.token.tag {
color: #c92c2c;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.function,
.token.inserted,
.token.selector,
.token.string {
color: #2f9c0a;
}
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.keyword {
color: #1990b8;
}
.token.important,
.token.regex {
color: #e90;
}
.language-css .token.string,
.style .token.string {
color: #a67f59;
background: rgba(255, 255, 255, 0.5);
}
.token.important {
font-weight: 400;
}
.token.bold {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.namespace {
opacity: 0.7;
}
@media screen and (max-width: 767px) {
pre[class*="language-"]:after,
pre[class*="language-"]:before {
bottom: 14px;
box-shadow: none;
}
}
pre[class*="language-"].line-numbers.line-numbers {
padding-left: 0;
}
pre[class*="language-"].line-numbers.line-numbers code {
padding-left: 3.8em;
}
pre[class*="language-"].line-numbers.line-numbers .line-numbers-rows {
left: 0;
}
pre[class*="language-"][data-line] {
padding-top: 0;
padding-bottom: 0;
padding-left: 0;
}
pre[data-line] code {
position: relative;
padding-left: 4em;
}
pre .line-highlight {
margin-top: 0;
}

View File

@@ -8,131 +8,183 @@ FakeModal.prototype.show = function () { alert('open fake modal') }
/* Common mixin functions used in apps */
const commonMixins = {
data() {
return {
loading: 0,
}
},
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];
},
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);
},
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);
}
},
// Ajax error message
handleError: function (error) {
// handle error
if (error.response && error.response.data) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (error.response.data.Error) {
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
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();
},
// 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 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 Post request
*
* @params string url
* @params array array parameters Object/array
* @params function callback function
*/
post: function (url, values, callback) {
let self = this;
self.loading++;
axios.post(url, values)
.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--;
}
});
}
}
/**
* 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--;
}
});
},
allAttachments: function (message) {
let a = [];
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
}
return a.length ? a : false;
},
isImage(a) {
return a.ContentType.match(/^image\//);
},
attachmentIcon: function (a) {
let ext = a.FileName.split('.').pop().toLowerCase();
if (a.ContentType.match(/^image\//)) {
return 'bi-file-image-fill';
}
if (a.ContentType.match(/\/pdf$/) || ext == 'pdf') {
return 'bi-file-pdf-fill';
}
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) {
return 'bi-file-word-fill';
}
if (['xls', 'xlsx', 'ods'].includes(ext)) {
return 'bi-file-spreadsheet-fill';
}
if (['ppt', 'pptx', 'key', 'ppt', 'odp'].includes(ext)) {
return 'bi-file-slides-fill';
}
if (['zip', 'tar', 'rar', 'bz2', 'gz', 'xz'].includes(ext)) {
return 'bi-file-zip-fill';
}
if (a.ContentType.match(/^audio\//)) {
return 'bi-file-music-fill';
}
if (a.ContentType.match(/^video\//)) {
return 'bi-file-play-fill';
}
if (a.ContentType.match(/\/calendar$/)) {
return 'bi-file-check-fill';
}
if (a.ContentType.match(/^text\//) || ['txt', 'sh', 'log'].includes(ext)) {
return 'bi-file-text-fill';
}
return 'bi-file-arrow-down-fill';
}
}
}

View File

@@ -0,0 +1,37 @@
<script>
import commonMixins from '../mixins.js';
export default {
props: {
message: Object,
attachments: Object
},
mixins: [commonMixins]
}
</script>
<template>
<div class="mt-4 border-top pt-4">
<a v-for="part in attachments" :href="'api/'+message.ID+'/part/'+part.PartID" class="card attachment float-start me-3 mb-3" target="_blank" style="width: 180px">
<img v-if="isImage(part)" :src="'api/'+message.ID+'/part/'+part.PartID+'/thumb'" class="card-img-top" alt="">
<img v-else src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAB4AQMAAABhKUq+AAAAA1BMVEX///+nxBvIAAAAGUlEQVQYGe3BgQAAAADDoPtTT+EA1QAAgFsLQAAB12s2WgAAAABJRU5ErkJggg==" class="card-img-top" alt="">
<div class="icon" v-if="!isImage(part)">
<i class="bi" :class="attachmentIcon(part)"></i>
</div>
<div class="card-body border-0">
<p class="mb-1 text-muted">
<i class="bi me-1" :class="attachmentIcon(part)"></i>
<small>{{ getFileSize(part.Size) }}</small>
</p>
<p class="card-text mb-0 small">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</p>
</div>
<div class="card-footer small border-0 text-center text-truncate">
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</div>
</a>
</div>
</template>

View File

@@ -1,70 +1,106 @@
<script>
import commonMixins from '../mixins.js';
import moment from 'moment'
import moment from 'moment';
import Prism from "prismjs";
import Attachments from './Attachments.vue';
export default {
props: {
message: Object,
mailbox: Object,
message: Object
},
components: {
Attachments
},
mixins: [commonMixins],
data() {
return {
srcURI: false,
iframes: [], // for resizing
}
},
watch: {
message: {
handler(newQuestion) {
let self = this;
// delay 100ms to select first tab and add HTML highlighting (prev/next)
window.setTimeout(function() {
self.renderUI();
}, 100)
},
// force eager callback execution
immediate: true
}
},
mounted() {
var self = this;
let self = this;
window.addEventListener("resize", self.resizeIframes);
// click the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click();
document.activeElement.blur(); // blur focus
window.setTimeout(function(){
let p = document.getElementById('preview-html');
if (p) {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i];
let href = anchorEl.getAttribute('href');
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank');
}
}
}
}, 200);
var tabEl = document.getElementById('nav-source-tab');
self.renderUI();
var tabEl = document.getElementById('nav-raw-tab');
tabEl.addEventListener('shown.bs.tab', function (event) {
self.srcURI = 'api/' + self.mailbox + '/' + self.message.ID + '/source';
self.srcURI = 'api/' + self.message.ID + '/raw';
});
},
unmounted: function() {
window.removeEventListener("resize", this.resizeIframes);
},
methods: {
renderUI: function() {
let self = this;
// click the first non-disabled tab
document.querySelector('#nav-tab button:not([disabled])').click();
document.activeElement.blur(); // blur focus
document.getElementById('message-view').scrollTop = 0;
window.setTimeout(function(){
let p = document.getElementById('preview-html');
if (p) {
// make links open in new window
let anchorEls = p.contentWindow.document.body.querySelectorAll('a');
for (var i = 0; i < anchorEls.length; i++) {
let anchorEl = anchorEls[i];
let href = anchorEl.getAttribute('href');
if (href && href.match(/^http/)) {
anchorEl.setAttribute('target', '_blank');
}
}
self.resizeIframes();
}
}, 200);
// html highlighting
window.Prism = window.Prism || {};
window.Prism.manual = true;
Prism.highlightAll();
},
resizeIframe: function(el) {
let i = el.target;
i.style.height = i.contentWindow.document.body.scrollHeight + 50 + 'px';
},
allAttachments: function(message){
let a = [];
for (let i in message.Attachments) {
a.push(message.Attachments[i]);
resizeIframes: function() {
let h = document.getElementById('preview-html');
if (h) {
h.style.height = h.contentWindow.document.body.scrollHeight + 50 + 'px';
}
for (let i in message.OtherParts) {
a.push(message.OtherParts[i]);
let s = document.getElementById('message-src');
if (s) {
s.style.height = s.contentWindow.document.body.scrollHeight + 50 + 'px';
}
for (let i in message.Inline) {
a.push(message.Inline[i]);
}
return a.length ? a : false;
},
messageDate: function(d) {
return moment(d).format('ddd, D MMM YYYY, h:mm a');
}
@@ -73,73 +109,97 @@ export default {
</script>
<template>
<div v-if="message" class="mh-100" style="overflow-y: scroll;">
<table class="messageHeaders">
<tbody>
<tr class="small">
<th>From</th>
<td>
<span v-if="message.From">
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Address">&lt;{{ message.From.Address }}&gt;</span>
</span>
<span v-else>
[ Unknown ]
</span>
</td>
</tr>
<tr class="small">
<th>To</th>
<td>
<span v-for="(t, i) in message.To">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Cc" class="small">
<th>CC</th>
<td>
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Bcc" class="small">
<th>CC</th>
<td>
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr>
<th class="small">Subject</th>
<td><strong>{{ message.Subject }}</strong></td>
</tr>
</tbody>
</table>
<div v-if="message" id="message-view" class="mh-100" style="overflow-y: scroll;">
<div class="row w-100">
<div class="col-md">
<table class="messageHeaders">
<tbody>
<tr class="small">
<th>From</th>
<td class="privacy">
<span v-if="message.From">
<span v-if="message.From.Name">{{ message.From.Name + " " }}</span>
<span v-if="message.From.Address">&lt;{{ message.From.Address }}&gt;</span>
</span>
<span v-else>
[ Unknown ]
</span>
</td>
</tr>
<tr class="small">
<th>To</th>
<td class="privacy">
<span v-if="message.To" v-for="(t, i) in message.To">
<template v-if="i > 0">, </template>
<span class="text-nowrap">{{ t.Name + " <" + t.Address +">" }}</span>
</span>
<span v-else>Undisclosed recipients</span>
</td>
</tr>
<tr v-if="message.Cc" class="small">
<th>CC</th>
<td class="privacy">
<span v-for="(t, i) in message.Cc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr v-if="message.Bcc" class="small">
<th>CC</th>
<td class="privacy">
<span v-for="(t, i) in message.Bcc">
<template v-if="i > 0">,</template>
{{ t.Name + " <" + t.Address +">" }}
</span>
</td>
</tr>
<tr>
<th class="small">Subject</th>
<td><strong>{{ message.Subject }}</strong></td>
</tr>
<tr class="d-md-none">
<th class="small">Date</th>
<td>{{ messageDate(message.Date) }}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-auto text-md-end mt-md-3">
<p class="text-muted small d-none d-md-block"><small>{{ messageDate(message.Date) }}</small></p>
<div class="dropdown mt-2" v-if="allAttachments(message)">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Attachment<span v-if="allAttachments(message).length > 1">s</span>
({{ allAttachments(message).length }})
</button>
<ul class="dropdown-menu">
<li v-for="part in allAttachments(message)">
<a :href="'api/'+message.ID+'/part/'+part.PartID" type="button"
class="dropdown-item" target="_blank">
<i class="bi" :class="attachmentIcon(part)"></i>
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
<small class="text-muted ms-2">{{ getFileSize(part.Size) }}</small>
</a>
</li>
</ul>
</div>
</div>
</div>
<nav>
<div class="nav nav-tabs my-3" id="nav-tab" role="tablist">
<button class="nav-link" id="nav-html-tab" data-bs-toggle="tab"
data-bs-target="#nav-html" type="button" role="tab" aria-controls="nav-html"
aria-selected="true" :disabled="message.HTML == ''" :class="message.HTML == '' ? 'disabled':''">HTML</button>
aria-selected="true" v-if="message.HTML">HTML</button>
<button class="nav-link" id="nav-html-source-tab" data-bs-toggle="tab"
data-bs-target="#nav-html-source" type="button" role="tab" aria-controls="nav-html-source"
aria-selected="false" v-if="message.HTMLSource">HTML Source</button>
<button class="nav-link" id="nav-plain-text-tab" data-bs-toggle="tab"
data-bs-target="#nav-plain-text" type="button" role="tab" aria-controls="nav-plain-text"
aria-selected="false" :class="message.HTML == '' ? 'show':''">Plain<span class="d-none d-md-inline"> text</span></button>
<button class="nav-link" id="nav-source-tab" data-bs-toggle="tab"
data-bs-target="#nav-source" type="button" role="tab" aria-controls="nav-source"
aria-selected="false">Source</button>
<button class="nav-link" id="nav-mime-tab" data-bs-toggle="tab" data-bs-target="#nav-mime"
type="button" role="tab" aria-controls="nav-mime" aria-selected="false"
:disabled="!allAttachments(message)" :class="!allAttachments(message) ? 'disabled':''"
>Attachments <span v-if="allAttachments(message)">({{allAttachments(message).length}})</span></button>
<div class="d-none d-lg-block ms-auto small mt-3 me-2 text-muted">
<small>{{ messageDate(message.Date) }}</small>
</div>
aria-selected="false" :class="message.HTML == '' ? 'show':''">Text</button>
<button class="nav-link" id="nav-raw-tab" data-bs-toggle="tab"
data-bs-target="#nav-raw" type="button" role="tab" aria-controls="nav-raw"
aria-selected="false">Raw</button>
</div>
</nav>
<div class="tab-content mb-5" id="nav-tabContent">
@@ -148,27 +208,22 @@ export default {
<iframe target-blank="" class="tab-pane" id="preview-html" :srcdoc="message.HTML" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 100%;">
</iframe>
<Attachments v-if="allAttachments(message).length" :message="message" :attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-html-source" role="tabpanel"
aria-labelledby="nav-html-source-tab" tabindex="0" v-if="message.HTMLSource">
<pre><code class="language-html">{{ message.HTMLSource }}</code></pre>
</div>
<div class="tab-pane fade" id="nav-plain-text" role="tabpanel"
aria-labelledby="nav-plain-text-tab" tabindex="0" :class="message.HTML == '' ? 'show':''">
{{ message.Text }}
<div class="text-view">{{ message.Text }}</div>
<Attachments v-if="allAttachments(message).length" :message="message" :attachments="allAttachments(message)"></Attachments>
</div>
<div class="tab-pane fade" id="nav-source" role="tabpanel" aria-labelledby="nav-source-tab"
<div class="tab-pane fade" id="nav-raw" role="tabpanel" aria-labelledby="nav-raw-tab"
tabindex="0">
<iframe v-if="srcURI" :src="srcURI" v-on:load="resizeIframe"
seamless frameborder="0" style="width: 100%; height: 300px;" id="message-src"></iframe>
</div>
<div class="tab-pane fade" id="nav-mime" role="tabpanel" aria-labelledby="nav-mime-tab"
tabindex="0">
<div v-if="allAttachments(message)" v-for="part in allAttachments(message)" class="mime-part mb-2">
<a :href="'api/'+mailbox+'/'+message.ID+'/part/'+part.PartID" type="button"
class="btn btn-outline-secondary btn-sm me-2" target="_blank">
<i class="bi bi-file-arrow-down-fill"></i>
{{ part.FileName != '' ? part.FileName : '[ unknown ]' }}
</a>
<small class="text-muted">{{ getFileSize(part.Size) }}</small>
</div>
</div>
</div>
</div>
</template>

BIN
server/ui/mailpit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"time"
"github.com/axllent/mailpit/config"
"github.com/gorilla/websocket"
)
@@ -52,32 +53,6 @@ type Client struct {
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
@@ -124,16 +99,39 @@ func (c *Client) writePump() {
// ServeWs handles websocket requests from the peer.
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
if config.UIAuthFile != "" {
if config.UIAuthFile != "" {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthResponse(w)
return
}
if !config.UIAuth.Match(user, pass) {
basicAuthResponse(w)
return
}
}
}
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()
}
// BasicAuthResponse returns an basic auth response to the browser
func basicAuthResponse(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="Login"`)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte("Unauthorised.\n"))
}

View File

@@ -49,7 +49,7 @@ func (h *Hub) Run() {
close(client.send)
}
case message := <-h.Broadcast:
logger.Log().Debugf("Message received: %s", message)
// logger.Log().Debugf("[broadcast] %s", message)
for client := range h.Clients {
select {
case client.send <- message:

View File

@@ -4,11 +4,12 @@ import (
"bytes"
"net"
"net/mail"
"regexp"
"github.com/axllent/mailpit/config"
"github.com/axllent/mailpit/logger"
"github.com/axllent/mailpit/storage"
s "github.com/mhale/smtpd"
"github.com/mhale/smtpd"
)
func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
@@ -18,8 +19,16 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
return err
}
if _, err := storage.Store(storage.DefaultMailbox, data); err != nil {
logger.Log().Errorf("error storing message: %s", err.Error())
if _, err := storage.Store(data); err != nil {
// Value with size 4800709 exceeded 1048576 limit
re := regexp.MustCompile(`(Value with size \d+ exceeded \d+ limit)`)
tooLarge := re.FindStringSubmatch(err.Error())
if len(tooLarge) > 0 {
logger.Log().Errorf("[db] error storing message: %s", tooLarge[0])
} else {
logger.Log().Errorf("[db] error storing message")
logger.Log().Errorf(err.Error())
}
return err
}
@@ -28,12 +37,45 @@ func mailHandler(origin net.Addr, from string, to []string, data []byte) error {
return nil
}
func authHandler(remoteAddr net.Addr, mechanism string, username []byte, password []byte, shared []byte) (bool, error) {
return config.SMTPAuth.Match(string(username), string(password)), nil
}
// Listen starts the SMTPD server
func Listen() error {
logger.Log().Infof("[smtp] starting on %s", config.SMTPListen)
if err := s.ListenAndServe(config.SMTPListen, mailHandler, "Mailpit", ""); err != nil {
return err
if config.SMTPSSLCert != "" {
logger.Log().Info("[smtp] enabling TLS")
}
if config.SMTPAuthFile != "" {
logger.Log().Info("[smtp] enabling authentication")
}
return nil
logger.Log().Infof("[smtp] starting on %s", config.SMTPListen)
return listenAndServe(config.SMTPListen, mailHandler, authHandler)
}
func listenAndServe(addr string, handler smtpd.Handler, authHandler smtpd.AuthHandler) error {
srv := &smtpd.Server{
Addr: addr,
Handler: handler,
Appname: "Mailpit",
Hostname: "",
AuthHandler: nil,
AuthRequired: false,
}
if config.SMTPAuthFile != "" {
srv.AuthHandler = authHandler
srv.AuthRequired = true
}
if config.SMTPSSLCert != "" {
err := srv.ConfigureTLS(config.SMTPSSLCert, config.SMTPSSLKey)
if err != nil {
return err
}
}
return srv.ListenAndServe()
}

View File

@@ -2,38 +2,75 @@ package storage
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/mail"
"os"
"os/signal"
"path"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"github.com/GuiaBolso/darwin"
"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/v2"
"github.com/klauspost/compress/zstd"
"github.com/leporo/sqlf"
"github.com/mattn/go-shellwords"
uuid "github.com/satori/go.uuid"
// sqlite (native) - https://gitlab.com/cznic/sqlite
_ "modernc.org/sqlite"
)
var (
db *clover.DB
db *sql.DB
dbFile string
dbIsTemp bool
dbLastAction time.Time
dbIsIdle bool
dbDataDeleted bool
// DefaultMailbox allowing for potential exampnsion in the future
DefaultMailbox = "catchall"
// zstd compression encoder & decoder
dbEncoder, _ = zstd.NewWriter(nil)
dbDecoder, _ = zstd.NewReader(nil)
count int
per100start = time.Now()
dbMigrations = []darwin.Migration{
{
Version: 1.0,
Description: "Creating tables",
Script: `CREATE TABLE IF NOT EXISTS mailbox (
Sort INTEGER PRIMARY KEY AUTOINCREMENT,
ID TEXT NOT NULL,
Data BLOB,
Search TEXT,
Read INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sort ON mailbox (Sort);
CREATE UNIQUE INDEX IF NOT EXISTS idx_id ON mailbox (ID);
CREATE INDEX IF NOT EXISTS idx_read ON mailbox (Read);
CREATE TABLE IF NOT EXISTS mailbox_data (
ID TEXT KEY NOT NULL,
Email BLOB
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_data_id ON mailbox_data (ID);`,
},
}
)
// CloverStore struct
type CloverStore struct {
// DBMailSummary struct for storing mail summary
type DBMailSummary struct {
Created time.Time
Read bool
From *mail.Address
To []*mail.Address
Cc []*mail.Address
@@ -42,202 +79,158 @@ type CloverStore struct {
Size int
Inline int
Attachments int
SearchText string
}
// InitDB will initialise the database.
// If config.DataDir is empty then it will be in memory.
// InitDB will initialise the database
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)
}()
p := config.DataFile
if p == "" {
// when no path is provided then we create a temporary file
// which will get deleted on Close(), SIGINT or SIGTERM
p = fmt.Sprintf("%s-%d.db", path.Join(os.TempDir(), "mailpit"), time.Now().UnixNano())
dbIsTemp = true
logger.Log().Debugf("[db] using temporary database: %s", p)
} else {
logger.Log().Debug("[db] initialising memory data storage")
db, err = clover.Open("", clover.InMemoryMode(true))
if err != nil {
return err
}
p = filepath.Clean(p)
}
// auto-prune
if config.MaxMessages > 0 {
go pruneCron()
}
logger.Log().Debugf("[db] opening database %s", p)
// create catch-all collection
return CreateMailbox(DefaultMailbox)
}
var err error
// ListMailboxes returns a slice of mailboxes (collections)
func ListMailboxes() ([]data.MailboxSummary, error) {
mailboxes, err := db.ListCollections()
dsn := fmt.Sprintf("file:%s?cache=shared", p)
db, err = sql.Open("sqlite", dsn)
if err != nil {
return nil, err
return err
}
results := []data.MailboxSummary{}
// prevent "database locked" errors
// @see https://github.com/mattn/go-sqlite3#faq
db.SetMaxOpenConns(1)
for _, m := range mailboxes {
// ignore *_data collections
if strings.HasSuffix(m, "_data") {
continue
}
stats := StatsGet(m)
mb := data.MailboxSummary{}
mb.Name = m
mb.Slug = m
mb.Total = stats.Total
mb.Unread = stats.Unread
if mb.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)
// create tables if necessary & apply migrations
if err := dbApplyMigrations(); err != nil {
return err
}
return results, nil
dbFile = p
dbLastAction = time.Now()
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
fmt.Printf("[db] got %s signal, shutting down\n", s)
Close()
os.Exit(0)
}()
// auto-prune & delete
go dbCron()
return 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
}
// Create tables and apply migrations if required
func dbApplyMigrations() error {
driver := darwin.NewGenericDriver(db, darwin.SqliteDialect{})
return ok
d := darwin.New(driver, dbMigrations, nil)
return d.Migrate()
}
// 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
// Close will close the database, and delete if a temporary table
func Close() {
if db != nil {
if err := db.Close(); err != nil {
logger.Log().Warning("[db] error closing database, ignoring")
}
}
return statsRefresh(name)
if dbIsTemp && isFile(dbFile) {
logger.Log().Debugf("[db] deleting temporary file %s", dbFile)
if err := os.Remove(dbFile); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
}
}
}
// 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)
// Store will save an email to the database tables
func Store(body []byte) (string, error) {
// Parse message body with enmime.
env, err := enmime.ReadEnvelope(r)
env, err := enmime.ReadEnvelope(bytes.NewReader(body))
if err != nil {
return "", err
logger.Log().Warningf("[db] %s", err.Error())
return "", nil
}
var from *mail.Address
fromData := addressToSlice(env, "From")
if len(fromData) > 0 {
from = fromData[0]
fromJSON := addressToSlice(env, "From")
if len(fromJSON) > 0 {
from = fromJSON[0]
} else if env.GetHeader("From") != "" {
from = &mail.Address{Name: env.GetHeader("From")}
}
obj := CloverStore{
obj := DBMailSummary{
Created: time.Now(),
From: from,
To: addressToSlice(env, "To"),
Cc: addressToSlice(env, "Cc"),
Bcc: addressToSlice(env, "Bcc"),
Subject: env.GetHeader("Subject"),
Size: len(b),
Size: len(body),
Inline: len(env.Inlines),
Attachments: len(env.Attachments),
SearchText: createSearchText(env),
}
doc := clover.NewDocumentOf(obj)
// generate the search text
searchText := createSearchText(env)
id, err := db.InsertOne(mailbox, doc)
// generate unique ID
id := uuid.NewV4().String()
b, err := json.Marshal(obj)
// begin a transaction to ensure both the message
// and data are stored successfully
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
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
}
// roll back if it fails
defer tx.Rollback()
statsAddNewMessage(mailbox)
count++
if count%100 == 0 {
logger.Log().Infof("100 messages added in %s", time.Since(per100start))
per100start = time.Now()
}
d, err := db.FindById(DefaultMailbox, id)
// insert summary
_, err = tx.Exec("INSERT INTO mailbox(ID, Data, Search, Read) values(?,?,?, 0)", id, string(b), searchText)
if err != nil {
return "", err
}
// insert compressed raw message
compressed := dbEncoder.EncodeAll(body, make([]byte, 0, len(body)))
_, err = tx.Exec("INSERT INTO mailbox_data(ID, Email) values(?,?)", id, string(compressed))
if err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
// return summary
c := &data.Summary{}
if err := d.Unmarshal(c); err != nil {
if err := json.Unmarshal(b, c); err != nil {
return "", err
}
@@ -245,146 +238,121 @@ func Store(mailbox string, b []byte) (string, error) {
websockets.Broadcast("new", c)
dbLastAction = time.Now()
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
// List returns a subset of messages from the mailbox,
// sorted latest to oldest
func List(start, limit int) ([]data.Summary, error) {
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", cleanString(regexp.QuoteMeta(search)))
q, err := db.FindAll(clover.NewQuery(mailbox).
Skip(start).
q := sqlf.From("mailbox").
Select(`ID, Data, Read`).
OrderBy("Sort DESC").
Limit(limit).
Sort(clover.SortOption{Field: "Created", Direction: -1}).
Where(clover.Field("SearchText").Like(sq)))
if err != nil {
return nil, err
}
Offset(start)
results := []data.Summary{}
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var id string
var summary string
var read int
em := data.Summary{}
for _, d := range q {
cs := &CloverStore{}
if err := d.Unmarshal(cs); err != nil {
return nil, err
if err := row.Scan(&id, &summary, &read); err != nil {
logger.Log().Error(err)
return
}
results = append(results, cs.Summary(d.ObjectId()))
err := json.Unmarshal([]byte(summary), &em)
if err != nil {
logger.Log().Error(err)
return
}
em.ID = id
em.Read = read == 1
results = append(results, em)
}); err != nil {
return results, err
}
dbLastAction = time.Now()
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))
}
// Search will search a mailbox for search terms.
// The search is broken up by segments (exact phrases can be quoted), and interprits specific terms such as:
// is:read, is:unread, has:attachment, to:<term>, from:<term> & subject:<term>
// Negative searches also also included by prefixing the search term with a `-` or `!`
func Search(search string) ([]data.Summary, error) {
results := []data.Summary{}
start := time.Now()
// 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,
s := strings.ToLower(search)
// add another quote if missing closing quote
quotes := strings.Count(s, `"`)
if quotes%2 != 0 {
s += `"`
}
return s
p := shellwords.NewParser()
args, err := p.Parse(s)
if err != nil {
// return errors.New("Your search contains invalid characters")
panic(err)
}
// generate the SQL based on arguments
q := searchParser(args)
if err := q.QueryAndClose(nil, db, func(row *sql.Rows) {
var id string
var summary string
var read int
var ignore string
em := data.Summary{}
if err := row.Scan(&id, &summary, &read, &ignore, &ignore, &ignore, &ignore); err != nil {
logger.Log().Error(err)
return
}
err := json.Unmarshal([]byte(summary), &em)
if err != nil {
logger.Log().Error(err)
return
}
em.ID = id
em.Read = read == 1
results = append(results, em)
}); err != nil {
return results, err
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] search for \"%s\" in %s", search, elapsed)
dbLastAction = time.Now()
return results, err
}
// 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)
// GetMessage returns a data.Message generated from the mailbox_data collection.
func GetMessage(id string) (*data.Message, error) {
raw, err := GetMessageRaw(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))
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
@@ -399,23 +367,20 @@ func GetMessage(mailbox, id string) (*data.Message, error) {
from = &mail.Address{Name: env.GetHeader("From")}
}
date, err := env.Date()
if err != nil {
// date =
}
date, _ := env.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,
ID: id,
Read: true,
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,
HTMLSource: env.HTML,
}
html := env.HTML
@@ -444,29 +409,52 @@ func GetMessage(mailbox, id string) (*data.Message, error) {
obj.HTML = html
msg, err := db.FindById(mailbox, id)
if err == nil && !msg.Get("Read").(bool) {
updates := make(map[string]interface{})
updates["Read"] = true
if err := db.UpdateById(mailbox, id, updates); err != nil {
return nil, err
}
statsReadOneMessage(mailbox)
// mark message as read
if err := MarkRead(id); err != nil {
return &obj, err
}
dbLastAction = time.Now()
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)
// GetMessageRaw returns an []byte of the full message
func GetMessageRaw(id string) ([]byte, error) {
var i string
var msg string
q := sqlf.From("mailbox_data").
Select(`ID`).To(&i).
Select(`Email`).To(&msg).
Where(`ID = ?`, id)
err := q.QueryRowAndClose(context.Background(), db)
if err != nil {
return nil, err
}
r := bytes.NewReader(data)
if i == "" {
return nil, errors.New("message not found")
}
raw, err := dbDecoder.DecodeAll([]byte(msg), nil)
if err != nil {
return nil, fmt.Errorf("error decompressing message: %s", err.Error())
}
dbLastAction = time.Now()
return raw, err
}
// GetAttachmentPart returns an *enmime.Part (attachment or inline) from a message
func GetAttachmentPart(id, partID string) (*enmime.Part, error) {
raw, err := GetMessageRaw(id)
if err != nil {
return nil, err
}
r := bytes.NewReader(raw)
env, err := enmime.ReadEnvelope(r)
if err != nil {
@@ -491,77 +479,206 @@ func GetAttachmentPart(mailbox, id, partID string) (*enmime.Part, error) {
}
}
dbLastAction = time.Now()
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
// MarkRead will mark a message as read
func MarkRead(id string) error {
if !IsUnread(id) {
return nil
}
if q == nil {
return nil, errors.New("message not found")
_, err := sqlf.Update("mailbox").
Set("Read", 1).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as read", id)
}
data := q.Get("Data").(string)
return []byte(data), err
return err
}
// UnreadMessage will delete all messages from a mailbox
func UnreadMessage(mailbox, id string) error {
updates := make(map[string]interface{})
updates["Read"] = false
// MarkAllRead will mark all messages as read
func MarkAllRead() error {
var (
start = time.Now()
total = CountUnread()
)
statsUnreadOneMessage(mailbox)
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
}
statsDeleteOneMessage(mailbox)
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))
_, err := sqlf.Update("mailbox").
Set("Read", 1).
ExecAndClose(context.Background(), db)
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
}
}
elapsed := time.Since(start)
logger.Log().Debugf("[db] marked %d messages as read in %s", total, elapsed)
// resets stats for mailbox
statsRefresh(mailbox)
elapsed := time.Since(totalStart)
logger.Log().Infof("Deleted %d messages from %s in %s", totalMessages, mailbox, elapsed)
dbLastAction = time.Now()
return nil
}
// MarkUnread will mark a message as unread
func MarkUnread(id string) error {
if IsUnread(id) {
return nil
}
_, err := sqlf.Update("mailbox").
Set("Read", 0).
Where("ID = ?", id).
ExecAndClose(context.Background(), db)
if err == nil {
logger.Log().Debugf("[db] marked message %s as unread", id)
}
dbLastAction = time.Now()
return err
}
// DeleteOneMessage will delete a single message from a mailbox
func DeleteOneMessage(id string) error {
// begin a transaction to ensure both the message
// and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM mailbox WHERE ID = ?", id)
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM mailbox_data WHERE ID = ?", id)
if err != nil {
return err
}
err = tx.Commit()
if err == nil {
logger.Log().Debugf("[db] deleted message %s", id)
}
dbLastAction = time.Now()
dbDataDeleted = true
return err
}
// DeleteAllMessages will delete all messages from a mailbox
func DeleteAllMessages() error {
var (
start = time.Now()
total int
)
_ = sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
QueryRowAndClose(nil, db)
// begin a transaction to ensure both the message
// summaries and data are deleted successfully
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}
// roll back if it fails
defer tx.Rollback()
_, err = tx.Exec("DELETE FROM mailbox")
if err != nil {
return err
}
_, err = tx.Exec("DELETE FROM mailbox_data")
if err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
_, err = db.Exec("VACUUM")
if err == nil {
elapsed := time.Since(start)
logger.Log().Debugf("[db] deleted %d messages in %s", total, elapsed)
}
dbLastAction = time.Now()
dbDataDeleted = false
return err
}
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet() data.MailboxStats {
var (
start = time.Now()
total = CountTotal()
unread = CountUnread()
)
logger.Log().Debugf("[db] statistics calculated in %s", time.Since(start))
dbLastAction = time.Now()
return data.MailboxStats{
Total: total,
Unread: unread,
}
}
// CountTotal returns the number of emails in the database
func CountTotal() int {
var total int
_ = sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
QueryRowAndClose(nil, db)
return total
}
// CountUnread returns the number of emails in the database that are unread.
// If an ID is supplied, then it is just limited to that message.
func CountUnread() int {
var total int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&total).
Where("Read = ?", 0)
_ = q.QueryRowAndClose(nil, db)
return total
}
// IsUnread returns the number of emails in the database that are unread.
// If an ID is supplied, then it is just limited to that message.
func IsUnread(id string) bool {
var unread int
q := sqlf.From("mailbox").
Select("COUNT(*)").To(&unread).
Where("Read = ?", 0).
Where("ID = ?", id)
_ = q.QueryRowAndClose(nil, db)
return unread == 1
}

View File

@@ -15,98 +15,94 @@ import (
var (
testTextEmail []byte
testMimeEmail []byte
testRuns = 100
)
func TestTextEmailInserts(t *testing.T) {
setup()
defer Close()
t.Log("Testing text email storage")
start := time.Now()
for i := 0; i < 1000; i++ {
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
assertEqualStats(t, 0, 0)
for i := 0; i < testRuns; i++ {
if _, err := Store(testTextEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
count, err := Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), testRuns, "Incorrect number of text emails stored")
assertEqual(t, count, 1000, "incorrect number of text emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
t.Logf("inserted 1,000 text emails in %s\n", time.Since(start))
assertEqualStats(t, testRuns, testRuns)
delStart := time.Now()
if err := DeleteAllMessages(DefaultMailbox); err != nil {
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
count, err = Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), 0, "incorrect number of text emails deleted")
assertEqual(t, count, 0, "incorrect number of text emails deleted")
t.Logf("deleted %d text emails in %s", testRuns, time.Since(delStart))
t.Logf("deleted 1,000 text emails in %s\n", time.Since(delStart))
db.Close()
assertEqualStats(t, 0, 0)
}
func TestMimeEmailInserts(t *testing.T) {
setup()
defer Close()
t.Log("Testing mime email storage")
start := time.Now()
for i := 0; i < 1000; i++ {
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
assertEqualStats(t, 0, 0)
for i := 0; i < testRuns; i++ {
if _, err := Store(testMimeEmail); err != nil {
t.Log("error ", err)
t.Fail()
}
}
count, err := Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), testRuns, "Incorrect number of mime emails stored")
assertEqual(t, count, 1000, "incorrect number of mime emails stored")
t.Logf("Inserted %d text emails in %s", testRuns, time.Since(start))
t.Logf("inserted 1,000 emails with mime attachments in %s\n", time.Since(start))
assertEqualStats(t, testRuns, testRuns)
delStart := time.Now()
if err := DeleteAllMessages(DefaultMailbox); err != nil {
if err := DeleteAllMessages(); err != nil {
t.Log("error ", err)
t.Fail()
}
count, err = Count(DefaultMailbox)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, CountTotal(), 0, "incorrect number of mime emails deleted")
assertEqual(t, count, 0, "incorrect number of mime emails deleted")
t.Logf("deleted %d mime emails in %s", testRuns, time.Since(delStart))
t.Logf("deleted 1,000 mime emails in %s\n", time.Since(delStart))
db.Close()
assertEqualStats(t, 0, 0)
}
func TestRetrieveMimeEmail(t *testing.T) {
setup()
defer Close()
id, err := Store(DefaultMailbox, testMimeEmail)
t.Log("Testing mime email retrieval")
id, err := Store(testMimeEmail)
if err != nil {
t.Log("error ", err)
t.Fail()
}
msg, err := GetMessage(DefaultMailbox, id)
msg, err := GetMessage(id)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -122,18 +118,28 @@ func TestRetrieveMimeEmail(t *testing.T) {
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")
db.Close()
attachmentData, err := GetAttachmentPart(id, msg.Attachments[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(attachmentData.Content), msg.Attachments[0].Size, "attachment size does not match")
inlineData, err := GetAttachmentPart(id, msg.Inline[0].PartID)
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(inlineData.Content), msg.Inline[0].Size, "inline attachment size does not match")
}
func TestSearch(t *testing.T) {
setup()
defer Close()
for i := 0; i < 1000; i++ {
t.Log("Testing search")
for i := 0; i < testRuns; i++ {
msg := enmime.Builder().
From(fmt.Sprintf("From %d", i), fmt.Sprintf("from-%d@example.com", i)).
Subject(fmt.Sprintf("Subject line %d end", i)).
@@ -153,13 +159,13 @@ func TestSearch(t *testing.T) {
t.Fail()
}
if _, err := Store(DefaultMailbox, buf.Bytes()); err != nil {
if _, err := Store(buf.Bytes()); err != nil {
t.Log("error ", err)
t.Fail()
}
}
for i := 1; i < 101; i++ {
for i := 1; i < 51; i++ {
// search a random something that will return a single result
searchIndx := rand.Intn(4) + 1
var search string
@@ -169,12 +175,12 @@ func TestSearch(t *testing.T) {
case 2:
search = fmt.Sprintf("to-%d@example.com", i)
case 3:
search = fmt.Sprintf("Subject line %d end", i)
search = fmt.Sprintf("\"Subject line %d end\"", i)
default:
search = fmt.Sprintf("the email body %d jdsauk dwqmdqw", i)
search = fmt.Sprintf("\"the email body %d jdsauk dwqmdqw\"", i)
}
summaries, err := Search(DefaultMailbox, search, 0, 200)
summaries, err := Search(search)
if err != nil {
t.Log("error ", err)
t.Fail()
@@ -190,44 +196,44 @@ func TestSearch(t *testing.T) {
}
// search something that will return 200 rsults
summaries, err := Search(DefaultMailbox, "This is the email body", 0, 200)
summaries, err := Search("This is the email body")
if err != nil {
t.Log("error ", err)
t.Fail()
}
assertEqual(t, len(summaries), 200, "200 search results expected")
db.Close()
assertEqual(t, len(summaries), testRuns, "search results expected")
}
func BenchmarkImportText(b *testing.B) {
setup()
defer Close()
for i := 0; i < b.N; i++ {
if _, err := Store(DefaultMailbox, testTextEmail); err != nil {
if _, err := Store(testTextEmail); err != nil {
b.Log("error ", err)
b.Fail()
}
}
db.Close()
}
func BenchmarkImportMime(b *testing.B) {
setup()
defer Close()
for i := 0; i < b.N; i++ {
if _, err := Store(DefaultMailbox, testMimeEmail); err != nil {
if _, err := Store(testMimeEmail); err != nil {
b.Log("error ", err)
b.Fail()
}
}
db.Close()
}
func setup() {
config.NoLogging = true
config.MaxMessages = 0
config.DataFile = ""
if err := InitDB(); err != nil {
panic(err)
}
@@ -243,7 +249,6 @@ func setup() {
if err != nil {
panic(err)
}
}
func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
@@ -253,3 +258,14 @@ func assertEqual(t *testing.T, a interface{}, b interface{}, message string) {
message = fmt.Sprintf("%s: \"%v\" != \"%v\"", message, a, b)
t.Fatal(message)
}
func assertEqualStats(t *testing.T, total int, unread int) {
s := StatsGet()
if total != s.Total {
t.Fatal(fmt.Sprintf("Incorrect total mailbox stats: \"%d\" != \"%d\"", total, s.Total))
}
if unread != s.Unread {
t.Fatal(fmt.Sprintf("Incorrect unread mailbox stats: \"%d\" != \"%d\"", unread, s.Unread))
}
}

95
storage/search.go Normal file
View File

@@ -0,0 +1,95 @@
package storage
import (
"regexp"
"strings"
"github.com/leporo/sqlf"
)
// SearchParser returns the SQL syntax for the database search based on the search arguments
func searchParser(args []string) *sqlf.Stmt {
q := sqlf.From("mailbox").
Select(`ID, Data, read,
json_extract(Data, '$.To') as ToJSON,
json_extract(Data, '$.From') as FromJSON,
json_extract(Data, '$.Subject') as Subject,
json_extract(Data, '$.Attachments') as Attachments
`).
OrderBy("Sort DESC").
Limit(200)
for _, w := range args {
if cleanString(w) == "" {
continue
}
exclude := false
// search terms starting with a `-` or `!` imply an exclude
if len(w) > 1 && (strings.HasPrefix(w, "-") || strings.HasPrefix(w, "!")) {
exclude = true
w = w[1:]
}
re := regexp.MustCompile(`[a-zA-Z0-9]+`)
if !re.MatchString(w) {
continue
}
if strings.HasPrefix(w, "to:") {
w = cleanString(w[3:])
if w != "" {
if exclude {
q.Where("ToJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("ToJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "from:") {
w = cleanString(w[5:])
if w != "" {
if exclude {
q.Where("FromJSON NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("FromJSON LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if strings.HasPrefix(w, "subject:") {
w = cleanString(w[8:])
if w != "" {
if exclude {
q.Where("Subject NOT LIKE ?", "%"+escPercentChar(w)+"%")
} else {
q.Where("Subject LIKE ?", "%"+escPercentChar(w)+"%")
}
}
} else if w == "is:read" {
if exclude {
q.Where("Read = 0")
} else {
q.Where("Read = 1")
}
} else if w == "is:unread" {
if exclude {
q.Where("Read = 1")
} else {
q.Where("Read = 0")
}
} else if w == "has:attachment" || w == "has:attachments" {
if exclude {
q.Where("Attachments = 0")
} else {
q.Where("Attachments > 0")
}
} else {
// search text
if exclude {
q.Where("search NOT LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
} else {
q.Where("search LIKE ?", "%"+cleanString(escPercentChar(w))+"%")
}
}
}
return q
}

View File

@@ -1,103 +0,0 @@
package storage
import (
"sync"
"github.com/axllent/mailpit/data"
"github.com/axllent/mailpit/logger"
"github.com/ostafen/clover/v2"
)
var (
mailboxStats = map[string]data.MailboxStats{}
statsLock = sync.RWMutex{}
)
// StatsGet returns the total/unread statistics for a mailbox
func StatsGet(mailbox string) data.MailboxStats {
statsLock.Lock()
defer statsLock.Unlock()
s, ok := mailboxStats[mailbox]
if !ok {
return data.MailboxStats{
Total: 0,
Unread: 0,
}
}
return s
}
// Refresh will completely refresh the existing stats for a given mailbox
func statsRefresh(mailbox string) error {
logger.Log().Debugf("[stats] refreshing stats for %s", mailbox)
total, err := db.Count(clover.NewQuery(mailbox))
if err != nil {
return err
}
unread, err := db.Count(clover.NewQuery(mailbox).Where(clover.Field("Read").IsFalse()))
if err != nil {
return nil
}
statsLock.Lock()
mailboxStats[mailbox] = data.MailboxStats{
Total: total,
Unread: unread,
}
statsLock.Unlock()
return nil
}
func statsAddNewMessage(mailbox string) {
statsLock.Lock()
s, ok := mailboxStats[mailbox]
if ok {
mailboxStats[mailbox] = data.MailboxStats{
Total: s.Total + 1,
Unread: s.Unread + 1,
}
}
statsLock.Unlock()
}
// Deleting one will always mean it was read
func statsDeleteOneMessage(mailbox string) {
statsLock.Lock()
s, ok := mailboxStats[mailbox]
if ok {
mailboxStats[mailbox] = data.MailboxStats{
Total: s.Total - 1,
Unread: s.Unread,
}
}
statsLock.Unlock()
}
// Mark one message as read
func statsReadOneMessage(mailbox string) {
statsLock.Lock()
s, ok := mailboxStats[mailbox]
if ok {
mailboxStats[mailbox] = data.MailboxStats{
Total: s.Total,
Unread: s.Unread - 1,
}
}
statsLock.Unlock()
}
// Mark one message as unread
func statsUnreadOneMessage(mailbox string) {
statsLock.Lock()
s, ok := mailboxStats[mailbox]
if ok {
mailboxStats[mailbox] = data.MailboxStats{
Total: s.Total,
Unread: s.Unread + 1,
}
}
statsLock.Unlock()
}

View File

@@ -1,7 +1,10 @@
package storage
import (
"context"
"database/sql"
"net/mail"
"os"
"regexp"
"strings"
"time"
@@ -11,7 +14,7 @@ import (
"github.com/axllent/mailpit/server/websockets"
"github.com/jhillyerd/enmime"
"github.com/k3a/html2text"
"github.com/ostafen/clover/v2"
"github.com/leporo/sqlf"
)
// Return a header field as a []*mail.Address, or "null" is not found/empty
@@ -47,48 +50,116 @@ func createSearchText(env *enmime.Envelope) string {
return d
}
// cleanString removed unwanted characters from stored search text and search queries
// CleanString removes unwanted characters from stored search text and search queries
func cleanString(str string) string {
// remove/replace new lines
re := regexp.MustCompile(`(\r?\n|\t|>|<|"|:|\,|;)`)
str = re.ReplaceAllString(str, " ")
// remove duplicate whitespace and trim
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(str)), " "))
}
// Auto-prune runs every 5 minutes to automatically delete oldest messages
// Auto-prune runs every minute to automatically delete oldest messages
// if total is greater than the threshold
func pruneCron() {
func dbCron() {
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)
start := time.Now()
// check if database contains deleted data and has not beein in use
// for 5 minutes, if so VACUUM
currentTime := time.Now()
diff := currentTime.Sub(dbLastAction)
if dbDataDeleted && diff.Minutes() > 5 {
dbDataDeleted = false
_, err := db.Exec("VACUUM")
if err == nil {
elapsed := time.Since(start)
logger.Log().Debugf("[db] compressed idle database in %s", elapsed)
}
continue
}
for _, m := range mailboxes {
total, _ := db.Count(clover.NewQuery(m))
if total > config.MaxMessages {
limit := total - config.MaxMessages
if limit > 5000 {
limit = 5000
if config.MaxMessages > 0 {
q := sqlf.Select("ID").
From("mailbox").
OrderBy("Sort DESC").
Limit(5000).
Offset(config.MaxMessages)
ids := []string{}
if err := q.Query(nil, db, func(row *sql.Rows) {
var id string
if err := row.Scan(&id); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
return
}
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: %s", m, err.Error())
continue
}
elapsed := time.Since(start)
logger.Log().Infof("Pruned %d messages from %s in %s", limit, m, elapsed)
statsRefresh(m)
if !strings.HasSuffix(m, "_data") {
websockets.Broadcast("prune", nil)
ids = append(ids, id)
}); err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
if len(ids) == 0 {
continue
}
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
args := make([]interface{}, len(ids))
for i, id := range ids {
args[i] = id
}
_, err = tx.Query(`DELETE FROM mailbox WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
_, err = tx.Query(`DELETE FROM mailbox_data WHERE ID IN (?`+strings.Repeat(",?", len(ids)-1)+`)`, args...) // #nosec
if err != nil {
logger.Log().Errorf("[db] %s", err.Error())
continue
}
err = tx.Commit()
if err != nil {
logger.Log().Errorf(err.Error())
if err := tx.Rollback(); err != nil {
logger.Log().Errorf(err.Error())
}
}
dbDataDeleted = true
elapsed := time.Since(start)
logger.Log().Debugf("[db] auto-pruned %d messages in %s", len(ids), elapsed)
websockets.Broadcast("prune", nil)
}
}
}
// IsFile returns whether 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
}
// escPercentChar replaces `%` with `%%` for SQL searches
func escPercentChar(s string) string {
return strings.ReplaceAll(s, "%", "%%")
}

View File

@@ -8,6 +8,7 @@ import (
"io"
"os"
"path/filepath"
"strings"
"syscall"
)
@@ -184,6 +185,10 @@ func extract(filePath string, directory string) error {
}
fileInfo := header.FileInfo()
// paths could contain a '..', is used in a file system operations
if strings.Contains(fileInfo.Name(), "..") {
continue
}
dir := filepath.Join(directory, filepath.Dir(header.Name))
filename := filepath.Join(dir, fileInfo.Name())

View File

@@ -178,7 +178,7 @@ func GithubUpdate(repo, appName, currentVersion string) (string, error) {
// get the running binary
oldExec, err := os.Executable()
if err != nil {
panic(err)
return "", err
}
if err = replaceFile(oldExec, newExec); err != nil {